mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -50,6 +50,11 @@ const MattermostAccountSchemaBase = z
|
||||
})
|
||||
.optional(),
|
||||
commands: MattermostSlashCommandsSchema,
|
||||
interactions: z
|
||||
.object({
|
||||
callbackBaseUrl: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
172
extensions/mattermost/src/mattermost/directory.ts
Normal file
172
extensions/mattermost/src/mattermost/directory.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
335
extensions/mattermost/src/mattermost/interactions.test.ts
Normal file
335
extensions/mattermost/src/mattermost/interactions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
429
extensions/mattermost/src/mattermost/interactions.ts
Normal file
429
extensions/mattermost/src/mattermost/interactions.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
96
extensions/mattermost/src/normalize.test.ts
Normal file
96
extensions/mattermost/src/normalize.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user