feat(mattermost): add emoji reactions support

This commit is contained in:
Echo
2026-02-16 17:14:08 -05:00
committed by Peter Steinberger
parent b57d29d833
commit 2a2372cd6c
9 changed files with 607 additions and 2 deletions

View File

@@ -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;

View File

@@ -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<string, unknown> | undefined;
const accounts = mmBase?.accounts as Record<string, Record<string, unknown>> | 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<ResolvedMattermostAccount> = {
groups: {
resolveRequireMention: resolveMattermostGroupRequireMention,
},
actions: mattermostMessageActions,
messaging: {
normalizeTarget: normalizeMattermostMessagingTarget,
targetResolver: {

View File

@@ -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();

View File

@@ -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<unknown>("/anything", { method: "DELETE" });
expect(result).toBeUndefined();
});
});

View File

@@ -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 };

View File

@@ -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<void>;
onReaction?: (payload: MattermostEventPayload) => Promise<void>;
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;

View File

@@ -60,6 +60,12 @@ export type MonitorMattermostOpts = {
type FetchLike = (input: URL | RequestInfo, init?: RequestInit) => Promise<Response>;
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, {

View File

@@ -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();
});
});

View File

@@ -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<string, { userId: string; expiresAt: number }>();
async function resolveBotUserId(
client: MattermostClient,
cacheKey: string,
): Promise<string | null> {
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<Result> {
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<Result> {
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<void> {
await client.request<Record<string, unknown>>("/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<void> {
const emoji = encodeURIComponent(params.emojiName);
await client.request<unknown>(
`/users/${params.userId}/posts/${params.postId}/reactions/${emoji}`,
{
method: "DELETE",
},
);
}