mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
feat(mattermost): add emoji reactions support
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
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"}`,
|
||||
);
|
||||
}
|
||||
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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
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