feat(mattermost): add interactive buttons support (#19957)

Merged via squash.

Prepared head SHA: 8a25e60872
Co-authored-by: tonydehnke <36720180+tonydehnke@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
This commit is contained in:
Tony Dehnke
2026-03-05 21:44:57 +07:00
committed by GitHub
parent 9741e91a64
commit 136ca87f7b
17 changed files with 2064 additions and 91 deletions

View File

@@ -102,8 +102,9 @@ describe("mattermostPlugin", () => {
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
expect(actions).toContain("react");
expect(actions).not.toContain("send");
expect(actions).toContain("send");
expect(mattermostPlugin.actions?.supportsAction?.({ action: "react" })).toBe(true);
expect(mattermostPlugin.actions?.supportsAction?.({ action: "send" })).toBe(true);
});
it("hides react when mattermost is not configured", () => {
@@ -133,7 +134,7 @@ describe("mattermostPlugin", () => {
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
expect(actions).not.toContain("react");
expect(actions).not.toContain("send");
expect(actions).toContain("send");
});
it("respects per-account actions.reactions in listActions", () => {

View File

@@ -22,6 +22,15 @@ import {
type ResolvedMattermostAccount,
} from "./mattermost/accounts.js";
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
import {
listMattermostDirectoryGroups,
listMattermostDirectoryPeers,
} from "./mattermost/directory.js";
import {
buildButtonAttachments,
resolveInteractionCallbackUrl,
setInteractionSecret,
} from "./mattermost/interactions.js";
import { monitorMattermostProvider } from "./mattermost/monitor.js";
import { probeMattermost } from "./mattermost/probe.js";
import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
@@ -32,62 +41,91 @@ import { getMattermostRuntime } from "./runtime.js";
const mattermostMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined;
const baseReactions = actionsConfig?.reactions;
const hasReactionCapableAccount = listMattermostAccountIds(cfg)
const enabledAccounts = listMattermostAccountIds(cfg)
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
.filter((account) => account.enabled)
.filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim()))
.some((account) => {
const accountActions = account.config.actions as { reactions?: boolean } | undefined;
return (accountActions?.reactions ?? baseReactions ?? true) !== false;
});
.filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim()));
if (!hasReactionCapableAccount) {
return [];
const actions: ChannelMessageActionName[] = [];
// Send (buttons) is available whenever there's at least one enabled account
if (enabledAccounts.length > 0) {
actions.push("send");
}
return ["react"];
// React requires per-account reactions config check
const actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined;
const baseReactions = actionsConfig?.reactions;
const hasReactionCapableAccount = enabledAccounts.some((account) => {
const accountActions = account.config.actions as { reactions?: boolean } | undefined;
return (accountActions?.reactions ?? baseReactions ?? true) !== false;
});
if (hasReactionCapableAccount) {
actions.push("react");
}
return actions;
},
supportsAction: ({ action }) => {
return action === "react";
return action === "send" || action === "react";
},
supportsButtons: ({ cfg }) => {
const accounts = listMattermostAccountIds(cfg)
.map((id) => resolveMattermostAccount({ cfg, accountId: id }))
.filter((a) => a.enabled && a.botToken?.trim() && a.baseUrl?.trim());
return accounts.length > 0;
},
handleAction: async ({ action, params, cfg, accountId }) => {
if (action !== "react") {
throw new Error(`Mattermost action ${action} not supported`);
}
// Check reactions gate: per-account config takes precedence over base config
const mmBase = cfg?.channels?.mattermost as Record<string, unknown> | undefined;
const accounts = mmBase?.accounts as Record<string, Record<string, unknown>> | undefined;
const resolvedAccountId = accountId ?? resolveDefaultMattermostAccountId(cfg);
const acctConfig = accounts?.[resolvedAccountId];
const acctActions = acctConfig?.actions as { reactions?: boolean } | undefined;
const baseActions = mmBase?.actions as { reactions?: boolean } | undefined;
const reactionsEnabled = acctActions?.reactions ?? baseActions?.reactions ?? true;
if (!reactionsEnabled) {
throw new Error("Mattermost reactions are disabled in config");
}
if (action === "react") {
// Check reactions gate: per-account config takes precedence over base config
const mmBase = cfg?.channels?.mattermost as Record<string, unknown> | undefined;
const accounts = mmBase?.accounts as Record<string, Record<string, unknown>> | undefined;
const resolvedAccountId = accountId ?? resolveDefaultMattermostAccountId(cfg);
const acctConfig = accounts?.[resolvedAccountId];
const acctActions = acctConfig?.actions as { reactions?: boolean } | undefined;
const baseActions = mmBase?.actions as { reactions?: boolean } | undefined;
const reactionsEnabled = acctActions?.reactions ?? baseActions?.reactions ?? true;
if (!reactionsEnabled) {
throw new Error("Mattermost reactions are disabled in config");
}
const postIdRaw =
typeof (params as any)?.messageId === "string"
? (params as any).messageId
: typeof (params as any)?.postId === "string"
? (params as any).postId
: "";
const postId = postIdRaw.trim();
if (!postId) {
throw new Error("Mattermost react requires messageId (post id)");
}
const postIdRaw =
typeof (params as any)?.messageId === "string"
? (params as any).messageId
: typeof (params as any)?.postId === "string"
? (params as any).postId
: "";
const postId = postIdRaw.trim();
if (!postId) {
throw new Error("Mattermost react requires messageId (post id)");
}
const emojiRaw = typeof (params as any)?.emoji === "string" ? (params as any).emoji : "";
const emojiName = emojiRaw.trim().replace(/^:+|:+$/g, "");
if (!emojiName) {
throw new Error("Mattermost react requires emoji");
}
const emojiRaw = typeof (params as any)?.emoji === "string" ? (params as any).emoji : "";
const emojiName = emojiRaw.trim().replace(/^:+|:+$/g, "");
if (!emojiName) {
throw new Error("Mattermost react requires emoji");
}
const remove = (params as any)?.remove === true;
if (remove) {
const result = await removeMattermostReaction({
const remove = (params as any)?.remove === true;
if (remove) {
const result = await removeMattermostReaction({
cfg,
postId,
emojiName,
accountId: resolvedAccountId,
});
if (!result.ok) {
throw new Error(result.error);
}
return {
content: [
{ type: "text" as const, text: `Removed reaction :${emojiName}: from ${postId}` },
],
details: {},
};
}
const result = await addMattermostReaction({
cfg,
postId,
emojiName,
@@ -96,26 +134,92 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
if (!result.ok) {
throw new Error(result.error);
}
return {
content: [
{ type: "text" as const, text: `Removed reaction :${emojiName}: from ${postId}` },
],
content: [{ type: "text" as const, text: `Reacted with :${emojiName}: on ${postId}` }],
details: {},
};
}
const result = await addMattermostReaction({
cfg,
postId,
emojiName,
accountId: resolvedAccountId,
});
if (!result.ok) {
throw new Error(result.error);
if (action !== "send") {
throw new Error(`Unsupported Mattermost action: ${action}`);
}
// Send action with optional interactive buttons
const to =
typeof params.to === "string"
? params.to.trim()
: typeof params.target === "string"
? params.target.trim()
: "";
if (!to) {
throw new Error("Mattermost send requires a target (to).");
}
const message = typeof params.message === "string" ? params.message : "";
const replyToId = typeof params.replyToId === "string" ? params.replyToId : undefined;
const resolvedAccountId = accountId || undefined;
// Build props with button attachments if buttons are provided
let props: Record<string, unknown> | undefined;
if (params.buttons && Array.isArray(params.buttons)) {
const account = resolveMattermostAccount({ cfg, accountId: resolvedAccountId });
if (account.botToken) setInteractionSecret(account.accountId, account.botToken);
const callbackUrl = resolveInteractionCallbackUrl(account.accountId, cfg);
// Flatten 2D array (rows of buttons) to 1D — core schema sends Array<Array<Button>>
// but Mattermost doesn't have row layout, so we flatten all rows into a single list.
// Also supports 1D arrays for backward compatibility.
const rawButtons = (params.buttons as Array<unknown>).flatMap((item) =>
Array.isArray(item) ? item : [item],
) as Array<Record<string, unknown>>;
const buttons = rawButtons
.map((btn) => ({
id: String(btn.id ?? btn.callback_data ?? ""),
name: String(btn.text ?? btn.name ?? btn.label ?? ""),
style: (btn.style as "default" | "primary" | "danger") ?? "default",
context:
typeof btn.context === "object" && btn.context !== null
? (btn.context as Record<string, unknown>)
: undefined,
}))
.filter((btn) => btn.id && btn.name);
const attachmentText =
typeof params.attachmentText === "string" ? params.attachmentText : undefined;
props = {
attachments: buildButtonAttachments({
callbackUrl,
accountId: account.accountId,
buttons,
text: attachmentText,
}),
};
}
const mediaUrl =
typeof params.media === "string" ? params.media.trim() || undefined : undefined;
const result = await sendMessageMattermost(to, message, {
accountId: resolvedAccountId,
replyToId,
props,
mediaUrl,
});
return {
content: [{ type: "text" as const, text: `Reacted with :${emojiName}: on ${postId}` }],
content: [
{
type: "text" as const,
text: JSON.stringify({
ok: true,
channel: "mattermost",
messageId: result.messageId,
channelId: result.channelId,
}),
},
],
details: {},
};
},
@@ -249,6 +353,12 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
resolveRequireMention: resolveMattermostGroupRequireMention,
},
actions: mattermostMessageActions,
directory: {
listGroups: async (params) => listMattermostDirectoryGroups(params),
listGroupsLive: async (params) => listMattermostDirectoryGroups(params),
listPeers: async (params) => listMattermostDirectoryPeers(params),
listPeersLive: async (params) => listMattermostDirectoryPeers(params),
},
messaging: {
normalizeTarget: normalizeMattermostMessagingTarget,
targetResolver: {

View File

@@ -50,6 +50,11 @@ const MattermostAccountSchemaBase = z
})
.optional(),
commands: MattermostSlashCommandsSchema,
interactions: z
.object({
callbackBaseUrl: z.string().optional(),
})
.optional(),
})
.strict();

View File

@@ -1,19 +1,298 @@
import { describe, expect, it, vi } from "vitest";
import { createMattermostClient } from "./client.js";
import {
createMattermostClient,
createMattermostPost,
normalizeMattermostBaseUrl,
updateMattermostPost,
} from "./client.js";
describe("mattermost client", () => {
it("request returns undefined on 204 responses", async () => {
// ── Helper: mock fetch that captures requests ────────────────────────
function createMockFetch(response?: { status?: number; body?: unknown; contentType?: string }) {
const status = response?.status ?? 200;
const body = response?.body ?? {};
const contentType = response?.contentType ?? "application/json";
const calls: Array<{ url: string; init?: RequestInit }> = [];
const mockFetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
const urlStr = typeof url === "string" ? url : url.toString();
calls.push({ url: urlStr, init });
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": contentType },
});
});
return { mockFetch: mockFetch as unknown as typeof fetch, calls };
}
// ── normalizeMattermostBaseUrl ────────────────────────────────────────
describe("normalizeMattermostBaseUrl", () => {
it("strips trailing slashes", () => {
expect(normalizeMattermostBaseUrl("http://localhost:8065/")).toBe("http://localhost:8065");
});
it("strips /api/v4 suffix", () => {
expect(normalizeMattermostBaseUrl("http://localhost:8065/api/v4")).toBe(
"http://localhost:8065",
);
});
it("returns undefined for empty input", () => {
expect(normalizeMattermostBaseUrl("")).toBeUndefined();
expect(normalizeMattermostBaseUrl(null)).toBeUndefined();
expect(normalizeMattermostBaseUrl(undefined)).toBeUndefined();
});
it("preserves valid base URL", () => {
expect(normalizeMattermostBaseUrl("http://mm.example.com")).toBe("http://mm.example.com");
});
});
// ── createMattermostClient ───────────────────────────────────────────
describe("createMattermostClient", () => {
it("creates a client with normalized baseUrl", () => {
const { mockFetch } = createMockFetch();
const client = createMattermostClient({
baseUrl: "http://localhost:8065/",
botToken: "tok",
fetchImpl: mockFetch,
});
expect(client.baseUrl).toBe("http://localhost:8065");
expect(client.apiBaseUrl).toBe("http://localhost:8065/api/v4");
});
it("throws on empty baseUrl", () => {
expect(() => createMattermostClient({ baseUrl: "", botToken: "tok" })).toThrow(
"baseUrl is required",
);
});
it("sends Authorization header with Bearer token", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "u1" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "my-secret-token",
fetchImpl: mockFetch,
});
await client.request("/users/me");
const headers = new Headers(calls[0].init?.headers);
expect(headers.get("Authorization")).toBe("Bearer my-secret-token");
});
it("sets Content-Type for string bodies", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "p1" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await client.request("/posts", { method: "POST", body: JSON.stringify({ message: "hi" }) });
const headers = new Headers(calls[0].init?.headers);
expect(headers.get("Content-Type")).toBe("application/json");
});
it("throws on non-ok responses", async () => {
const { mockFetch } = createMockFetch({
status: 404,
body: { message: "Not Found" },
});
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await expect(client.request("/missing")).rejects.toThrow("Mattermost API 404");
});
it("returns undefined on 204 responses", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(null, { status: 204 });
});
const client = createMattermostClient({
baseUrl: "https://chat.example.com",
botToken: "test-token",
fetchImpl: fetchImpl as any,
});
const result = await client.request<unknown>("/anything", { method: "DELETE" });
expect(result).toBeUndefined();
});
});
// ── createMattermostPost ─────────────────────────────────────────────
describe("createMattermostPost", () => {
it("sends channel_id and message", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await createMattermostPost(client, {
channelId: "ch123",
message: "Hello world",
});
const body = JSON.parse(calls[0].init?.body as string);
expect(body.channel_id).toBe("ch123");
expect(body.message).toBe("Hello world");
});
it("includes rootId when provided", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "post2" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await createMattermostPost(client, {
channelId: "ch123",
message: "Reply",
rootId: "root456",
});
const body = JSON.parse(calls[0].init?.body as string);
expect(body.root_id).toBe("root456");
});
it("includes fileIds when provided", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "post3" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await createMattermostPost(client, {
channelId: "ch123",
message: "With file",
fileIds: ["file1", "file2"],
});
const body = JSON.parse(calls[0].init?.body as string);
expect(body.file_ids).toEqual(["file1", "file2"]);
});
it("includes props when provided (for interactive buttons)", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "post4" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
const props = {
attachments: [
{
text: "Choose:",
actions: [{ id: "btn1", type: "button", name: "Click" }],
},
],
};
await createMattermostPost(client, {
channelId: "ch123",
message: "Pick an option",
props,
});
const body = JSON.parse(calls[0].init?.body as string);
expect(body.props).toEqual(props);
expect(body.props.attachments[0].actions[0].type).toBe("button");
});
it("omits props when not provided", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "post5" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await createMattermostPost(client, {
channelId: "ch123",
message: "No props",
});
const body = JSON.parse(calls[0].init?.body as string);
expect(body.props).toBeUndefined();
});
});
// ── updateMattermostPost ─────────────────────────────────────────────
describe("updateMattermostPost", () => {
it("sends PUT to /posts/{id}", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await updateMattermostPost(client, "post1", { message: "Updated" });
expect(calls[0].url).toContain("/posts/post1");
expect(calls[0].init?.method).toBe("PUT");
});
it("includes post id in the body", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await updateMattermostPost(client, "post1", { message: "Updated" });
const body = JSON.parse(calls[0].init?.body as string);
expect(body.id).toBe("post1");
expect(body.message).toBe("Updated");
});
it("includes props for button completion updates", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await updateMattermostPost(client, "post1", {
message: "Original message",
props: {
attachments: [{ text: "✓ **do_now** selected by @tony" }],
},
});
const body = JSON.parse(calls[0].init?.body as string);
expect(body.message).toBe("Original message");
expect(body.props.attachments[0].text).toContain("✓");
expect(body.props.attachments[0].text).toContain("do_now");
});
it("omits message when not provided", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await updateMattermostPost(client, "post1", {
props: { attachments: [] },
});
const body = JSON.parse(calls[0].init?.body as string);
expect(body.id).toBe("post1");
expect(body.message).toBeUndefined();
expect(body.props).toEqual({ attachments: [] });
});
});

View File

@@ -138,6 +138,16 @@ export async function fetchMattermostChannel(
return await client.request<MattermostChannel>(`/channels/${channelId}`);
}
export async function fetchMattermostChannelByName(
client: MattermostClient,
teamId: string,
channelName: string,
): Promise<MattermostChannel> {
return await client.request<MattermostChannel>(
`/teams/${teamId}/channels/name/${encodeURIComponent(channelName)}`,
);
}
export async function sendMattermostTyping(
client: MattermostClient,
params: { channelId: string; parentId?: string },
@@ -172,9 +182,10 @@ export async function createMattermostPost(
message: string;
rootId?: string;
fileIds?: string[];
props?: Record<string, unknown>;
},
): Promise<MattermostPost> {
const payload: Record<string, string> = {
const payload: Record<string, unknown> = {
channel_id: params.channelId,
message: params.message,
};
@@ -182,7 +193,10 @@ export async function createMattermostPost(
payload.root_id = params.rootId;
}
if (params.fileIds?.length) {
(payload as Record<string, unknown>).file_ids = params.fileIds;
payload.file_ids = params.fileIds;
}
if (params.props) {
payload.props = params.props;
}
return await client.request<MattermostPost>("/posts", {
method: "POST",
@@ -203,6 +217,27 @@ export async function fetchMattermostUserTeams(
return await client.request<MattermostTeam[]>(`/users/${userId}/teams`);
}
export async function updateMattermostPost(
client: MattermostClient,
postId: string,
params: {
message?: string;
props?: Record<string, unknown>;
},
): Promise<MattermostPost> {
const payload: Record<string, unknown> = { id: postId };
if (params.message !== undefined) {
payload.message = params.message;
}
if (params.props !== undefined) {
payload.props = params.props;
}
return await client.request<MattermostPost>(`/posts/${postId}`, {
method: "PUT",
body: JSON.stringify(payload),
});
}
export async function uploadMattermostFile(
client: MattermostClient,
params: {

View File

@@ -0,0 +1,172 @@
import type {
ChannelDirectoryEntry,
OpenClawConfig,
RuntimeEnv,
} from "openclaw/plugin-sdk/mattermost";
import { listMattermostAccountIds, resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
fetchMattermostMe,
type MattermostChannel,
type MattermostClient,
type MattermostUser,
} from "./client.js";
export type MattermostDirectoryParams = {
cfg: OpenClawConfig;
accountId?: string | null;
query?: string | null;
limit?: number | null;
runtime: RuntimeEnv;
};
function buildClient(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): MattermostClient | null {
const account = resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId });
if (!account.enabled || !account.botToken || !account.baseUrl) {
return null;
}
return createMattermostClient({ baseUrl: account.baseUrl, botToken: account.botToken });
}
/**
* Build clients from ALL enabled accounts (deduplicated by token).
*
* We always scan every account because:
* - Private channels are only visible to bots that are members
* - The requesting agent's account may have an expired/invalid token
*
* This means a single healthy bot token is enough for directory discovery.
*/
function buildClients(params: MattermostDirectoryParams): MattermostClient[] {
const accountIds = listMattermostAccountIds(params.cfg);
const seen = new Set<string>();
const clients: MattermostClient[] = [];
for (const id of accountIds) {
const client = buildClient({ cfg: params.cfg, accountId: id });
if (client && !seen.has(client.token)) {
seen.add(client.token);
clients.push(client);
}
}
return clients;
}
/**
* List channels (public + private) visible to any configured bot account.
*
* NOTE: Uses per_page=200 which covers most instances. Mattermost does not
* return a "has more" indicator, so very large instances (200+ channels per bot)
* may see incomplete results. Pagination can be added if needed.
*/
export async function listMattermostDirectoryGroups(
params: MattermostDirectoryParams,
): Promise<ChannelDirectoryEntry[]> {
const clients = buildClients(params);
if (!clients.length) {
return [];
}
const q = params.query?.trim().toLowerCase() || "";
const seenIds = new Set<string>();
const entries: ChannelDirectoryEntry[] = [];
for (const client of clients) {
try {
const me = await fetchMattermostMe(client);
const channels = await client.request<MattermostChannel[]>(
`/users/${me.id}/channels?per_page=200`,
);
for (const ch of channels) {
if (ch.type !== "O" && ch.type !== "P") continue;
if (seenIds.has(ch.id)) continue;
if (q) {
const name = (ch.name ?? "").toLowerCase();
const display = (ch.display_name ?? "").toLowerCase();
if (!name.includes(q) && !display.includes(q)) continue;
}
seenIds.add(ch.id);
entries.push({
kind: "group" as const,
id: `channel:${ch.id}`,
name: ch.name ?? undefined,
handle: ch.display_name ?? undefined,
});
}
} catch (err) {
// Token may be expired/revoked — skip this account and try others
console.debug?.(
"[mattermost-directory] listGroups: skipping account:",
(err as Error)?.message,
);
continue;
}
}
return params.limit && params.limit > 0 ? entries.slice(0, params.limit) : entries;
}
/**
* List team members as peer directory entries.
*
* Uses only the first available client since all bots in a team see the same
* user list (unlike channels where membership varies). Uses the first team
* returned — multi-team setups will only see members from that team.
*
* NOTE: per_page=200 for member listing; same pagination caveat as groups.
*/
export async function listMattermostDirectoryPeers(
params: MattermostDirectoryParams,
): Promise<ChannelDirectoryEntry[]> {
const clients = buildClients(params);
if (!clients.length) {
return [];
}
// All bots see the same user list, so one client suffices (unlike channels
// where private channel membership varies per bot).
const client = clients[0];
try {
const me = await fetchMattermostMe(client);
const teams = await client.request<{ id: string }[]>("/users/me/teams");
if (!teams.length) {
return [];
}
// Uses first team — multi-team setups may need iteration in the future
const teamId = teams[0].id;
const q = params.query?.trim().toLowerCase() || "";
let users: MattermostUser[];
if (q) {
users = await client.request<MattermostUser[]>("/users/search", {
method: "POST",
body: JSON.stringify({ term: q, team_id: teamId }),
});
} else {
const members = await client.request<{ user_id: string }[]>(
`/teams/${teamId}/members?per_page=200`,
);
const userIds = members.map((m) => m.user_id).filter((id) => id !== me.id);
if (!userIds.length) {
return [];
}
users = await client.request<MattermostUser[]>("/users/ids", {
method: "POST",
body: JSON.stringify(userIds),
});
}
const entries = users
.filter((u) => u.id !== me.id)
.map((u) => ({
kind: "user" as const,
id: `user:${u.id}`,
name: u.username ?? undefined,
handle:
[u.first_name, u.last_name].filter(Boolean).join(" ").trim() || u.nickname || undefined,
}));
return params.limit && params.limit > 0 ? entries.slice(0, params.limit) : entries;
} catch (err) {
console.debug?.("[mattermost-directory] listPeers failed:", (err as Error)?.message);
return [];
}
}

View File

@@ -0,0 +1,335 @@
import { type IncomingMessage } from "node:http";
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import {
buildButtonAttachments,
generateInteractionToken,
getInteractionCallbackUrl,
getInteractionSecret,
isLocalhostRequest,
resolveInteractionCallbackUrl,
setInteractionCallbackUrl,
setInteractionSecret,
verifyInteractionToken,
} from "./interactions.js";
// ── HMAC token management ────────────────────────────────────────────
describe("setInteractionSecret / getInteractionSecret", () => {
beforeEach(() => {
setInteractionSecret("test-bot-token");
});
it("derives a deterministic secret from the bot token", () => {
setInteractionSecret("token-a");
const secretA = getInteractionSecret();
setInteractionSecret("token-a");
const secretA2 = getInteractionSecret();
expect(secretA).toBe(secretA2);
});
it("produces different secrets for different tokens", () => {
setInteractionSecret("token-a");
const secretA = getInteractionSecret();
setInteractionSecret("token-b");
const secretB = getInteractionSecret();
expect(secretA).not.toBe(secretB);
});
it("returns a hex string", () => {
expect(getInteractionSecret()).toMatch(/^[0-9a-f]+$/);
});
});
// ── Token generation / verification ──────────────────────────────────
describe("generateInteractionToken / verifyInteractionToken", () => {
beforeEach(() => {
setInteractionSecret("test-bot-token");
});
it("generates a hex token", () => {
const token = generateInteractionToken({ action_id: "click" });
expect(token).toMatch(/^[0-9a-f]{64}$/);
});
it("verifies a valid token", () => {
const context = { action_id: "do_now", item_id: "123" };
const token = generateInteractionToken(context);
expect(verifyInteractionToken(context, token)).toBe(true);
});
it("rejects a tampered token", () => {
const context = { action_id: "do_now" };
const token = generateInteractionToken(context);
const tampered = token.replace(/.$/, token.endsWith("0") ? "1" : "0");
expect(verifyInteractionToken(context, tampered)).toBe(false);
});
it("rejects a token generated with different context", () => {
const token = generateInteractionToken({ action_id: "a" });
expect(verifyInteractionToken({ action_id: "b" }, token)).toBe(false);
});
it("rejects tokens with wrong length", () => {
const context = { action_id: "test" };
expect(verifyInteractionToken(context, "short")).toBe(false);
});
it("is deterministic for the same context", () => {
const context = { action_id: "test", x: 1 };
const t1 = generateInteractionToken(context);
const t2 = generateInteractionToken(context);
expect(t1).toBe(t2);
});
it("produces the same token regardless of key order", () => {
const contextA = { action_id: "do_now", tweet_id: "123", action: "do" };
const contextB = { action: "do", action_id: "do_now", tweet_id: "123" };
const contextC = { tweet_id: "123", action: "do", action_id: "do_now" };
const tokenA = generateInteractionToken(contextA);
const tokenB = generateInteractionToken(contextB);
const tokenC = generateInteractionToken(contextC);
expect(tokenA).toBe(tokenB);
expect(tokenB).toBe(tokenC);
});
it("verifies a token when Mattermost reorders context keys", () => {
// Simulate: token generated with keys in one order, verified with keys in another
// (Mattermost reorders context keys when storing/returning interactive message payloads)
const originalContext = { action_id: "bm_do", tweet_id: "999", action: "do" };
const token = generateInteractionToken(originalContext);
// Mattermost returns keys in alphabetical order (or any arbitrary order)
const reorderedContext = { action: "do", action_id: "bm_do", tweet_id: "999" };
expect(verifyInteractionToken(reorderedContext, token)).toBe(true);
});
it("scopes tokens per account when account secrets differ", () => {
setInteractionSecret("acct-a", "bot-token-a");
setInteractionSecret("acct-b", "bot-token-b");
const context = { action_id: "do_now", item_id: "123" };
const tokenA = generateInteractionToken(context, "acct-a");
expect(verifyInteractionToken(context, tokenA, "acct-a")).toBe(true);
expect(verifyInteractionToken(context, tokenA, "acct-b")).toBe(false);
});
});
// ── Callback URL registry ────────────────────────────────────────────
describe("callback URL registry", () => {
it("stores and retrieves callback URLs", () => {
setInteractionCallbackUrl("acct1", "http://localhost:18789/mattermost/interactions/acct1");
expect(getInteractionCallbackUrl("acct1")).toBe(
"http://localhost:18789/mattermost/interactions/acct1",
);
});
it("returns undefined for unknown account", () => {
expect(getInteractionCallbackUrl("nonexistent-account-id")).toBeUndefined();
});
});
describe("resolveInteractionCallbackUrl", () => {
afterEach(() => {
setInteractionCallbackUrl("resolve-test", "");
});
it("prefers cached URL from registry", () => {
setInteractionCallbackUrl("cached", "http://cached:1234/path");
expect(resolveInteractionCallbackUrl("cached")).toBe("http://cached:1234/path");
});
it("falls back to computed URL from gateway port config", () => {
const url = resolveInteractionCallbackUrl("default", { gateway: { port: 9999 } });
expect(url).toBe("http://localhost:9999/mattermost/interactions/default");
});
it("uses default port 18789 when no config provided", () => {
const url = resolveInteractionCallbackUrl("myaccount");
expect(url).toBe("http://localhost:18789/mattermost/interactions/myaccount");
});
it("uses default port when gateway config has no port", () => {
const url = resolveInteractionCallbackUrl("acct", { gateway: {} });
expect(url).toBe("http://localhost:18789/mattermost/interactions/acct");
});
});
// ── buildButtonAttachments ───────────────────────────────────────────
describe("buildButtonAttachments", () => {
beforeEach(() => {
setInteractionSecret("test-bot-token");
});
it("returns an array with one attachment containing all buttons", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost:18789/mattermost/interactions/default",
buttons: [
{ id: "btn1", name: "Click Me" },
{ id: "btn2", name: "Skip", style: "danger" },
],
});
expect(result).toHaveLength(1);
expect(result[0].actions).toHaveLength(2);
});
it("sets type to 'button' on every action", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost:18789/cb",
buttons: [{ id: "a", name: "A" }],
});
expect(result[0].actions![0].type).toBe("button");
});
it("includes HMAC _token in integration context", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost:18789/cb",
buttons: [{ id: "test", name: "Test" }],
});
const action = result[0].actions![0];
expect(action.integration.context._token).toMatch(/^[0-9a-f]{64}$/);
});
it("includes sanitized action_id in integration context", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost:18789/cb",
buttons: [{ id: "my_action", name: "Do It" }],
});
const action = result[0].actions![0];
// sanitizeActionId strips hyphens and underscores (Mattermost routing bug #25747)
expect(action.integration.context.action_id).toBe("myaction");
expect(action.id).toBe("myaction");
});
it("merges custom context into integration context", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost:18789/cb",
buttons: [{ id: "btn", name: "Go", context: { tweet_id: "123", batch: true } }],
});
const ctx = result[0].actions![0].integration.context;
expect(ctx.tweet_id).toBe("123");
expect(ctx.batch).toBe(true);
expect(ctx.action_id).toBe("btn");
expect(ctx._token).toBeDefined();
});
it("passes callback URL to each button integration", () => {
const url = "http://localhost:18789/mattermost/interactions/default";
const result = buildButtonAttachments({
callbackUrl: url,
buttons: [
{ id: "a", name: "A" },
{ id: "b", name: "B" },
],
});
for (const action of result[0].actions!) {
expect(action.integration.url).toBe(url);
}
});
it("preserves button style", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost/cb",
buttons: [
{ id: "ok", name: "OK", style: "primary" },
{ id: "no", name: "No", style: "danger" },
],
});
expect(result[0].actions![0].style).toBe("primary");
expect(result[0].actions![1].style).toBe("danger");
});
it("uses provided text for the attachment", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost/cb",
buttons: [{ id: "x", name: "X" }],
text: "Choose an action:",
});
expect(result[0].text).toBe("Choose an action:");
});
it("defaults to empty string text when not provided", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost/cb",
buttons: [{ id: "x", name: "X" }],
});
expect(result[0].text).toBe("");
});
it("generates verifiable tokens", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost/cb",
buttons: [{ id: "verify_me", name: "V", context: { extra: "data" } }],
});
const ctx = result[0].actions![0].integration.context;
const token = ctx._token as string;
const { _token, ...contextWithoutToken } = ctx;
expect(verifyInteractionToken(contextWithoutToken, token)).toBe(true);
});
it("generates tokens that verify even when Mattermost reorders context keys", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost/cb",
buttons: [{ id: "do_action", name: "Do", context: { tweet_id: "42", category: "ai" } }],
});
const ctx = result[0].actions![0].integration.context;
const token = ctx._token as string;
// Simulate Mattermost returning context with keys in a different order
const reordered: Record<string, unknown> = {};
const keys = Object.keys(ctx).filter((k) => k !== "_token");
// Reverse the key order to simulate reordering
for (const key of keys.reverse()) {
reordered[key] = ctx[key];
}
expect(verifyInteractionToken(reordered, token)).toBe(true);
});
});
// ── isLocalhostRequest ───────────────────────────────────────────────
describe("isLocalhostRequest", () => {
function fakeReq(remoteAddress?: string): IncomingMessage {
return {
socket: { remoteAddress },
} as unknown as IncomingMessage;
}
it("accepts 127.0.0.1", () => {
expect(isLocalhostRequest(fakeReq("127.0.0.1"))).toBe(true);
});
it("accepts ::1", () => {
expect(isLocalhostRequest(fakeReq("::1"))).toBe(true);
});
it("accepts ::ffff:127.0.0.1", () => {
expect(isLocalhostRequest(fakeReq("::ffff:127.0.0.1"))).toBe(true);
});
it("rejects external addresses", () => {
expect(isLocalhostRequest(fakeReq("10.0.0.1"))).toBe(false);
expect(isLocalhostRequest(fakeReq("192.168.1.1"))).toBe(false);
});
it("rejects when socket has no remote address", () => {
expect(isLocalhostRequest(fakeReq(undefined))).toBe(false);
});
it("rejects when socket is missing", () => {
expect(isLocalhostRequest({} as IncomingMessage)).toBe(false);
});
});

View File

@@ -0,0 +1,429 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { getMattermostRuntime } from "../runtime.js";
import { updateMattermostPost, type MattermostClient } from "./client.js";
const INTERACTION_MAX_BODY_BYTES = 64 * 1024;
const INTERACTION_BODY_TIMEOUT_MS = 10_000;
/**
* Mattermost interactive message callback payload.
* Sent by Mattermost when a user clicks an action button.
* See: https://developers.mattermost.com/integrate/plugins/interactive-messages/
*/
export type MattermostInteractionPayload = {
user_id: string;
user_name?: string;
channel_id: string;
team_id?: string;
post_id: string;
trigger_id?: string;
type?: string;
data_source?: string;
context?: Record<string, unknown>;
};
export type MattermostInteractionResponse = {
update?: {
message: string;
props?: Record<string, unknown>;
};
ephemeral_text?: string;
};
// ── Callback URL registry ──────────────────────────────────────────────
const callbackUrls = new Map<string, string>();
export function setInteractionCallbackUrl(accountId: string, url: string): void {
callbackUrls.set(accountId, url);
}
export function getInteractionCallbackUrl(accountId: string): string | undefined {
return callbackUrls.get(accountId);
}
/**
* Resolve the interaction callback URL for an account.
* Prefers the in-memory registered URL (set by the gateway monitor).
* Falls back to computing it from the gateway port in config (for CLI callers).
*/
export function resolveInteractionCallbackUrl(
accountId: string,
cfg?: { gateway?: { port?: number } },
): string {
const cached = callbackUrls.get(accountId);
if (cached) {
return cached;
}
const port = typeof cfg?.gateway?.port === "number" ? cfg.gateway.port : 18789;
return `http://localhost:${port}/mattermost/interactions/${accountId}`;
}
// ── HMAC token management ──────────────────────────────────────────────
// Secret is derived from the bot token so it's stable across CLI and gateway processes.
const interactionSecrets = new Map<string, string>();
let defaultInteractionSecret: string | undefined;
function deriveInteractionSecret(botToken: string): string {
return createHmac("sha256", "openclaw-mattermost-interactions").update(botToken).digest("hex");
}
export function setInteractionSecret(accountIdOrBotToken: string, botToken?: string): void {
if (typeof botToken === "string") {
interactionSecrets.set(accountIdOrBotToken, deriveInteractionSecret(botToken));
return;
}
// Backward-compatible fallback for call sites/tests that only pass botToken.
defaultInteractionSecret = deriveInteractionSecret(accountIdOrBotToken);
}
export function getInteractionSecret(accountId?: string): string {
const scoped = accountId ? interactionSecrets.get(accountId) : undefined;
if (scoped) {
return scoped;
}
if (defaultInteractionSecret) {
return defaultInteractionSecret;
}
// Fallback for single-account runtimes that only registered scoped secrets.
if (interactionSecrets.size === 1) {
const first = interactionSecrets.values().next().value;
if (typeof first === "string") {
return first;
}
}
throw new Error(
"Interaction secret not initialized — call setInteractionSecret(accountId, botToken) first",
);
}
export function generateInteractionToken(
context: Record<string, unknown>,
accountId?: string,
): string {
const secret = getInteractionSecret(accountId);
// Sort keys for stable serialization — Mattermost may reorder context keys
const payload = JSON.stringify(context, Object.keys(context).sort());
return createHmac("sha256", secret).update(payload).digest("hex");
}
export function verifyInteractionToken(
context: Record<string, unknown>,
token: string,
accountId?: string,
): boolean {
const expected = generateInteractionToken(context, accountId);
if (expected.length !== token.length) {
return false;
}
return timingSafeEqual(Buffer.from(expected), Buffer.from(token));
}
// ── Button builder helpers ─────────────────────────────────────────────
export type MattermostButton = {
id: string;
type: "button" | "select";
name: string;
style?: "default" | "primary" | "danger";
integration: {
url: string;
context: Record<string, unknown>;
};
};
export type MattermostAttachment = {
text?: string;
actions?: MattermostButton[];
[key: string]: unknown;
};
/**
* Build Mattermost `props.attachments` with interactive buttons.
*
* Each button includes an HMAC token in its integration context so the
* callback handler can verify the request originated from a legitimate
* button click (Mattermost's recommended security pattern).
*/
/**
* Sanitize a button ID so Mattermost's action router can match it.
* Mattermost uses the action ID in the URL path `/api/v4/posts/{id}/actions/{actionId}`
* and IDs containing hyphens or underscores break the server-side routing.
* See: https://github.com/mattermost/mattermost/issues/25747
*/
function sanitizeActionId(id: string): string {
return id.replace(/[-_]/g, "");
}
export function buildButtonAttachments(params: {
callbackUrl: string;
accountId?: string;
buttons: Array<{
id: string;
name: string;
style?: "default" | "primary" | "danger";
context?: Record<string, unknown>;
}>;
text?: string;
}): MattermostAttachment[] {
const actions: MattermostButton[] = params.buttons.map((btn) => {
const safeId = sanitizeActionId(btn.id);
const context: Record<string, unknown> = {
action_id: safeId,
...btn.context,
};
const token = generateInteractionToken(context, params.accountId);
return {
id: safeId,
type: "button" as const,
name: btn.name,
style: btn.style,
integration: {
url: params.callbackUrl,
context: {
...context,
_token: token,
},
},
};
});
return [
{
text: params.text ?? "",
actions,
},
];
}
// ── Localhost validation ───────────────────────────────────────────────
const LOCALHOST_ADDRESSES = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
export function isLocalhostRequest(req: IncomingMessage): boolean {
const addr = req.socket?.remoteAddress;
if (!addr) {
return false;
}
return LOCALHOST_ADDRESSES.has(addr);
}
// ── Request body reader ────────────────────────────────────────────────
function readInteractionBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let totalBytes = 0;
const timer = setTimeout(() => {
req.destroy();
reject(new Error("Request body read timeout"));
}, INTERACTION_BODY_TIMEOUT_MS);
req.on("data", (chunk: Buffer) => {
totalBytes += chunk.length;
if (totalBytes > INTERACTION_MAX_BODY_BYTES) {
req.destroy();
clearTimeout(timer);
reject(new Error("Request body too large"));
return;
}
chunks.push(chunk);
});
req.on("end", () => {
clearTimeout(timer);
resolve(Buffer.concat(chunks).toString("utf8"));
});
req.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
});
}
// ── HTTP handler ───────────────────────────────────────────────────────
export function createMattermostInteractionHandler(params: {
client: MattermostClient;
botUserId: string;
accountId: string;
callbackUrl: string;
resolveSessionKey?: (channelId: string, userId: string) => Promise<string>;
dispatchButtonClick?: (opts: {
channelId: string;
userId: string;
userName: string;
actionId: string;
actionName: string;
postId: string;
}) => Promise<void>;
log?: (message: string) => void;
}): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
const { client, accountId, log } = params;
const core = getMattermostRuntime();
return async (req: IncomingMessage, res: ServerResponse) => {
// Only accept POST
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "POST");
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Method Not Allowed" }));
return;
}
// Verify request is from localhost
if (!isLocalhostRequest(req)) {
log?.(
`mattermost interaction: rejected non-localhost request from ${req.socket?.remoteAddress}`,
);
res.statusCode = 403;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Forbidden" }));
return;
}
let payload: MattermostInteractionPayload;
try {
const raw = await readInteractionBody(req);
payload = JSON.parse(raw) as MattermostInteractionPayload;
} catch (err) {
log?.(`mattermost interaction: failed to parse body: ${String(err)}`);
res.statusCode = 400;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Invalid request body" }));
return;
}
const context = payload.context;
if (!context) {
res.statusCode = 400;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Missing context" }));
return;
}
// Verify HMAC token
const token = context._token;
if (typeof token !== "string") {
log?.("mattermost interaction: missing _token in context");
res.statusCode = 403;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Missing token" }));
return;
}
// Strip _token before verification (it wasn't in the original context)
const { _token, ...contextWithoutToken } = context;
if (!verifyInteractionToken(contextWithoutToken, token, accountId)) {
log?.("mattermost interaction: invalid _token");
res.statusCode = 403;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Invalid token" }));
return;
}
const actionId = context.action_id;
if (typeof actionId !== "string") {
res.statusCode = 400;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Missing action_id in context" }));
return;
}
log?.(
`mattermost interaction: action=${actionId} user=${payload.user_name ?? payload.user_id} ` +
`post=${payload.post_id} channel=${payload.channel_id}`,
);
// Dispatch as system event so the agent can handle it.
// Wrapped in try/catch — the post update below must still run even if
// system event dispatch fails (e.g. missing sessionKey or channel lookup).
try {
const eventLabel =
`Mattermost button click: action="${actionId}" ` +
`by ${payload.user_name ?? payload.user_id} ` +
`in channel ${payload.channel_id}`;
const sessionKey = params.resolveSessionKey
? await params.resolveSessionKey(payload.channel_id, payload.user_id)
: `agent:main:mattermost:${accountId}:${payload.channel_id}`;
core.system.enqueueSystemEvent(eventLabel, {
sessionKey,
contextKey: `mattermost:interaction:${payload.post_id}:${actionId}`,
});
} catch (err) {
log?.(`mattermost interaction: system event dispatch failed: ${String(err)}`);
}
// Fetch the original post to preserve its message and find the clicked button name.
const userName = payload.user_name ?? payload.user_id;
let originalMessage = "";
let clickedButtonName = actionId; // fallback to action ID if we can't find the name
try {
const originalPost = await client.request<{
message?: string;
props?: Record<string, unknown>;
}>(`/posts/${payload.post_id}`);
originalMessage = originalPost?.message ?? "";
// Find the clicked button's display name from the original attachments
const postAttachments = Array.isArray(originalPost?.props?.attachments)
? (originalPost.props.attachments as Array<{
actions?: Array<{ id?: string; name?: string }>;
}>)
: [];
for (const att of postAttachments) {
const match = att.actions?.find((a) => a.id === actionId);
if (match?.name) {
clickedButtonName = match.name;
break;
}
}
} catch (err) {
log?.(`mattermost interaction: failed to fetch post ${payload.post_id}: ${String(err)}`);
}
// Update the post via API to replace buttons with a completion indicator.
try {
await updateMattermostPost(client, payload.post_id, {
message: originalMessage,
props: {
attachments: [
{
text: `✓ **${clickedButtonName}** selected by @${userName}`,
},
],
},
});
} catch (err) {
log?.(`mattermost interaction: failed to update post ${payload.post_id}: ${String(err)}`);
}
// Respond with empty JSON — the post update is handled above
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end("{}");
// Dispatch a synthetic inbound message so the agent responds to the button click.
if (params.dispatchButtonClick) {
try {
await params.dispatchButtonClick({
channelId: payload.channel_id,
userId: payload.user_id,
userName,
actionId,
actionName: clickedButtonName,
postId: payload.post_id,
});
} catch (err) {
log?.(`mattermost interaction: dispatchButtonClick failed: ${String(err)}`);
}
}
};
}

View File

@@ -18,6 +18,7 @@ import {
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntryIfEnabled,
isDangerousNameMatchingEnabled,
registerPluginHttpRoute,
resolveControlCommandGate,
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
@@ -42,6 +43,11 @@ import {
type MattermostPost,
type MattermostUser,
} from "./client.js";
import {
createMattermostInteractionHandler,
setInteractionCallbackUrl,
setInteractionSecret,
} from "./interactions.js";
import { isMattermostSenderAllowed, normalizeMattermostAllowList } from "./monitor-auth.js";
import {
createDedupeCache,
@@ -318,12 +324,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
// a different port.
const envPortRaw = process.env.OPENCLAW_GATEWAY_PORT?.trim();
const envPort = envPortRaw ? Number.parseInt(envPortRaw, 10) : NaN;
const gatewayPort =
const slashGatewayPort =
Number.isFinite(envPort) && envPort > 0 ? envPort : (cfg.gateway?.port ?? 18789);
const callbackUrl = resolveCallbackUrl({
const slashCallbackUrl = resolveCallbackUrl({
config: slashConfig,
gatewayPort,
gatewayPort: slashGatewayPort,
gatewayHost: cfg.gateway?.customBindHost ?? undefined,
});
@@ -332,7 +338,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
try {
const mmHost = new URL(baseUrl).hostname;
const callbackHost = new URL(callbackUrl).hostname;
const callbackHost = new URL(slashCallbackUrl).hostname;
// NOTE: We cannot infer network reachability from hostnames alone.
// Mattermost might be accessed via a public domain while still running on the same
@@ -340,7 +346,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
// So treat loopback callback URLs as an advisory warning only.
if (isLoopbackHost(callbackHost) && !isLoopbackHost(mmHost)) {
runtime.error?.(
`mattermost: slash commands callbackUrl resolved to ${callbackUrl} (loopback) while baseUrl is ${baseUrl}. This MAY be unreachable depending on your deployment. If native slash commands don't work, set channels.mattermost.commands.callbackUrl to a URL reachable from the Mattermost server (e.g. your public reverse proxy URL).`,
`mattermost: slash commands callbackUrl resolved to ${slashCallbackUrl} (loopback) while baseUrl is ${baseUrl}. This MAY be unreachable depending on your deployment. If native slash commands don't work, set channels.mattermost.commands.callbackUrl to a URL reachable from the Mattermost server (e.g. your public reverse proxy URL).`,
);
}
} catch {
@@ -390,7 +396,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
client,
teamId: team.id,
creatorUserId: botUserId,
callbackUrl,
callbackUrl: slashCallbackUrl,
commands: dedupedCommands,
log: (msg) => runtime.log?.(msg),
});
@@ -432,7 +438,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
});
runtime.log?.(
`mattermost: slash commands registered (${allRegistered.length} commands across ${teams.length} teams, callback=${callbackUrl})`,
`mattermost: slash commands registered (${allRegistered.length} commands across ${teams.length} teams, callback=${slashCallbackUrl})`,
);
}
} catch (err) {
@@ -440,6 +446,182 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
}
}
// ─── Interactive buttons registration ──────────────────────────────────────
// Derive a stable HMAC secret from the bot token so CLI and gateway share it.
setInteractionSecret(account.accountId, botToken);
// Register HTTP callback endpoint for interactive button clicks.
// Mattermost POSTs to this URL when a user clicks a button action.
const gatewayPort = typeof cfg.gateway?.port === "number" ? cfg.gateway.port : 18789;
const interactionPath = `/mattermost/interactions/${account.accountId}`;
const callbackUrl = `http://localhost:${gatewayPort}${interactionPath}`;
setInteractionCallbackUrl(account.accountId, callbackUrl);
const unregisterInteractions = registerPluginHttpRoute({
path: interactionPath,
fallbackPath: "/mattermost/interactions/default",
auth: "plugin",
handler: createMattermostInteractionHandler({
client,
botUserId,
accountId: account.accountId,
callbackUrl,
resolveSessionKey: async (channelId: string, userId: string) => {
const channelInfo = await resolveChannelInfo(channelId);
const kind = mapMattermostChannelTypeToChatType(channelInfo?.type);
const teamId = channelInfo?.team_id ?? undefined;
const route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "mattermost",
accountId: account.accountId,
teamId,
peer: {
kind,
id: kind === "direct" ? userId : channelId,
},
});
return route.sessionKey;
},
dispatchButtonClick: async (opts) => {
const channelInfo = await resolveChannelInfo(opts.channelId);
const kind = mapMattermostChannelTypeToChatType(channelInfo?.type);
const chatType = channelChatType(kind);
const teamId = channelInfo?.team_id ?? undefined;
const channelName = channelInfo?.name ?? undefined;
const channelDisplay = channelInfo?.display_name ?? channelName ?? opts.channelId;
const route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "mattermost",
accountId: account.accountId,
teamId,
peer: {
kind,
id: kind === "direct" ? opts.userId : opts.channelId,
},
});
const to = kind === "direct" ? `user:${opts.userId}` : `channel:${opts.channelId}`;
const bodyText = `[Button click: user @${opts.userName} selected "${opts.actionName}"]`;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: bodyText,
BodyForAgent: bodyText,
RawBody: bodyText,
CommandBody: bodyText,
From:
kind === "direct"
? `mattermost:${opts.userId}`
: kind === "group"
? `mattermost:group:${opts.channelId}`
: `mattermost:channel:${opts.channelId}`,
To: to,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: chatType,
ConversationLabel: `mattermost:${opts.userName}`,
GroupSubject: kind !== "direct" ? channelDisplay : undefined,
GroupChannel: channelName ? `#${channelName}` : undefined,
GroupSpace: teamId,
SenderName: opts.userName,
SenderId: opts.userId,
Provider: "mattermost" as const,
Surface: "mattermost" as const,
MessageSid: `interaction:${opts.postId}:${opts.actionId}`,
WasMentioned: true,
CommandAuthorized: true,
OriginatingChannel: "mattermost" as const,
OriginatingTo: to,
});
const textLimit = core.channel.text.resolveTextChunkLimit(
cfg,
"mattermost",
account.accountId,
{ fallbackLimit: account.textChunkLimit ?? 4000 },
);
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "mattermost",
accountId: account.accountId,
});
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg,
agentId: route.agentId,
channel: "mattermost",
accountId: account.accountId,
});
const typingCallbacks = createTypingCallbacks({
start: () => sendTypingIndicator(opts.channelId),
onStartError: (err) => {
logTypingFailure({
log: (message) => logger.debug?.(message),
channel: "mattermost",
target: opts.channelId,
error: err,
});
},
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
...prefixOptions,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => {
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
if (mediaUrls.length === 0) {
const chunkMode = core.channel.text.resolveChunkMode(
cfg,
"mattermost",
account.accountId,
);
const chunks = core.channel.text.chunkMarkdownTextWithMode(
text,
textLimit,
chunkMode,
);
for (const chunk of chunks.length > 0 ? chunks : [text]) {
if (!chunk) continue;
await sendMessageMattermost(to, chunk, {
accountId: account.accountId,
});
}
} else {
let first = true;
for (const mediaUrl of mediaUrls) {
const caption = first ? text : "";
first = false;
await sendMessageMattermost(to, caption, {
accountId: account.accountId,
mediaUrl,
});
}
}
runtime.log?.(`delivered button-click reply to ${to}`);
},
onError: (err, info) => {
runtime.error?.(`mattermost button-click ${info.kind} reply failed: ${String(err)}`);
},
onReplyStart: typingCallbacks.onReplyStart,
});
await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
disableBlockStreaming:
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
onModelSelected,
},
});
markDispatchIdle();
},
log: (msg) => runtime.log?.(msg),
}),
pluginId: "mattermost",
source: "mattermost-interactions",
accountId: account.accountId,
log: (msg: string) => runtime.log?.(msg),
});
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
const logger = core.logging.getChildLogger({ module: "mattermost" });
@@ -493,6 +675,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
},
filePathHint: fileId,
maxBytes: mediaMaxBytes,
// Allow fetching from the Mattermost server host (may be localhost or
// a private IP). Without this, SSRF guards block media downloads.
// Credit: #22594 (@webclerk)
ssrfPolicy: { allowedHostnames: [new URL(client.baseUrl).hostname] },
});
const saved = await core.channel.media.saveMediaBuffer(
fetched.buffer,
@@ -1296,17 +1482,21 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
}
}
await runWithReconnect(connectOnce, {
abortSignal: opts.abortSignal,
jitterRatio: 0.2,
onError: (err) => {
runtime.error?.(`mattermost connection failed: ${String(err)}`);
opts.statusSink?.({ lastError: String(err), connected: false });
},
onReconnect: (delayMs) => {
runtime.log?.(`mattermost reconnecting in ${Math.round(delayMs / 1000)}s`);
},
});
try {
await runWithReconnect(connectOnce, {
abortSignal: opts.abortSignal,
jitterRatio: 0.2,
onError: (err) => {
runtime.error?.(`mattermost connection failed: ${String(err)}`);
opts.statusSink?.({ lastError: String(err), connected: false });
},
onReconnect: (delayMs) => {
runtime.log?.(`mattermost reconnecting in ${Math.round(delayMs / 1000)}s`);
},
});
} finally {
unregisterInteractions?.();
}
if (slashShutdownCleanup) {
await slashShutdownCleanup;

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { sendMessageMattermost } from "./send.js";
import { parseMattermostTarget, sendMessageMattermost } from "./send.js";
const mockState = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})),
@@ -12,7 +12,9 @@ const mockState = vi.hoisted(() => ({
createMattermostClient: vi.fn(),
createMattermostDirectChannel: vi.fn(),
createMattermostPost: vi.fn(),
fetchMattermostChannelByName: vi.fn(),
fetchMattermostMe: vi.fn(),
fetchMattermostUserTeams: vi.fn(),
fetchMattermostUserByUsername: vi.fn(),
normalizeMattermostBaseUrl: vi.fn((input: string | undefined) => input?.trim() ?? ""),
uploadMattermostFile: vi.fn(),
@@ -30,7 +32,9 @@ vi.mock("./client.js", () => ({
createMattermostClient: mockState.createMattermostClient,
createMattermostDirectChannel: mockState.createMattermostDirectChannel,
createMattermostPost: mockState.createMattermostPost,
fetchMattermostChannelByName: mockState.fetchMattermostChannelByName,
fetchMattermostMe: mockState.fetchMattermostMe,
fetchMattermostUserTeams: mockState.fetchMattermostUserTeams,
fetchMattermostUserByUsername: mockState.fetchMattermostUserByUsername,
normalizeMattermostBaseUrl: mockState.normalizeMattermostBaseUrl,
uploadMattermostFile: mockState.uploadMattermostFile,
@@ -71,11 +75,16 @@ describe("sendMessageMattermost", () => {
mockState.createMattermostClient.mockReset();
mockState.createMattermostDirectChannel.mockReset();
mockState.createMattermostPost.mockReset();
mockState.fetchMattermostChannelByName.mockReset();
mockState.fetchMattermostMe.mockReset();
mockState.fetchMattermostUserTeams.mockReset();
mockState.fetchMattermostUserByUsername.mockReset();
mockState.uploadMattermostFile.mockReset();
mockState.createMattermostClient.mockReturnValue({});
mockState.createMattermostPost.mockResolvedValue({ id: "post-1" });
mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-user" });
mockState.fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }]);
mockState.fetchMattermostChannelByName.mockResolvedValue({ id: "town-square" });
mockState.uploadMattermostFile.mockResolvedValue({ id: "file-1" });
});
@@ -148,3 +157,86 @@ describe("sendMessageMattermost", () => {
);
});
});
describe("parseMattermostTarget", () => {
it("parses channel: prefix with valid ID as channel id", () => {
const target = parseMattermostTarget("channel:dthcxgoxhifn3pwh65cut3ud3w");
expect(target).toEqual({ kind: "channel", id: "dthcxgoxhifn3pwh65cut3ud3w" });
});
it("parses channel: prefix with non-ID as channel name", () => {
const target = parseMattermostTarget("channel:abc123");
expect(target).toEqual({ kind: "channel-name", name: "abc123" });
});
it("parses user: prefix as user id", () => {
const target = parseMattermostTarget("user:usr456");
expect(target).toEqual({ kind: "user", id: "usr456" });
});
it("parses mattermost: prefix as user id", () => {
const target = parseMattermostTarget("mattermost:usr789");
expect(target).toEqual({ kind: "user", id: "usr789" });
});
it("parses @ prefix as username", () => {
const target = parseMattermostTarget("@alice");
expect(target).toEqual({ kind: "user", username: "alice" });
});
it("parses # prefix as channel name", () => {
const target = parseMattermostTarget("#off-topic");
expect(target).toEqual({ kind: "channel-name", name: "off-topic" });
});
it("parses # prefix with spaces", () => {
const target = parseMattermostTarget(" #general ");
expect(target).toEqual({ kind: "channel-name", name: "general" });
});
it("treats 26-char alphanumeric bare string as channel id", () => {
const target = parseMattermostTarget("dthcxgoxhifn3pwh65cut3ud3w");
expect(target).toEqual({ kind: "channel", id: "dthcxgoxhifn3pwh65cut3ud3w" });
});
it("treats non-ID bare string as channel name", () => {
const target = parseMattermostTarget("off-topic");
expect(target).toEqual({ kind: "channel-name", name: "off-topic" });
});
it("treats channel: with non-ID value as channel name", () => {
const target = parseMattermostTarget("channel:off-topic");
expect(target).toEqual({ kind: "channel-name", name: "off-topic" });
});
it("throws on empty string", () => {
expect(() => parseMattermostTarget("")).toThrow("Recipient is required");
});
it("throws on empty # prefix", () => {
expect(() => parseMattermostTarget("#")).toThrow("Channel name is required");
});
it("throws on empty @ prefix", () => {
expect(() => parseMattermostTarget("@")).toThrow("Username is required");
});
it("parses channel:#name as channel name", () => {
const target = parseMattermostTarget("channel:#off-topic");
expect(target).toEqual({ kind: "channel-name", name: "off-topic" });
});
it("parses channel:#name with spaces", () => {
const target = parseMattermostTarget(" channel: #general ");
expect(target).toEqual({ kind: "channel-name", name: "general" });
});
it("is case-insensitive for prefixes", () => {
expect(parseMattermostTarget("CHANNEL:dthcxgoxhifn3pwh65cut3ud3w")).toEqual({
kind: "channel",
id: "dthcxgoxhifn3pwh65cut3ud3w",
});
expect(parseMattermostTarget("User:XYZ")).toEqual({ kind: "user", id: "XYZ" });
expect(parseMattermostTarget("Mattermost:QRS")).toEqual({ kind: "user", id: "QRS" });
});
});

View File

@@ -5,8 +5,10 @@ import {
createMattermostClient,
createMattermostDirectChannel,
createMattermostPost,
fetchMattermostChannelByName,
fetchMattermostMe,
fetchMattermostUserByUsername,
fetchMattermostUserTeams,
normalizeMattermostBaseUrl,
uploadMattermostFile,
type MattermostUser,
@@ -20,6 +22,7 @@ export type MattermostSendOpts = {
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
replyToId?: string;
props?: Record<string, unknown>;
};
export type MattermostSendResult = {
@@ -29,10 +32,12 @@ export type MattermostSendResult = {
type MattermostTarget =
| { kind: "channel"; id: string }
| { kind: "channel-name"; name: string }
| { kind: "user"; id?: string; username?: string };
const botUserCache = new Map<string, MattermostUser>();
const userByNameCache = new Map<string, MattermostUser>();
const channelByNameCache = new Map<string, string>();
const getCore = () => getMattermostRuntime();
@@ -50,7 +55,12 @@ function isHttpUrl(value: string): boolean {
return /^https?:\/\//i.test(value);
}
function parseMattermostTarget(raw: string): MattermostTarget {
/** Mattermost IDs are 26-character lowercase alphanumeric strings. */
function isMattermostId(value: string): boolean {
return /^[a-z0-9]{26}$/.test(value);
}
export function parseMattermostTarget(raw: string): MattermostTarget {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error("Recipient is required for Mattermost sends");
@@ -61,6 +71,16 @@ function parseMattermostTarget(raw: string): MattermostTarget {
if (!id) {
throw new Error("Channel id is required for Mattermost sends");
}
if (id.startsWith("#")) {
const name = id.slice(1).trim();
if (!name) {
throw new Error("Channel name is required for Mattermost sends");
}
return { kind: "channel-name", name };
}
if (!isMattermostId(id)) {
return { kind: "channel-name", name: id };
}
return { kind: "channel", id };
}
if (lower.startsWith("user:")) {
@@ -84,6 +104,16 @@ function parseMattermostTarget(raw: string): MattermostTarget {
}
return { kind: "user", username };
}
if (trimmed.startsWith("#")) {
const name = trimmed.slice(1).trim();
if (!name) {
throw new Error("Channel name is required for Mattermost sends");
}
return { kind: "channel-name", name };
}
if (!isMattermostId(trimmed)) {
return { kind: "channel-name", name: trimmed };
}
return { kind: "channel", id: trimmed };
}
@@ -116,6 +146,34 @@ async function resolveUserIdByUsername(params: {
return user.id;
}
async function resolveChannelIdByName(params: {
baseUrl: string;
token: string;
name: string;
}): Promise<string> {
const { baseUrl, token, name } = params;
const key = `${cacheKey(baseUrl, token)}::channel::${name.toLowerCase()}`;
const cached = channelByNameCache.get(key);
if (cached) {
return cached;
}
const client = createMattermostClient({ baseUrl, botToken: token });
const me = await fetchMattermostMe(client);
const teams = await fetchMattermostUserTeams(client, me.id);
for (const team of teams) {
try {
const channel = await fetchMattermostChannelByName(client, team.id, name);
if (channel?.id) {
channelByNameCache.set(key, channel.id);
return channel.id;
}
} catch {
// Channel not found in this team, try next
}
}
throw new Error(`Mattermost channel "#${name}" not found in any team the bot belongs to`);
}
async function resolveTargetChannelId(params: {
target: MattermostTarget;
baseUrl: string;
@@ -124,6 +182,13 @@ async function resolveTargetChannelId(params: {
if (params.target.kind === "channel") {
return params.target.id;
}
if (params.target.kind === "channel-name") {
return await resolveChannelIdByName({
baseUrl: params.baseUrl,
token: params.token,
name: params.target.name,
});
}
const userId = params.target.id
? params.target.id
: await resolveUserIdByUsername({
@@ -221,6 +286,7 @@ export async function sendMessageMattermost(
message,
rootId: opts.replyToId,
fileIds,
props: opts.props,
});
core.channel.activity.record({

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
describe("normalizeMattermostMessagingTarget", () => {
it("returns undefined for empty input", () => {
expect(normalizeMattermostMessagingTarget("")).toBeUndefined();
expect(normalizeMattermostMessagingTarget(" ")).toBeUndefined();
});
it("normalizes channel: prefix", () => {
expect(normalizeMattermostMessagingTarget("channel:abc123")).toBe("channel:abc123");
expect(normalizeMattermostMessagingTarget("Channel:ABC")).toBe("channel:ABC");
});
it("normalizes group: prefix to channel:", () => {
expect(normalizeMattermostMessagingTarget("group:abc123")).toBe("channel:abc123");
});
it("normalizes user: prefix", () => {
expect(normalizeMattermostMessagingTarget("user:abc123")).toBe("user:abc123");
});
it("normalizes mattermost: prefix to user:", () => {
expect(normalizeMattermostMessagingTarget("mattermost:abc123")).toBe("user:abc123");
});
it("keeps @username targets", () => {
expect(normalizeMattermostMessagingTarget("@alice")).toBe("@alice");
expect(normalizeMattermostMessagingTarget("@Alice")).toBe("@Alice");
});
it("returns undefined for #channel (triggers directory lookup)", () => {
expect(normalizeMattermostMessagingTarget("#bookmarks")).toBeUndefined();
expect(normalizeMattermostMessagingTarget("#off-topic")).toBeUndefined();
expect(normalizeMattermostMessagingTarget("# ")).toBeUndefined();
});
it("returns undefined for bare names (triggers directory lookup)", () => {
expect(normalizeMattermostMessagingTarget("bookmarks")).toBeUndefined();
expect(normalizeMattermostMessagingTarget("off-topic")).toBeUndefined();
});
it("returns undefined for empty prefixed values", () => {
expect(normalizeMattermostMessagingTarget("channel:")).toBeUndefined();
expect(normalizeMattermostMessagingTarget("user:")).toBeUndefined();
expect(normalizeMattermostMessagingTarget("@")).toBeUndefined();
expect(normalizeMattermostMessagingTarget("#")).toBeUndefined();
});
});
describe("looksLikeMattermostTargetId", () => {
it("returns false for empty input", () => {
expect(looksLikeMattermostTargetId("")).toBe(false);
expect(looksLikeMattermostTargetId(" ")).toBe(false);
});
it("recognizes prefixed targets", () => {
expect(looksLikeMattermostTargetId("channel:abc")).toBe(true);
expect(looksLikeMattermostTargetId("Channel:abc")).toBe(true);
expect(looksLikeMattermostTargetId("user:abc")).toBe(true);
expect(looksLikeMattermostTargetId("group:abc")).toBe(true);
expect(looksLikeMattermostTargetId("mattermost:abc")).toBe(true);
});
it("recognizes @username", () => {
expect(looksLikeMattermostTargetId("@alice")).toBe(true);
});
it("does NOT recognize #channel (should go to directory)", () => {
expect(looksLikeMattermostTargetId("#bookmarks")).toBe(false);
expect(looksLikeMattermostTargetId("#off-topic")).toBe(false);
});
it("recognizes 26-char alphanumeric Mattermost IDs", () => {
expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz")).toBe(true);
expect(looksLikeMattermostTargetId("12345678901234567890123456")).toBe(true);
expect(looksLikeMattermostTargetId("AbCdEf1234567890abcdef1234")).toBe(true);
});
it("recognizes DM channel format (26__26)", () => {
expect(
looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz__12345678901234567890123456"),
).toBe(true);
});
it("rejects short strings that are not Mattermost IDs", () => {
expect(looksLikeMattermostTargetId("password")).toBe(false);
expect(looksLikeMattermostTargetId("hi")).toBe(false);
expect(looksLikeMattermostTargetId("bookmarks")).toBe(false);
expect(looksLikeMattermostTargetId("off-topic")).toBe(false);
});
it("rejects strings longer than 26 chars that are not DM format", () => {
expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz1")).toBe(false);
});
});

View File

@@ -25,13 +25,16 @@ export function normalizeMattermostMessagingTarget(raw: string): string | undefi
return id ? `@${id}` : undefined;
}
if (trimmed.startsWith("#")) {
const id = trimmed.slice(1).trim();
return id ? `channel:${id}` : undefined;
// Strip # prefix and fall through to directory lookup (same as bare names).
// The core's resolveMessagingTarget will use the directory adapter to
// resolve the channel name to its Mattermost ID.
return undefined;
}
return `channel:${trimmed}`;
// Bare name without prefix — return undefined to allow directory lookup
return undefined;
}
export function looksLikeMattermostTargetId(raw: string): boolean {
export function looksLikeMattermostTargetId(raw: string, normalized?: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
@@ -39,8 +42,9 @@ export function looksLikeMattermostTargetId(raw: string): boolean {
if (/^(user|channel|group|mattermost):/i.test(trimmed)) {
return true;
}
if (/^[@#]/.test(trimmed)) {
if (trimmed.startsWith("@")) {
return true;
}
return /^[a-z0-9]{8,}$/i.test(trimmed);
// Mattermost IDs: 26-char alnum, or DM channels like "abc123__xyz789" (53 chars)
return /^[a-z0-9]{26}$/i.test(trimmed) || /^[a-z0-9]{26}__[a-z0-9]{26}$/i.test(trimmed);
}

View File

@@ -70,6 +70,10 @@ export type MattermostAccountConfig = {
/** Explicit callback URL (e.g. behind reverse proxy). */
callbackUrl?: string;
};
interactions?: {
/** External base URL used for Mattermost interaction callbacks. */
callbackBaseUrl?: string;
};
};
export type MattermostConfig = {