mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 00:10:21 +00:00
feat(mattermost): add emoji reactions support
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||||
import { createReplyPrefixOptions } 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";
|
import { mattermostPlugin } from "./channel.js";
|
||||||
|
|
||||||
describe("mattermostPlugin", () => {
|
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", () => {
|
describe("config", () => {
|
||||||
it("formats allowFrom entries", () => {
|
it("formats allowFrom entries", () => {
|
||||||
const formatAllowFrom = mattermostPlugin.config.formatAllowFrom;
|
const formatAllowFrom = mattermostPlugin.config.formatAllowFrom;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
migrateBaseNameToDefaultAccount,
|
migrateBaseNameToDefaultAccount,
|
||||||
normalizeAccountId,
|
normalizeAccountId,
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
|
type ChannelMessageActionAdapter,
|
||||||
|
type ChannelMessageActionName,
|
||||||
type ChannelPlugin,
|
type ChannelPlugin,
|
||||||
} from "openclaw/plugin-sdk";
|
} from "openclaw/plugin-sdk";
|
||||||
import { MattermostConfigSchema } from "./config-schema.js";
|
import { MattermostConfigSchema } from "./config-schema.js";
|
||||||
@@ -20,11 +22,102 @@ import {
|
|||||||
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
|
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
|
||||||
import { monitorMattermostProvider } from "./mattermost/monitor.js";
|
import { monitorMattermostProvider } from "./mattermost/monitor.js";
|
||||||
import { probeMattermost } from "./mattermost/probe.js";
|
import { probeMattermost } from "./mattermost/probe.js";
|
||||||
|
import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
|
||||||
import { sendMessageMattermost } from "./mattermost/send.js";
|
import { sendMessageMattermost } from "./mattermost/send.js";
|
||||||
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
|
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
|
||||||
import { mattermostOnboardingAdapter } from "./onboarding.js";
|
import { mattermostOnboardingAdapter } from "./onboarding.js";
|
||||||
import { getMattermostRuntime } from "./runtime.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 = {
|
const meta = {
|
||||||
id: "mattermost",
|
id: "mattermost",
|
||||||
label: "Mattermost",
|
label: "Mattermost",
|
||||||
@@ -146,6 +239,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||||||
groups: {
|
groups: {
|
||||||
resolveRequireMention: resolveMattermostGroupRequireMention,
|
resolveRequireMention: resolveMattermostGroupRequireMention,
|
||||||
},
|
},
|
||||||
|
actions: mattermostMessageActions,
|
||||||
messaging: {
|
messaging: {
|
||||||
normalizeTarget: normalizeMattermostMessagingTarget,
|
normalizeTarget: normalizeMattermostMessagingTarget,
|
||||||
targetResolver: {
|
targetResolver: {
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ const MattermostAccountSchemaBase = z
|
|||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
responsePrefix: z.string().optional(),
|
responsePrefix: z.string().optional(),
|
||||||
|
actions: z
|
||||||
|
.object({
|
||||||
|
reactions: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
19
extensions/mattermost/src/mattermost/client.test.ts
Normal file
19
extensions/mattermost/src/mattermost/client.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -97,7 +97,17 @@ export function createMattermostClient(params: {
|
|||||||
`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`,
|
`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 };
|
return { baseUrl, apiBaseUrl, token, request };
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type MattermostEventPayload = {
|
|||||||
event?: string;
|
event?: string;
|
||||||
data?: {
|
data?: {
|
||||||
post?: string;
|
post?: string;
|
||||||
|
reaction?: string;
|
||||||
channel_id?: string;
|
channel_id?: string;
|
||||||
channel_name?: string;
|
channel_name?: string;
|
||||||
channel_display_name?: string;
|
channel_display_name?: string;
|
||||||
@@ -51,6 +52,7 @@ type CreateMattermostConnectOnceOpts = {
|
|||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
nextSeq: () => number;
|
nextSeq: () => number;
|
||||||
onPosted: (post: MattermostPost, payload: MattermostEventPayload) => Promise<void>;
|
onPosted: (post: MattermostPost, payload: MattermostEventPayload) => Promise<void>;
|
||||||
|
onReaction?: (payload: MattermostEventPayload) => Promise<void>;
|
||||||
webSocketFactory?: MattermostWebSocketFactory;
|
webSocketFactory?: MattermostWebSocketFactory;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,6 +137,29 @@ export function createMattermostConnectOnce(
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on("message", async (data) => {
|
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);
|
const parsed = parsePostedEvent(data);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ export type MonitorMattermostOpts = {
|
|||||||
type FetchLike = (input: URL | RequestInfo, init?: RequestInit) => Promise<Response>;
|
type FetchLike = (input: URL | RequestInfo, init?: RequestInit) => Promise<Response>;
|
||||||
type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
|
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_TTL_MS = 5 * 60_000;
|
||||||
const RECENT_MATTERMOST_MESSAGE_MAX = 2000;
|
const RECENT_MATTERMOST_MESSAGE_MAX = 2000;
|
||||||
const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
|
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({
|
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "mattermost",
|
channel: "mattermost",
|
||||||
@@ -866,6 +1011,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
onPosted: async (post, payload) => {
|
onPosted: async (post, payload) => {
|
||||||
await debouncer.enqueue({ post, payload });
|
await debouncer.enqueue({ post, payload });
|
||||||
},
|
},
|
||||||
|
onReaction: async (payload) => {
|
||||||
|
await handleReactionEvent(payload);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await runWithReconnect(connectOnce, {
|
await runWithReconnect(connectOnce, {
|
||||||
|
|||||||
80
extensions/mattermost/src/mattermost/reactions.test.ts
Normal file
80
extensions/mattermost/src/mattermost/reactions.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
122
extensions/mattermost/src/mattermost/reactions.ts
Normal file
122
extensions/mattermost/src/mattermost/reactions.ts
Normal 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",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user