diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index 1799c538f5..016805d0ba 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { createReplyPrefixOptions } from "openclaw/plugin-sdk"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { mattermostPlugin } from "./channel.js"; describe("mattermostPlugin", () => { @@ -37,6 +37,108 @@ describe("mattermostPlugin", () => { }); }); + describe("messageActions", () => { + it("exposes react when mattermost is configured", () => { + const cfg: OpenClawConfig = { + channels: { + mattermost: { + enabled: true, + botToken: "test-token", + baseUrl: "https://chat.example.com", + }, + }, + }; + + const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? []; + expect(actions).toContain("react"); + expect(actions).not.toContain("send"); + expect(mattermostPlugin.actions?.supportsAction?.({ action: "react" })).toBe(true); + }); + + it("hides react when mattermost is not configured", () => { + const cfg: OpenClawConfig = { + channels: { + mattermost: { + enabled: true, + }, + }, + }; + + const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? []; + expect(actions).toEqual([]); + }); + + it("hides react when actions.reactions is false", () => { + const cfg: OpenClawConfig = { + channels: { + mattermost: { + enabled: true, + botToken: "test-token", + baseUrl: "https://chat.example.com", + actions: { reactions: false }, + }, + }, + }; + + const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? []; + expect(actions).not.toContain("react"); + expect(actions).not.toContain("send"); + }); + + it("handles react by calling Mattermost reactions API", async () => { + const cfg: OpenClawConfig = { + channels: { + mattermost: { + enabled: true, + botToken: "test-token", + baseUrl: "https://chat.example.com", + }, + }, + }; + + const fetchImpl = vi.fn(async (url: any, init?: any) => { + if (String(url).endsWith("/api/v4/users/me")) { + return new Response(JSON.stringify({ id: "BOT123" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (String(url).endsWith("/api/v4/reactions")) { + expect(init?.method).toBe("POST"); + expect(JSON.parse(init?.body)).toEqual({ + user_id: "BOT123", + post_id: "POST1", + emoji_name: "thumbsup", + }); + return new Response(JSON.stringify({ ok: true }), { + status: 201, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`unexpected url: ${url}`); + }); + + const prevFetch = globalThis.fetch; + (globalThis as any).fetch = fetchImpl; + try { + const result = await mattermostPlugin.actions?.handleAction?.({ + channel: "mattermost", + action: "react", + params: { messageId: "POST1", emoji: "thumbsup" }, + cfg, + accountId: "default", + } as any); + + expect(result?.content).toEqual([ + { type: "text", text: "Reacted with :thumbsup: on POST1" }, + ]); + expect(result?.details).toEqual({}); + } finally { + (globalThis as any).fetch = prevFetch; + } + }); + }); + describe("config", () => { it("formats allowFrom entries", () => { const formatAllowFrom = mattermostPlugin.config.formatAllowFrom; diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index a658dbb04e..144c963fe9 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -7,6 +7,8 @@ import { migrateBaseNameToDefaultAccount, normalizeAccountId, setAccountEnabledInConfigSection, + type ChannelMessageActionAdapter, + type ChannelMessageActionName, type ChannelPlugin, } from "openclaw/plugin-sdk"; import { MattermostConfigSchema } from "./config-schema.js"; @@ -20,11 +22,102 @@ import { import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; import { monitorMattermostProvider } from "./mattermost/monitor.js"; import { probeMattermost } from "./mattermost/probe.js"; +import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js"; import { sendMessageMattermost } from "./mattermost/send.js"; import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js"; import { mattermostOnboardingAdapter } from "./onboarding.js"; import { getMattermostRuntime } from "./runtime.js"; +const mattermostMessageActions: ChannelMessageActionAdapter = { + listActions: ({ cfg }) => { + const accounts = listMattermostAccountIds(cfg) + .map((accountId) => resolveMattermostAccount({ cfg, accountId })) + .filter((account) => account.enabled) + .filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim())); + + if (accounts.length === 0) { + return []; + } + + const actions: ChannelMessageActionName[] = []; + const actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined; + const reactionsEnabled = actionsConfig?.reactions !== false; + if (reactionsEnabled) { + actions.push("react"); + } + return actions; + }, + supportsAction: ({ action }) => { + return action === "react"; + }, + 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 | undefined; + const accounts = mmBase?.accounts as Record> | undefined; + const acctConfig = accountId && accounts ? accounts[accountId] : undefined; + 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 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 = Boolean((params as any)?.remove); + if (remove) { + const result = await removeMattermostReaction({ + cfg, + postId, + emojiName, + accountId: accountId ?? undefined, + }); + 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, + accountId: accountId ?? undefined, + }); + if (!result.ok) { + throw new Error(result.error); + } + + return { + content: [{ type: "text" as const, text: `Reacted with :${emojiName}: on ${postId}` }], + details: {}, + }; + }, +}; + const meta = { id: "mattermost", label: "Mattermost", @@ -146,6 +239,7 @@ export const mattermostPlugin: ChannelPlugin = { groups: { resolveRequireMention: resolveMattermostGroupRequireMention, }, + actions: mattermostMessageActions, messaging: { normalizeTarget: normalizeMattermostMessagingTarget, targetResolver: { diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index 4d0fcecdc0..7628613a16 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -28,6 +28,11 @@ const MattermostAccountSchemaBase = z blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), responsePrefix: z.string().optional(), + actions: z + .object({ + reactions: z.boolean().optional(), + }) + .optional(), }) .strict(); diff --git a/extensions/mattermost/src/mattermost/client.test.ts b/extensions/mattermost/src/mattermost/client.test.ts new file mode 100644 index 0000000000..2bdb1747ee --- /dev/null +++ b/extensions/mattermost/src/mattermost/client.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMattermostClient } from "./client.js"; + +describe("mattermost client", () => { + it("request 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("/anything", { method: "DELETE" }); + expect(result).toBeUndefined(); + }); +}); diff --git a/extensions/mattermost/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts index a3e1518341..f0a0fd26ad 100644 --- a/extensions/mattermost/src/mattermost/client.ts +++ b/extensions/mattermost/src/mattermost/client.ts @@ -97,7 +97,17 @@ export function createMattermostClient(params: { `Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`, ); } - return (await res.json()) as T; + + if (res.status === 204) { + return undefined as T; + } + + const contentType = res.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + return (await res.json()) as T; + } + + return (await res.text()) as T; }; return { baseUrl, apiBaseUrl, token, request }; diff --git a/extensions/mattermost/src/mattermost/monitor-websocket.ts b/extensions/mattermost/src/mattermost/monitor-websocket.ts index 72fae6be87..1595aa5162 100644 --- a/extensions/mattermost/src/mattermost/monitor-websocket.ts +++ b/extensions/mattermost/src/mattermost/monitor-websocket.ts @@ -7,6 +7,7 @@ export type MattermostEventPayload = { event?: string; data?: { post?: string; + reaction?: string; channel_id?: string; channel_name?: string; channel_display_name?: string; @@ -51,6 +52,7 @@ type CreateMattermostConnectOnceOpts = { runtime: RuntimeEnv; nextSeq: () => number; onPosted: (post: MattermostPost, payload: MattermostEventPayload) => Promise; + onReaction?: (payload: MattermostEventPayload) => Promise; webSocketFactory?: MattermostWebSocketFactory; }; @@ -135,6 +137,29 @@ export function createMattermostConnectOnce( }); ws.on("message", async (data) => { + const raw = rawDataToString(data); + let payload: MattermostEventPayload; + try { + payload = JSON.parse(raw) as MattermostEventPayload; + } catch { + return; + } + + if (payload.event === "reaction_added" || payload.event === "reaction_removed") { + if (!opts.onReaction) { + return; + } + try { + await opts.onReaction(payload); + } catch (err) { + opts.runtime.error?.(`mattermost reaction handler failed: ${String(err)}`); + } + return; + } + + if (payload.event !== "posted") { + return; + } const parsed = parsePostedEvent(data); if (!parsed) { return; diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index db31051356..5cee9fb47e 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -60,6 +60,12 @@ export type MonitorMattermostOpts = { type FetchLike = (input: URL | RequestInfo, init?: RequestInit) => Promise; type MediaKind = "image" | "audio" | "video" | "document" | "unknown"; +type MattermostReaction = { + user_id?: string; + post_id?: string; + emoji_name?: string; + create_at?: number; +}; const RECENT_MATTERMOST_MESSAGE_TTL_MS = 5 * 60_000; const RECENT_MATTERMOST_MESSAGE_MAX = 2000; const CHANNEL_CACHE_TTL_MS = 5 * 60_000; @@ -796,6 +802,145 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } }; + const handleReactionEvent = async (payload: MattermostEventPayload) => { + const reactionData = payload.data?.reaction; + if (!reactionData) { + return; + } + let reaction: MattermostReaction | null = null; + if (typeof reactionData === "string") { + try { + reaction = JSON.parse(reactionData) as MattermostReaction; + } catch { + return; + } + } else if (typeof reactionData === "object") { + reaction = reactionData as MattermostReaction; + } + if (!reaction) { + return; + } + + const userId = reaction.user_id?.trim(); + const postId = reaction.post_id?.trim(); + const emojiName = reaction.emoji_name?.trim(); + if (!userId || !postId || !emojiName) { + return; + } + + // Skip reactions from the bot itself + if (userId === botUserId) { + return; + } + + const isRemoved = payload.event === "reaction_removed"; + const action = isRemoved ? "removed" : "added"; + + const senderInfo = await resolveUserInfo(userId); + const senderName = senderInfo?.username?.trim() || userId; + + // Resolve the channel from broadcast or post to route to the correct agent session + const channelId = payload.broadcast?.channel_id; + if (!channelId) { + // Without a channel id we cannot verify DM/group policies — drop to be safe + logVerboseMessage( + `mattermost: drop reaction (no channel_id in broadcast, cannot enforce policy)`, + ); + return; + } + const channelInfo = await resolveChannelInfo(channelId); + if (!channelInfo?.type) { + // Cannot determine channel type — drop to avoid policy bypass + logVerboseMessage(`mattermost: drop reaction (cannot resolve channel type for ${channelId})`); + return; + } + const kind = channelKind(channelInfo.type); + + // Enforce DM/group policy and allowlist checks (same as normal messages) + if (kind === "direct") { + const dmPolicy = account.config.dmPolicy ?? "pairing"; + if (dmPolicy === "disabled") { + logVerboseMessage(`mattermost: drop reaction (dmPolicy=disabled sender=${userId})`); + return; + } + // For pairing/allowlist modes, only allow reactions from approved senders + if (dmPolicy !== "open") { + const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []); + const storeAllowFrom = normalizeAllowList( + await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []), + ); + const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom])); + const allowed = isSenderAllowed({ + senderId: userId, + senderName, + allowFrom: effectiveAllowFrom, + }); + if (!allowed) { + logVerboseMessage( + `mattermost: drop reaction (dmPolicy=${dmPolicy} sender=${userId} not allowed)`, + ); + return; + } + } + } else if (kind) { + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy === "disabled") { + logVerboseMessage(`mattermost: drop reaction (groupPolicy=disabled channel=${channelId})`); + return; + } + if (groupPolicy === "allowlist") { + const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []); + const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []); + const storeAllowFrom = normalizeAllowList( + await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []), + ); + const effectiveGroupAllowFrom = Array.from( + new Set([ + ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom), + ...storeAllowFrom, + ]), + ); + // Drop when allowlist is empty (same as normal message handler) + const allowed = + effectiveGroupAllowFrom.length > 0 && + isSenderAllowed({ + senderId: userId, + senderName, + allowFrom: effectiveGroupAllowFrom, + }); + if (!allowed) { + logVerboseMessage(`mattermost: drop reaction (groupPolicy=allowlist sender=${userId})`); + return; + } + } + } + + 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, + }, + }); + const sessionKey = route.sessionKey; + + const eventText = `Mattermost reaction ${action}: :${emojiName}: by @${senderName} on post ${postId} in channel ${channelId}`; + + core.system.enqueueSystemEvent(eventText, { + sessionKey, + contextKey: `mattermost:reaction:${postId}:${emojiName}:${userId}:${action}`, + }); + + logVerboseMessage( + `mattermost reaction: ${action} :${emojiName}: by ${senderName} on ${postId}`, + ); + }; + const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({ cfg, channel: "mattermost", @@ -866,6 +1011,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} onPosted: async (post, payload) => { await debouncer.enqueue({ post, payload }); }, + onReaction: async (payload) => { + await handleReactionEvent(payload); + }, }); await runWithReconnect(connectOnce, { diff --git a/extensions/mattermost/src/mattermost/reactions.test.ts b/extensions/mattermost/src/mattermost/reactions.test.ts new file mode 100644 index 0000000000..dd8fe72657 --- /dev/null +++ b/extensions/mattermost/src/mattermost/reactions.test.ts @@ -0,0 +1,80 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; +import { addMattermostReaction, removeMattermostReaction } from "./reactions.js"; + +function createCfg(): OpenClawConfig { + return { + channels: { + mattermost: { + enabled: true, + botToken: "test-token", + baseUrl: "https://chat.example.com", + }, + }, + }; +} + +describe("mattermost reactions", () => { + it("adds reactions by calling /users/me then POST /reactions", async () => { + const fetchImpl = vi.fn(async (url: any, init?: any) => { + if (String(url).endsWith("/api/v4/users/me")) { + return new Response(JSON.stringify({ id: "BOT123" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (String(url).endsWith("/api/v4/reactions")) { + expect(init?.method).toBe("POST"); + expect(JSON.parse(init?.body)).toEqual({ + user_id: "BOT123", + post_id: "POST1", + emoji_name: "thumbsup", + }); + return new Response(JSON.stringify({ ok: true }), { + status: 201, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`unexpected url: ${url}`); + }); + + const result = await addMattermostReaction({ + cfg: createCfg(), + postId: "POST1", + emojiName: "thumbsup", + fetchImpl, + }); + + expect(result).toEqual({ ok: true }); + expect(fetchImpl).toHaveBeenCalled(); + }); + + it("removes reactions by calling /users/me then DELETE /users/:id/posts/:postId/reactions/:emoji", async () => { + const fetchImpl = vi.fn(async (url: any, init?: any) => { + if (String(url).endsWith("/api/v4/users/me")) { + return new Response(JSON.stringify({ id: "BOT123" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (String(url).endsWith("/api/v4/users/BOT123/posts/POST1/reactions/thumbsup")) { + expect(init?.method).toBe("DELETE"); + return new Response(null, { + status: 204, + headers: { "content-type": "text/plain" }, + }); + } + throw new Error(`unexpected url: ${url}`); + }); + + const result = await removeMattermostReaction({ + cfg: createCfg(), + postId: "POST1", + emojiName: "thumbsup", + fetchImpl, + }); + + expect(result).toEqual({ ok: true }); + expect(fetchImpl).toHaveBeenCalled(); + }); +}); diff --git a/extensions/mattermost/src/mattermost/reactions.ts b/extensions/mattermost/src/mattermost/reactions.ts new file mode 100644 index 0000000000..1c557a263e --- /dev/null +++ b/extensions/mattermost/src/mattermost/reactions.ts @@ -0,0 +1,122 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { resolveMattermostAccount } from "./accounts.js"; +import { createMattermostClient, fetchMattermostMe, type MattermostClient } from "./client.js"; + +type Result = { ok: true } | { ok: false; error: string }; + +const BOT_USER_CACHE_TTL_MS = 10 * 60_000; +const botUserIdCache = new Map(); + +async function resolveBotUserId( + client: MattermostClient, + cacheKey: string, +): Promise { + const cached = botUserIdCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.userId; + } + const me = await fetchMattermostMe(client); + const userId = me?.id?.trim(); + if (!userId) { + return null; + } + botUserIdCache.set(cacheKey, { userId, expiresAt: Date.now() + BOT_USER_CACHE_TTL_MS }); + return userId; +} + +export async function addMattermostReaction(params: { + cfg: OpenClawConfig; + postId: string; + emojiName: string; + accountId?: string | null; + fetchImpl?: typeof fetch; +}): Promise { + const resolved = resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId }); + const baseUrl = resolved.baseUrl?.trim(); + const botToken = resolved.botToken?.trim(); + if (!baseUrl || !botToken) { + return { ok: false, error: "Mattermost botToken/baseUrl missing." }; + } + + const client = createMattermostClient({ + baseUrl, + botToken, + fetchImpl: params.fetchImpl, + }); + + const cacheKey = `${baseUrl}:${botToken}`; + const userId = await resolveBotUserId(client, cacheKey); + if (!userId) { + return { ok: false, error: "Mattermost reactions failed: could not resolve bot user id." }; + } + + await createReaction(client, { + userId, + postId: params.postId, + emojiName: params.emojiName, + }); + + return { ok: true }; +} + +export async function removeMattermostReaction(params: { + cfg: OpenClawConfig; + postId: string; + emojiName: string; + accountId?: string | null; + fetchImpl?: typeof fetch; +}): Promise { + const resolved = resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId }); + const baseUrl = resolved.baseUrl?.trim(); + const botToken = resolved.botToken?.trim(); + if (!baseUrl || !botToken) { + return { ok: false, error: "Mattermost botToken/baseUrl missing." }; + } + + const client = createMattermostClient({ + baseUrl, + botToken, + fetchImpl: params.fetchImpl, + }); + + const cacheKey = `${baseUrl}:${botToken}`; + const userId = await resolveBotUserId(client, cacheKey); + if (!userId) { + return { ok: false, error: "Mattermost reactions failed: could not resolve bot user id." }; + } + + await deleteReaction(client, { + userId, + postId: params.postId, + emojiName: params.emojiName, + }); + + return { ok: true }; +} + +async function createReaction( + client: MattermostClient, + params: { userId: string; postId: string; emojiName: string }, +): Promise { + await client.request>("/reactions", { + method: "POST", + body: JSON.stringify({ + user_id: params.userId, + post_id: params.postId, + emoji_name: params.emojiName, + }), + }); +} + +async function deleteReaction( + client: MattermostClient, + params: { userId: string; postId: string; emojiName: string }, +): Promise { + const emoji = encodeURIComponent(params.emojiName); + await client.request( + `/users/${params.userId}/posts/${params.postId}/reactions/${emoji}`, + { + method: "DELETE", + }, + ); +}