Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
17
openclaw/extensions/mattermost/index.ts
Normal file
17
openclaw/extensions/mattermost/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import { mattermostPlugin } from "./src/channel.js";
|
||||
import { setMattermostRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "mattermost",
|
||||
name: "Mattermost",
|
||||
description: "Mattermost channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
setMattermostRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: mattermostPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
9
openclaw/extensions/mattermost/openclaw.plugin.json
Normal file
9
openclaw/extensions/mattermost/openclaw.plugin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "mattermost",
|
||||
"channels": ["mattermost"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
25
openclaw/extensions/mattermost/package.json
Normal file
25
openclaw/extensions/mattermost/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@openclaw/mattermost",
|
||||
"version": "2026.2.26",
|
||||
"description": "OpenClaw Mattermost channel plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"channel": {
|
||||
"id": "mattermost",
|
||||
"label": "Mattermost",
|
||||
"selectionLabel": "Mattermost (plugin)",
|
||||
"docsPath": "/channels/mattermost",
|
||||
"docsLabel": "mattermost",
|
||||
"blurb": "self-hosted Slack-style chat; install the plugin to enable.",
|
||||
"order": 65
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/mattermost",
|
||||
"localPath": "extensions/mattermost",
|
||||
"defaultChoice": "npm"
|
||||
}
|
||||
}
|
||||
}
|
||||
235
openclaw/extensions/mattermost/src/channel.test.ts
Normal file
235
openclaw/extensions/mattermost/src/channel.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { createReplyPrefixOptions } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { mattermostPlugin } from "./channel.js";
|
||||
import { resetMattermostReactionBotUserCacheForTests } from "./mattermost/reactions.js";
|
||||
import {
|
||||
createMattermostReactionFetchMock,
|
||||
createMattermostTestConfig,
|
||||
withMockedGlobalFetch,
|
||||
} from "./mattermost/reactions.test-helpers.js";
|
||||
|
||||
describe("mattermostPlugin", () => {
|
||||
describe("messaging", () => {
|
||||
it("keeps @username targets", () => {
|
||||
const normalize = mattermostPlugin.messaging?.normalizeTarget;
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(normalize("@Alice")).toBe("@Alice");
|
||||
expect(normalize("@alice")).toBe("@alice");
|
||||
});
|
||||
|
||||
it("normalizes mattermost: prefix to user:", () => {
|
||||
const normalize = mattermostPlugin.messaging?.normalizeTarget;
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(normalize("mattermost:USER123")).toBe("user:USER123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pairing", () => {
|
||||
it("normalizes allowlist entries", () => {
|
||||
const normalize = mattermostPlugin.pairing?.normalizeAllowEntry;
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(normalize("@Alice")).toBe("alice");
|
||||
expect(normalize("user:USER123")).toBe("user123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("capabilities", () => {
|
||||
it("declares reactions support", () => {
|
||||
expect(mattermostPlugin.capabilities?.reactions).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("messageActions", () => {
|
||||
beforeEach(() => {
|
||||
resetMattermostReactionBotUserCacheForTests();
|
||||
});
|
||||
|
||||
const runReactAction = async (params: Record<string, unknown>, fetchMode: "add" | "remove") => {
|
||||
const cfg = createMattermostTestConfig();
|
||||
const fetchImpl = createMattermostReactionFetchMock({
|
||||
mode: fetchMode,
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
});
|
||||
|
||||
return await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => {
|
||||
return await mattermostPlugin.actions?.handleAction?.({
|
||||
channel: "mattermost",
|
||||
action: "react",
|
||||
params,
|
||||
cfg,
|
||||
accountId: "default",
|
||||
} as any);
|
||||
});
|
||||
};
|
||||
|
||||
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("respects per-account actions.reactions in listActions", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
actions: { reactions: false },
|
||||
accounts: {
|
||||
default: {
|
||||
enabled: true,
|
||||
botToken: "test-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
actions: { reactions: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
|
||||
expect(actions).toContain("react");
|
||||
});
|
||||
|
||||
it("blocks react when default account disables reactions and accountId is omitted", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
actions: { reactions: true },
|
||||
accounts: {
|
||||
default: {
|
||||
enabled: true,
|
||||
botToken: "test-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
actions: { reactions: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
mattermostPlugin.actions?.handleAction?.({
|
||||
channel: "mattermost",
|
||||
action: "react",
|
||||
params: { messageId: "POST1", emoji: "thumbsup" },
|
||||
cfg,
|
||||
} as any),
|
||||
).rejects.toThrow("Mattermost reactions are disabled in config");
|
||||
});
|
||||
|
||||
it("handles react by calling Mattermost reactions API", async () => {
|
||||
const result = await runReactAction({ messageId: "POST1", emoji: "thumbsup" }, "add");
|
||||
|
||||
expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]);
|
||||
expect(result?.details).toEqual({});
|
||||
});
|
||||
|
||||
it("only treats boolean remove flag as removal", async () => {
|
||||
const result = await runReactAction(
|
||||
{ messageId: "POST1", emoji: "thumbsup", remove: "true" },
|
||||
"add",
|
||||
);
|
||||
|
||||
expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]);
|
||||
});
|
||||
|
||||
it("removes reaction when remove flag is boolean true", async () => {
|
||||
const result = await runReactAction(
|
||||
{ messageId: "POST1", emoji: "thumbsup", remove: true },
|
||||
"remove",
|
||||
);
|
||||
|
||||
expect(result?.content).toEqual([
|
||||
{ type: "text", text: "Removed reaction :thumbsup: from POST1" },
|
||||
]);
|
||||
expect(result?.details).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("config", () => {
|
||||
it("formats allowFrom entries", () => {
|
||||
const formatAllowFrom = mattermostPlugin.config.formatAllowFrom!;
|
||||
|
||||
const formatted = formatAllowFrom({
|
||||
cfg: {} as OpenClawConfig,
|
||||
allowFrom: ["@Alice", "user:USER123", "mattermost:BOT999"],
|
||||
});
|
||||
expect(formatted).toEqual(["@alice", "user123", "bot999"]);
|
||||
});
|
||||
|
||||
it("uses account responsePrefix overrides", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
responsePrefix: "[Channel]",
|
||||
accounts: {
|
||||
default: { responsePrefix: "[Account]" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const prefixContext = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
channel: "mattermost",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(prefixContext.responsePrefix).toBe("[Account]");
|
||||
});
|
||||
});
|
||||
});
|
||||
439
openclaw/extensions/mattermost/src/channel.ts
Normal file
439
openclaw/extensions/mattermost/src/channel.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelMessageActionName,
|
||||
type ChannelPlugin,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { MattermostConfigSchema } from "./config-schema.js";
|
||||
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
|
||||
import {
|
||||
listMattermostAccountIds,
|
||||
resolveDefaultMattermostAccountId,
|
||||
resolveMattermostAccount,
|
||||
type ResolvedMattermostAccount,
|
||||
} from "./mattermost/accounts.js";
|
||||
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 actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined;
|
||||
const baseReactions = actionsConfig?.reactions;
|
||||
const hasReactionCapableAccount = listMattermostAccountIds(cfg)
|
||||
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled)
|
||||
.filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim()))
|
||||
.some((account) => {
|
||||
const accountActions = account.config.actions as { reactions?: boolean } | undefined;
|
||||
return (accountActions?.reactions ?? baseReactions ?? true) !== false;
|
||||
});
|
||||
|
||||
if (!hasReactionCapableAccount) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ["react"];
|
||||
},
|
||||
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 resolvedAccountId = accountId ?? resolveDefaultMattermostAccountId(cfg);
|
||||
const acctConfig = accounts?.[resolvedAccountId];
|
||||
const acctActions = acctConfig?.actions as { reactions?: boolean } | undefined;
|
||||
const baseActions = mmBase?.actions as { reactions?: boolean } | undefined;
|
||||
const reactionsEnabled = acctActions?.reactions ?? baseActions?.reactions ?? true;
|
||||
if (!reactionsEnabled) {
|
||||
throw new Error("Mattermost reactions are disabled in config");
|
||||
}
|
||||
|
||||
const postIdRaw =
|
||||
typeof (params as any)?.messageId === "string"
|
||||
? (params as any).messageId
|
||||
: typeof (params as any)?.postId === "string"
|
||||
? (params as any).postId
|
||||
: "";
|
||||
const postId = postIdRaw.trim();
|
||||
if (!postId) {
|
||||
throw new Error("Mattermost react requires messageId (post id)");
|
||||
}
|
||||
|
||||
const emojiRaw = typeof (params as any)?.emoji === "string" ? (params as any).emoji : "";
|
||||
const emojiName = emojiRaw.trim().replace(/^:+|:+$/g, "");
|
||||
if (!emojiName) {
|
||||
throw new Error("Mattermost react requires emoji");
|
||||
}
|
||||
|
||||
const remove = (params as any)?.remove === true;
|
||||
if (remove) {
|
||||
const result = await removeMattermostReaction({
|
||||
cfg,
|
||||
postId,
|
||||
emojiName,
|
||||
accountId: resolvedAccountId,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: `Removed reaction :${emojiName}: from ${postId}` },
|
||||
],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
const result = await addMattermostReaction({
|
||||
cfg,
|
||||
postId,
|
||||
emojiName,
|
||||
accountId: resolvedAccountId,
|
||||
});
|
||||
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",
|
||||
selectionLabel: "Mattermost (plugin)",
|
||||
detailLabel: "Mattermost Bot",
|
||||
docsPath: "/channels/mattermost",
|
||||
docsLabel: "mattermost",
|
||||
blurb: "self-hosted Slack-style chat; install the plugin to enable.",
|
||||
systemImage: "bubble.left.and.bubble.right",
|
||||
order: 65,
|
||||
quickstartAllowFrom: true,
|
||||
} as const;
|
||||
|
||||
function normalizeAllowEntry(entry: string): string {
|
||||
return entry
|
||||
.trim()
|
||||
.replace(/^(mattermost|user):/i, "")
|
||||
.replace(/^@/, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function formatAllowEntry(entry: string): string {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const username = trimmed.slice(1).trim();
|
||||
return username ? `@${username.toLowerCase()}` : "";
|
||||
}
|
||||
return trimmed.replace(/^(mattermost|user):/i, "").toLowerCase();
|
||||
}
|
||||
|
||||
export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
||||
id: "mattermost",
|
||||
meta: {
|
||||
...meta,
|
||||
},
|
||||
onboarding: mattermostOnboardingAdapter,
|
||||
pairing: {
|
||||
idLabel: "mattermostUserId",
|
||||
normalizeAllowEntry: (entry) => normalizeAllowEntry(entry),
|
||||
notifyApproval: async ({ id }) => {
|
||||
console.log(`[mattermost] User ${id} approved for pairing`);
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "channel", "group", "thread"],
|
||||
reactions: true,
|
||||
threads: true,
|
||||
media: true,
|
||||
},
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
reload: { configPrefixes: ["channels.mattermost"] },
|
||||
configSchema: buildChannelConfigSchema(MattermostConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listMattermostAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultMattermostAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg,
|
||||
sectionKey: "mattermost",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg,
|
||||
sectionKey: "mattermost",
|
||||
accountId,
|
||||
clearBaseFields: ["botToken", "baseUrl", "name"],
|
||||
}),
|
||||
isConfigured: (account) => Boolean(account.botToken && account.baseUrl),
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.botToken && account.baseUrl),
|
||||
botTokenSource: account.botTokenSource,
|
||||
baseUrl: account.baseUrl,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveMattermostAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) =>
|
||||
String(entry),
|
||||
),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom.map((entry) => formatAllowEntry(String(entry))).filter(Boolean),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const useAccountPath = Boolean(cfg.channels?.mattermost?.accounts?.[resolvedAccountId]);
|
||||
const basePath = useAccountPath
|
||||
? `channels.mattermost.accounts.${resolvedAccountId}.`
|
||||
: "channels.mattermost.";
|
||||
return {
|
||||
policy: account.config.dmPolicy ?? "pairing",
|
||||
allowFrom: account.config.allowFrom ?? [],
|
||||
policyPath: `${basePath}dmPolicy`,
|
||||
allowFromPath: basePath,
|
||||
approveHint: formatPairingApproveHint("mattermost"),
|
||||
normalizeEntry: (raw) => normalizeAllowEntry(raw),
|
||||
};
|
||||
},
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
||||
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: cfg.channels?.mattermost !== undefined,
|
||||
groupPolicy: account.config.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
if (groupPolicy !== "open") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
`- Mattermost channels: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.mattermost.groupPolicy="allowlist" + channels.mattermost.groupAllowFrom to restrict senders.`,
|
||||
];
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveMattermostGroupRequireMention,
|
||||
},
|
||||
actions: mattermostMessageActions,
|
||||
messaging: {
|
||||
normalizeTarget: normalizeMattermostMessagingTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeMattermostTargetId,
|
||||
hint: "<channelId|user:ID|channel:ID>",
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getMattermostRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
resolveTarget: ({ to }) => {
|
||||
const trimmed = to?.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
"Delivering to Mattermost requires --to <channelId|@username|user:ID|channel:ID>",
|
||||
),
|
||||
};
|
||||
}
|
||||
return { ok: true, to: trimmed };
|
||||
},
|
||||
sendText: async ({ to, text, accountId, replyToId }) => {
|
||||
const result = await sendMessageMattermost(to, text, {
|
||||
accountId: accountId ?? undefined,
|
||||
replyToId: replyToId ?? undefined,
|
||||
});
|
||||
return { channel: "mattermost", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
|
||||
const result = await sendMessageMattermost(to, text, {
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl,
|
||||
replyToId: replyToId ?? undefined,
|
||||
});
|
||||
return { channel: "mattermost", ...result };
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
connected: false,
|
||||
lastConnectedAt: null,
|
||||
lastDisconnect: null,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
botTokenSource: snapshot.botTokenSource ?? "none",
|
||||
running: snapshot.running ?? false,
|
||||
connected: snapshot.connected ?? false,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
baseUrl: snapshot.baseUrl ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account, timeoutMs }) => {
|
||||
const token = account.botToken?.trim();
|
||||
const baseUrl = account.baseUrl?.trim();
|
||||
if (!token || !baseUrl) {
|
||||
return { ok: false, error: "bot token or baseUrl missing" };
|
||||
}
|
||||
return await probeMattermost(baseUrl, token, timeoutMs);
|
||||
},
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.botToken && account.baseUrl),
|
||||
botTokenSource: account.botTokenSource,
|
||||
baseUrl: account.baseUrl,
|
||||
running: runtime?.running ?? false,
|
||||
connected: runtime?.connected ?? false,
|
||||
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
||||
lastDisconnect: runtime?.lastDisconnect ?? null,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
probe,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
}),
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: "mattermost",
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ accountId, input }) => {
|
||||
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return "Mattermost env vars can only be used for the default account.";
|
||||
}
|
||||
const token = input.botToken ?? input.token;
|
||||
const baseUrl = input.httpUrl;
|
||||
if (!input.useEnv && (!token || !baseUrl)) {
|
||||
return "Mattermost requires --bot-token and --http-url (or --use-env).";
|
||||
}
|
||||
if (baseUrl && !normalizeMattermostBaseUrl(baseUrl)) {
|
||||
return "Mattermost --http-url must include a valid base URL.";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const token = input.botToken ?? input.token;
|
||||
const baseUrl = input.httpUrl?.trim();
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: "mattermost",
|
||||
accountId,
|
||||
name: input.name,
|
||||
});
|
||||
const next =
|
||||
accountId !== DEFAULT_ACCOUNT_ID
|
||||
? migrateBaseNameToDefaultAccount({
|
||||
cfg: namedConfig,
|
||||
channelKey: "mattermost",
|
||||
})
|
||||
: namedConfig;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
mattermost: {
|
||||
...next.channels?.mattermost,
|
||||
enabled: true,
|
||||
...(input.useEnv
|
||||
? {}
|
||||
: {
|
||||
...(token ? { botToken: token } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
mattermost: {
|
||||
...next.channels?.mattermost,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.mattermost?.accounts,
|
||||
[accountId]: {
|
||||
...next.channels?.mattermost?.accounts?.[accountId],
|
||||
enabled: true,
|
||||
...(token ? { botToken: token } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
baseUrl: account.baseUrl,
|
||||
botTokenSource: account.botTokenSource,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting channel`);
|
||||
return monitorMattermostProvider({
|
||||
botToken: account.botToken ?? undefined,
|
||||
baseUrl: account.baseUrl ?? undefined,
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
62
openclaw/extensions/mattermost/src/config-schema.ts
Normal file
62
openclaw/extensions/mattermost/src/config-schema.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
BlockStreamingCoalesceSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
requireOpenAllowFrom,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
const MattermostAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
dangerouslyAllowNameMatching: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
enabled: z.boolean().optional(),
|
||||
configWrites: z.boolean().optional(),
|
||||
botToken: z.string().optional(),
|
||||
baseUrl: z.string().optional(),
|
||||
chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(),
|
||||
oncharPrefixes: z.array(z.string()).optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
actions: z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const MattermostAccountSchema = MattermostAccountSchemaBase.superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
|
||||
export const MattermostConfigSchema = MattermostAccountSchemaBase.extend({
|
||||
accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
15
openclaw/extensions/mattermost/src/group-mentions.ts
Normal file
15
openclaw/extensions/mattermost/src/group-mentions.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ChannelGroupContext } from "openclaw/plugin-sdk";
|
||||
import { resolveMattermostAccount } from "./mattermost/accounts.js";
|
||||
|
||||
export function resolveMattermostGroupRequireMention(
|
||||
params: ChannelGroupContext,
|
||||
): boolean | undefined {
|
||||
const account = resolveMattermostAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (typeof account.requireMention === "boolean") {
|
||||
return account.requireMention;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
128
openclaw/extensions/mattermost/src/mattermost/accounts.ts
Normal file
128
openclaw/extensions/mattermost/src/mattermost/accounts.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type { MattermostAccountConfig, MattermostChatMode } from "../types.js";
|
||||
import { normalizeMattermostBaseUrl } from "./client.js";
|
||||
|
||||
export type MattermostTokenSource = "env" | "config" | "none";
|
||||
export type MattermostBaseUrlSource = "env" | "config" | "none";
|
||||
|
||||
export type ResolvedMattermostAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
botToken?: string;
|
||||
baseUrl?: string;
|
||||
botTokenSource: MattermostTokenSource;
|
||||
baseUrlSource: MattermostBaseUrlSource;
|
||||
config: MattermostAccountConfig;
|
||||
chatmode?: MattermostChatMode;
|
||||
oncharPrefixes?: string[];
|
||||
requireMention?: boolean;
|
||||
textChunkLimit?: number;
|
||||
blockStreaming?: boolean;
|
||||
blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
|
||||
};
|
||||
|
||||
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const accounts = cfg.channels?.mattermost?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(accounts).filter(Boolean);
|
||||
}
|
||||
|
||||
export function listMattermostAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const ids = listConfiguredAccountIds(cfg);
|
||||
if (ids.length === 0) {
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
return ids.toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function resolveDefaultMattermostAccountId(cfg: OpenClawConfig): string {
|
||||
const ids = listMattermostAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): MattermostAccountConfig | undefined {
|
||||
const accounts = cfg.channels?.mattermost?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
return accounts[accountId] as MattermostAccountConfig | undefined;
|
||||
}
|
||||
|
||||
function mergeMattermostAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): MattermostAccountConfig {
|
||||
const { accounts: _ignored, ...base } = (cfg.channels?.mattermost ??
|
||||
{}) as MattermostAccountConfig & { accounts?: unknown };
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined {
|
||||
if (config.chatmode === "oncall") {
|
||||
return true;
|
||||
}
|
||||
if (config.chatmode === "onmessage") {
|
||||
return false;
|
||||
}
|
||||
if (config.chatmode === "onchar") {
|
||||
return true;
|
||||
}
|
||||
return config.requireMention;
|
||||
}
|
||||
|
||||
export function resolveMattermostAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedMattermostAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = params.cfg.channels?.mattermost?.enabled !== false;
|
||||
const merged = mergeMattermostAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const envToken = allowEnv ? process.env.MATTERMOST_BOT_TOKEN?.trim() : undefined;
|
||||
const envUrl = allowEnv ? process.env.MATTERMOST_URL?.trim() : undefined;
|
||||
const configToken = merged.botToken?.trim();
|
||||
const configUrl = merged.baseUrl?.trim();
|
||||
const botToken = configToken || envToken;
|
||||
const baseUrl = normalizeMattermostBaseUrl(configUrl || envUrl);
|
||||
const requireMention = resolveMattermostRequireMention(merged);
|
||||
|
||||
const botTokenSource: MattermostTokenSource = configToken ? "config" : envToken ? "env" : "none";
|
||||
const baseUrlSource: MattermostBaseUrlSource = configUrl ? "config" : envUrl ? "env" : "none";
|
||||
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
botToken,
|
||||
baseUrl,
|
||||
botTokenSource,
|
||||
baseUrlSource,
|
||||
config: merged,
|
||||
chatmode: merged.chatmode,
|
||||
oncharPrefixes: merged.oncharPrefixes,
|
||||
requireMention,
|
||||
textChunkLimit: merged.textChunkLimit,
|
||||
blockStreaming: merged.blockStreaming,
|
||||
blockStreamingCoalesce: merged.blockStreamingCoalesce,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledMattermostAccounts(cfg: OpenClawConfig): ResolvedMattermostAccount[] {
|
||||
return listMattermostAccountIds(cfg)
|
||||
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
19
openclaw/extensions/mattermost/src/mattermost/client.test.ts
Normal file
19
openclaw/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();
|
||||
});
|
||||
});
|
||||
230
openclaw/extensions/mattermost/src/mattermost/client.ts
Normal file
230
openclaw/extensions/mattermost/src/mattermost/client.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
export type MattermostClient = {
|
||||
baseUrl: string;
|
||||
apiBaseUrl: string;
|
||||
token: string;
|
||||
request: <T>(path: string, init?: RequestInit) => Promise<T>;
|
||||
};
|
||||
|
||||
export type MattermostUser = {
|
||||
id: string;
|
||||
username?: string | null;
|
||||
nickname?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
};
|
||||
|
||||
export type MattermostChannel = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
display_name?: string | null;
|
||||
type?: string | null;
|
||||
team_id?: string | null;
|
||||
};
|
||||
|
||||
export type MattermostPost = {
|
||||
id: string;
|
||||
user_id?: string | null;
|
||||
channel_id?: string | null;
|
||||
message?: string | null;
|
||||
file_ids?: string[] | null;
|
||||
type?: string | null;
|
||||
root_id?: string | null;
|
||||
create_at?: number | null;
|
||||
props?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type MattermostFileInfo = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
mime_type?: string | null;
|
||||
size?: number | null;
|
||||
};
|
||||
|
||||
export function normalizeMattermostBaseUrl(raw?: string | null): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const withoutTrailing = trimmed.replace(/\/+$/, "");
|
||||
return withoutTrailing.replace(/\/api\/v4$/i, "");
|
||||
}
|
||||
|
||||
function buildMattermostApiUrl(baseUrl: string, path: string): string {
|
||||
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
||||
if (!normalized) {
|
||||
throw new Error("Mattermost baseUrl is required");
|
||||
}
|
||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${normalized}/api/v4${suffix}`;
|
||||
}
|
||||
|
||||
export async function readMattermostError(res: Response): Promise<string> {
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
const data = (await res.json()) as { message?: string } | undefined;
|
||||
if (data?.message) {
|
||||
return data.message;
|
||||
}
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
export function createMattermostClient(params: {
|
||||
baseUrl: string;
|
||||
botToken: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): MattermostClient {
|
||||
const baseUrl = normalizeMattermostBaseUrl(params.baseUrl);
|
||||
if (!baseUrl) {
|
||||
throw new Error("Mattermost baseUrl is required");
|
||||
}
|
||||
const apiBaseUrl = `${baseUrl}/api/v4`;
|
||||
const token = params.botToken.trim();
|
||||
const fetchImpl = params.fetchImpl ?? fetch;
|
||||
|
||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const url = buildMattermostApiUrl(baseUrl, path);
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
if (typeof init?.body === "string" && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
const res = await fetchImpl(url, { ...init, headers });
|
||||
if (!res.ok) {
|
||||
const detail = await readMattermostError(res);
|
||||
throw new Error(
|
||||
`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
export async function fetchMattermostMe(client: MattermostClient): Promise<MattermostUser> {
|
||||
return await client.request<MattermostUser>("/users/me");
|
||||
}
|
||||
|
||||
export async function fetchMattermostUser(
|
||||
client: MattermostClient,
|
||||
userId: string,
|
||||
): Promise<MattermostUser> {
|
||||
return await client.request<MattermostUser>(`/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function fetchMattermostUserByUsername(
|
||||
client: MattermostClient,
|
||||
username: string,
|
||||
): Promise<MattermostUser> {
|
||||
return await client.request<MattermostUser>(`/users/username/${encodeURIComponent(username)}`);
|
||||
}
|
||||
|
||||
export async function fetchMattermostChannel(
|
||||
client: MattermostClient,
|
||||
channelId: string,
|
||||
): Promise<MattermostChannel> {
|
||||
return await client.request<MattermostChannel>(`/channels/${channelId}`);
|
||||
}
|
||||
|
||||
export async function sendMattermostTyping(
|
||||
client: MattermostClient,
|
||||
params: { channelId: string; parentId?: string },
|
||||
): Promise<void> {
|
||||
const payload: Record<string, string> = {
|
||||
channel_id: params.channelId,
|
||||
};
|
||||
const parentId = params.parentId?.trim();
|
||||
if (parentId) {
|
||||
payload.parent_id = parentId;
|
||||
}
|
||||
await client.request<Record<string, unknown>>("/users/me/typing", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMattermostDirectChannel(
|
||||
client: MattermostClient,
|
||||
userIds: string[],
|
||||
): Promise<MattermostChannel> {
|
||||
return await client.request<MattermostChannel>("/channels/direct", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(userIds),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMattermostPost(
|
||||
client: MattermostClient,
|
||||
params: {
|
||||
channelId: string;
|
||||
message: string;
|
||||
rootId?: string;
|
||||
fileIds?: string[];
|
||||
},
|
||||
): Promise<MattermostPost> {
|
||||
const payload: Record<string, string> = {
|
||||
channel_id: params.channelId,
|
||||
message: params.message,
|
||||
};
|
||||
if (params.rootId) {
|
||||
payload.root_id = params.rootId;
|
||||
}
|
||||
if (params.fileIds?.length) {
|
||||
(payload as Record<string, unknown>).file_ids = params.fileIds;
|
||||
}
|
||||
return await client.request<MattermostPost>("/posts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadMattermostFile(
|
||||
client: MattermostClient,
|
||||
params: {
|
||||
channelId: string;
|
||||
buffer: Buffer;
|
||||
fileName: string;
|
||||
contentType?: string;
|
||||
},
|
||||
): Promise<MattermostFileInfo> {
|
||||
const form = new FormData();
|
||||
const fileName = params.fileName?.trim() || "upload";
|
||||
const bytes = Uint8Array.from(params.buffer);
|
||||
const blob = params.contentType
|
||||
? new Blob([bytes], { type: params.contentType })
|
||||
: new Blob([bytes]);
|
||||
form.append("files", blob, fileName);
|
||||
form.append("channel_id", params.channelId);
|
||||
|
||||
const res = await fetch(`${client.apiBaseUrl}/files`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${client.token}`,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await readMattermostError(res);
|
||||
throw new Error(`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { file_infos?: MattermostFileInfo[] };
|
||||
const info = data.file_infos?.[0];
|
||||
if (!info?.id) {
|
||||
throw new Error("Mattermost file upload failed");
|
||||
}
|
||||
return info;
|
||||
}
|
||||
9
openclaw/extensions/mattermost/src/mattermost/index.ts
Normal file
9
openclaw/extensions/mattermost/src/mattermost/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
listEnabledMattermostAccounts,
|
||||
listMattermostAccountIds,
|
||||
resolveDefaultMattermostAccountId,
|
||||
resolveMattermostAccount,
|
||||
} from "./accounts.js";
|
||||
export { monitorMattermostProvider } from "./monitor.js";
|
||||
export { probeMattermost } from "./probe.js";
|
||||
export { sendMessageMattermost } from "./send.js";
|
||||
@@ -0,0 +1,58 @@
|
||||
import { resolveAllowlistMatchSimple, resolveEffectiveAllowFromLists } from "openclaw/plugin-sdk";
|
||||
|
||||
export function normalizeMattermostAllowEntry(entry: string): string {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return "*";
|
||||
}
|
||||
return trimmed
|
||||
.replace(/^(mattermost|user):/i, "")
|
||||
.replace(/^@/, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function normalizeMattermostAllowList(entries: Array<string | number>): string[] {
|
||||
const normalized = entries
|
||||
.map((entry) => normalizeMattermostAllowEntry(String(entry)))
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(normalized));
|
||||
}
|
||||
|
||||
export function resolveMattermostEffectiveAllowFromLists(params: {
|
||||
allowFrom?: Array<string | number> | null;
|
||||
groupAllowFrom?: Array<string | number> | null;
|
||||
storeAllowFrom?: Array<string | number> | null;
|
||||
dmPolicy?: string | null;
|
||||
}): {
|
||||
effectiveAllowFrom: string[];
|
||||
effectiveGroupAllowFrom: string[];
|
||||
} {
|
||||
return resolveEffectiveAllowFromLists({
|
||||
allowFrom: normalizeMattermostAllowList(params.allowFrom ?? []),
|
||||
groupAllowFrom: normalizeMattermostAllowList(params.groupAllowFrom ?? []),
|
||||
storeAllowFrom: normalizeMattermostAllowList(params.storeAllowFrom ?? []),
|
||||
dmPolicy: params.dmPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
export function isMattermostSenderAllowed(params: {
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
allowFrom: string[];
|
||||
allowNameMatching?: boolean;
|
||||
}): boolean {
|
||||
const allowFrom = normalizeMattermostAllowList(params.allowFrom);
|
||||
if (allowFrom.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const match = resolveAllowlistMatchSimple({
|
||||
allowFrom,
|
||||
senderId: normalizeMattermostAllowEntry(params.senderId),
|
||||
senderName: params.senderName ? normalizeMattermostAllowEntry(params.senderName) : undefined,
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
});
|
||||
return match.allowed;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
formatInboundFromLabel as formatInboundFromLabelShared,
|
||||
resolveThreadSessionKeys as resolveThreadSessionKeysShared,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk";
|
||||
export { createDedupeCache, rawDataToString } from "openclaw/plugin-sdk";
|
||||
|
||||
export type ResponsePrefixContext = {
|
||||
model?: string;
|
||||
modelFull?: string;
|
||||
provider?: string;
|
||||
thinkingLevel?: string;
|
||||
identityName?: string;
|
||||
};
|
||||
|
||||
export function extractShortModelName(fullModel: string): string {
|
||||
const slash = fullModel.lastIndexOf("/");
|
||||
const modelPart = slash >= 0 ? fullModel.slice(slash + 1) : fullModel;
|
||||
return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, "");
|
||||
}
|
||||
|
||||
export const formatInboundFromLabel = formatInboundFromLabelShared;
|
||||
|
||||
function normalizeAgentId(value: string | undefined | null): string {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return "main";
|
||||
}
|
||||
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
return (
|
||||
trimmed
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "")
|
||||
.slice(0, 64) || "main"
|
||||
);
|
||||
}
|
||||
|
||||
type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
|
||||
|
||||
function listAgents(cfg: OpenClawConfig): AgentEntry[] {
|
||||
const list = cfg.agents?.list;
|
||||
if (!Array.isArray(list)) {
|
||||
return [];
|
||||
}
|
||||
return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
|
||||
}
|
||||
|
||||
function resolveAgentEntry(cfg: OpenClawConfig, agentId: string): AgentEntry | undefined {
|
||||
const id = normalizeAgentId(agentId);
|
||||
return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
|
||||
}
|
||||
|
||||
export function resolveIdentityName(cfg: OpenClawConfig, agentId: string): string | undefined {
|
||||
const entry = resolveAgentEntry(cfg, agentId);
|
||||
return entry?.identity?.name?.trim() || undefined;
|
||||
}
|
||||
|
||||
export function resolveThreadSessionKeys(params: {
|
||||
baseSessionKey: string;
|
||||
threadId?: string | null;
|
||||
parentSessionKey?: string;
|
||||
useSuffix?: boolean;
|
||||
}): { sessionKey: string; parentSessionKey?: string } {
|
||||
return resolveThreadSessionKeysShared({
|
||||
...params,
|
||||
normalizeThreadId: (threadId) => threadId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const DEFAULT_ONCHAR_PREFIXES = [">", "!"];
|
||||
|
||||
export function resolveOncharPrefixes(prefixes: string[] | undefined): string[] {
|
||||
const cleaned = prefixes?.map((entry) => entry.trim()).filter(Boolean) ?? DEFAULT_ONCHAR_PREFIXES;
|
||||
return cleaned.length > 0 ? cleaned : DEFAULT_ONCHAR_PREFIXES;
|
||||
}
|
||||
|
||||
export function stripOncharPrefix(
|
||||
text: string,
|
||||
prefixes: string[],
|
||||
): { triggered: boolean; stripped: string } {
|
||||
const trimmed = text.trimStart();
|
||||
for (const prefix of prefixes) {
|
||||
if (!prefix) {
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return {
|
||||
triggered: true,
|
||||
stripped: trimmed.slice(prefix.length).trimStart(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { triggered: false, stripped: text };
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createMattermostConnectOnce,
|
||||
type MattermostWebSocketLike,
|
||||
WebSocketClosedBeforeOpenError,
|
||||
} from "./monitor-websocket.js";
|
||||
import { runWithReconnect } from "./reconnect.js";
|
||||
|
||||
class FakeWebSocket implements MattermostWebSocketLike {
|
||||
public readonly sent: string[] = [];
|
||||
public closeCalls = 0;
|
||||
public terminateCalls = 0;
|
||||
private openListeners: Array<() => void> = [];
|
||||
private messageListeners: Array<(data: Buffer) => void | Promise<void>> = [];
|
||||
private closeListeners: Array<(code: number, reason: Buffer) => void> = [];
|
||||
private errorListeners: Array<(err: unknown) => void> = [];
|
||||
|
||||
on(event: "open", listener: () => void): void;
|
||||
on(event: "message", listener: (data: Buffer) => void | Promise<void>): void;
|
||||
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
|
||||
on(event: "error", listener: (err: unknown) => void): void;
|
||||
on(event: "open" | "message" | "close" | "error", listener: unknown): void {
|
||||
if (event === "open") {
|
||||
this.openListeners.push(listener as () => void);
|
||||
return;
|
||||
}
|
||||
if (event === "message") {
|
||||
this.messageListeners.push(listener as (data: Buffer) => void | Promise<void>);
|
||||
return;
|
||||
}
|
||||
if (event === "close") {
|
||||
this.closeListeners.push(listener as (code: number, reason: Buffer) => void);
|
||||
return;
|
||||
}
|
||||
this.errorListeners.push(listener as (err: unknown) => void);
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closeCalls++;
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminateCalls++;
|
||||
}
|
||||
|
||||
emitOpen(): void {
|
||||
for (const listener of this.openListeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
emitMessage(data: Buffer): void {
|
||||
for (const listener of this.messageListeners) {
|
||||
void listener(data);
|
||||
}
|
||||
}
|
||||
|
||||
emitClose(code: number, reason = ""): void {
|
||||
const buffer = Buffer.from(reason, "utf8");
|
||||
for (const listener of this.closeListeners) {
|
||||
listener(code, buffer);
|
||||
}
|
||||
}
|
||||
|
||||
emitError(err: unknown): void {
|
||||
for (const listener of this.errorListeners) {
|
||||
listener(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const testRuntime = (): RuntimeEnv =>
|
||||
({
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as RuntimeEnv["exit"],
|
||||
}) as RuntimeEnv;
|
||||
|
||||
describe("mattermost websocket monitor", () => {
|
||||
it("rejects when websocket closes before open", async () => {
|
||||
const socket = new FakeWebSocket();
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime: testRuntime(),
|
||||
nextSeq: () => 1,
|
||||
onPosted: async () => {},
|
||||
webSocketFactory: () => socket,
|
||||
});
|
||||
|
||||
queueMicrotask(() => {
|
||||
socket.emitClose(1006, "connection refused");
|
||||
});
|
||||
|
||||
const failure = connectOnce();
|
||||
await expect(failure).rejects.toBeInstanceOf(WebSocketClosedBeforeOpenError);
|
||||
await expect(failure).rejects.toMatchObject({
|
||||
message: "websocket closed before open (code 1006)",
|
||||
});
|
||||
});
|
||||
|
||||
it("retries when first attempt errors before open and next attempt succeeds", async () => {
|
||||
const abort = new AbortController();
|
||||
const reconnectDelays: number[] = [];
|
||||
const onError = vi.fn();
|
||||
const patches: Array<Record<string, unknown>> = [];
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
let disconnects = 0;
|
||||
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime: testRuntime(),
|
||||
nextSeq: (() => {
|
||||
let seq = 1;
|
||||
return () => seq++;
|
||||
})(),
|
||||
onPosted: async () => {},
|
||||
abortSignal: abort.signal,
|
||||
statusSink: (patch) => {
|
||||
patches.push(patch as Record<string, unknown>);
|
||||
if (patch.lastDisconnect) {
|
||||
disconnects++;
|
||||
if (disconnects >= 2) {
|
||||
abort.abort();
|
||||
}
|
||||
}
|
||||
},
|
||||
webSocketFactory: () => {
|
||||
const socket = new FakeWebSocket();
|
||||
const attempt = sockets.length;
|
||||
sockets.push(socket);
|
||||
queueMicrotask(() => {
|
||||
if (attempt === 0) {
|
||||
socket.emitError(new Error("boom"));
|
||||
socket.emitClose(1006, "connection refused");
|
||||
return;
|
||||
}
|
||||
socket.emitOpen();
|
||||
socket.emitClose(1000);
|
||||
});
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
|
||||
await runWithReconnect(connectOnce, {
|
||||
abortSignal: abort.signal,
|
||||
initialDelayMs: 1,
|
||||
onError,
|
||||
onReconnect: (delay) => reconnectDelays.push(delay),
|
||||
});
|
||||
|
||||
expect(sockets).toHaveLength(2);
|
||||
expect(sockets[0].closeCalls).toBe(1);
|
||||
expect(sockets[1].sent).toHaveLength(1);
|
||||
expect(JSON.parse(sockets[1].sent[0])).toMatchObject({
|
||||
action: "authentication_challenge",
|
||||
data: { token: "token" },
|
||||
seq: 1,
|
||||
});
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(reconnectDelays).toEqual([1]);
|
||||
expect(patches.some((patch) => patch.connected === true)).toBe(true);
|
||||
expect(patches.filter((patch) => patch.connected === false)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("dispatches reaction events to the reaction handler", async () => {
|
||||
const socket = new FakeWebSocket();
|
||||
const onPosted = vi.fn(async () => {});
|
||||
const onReaction = vi.fn(async (payload) => payload);
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime: testRuntime(),
|
||||
nextSeq: () => 1,
|
||||
onPosted,
|
||||
onReaction,
|
||||
webSocketFactory: () => socket,
|
||||
});
|
||||
|
||||
const connected = connectOnce();
|
||||
queueMicrotask(() => {
|
||||
socket.emitOpen();
|
||||
socket.emitMessage(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
event: "reaction_added",
|
||||
data: {
|
||||
reaction: JSON.stringify({
|
||||
user_id: "user-1",
|
||||
post_id: "post-1",
|
||||
emoji_name: "thumbsup",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
socket.emitClose(1000);
|
||||
});
|
||||
|
||||
await connected;
|
||||
|
||||
expect(onReaction).toHaveBeenCalledTimes(1);
|
||||
expect(onPosted).not.toHaveBeenCalled();
|
||||
const payload = onReaction.mock.calls[0]?.[0];
|
||||
expect(payload).toMatchObject({
|
||||
event: "reaction_added",
|
||||
data: {
|
||||
reaction: JSON.stringify({
|
||||
user_id: "user-1",
|
||||
post_id: "post-1",
|
||||
emoji_name: "thumbsup",
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(payload.data?.reaction).toBe(
|
||||
JSON.stringify({
|
||||
user_id: "user-1",
|
||||
post_id: "post-1",
|
||||
emoji_name: "thumbsup",
|
||||
}),
|
||||
);
|
||||
expect(payload.data?.reaction).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { ChannelAccountSnapshot, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import WebSocket from "ws";
|
||||
import type { MattermostPost } from "./client.js";
|
||||
import { rawDataToString } from "./monitor-helpers.js";
|
||||
|
||||
export type MattermostEventPayload = {
|
||||
event?: string;
|
||||
data?: {
|
||||
post?: string;
|
||||
reaction?: string;
|
||||
channel_id?: string;
|
||||
channel_name?: string;
|
||||
channel_display_name?: string;
|
||||
channel_type?: string;
|
||||
sender_name?: string;
|
||||
team_id?: string;
|
||||
};
|
||||
broadcast?: {
|
||||
channel_id?: string;
|
||||
team_id?: string;
|
||||
user_id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MattermostWebSocketLike = {
|
||||
on(event: "open", listener: () => void): void;
|
||||
on(event: "message", listener: (data: WebSocket.RawData) => void | Promise<void>): void;
|
||||
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
|
||||
on(event: "error", listener: (err: unknown) => void): void;
|
||||
send(data: string): void;
|
||||
close(): void;
|
||||
terminate(): void;
|
||||
};
|
||||
|
||||
export type MattermostWebSocketFactory = (url: string) => MattermostWebSocketLike;
|
||||
|
||||
export class WebSocketClosedBeforeOpenError extends Error {
|
||||
constructor(
|
||||
public readonly code: number,
|
||||
public readonly reason?: string,
|
||||
) {
|
||||
super(`websocket closed before open (code ${code})`);
|
||||
this.name = "WebSocketClosedBeforeOpenError";
|
||||
}
|
||||
}
|
||||
|
||||
type CreateMattermostConnectOnceOpts = {
|
||||
wsUrl: string;
|
||||
botToken: string;
|
||||
abortSignal?: AbortSignal;
|
||||
statusSink?: (patch: Partial<ChannelAccountSnapshot>) => void;
|
||||
runtime: RuntimeEnv;
|
||||
nextSeq: () => number;
|
||||
onPosted: (post: MattermostPost, payload: MattermostEventPayload) => Promise<void>;
|
||||
onReaction?: (payload: MattermostEventPayload) => Promise<void>;
|
||||
webSocketFactory?: MattermostWebSocketFactory;
|
||||
};
|
||||
|
||||
export const defaultMattermostWebSocketFactory: MattermostWebSocketFactory = (url) =>
|
||||
new WebSocket(url) as MattermostWebSocketLike;
|
||||
|
||||
export function parsePostedPayload(
|
||||
payload: MattermostEventPayload,
|
||||
): { payload: MattermostEventPayload; post: MattermostPost } | null {
|
||||
if (payload.event !== "posted") {
|
||||
return null;
|
||||
}
|
||||
const postData = payload.data?.post;
|
||||
if (!postData) {
|
||||
return null;
|
||||
}
|
||||
let post: MattermostPost | null = null;
|
||||
if (typeof postData === "string") {
|
||||
try {
|
||||
post = JSON.parse(postData) as MattermostPost;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} else if (typeof postData === "object") {
|
||||
post = postData as MattermostPost;
|
||||
}
|
||||
if (!post) {
|
||||
return null;
|
||||
}
|
||||
return { payload, post };
|
||||
}
|
||||
|
||||
export function parsePostedEvent(
|
||||
data: WebSocket.RawData,
|
||||
): { payload: MattermostEventPayload; post: MattermostPost } | null {
|
||||
const raw = rawDataToString(data);
|
||||
let payload: MattermostEventPayload;
|
||||
try {
|
||||
payload = JSON.parse(raw) as MattermostEventPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return parsePostedPayload(payload);
|
||||
}
|
||||
|
||||
export function createMattermostConnectOnce(
|
||||
opts: CreateMattermostConnectOnceOpts,
|
||||
): () => Promise<void> {
|
||||
const webSocketFactory = opts.webSocketFactory ?? defaultMattermostWebSocketFactory;
|
||||
return async () => {
|
||||
const ws = webSocketFactory(opts.wsUrl);
|
||||
const onAbort = () => ws.terminate();
|
||||
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
let opened = false;
|
||||
let settled = false;
|
||||
const resolveOnce = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve();
|
||||
};
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
reject(error);
|
||||
};
|
||||
|
||||
ws.on("open", () => {
|
||||
opened = true;
|
||||
opts.statusSink?.({
|
||||
connected: true,
|
||||
lastConnectedAt: Date.now(),
|
||||
lastError: null,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
seq: opts.nextSeq(),
|
||||
action: "authentication_challenge",
|
||||
data: { token: opts.botToken },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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 = parsePostedPayload(payload);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await opts.onPosted(parsed.post, parsed.payload);
|
||||
} catch (err) {
|
||||
opts.runtime.error?.(`mattermost handler failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", (code, reason) => {
|
||||
const message = reasonToString(reason);
|
||||
opts.statusSink?.({
|
||||
connected: false,
|
||||
lastDisconnect: {
|
||||
at: Date.now(),
|
||||
status: code,
|
||||
error: message || undefined,
|
||||
},
|
||||
});
|
||||
if (opened) {
|
||||
resolveOnce();
|
||||
return;
|
||||
}
|
||||
rejectOnce(new WebSocketClosedBeforeOpenError(code, message || undefined));
|
||||
});
|
||||
|
||||
ws.on("error", (err) => {
|
||||
opts.runtime.error?.(`mattermost websocket error: ${String(err)}`);
|
||||
opts.statusSink?.({
|
||||
lastError: String(err),
|
||||
});
|
||||
try {
|
||||
ws.close();
|
||||
} catch {}
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
opts.abortSignal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function reasonToString(reason: Buffer | string | undefined): string {
|
||||
if (!reason) {
|
||||
return "";
|
||||
}
|
||||
if (typeof reason === "string") {
|
||||
return reason;
|
||||
}
|
||||
return reason.length > 0 ? reason.toString("utf8") : "";
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { resolveControlCommandGate } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveMattermostEffectiveAllowFromLists } from "./monitor-auth.js";
|
||||
|
||||
describe("mattermost monitor authz", () => {
|
||||
it("keeps DM allowlist merged with pairing-store entries", () => {
|
||||
const resolved = resolveMattermostEffectiveAllowFromLists({
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["@trusted-user"],
|
||||
groupAllowFrom: ["@group-owner"],
|
||||
storeAllowFrom: ["user:attacker"],
|
||||
});
|
||||
|
||||
expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]);
|
||||
});
|
||||
|
||||
it("uses explicit groupAllowFrom without pairing-store inheritance", () => {
|
||||
const resolved = resolveMattermostEffectiveAllowFromLists({
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["@trusted-user"],
|
||||
groupAllowFrom: ["@group-owner"],
|
||||
storeAllowFrom: ["user:attacker"],
|
||||
});
|
||||
|
||||
expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]);
|
||||
});
|
||||
|
||||
it("does not inherit pairing-store entries into group allowlist", () => {
|
||||
const resolved = resolveMattermostEffectiveAllowFromLists({
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["@trusted-user"],
|
||||
storeAllowFrom: ["user:attacker"],
|
||||
});
|
||||
|
||||
expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]);
|
||||
expect(resolved.effectiveGroupAllowFrom).toEqual(["trusted-user"]);
|
||||
});
|
||||
|
||||
it("does not auto-authorize DM commands in open mode without allowlists", () => {
|
||||
const resolved = resolveMattermostEffectiveAllowFromLists({
|
||||
dmPolicy: "open",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
storeAllowFrom: [],
|
||||
});
|
||||
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups: true,
|
||||
authorizers: [
|
||||
{ configured: resolved.effectiveAllowFrom.length > 0, allowed: false },
|
||||
{ configured: resolved.effectiveGroupAllowFrom.length > 0, allowed: false },
|
||||
],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
});
|
||||
|
||||
expect(commandGate.commandAuthorized).toBe(false);
|
||||
});
|
||||
});
|
||||
1015
openclaw/extensions/mattermost/src/mattermost/monitor.ts
Normal file
1015
openclaw/extensions/mattermost/src/mattermost/monitor.ts
Normal file
File diff suppressed because it is too large
Load Diff
97
openclaw/extensions/mattermost/src/mattermost/probe.test.ts
Normal file
97
openclaw/extensions/mattermost/src/mattermost/probe.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { probeMattermost } from "./probe.js";
|
||||
|
||||
const mockFetch = vi.fn<typeof fetch>();
|
||||
|
||||
describe("probeMattermost", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("returns baseUrl missing for empty base URL", async () => {
|
||||
await expect(probeMattermost(" ", "token")).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "baseUrl missing",
|
||||
});
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes base URL and returns bot info", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ id: "bot-1", username: "clawbot" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await probeMattermost("https://mm.example.com/api/v4/", "bot-token");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://mm.example.com/api/v4/users/me",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer bot-token" },
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
status: 200,
|
||||
bot: { id: "bot-1", username: "clawbot" },
|
||||
}),
|
||||
);
|
||||
expect(result.elapsedMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("returns API error details from JSON response", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ message: "invalid auth token" }), {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(probeMattermost("https://mm.example.com", "bad-token")).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: false,
|
||||
status: 401,
|
||||
error: "invalid auth token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to statusText when error body is empty", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response("", {
|
||||
status: 403,
|
||||
statusText: "Forbidden",
|
||||
headers: { "content-type": "text/plain" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: "Forbidden",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns fetch error when request throws", async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error("network down"));
|
||||
|
||||
await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: false,
|
||||
status: null,
|
||||
error: "network down",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
61
openclaw/extensions/mattermost/src/mattermost/probe.ts
Normal file
61
openclaw/extensions/mattermost/src/mattermost/probe.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk";
|
||||
import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js";
|
||||
|
||||
export type MattermostProbe = BaseProbeResult & {
|
||||
status?: number | null;
|
||||
elapsedMs?: number | null;
|
||||
bot?: MattermostUser;
|
||||
};
|
||||
|
||||
export async function probeMattermost(
|
||||
baseUrl: string,
|
||||
botToken: string,
|
||||
timeoutMs = 2500,
|
||||
): Promise<MattermostProbe> {
|
||||
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
||||
if (!normalized) {
|
||||
return { ok: false, error: "baseUrl missing" };
|
||||
}
|
||||
const url = `${normalized}/api/v4/users/me`;
|
||||
const start = Date.now();
|
||||
const controller = timeoutMs > 0 ? new AbortController() : undefined;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
if (controller) {
|
||||
timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${botToken}` },
|
||||
signal: controller?.signal,
|
||||
});
|
||||
const elapsedMs = Date.now() - start;
|
||||
if (!res.ok) {
|
||||
const detail = await readMattermostError(res);
|
||||
return {
|
||||
ok: false,
|
||||
status: res.status,
|
||||
error: detail || res.statusText,
|
||||
elapsedMs,
|
||||
};
|
||||
}
|
||||
const bot = (await res.json()) as MattermostUser;
|
||||
return {
|
||||
ok: true,
|
||||
status: res.status,
|
||||
elapsedMs,
|
||||
bot,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
status: null,
|
||||
error: message,
|
||||
elapsedMs: Date.now() - start,
|
||||
};
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { expect, vi } from "vitest";
|
||||
|
||||
export function createMattermostTestConfig(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
botToken: "test-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMattermostReactionFetchMock(params: {
|
||||
postId: string;
|
||||
emojiName: string;
|
||||
mode: "add" | "remove" | "both";
|
||||
userId?: string;
|
||||
status?: number;
|
||||
body?: unknown;
|
||||
}) {
|
||||
const userId = params.userId ?? "BOT123";
|
||||
const mode = params.mode;
|
||||
const allowAdd = mode === "add" || mode === "both";
|
||||
const allowRemove = mode === "remove" || mode === "both";
|
||||
const addStatus = params.status ?? 201;
|
||||
const removeStatus = params.status ?? 204;
|
||||
const removePath = `/api/v4/users/${userId}/posts/${params.postId}/reactions/${encodeURIComponent(params.emojiName)}`;
|
||||
|
||||
return vi.fn(async (url: any, init?: any) => {
|
||||
if (String(url).endsWith("/api/v4/users/me")) {
|
||||
return new Response(JSON.stringify({ id: userId }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (allowAdd && String(url).endsWith("/api/v4/reactions")) {
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(JSON.parse(init?.body)).toEqual({
|
||||
user_id: userId,
|
||||
post_id: params.postId,
|
||||
emoji_name: params.emojiName,
|
||||
});
|
||||
|
||||
const responseBody = params.body === undefined ? { ok: true } : params.body;
|
||||
return new Response(
|
||||
responseBody === null ? null : JSON.stringify(responseBody),
|
||||
responseBody === null
|
||||
? { status: addStatus, headers: { "content-type": "text/plain" } }
|
||||
: { status: addStatus, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (allowRemove && String(url).endsWith(removePath)) {
|
||||
expect(init?.method).toBe("DELETE");
|
||||
const responseBody = params.body === undefined ? null : params.body;
|
||||
return new Response(
|
||||
responseBody === null ? null : JSON.stringify(responseBody),
|
||||
responseBody === null
|
||||
? { status: removeStatus, headers: { "content-type": "text/plain" } }
|
||||
: { status: removeStatus, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected url: ${url}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function withMockedGlobalFetch<T>(
|
||||
fetchImpl: typeof fetch,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const prevFetch = globalThis.fetch;
|
||||
(globalThis as any).fetch = fetchImpl;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
(globalThis as any).fetch = prevFetch;
|
||||
}
|
||||
}
|
||||
103
openclaw/extensions/mattermost/src/mattermost/reactions.test.ts
Normal file
103
openclaw/extensions/mattermost/src/mattermost/reactions.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
addMattermostReaction,
|
||||
removeMattermostReaction,
|
||||
resetMattermostReactionBotUserCacheForTests,
|
||||
} from "./reactions.js";
|
||||
import {
|
||||
createMattermostReactionFetchMock,
|
||||
createMattermostTestConfig,
|
||||
} from "./reactions.test-helpers.js";
|
||||
|
||||
describe("mattermost reactions", () => {
|
||||
beforeEach(() => {
|
||||
resetMattermostReactionBotUserCacheForTests();
|
||||
});
|
||||
|
||||
it("adds reactions by calling /users/me then POST /reactions", async () => {
|
||||
const fetchMock = createMattermostReactionFetchMock({
|
||||
mode: "add",
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
});
|
||||
|
||||
const result = await addMattermostReaction({
|
||||
cfg: createMattermostTestConfig(),
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a Result error when add reaction API call fails", async () => {
|
||||
const fetchMock = createMattermostReactionFetchMock({
|
||||
mode: "add",
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
status: 500,
|
||||
body: { id: "err", message: "boom" },
|
||||
});
|
||||
|
||||
const result = await addMattermostReaction({
|
||||
cfg: createMattermostTestConfig(),
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("Mattermost add reaction failed");
|
||||
}
|
||||
});
|
||||
|
||||
it("removes reactions by calling /users/me then DELETE /users/:id/posts/:postId/reactions/:emoji", async () => {
|
||||
const fetchMock = createMattermostReactionFetchMock({
|
||||
mode: "remove",
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
});
|
||||
|
||||
const result = await removeMattermostReaction({
|
||||
cfg: createMattermostTestConfig(),
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caches the bot user id across reaction mutations", async () => {
|
||||
const fetchMock = createMattermostReactionFetchMock({
|
||||
mode: "both",
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
});
|
||||
|
||||
const cfg = createMattermostTestConfig();
|
||||
const addResult = await addMattermostReaction({
|
||||
cfg,
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||
});
|
||||
const removeResult = await removeMattermostReaction({
|
||||
cfg,
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
const usersMeCalls = fetchMock.mock.calls.filter((call) =>
|
||||
String(call[0]).endsWith("/api/v4/users/me"),
|
||||
);
|
||||
expect(addResult).toEqual({ ok: true });
|
||||
expect(removeResult).toEqual({ ok: true });
|
||||
expect(usersMeCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
124
openclaw/extensions/mattermost/src/mattermost/reactions.ts
Normal file
124
openclaw/extensions/mattermost/src/mattermost/reactions.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
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 };
|
||||
type ReactionParams = {
|
||||
cfg: OpenClawConfig;
|
||||
postId: string;
|
||||
emojiName: string;
|
||||
accountId?: string | null;
|
||||
fetchImpl?: typeof fetch;
|
||||
};
|
||||
type ReactionMutation = (client: MattermostClient, params: MutationPayload) => Promise<void>;
|
||||
type MutationPayload = { userId: string; postId: string; emojiName: 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> {
|
||||
return runMattermostReaction(params, {
|
||||
action: "add",
|
||||
mutation: createReaction,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeMattermostReaction(params: {
|
||||
cfg: OpenClawConfig;
|
||||
postId: string;
|
||||
emojiName: string;
|
||||
accountId?: string | null;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<Result> {
|
||||
return runMattermostReaction(params, {
|
||||
action: "remove",
|
||||
mutation: deleteReaction,
|
||||
});
|
||||
}
|
||||
|
||||
export function resetMattermostReactionBotUserCacheForTests(): void {
|
||||
botUserIdCache.clear();
|
||||
}
|
||||
|
||||
async function runMattermostReaction(
|
||||
params: ReactionParams,
|
||||
options: {
|
||||
action: "add" | "remove";
|
||||
mutation: ReactionMutation;
|
||||
},
|
||||
): 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." };
|
||||
}
|
||||
|
||||
try {
|
||||
await options.mutation(client, {
|
||||
userId,
|
||||
postId: params.postId,
|
||||
emojiName: params.emojiName,
|
||||
});
|
||||
} catch (err) {
|
||||
return { ok: false, error: `Mattermost ${options.action} reaction failed: ${String(err)}` };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function createReaction(client: MattermostClient, params: MutationPayload): 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: MutationPayload): Promise<void> {
|
||||
const emoji = encodeURIComponent(params.emojiName);
|
||||
await client.request<unknown>(
|
||||
`/users/${params.userId}/posts/${params.postId}/reactions/${emoji}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
}
|
||||
192
openclaw/extensions/mattermost/src/mattermost/reconnect.test.ts
Normal file
192
openclaw/extensions/mattermost/src/mattermost/reconnect.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runWithReconnect } from "./reconnect.js";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("runWithReconnect", () => {
|
||||
it("retries after connectFn resolves (normal close)", async () => {
|
||||
let callCount = 0;
|
||||
const abort = new AbortController();
|
||||
const connectFn = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount >= 3) {
|
||||
abort.abort();
|
||||
}
|
||||
});
|
||||
|
||||
await runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
initialDelayMs: 1,
|
||||
});
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("retries after connectFn throws (connection error)", async () => {
|
||||
let callCount = 0;
|
||||
const abort = new AbortController();
|
||||
const onError = vi.fn();
|
||||
const connectFn = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount < 3) {
|
||||
throw new Error("fetch failed");
|
||||
}
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
await runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
onError,
|
||||
initialDelayMs: 1,
|
||||
});
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(3);
|
||||
expect(onError).toHaveBeenCalledTimes(2);
|
||||
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: "fetch failed" }));
|
||||
});
|
||||
|
||||
it("uses exponential backoff on consecutive errors, capped at maxDelayMs", async () => {
|
||||
const abort = new AbortController();
|
||||
const delays: number[] = [];
|
||||
let callCount = 0;
|
||||
const connectFn = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount >= 6) {
|
||||
abort.abort();
|
||||
return;
|
||||
}
|
||||
throw new Error("connection refused");
|
||||
});
|
||||
|
||||
await runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
onReconnect: (delayMs) => delays.push(delayMs),
|
||||
// Keep this test fast: validate the exponential pattern, not real-time waiting.
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 10,
|
||||
});
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(6);
|
||||
// 5 errors produce delays: 1, 2, 4, 8, 10(cap)
|
||||
// 6th succeeds -> delay resets to 100
|
||||
// But 6th also aborts → onReconnect NOT called (abort check fires first)
|
||||
expect(delays).toEqual([1, 2, 4, 8, 10]);
|
||||
});
|
||||
|
||||
it("resets backoff after successful connection", async () => {
|
||||
const abort = new AbortController();
|
||||
const delays: number[] = [];
|
||||
let callCount = 0;
|
||||
const connectFn = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
throw new Error("first failure");
|
||||
}
|
||||
if (callCount === 2) {
|
||||
return; // success
|
||||
}
|
||||
if (callCount === 3) {
|
||||
throw new Error("second failure");
|
||||
}
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
await runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
onReconnect: (delayMs) => delays.push(delayMs),
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 60_000,
|
||||
});
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(4);
|
||||
// call 1: fail -> delay 1
|
||||
// call 2: success → delay resets to 1
|
||||
// call 3: fail -> delay 1 (reset held)
|
||||
// call 4: success + abort → no onReconnect
|
||||
expect(delays).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it("stops immediately when abort signal is pre-fired", async () => {
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
const connectFn = vi.fn(async () => {});
|
||||
|
||||
await runWithReconnect(connectFn, { abortSignal: abort.signal });
|
||||
|
||||
expect(connectFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops after current connection when abort fires mid-connection", async () => {
|
||||
const abort = new AbortController();
|
||||
const connectFn = vi.fn(async () => {
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
await runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
initialDelayMs: 1,
|
||||
});
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("abort signal interrupts backoff sleep immediately", async () => {
|
||||
const abort = new AbortController();
|
||||
const connectFn = vi.fn(async () => {
|
||||
// Schedule abort to fire 10ms into the 60s sleep
|
||||
setTimeout(() => abort.abort(), 10);
|
||||
});
|
||||
|
||||
const start = Date.now();
|
||||
await runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
initialDelayMs: 60_000,
|
||||
});
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(1);
|
||||
expect(elapsed).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
it("applies jitter to reconnect delay when configured", async () => {
|
||||
const abort = new AbortController();
|
||||
const delays: number[] = [];
|
||||
let callCount = 0;
|
||||
const connectFn = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
throw new Error("connection refused");
|
||||
}
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
await runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
onReconnect: (delayMs) => delays.push(delayMs),
|
||||
initialDelayMs: 10,
|
||||
jitterRatio: 0.5,
|
||||
random: () => 1,
|
||||
});
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(2);
|
||||
expect(delays).toEqual([15]);
|
||||
});
|
||||
|
||||
it("supports strategy hook to stop reconnecting after failure", async () => {
|
||||
const onReconnect = vi.fn();
|
||||
const connectFn = vi.fn(async () => {
|
||||
throw new Error("fatal");
|
||||
});
|
||||
|
||||
await runWithReconnect(connectFn, {
|
||||
initialDelayMs: 1,
|
||||
onReconnect,
|
||||
shouldReconnect: (params) => params.outcome !== "rejected",
|
||||
});
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(1);
|
||||
expect(onReconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
103
openclaw/extensions/mattermost/src/mattermost/reconnect.ts
Normal file
103
openclaw/extensions/mattermost/src/mattermost/reconnect.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
export type ReconnectOutcome = "resolved" | "rejected";
|
||||
|
||||
export type ShouldReconnectParams = {
|
||||
attempt: number;
|
||||
delayMs: number;
|
||||
outcome: ReconnectOutcome;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
export type RunWithReconnectOpts = {
|
||||
abortSignal?: AbortSignal;
|
||||
onError?: (err: unknown) => void;
|
||||
onReconnect?: (delayMs: number) => void;
|
||||
initialDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
jitterRatio?: number;
|
||||
random?: () => number;
|
||||
shouldReconnect?: (params: ShouldReconnectParams) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconnection loop with exponential backoff.
|
||||
*
|
||||
* Calls `connectFn` in a while loop. On normal resolve (connection closed),
|
||||
* the backoff resets. On thrown error (connection failed), the current delay is
|
||||
* used, then doubled for the next retry.
|
||||
* The loop exits when `abortSignal` fires.
|
||||
*/
|
||||
export async function runWithReconnect(
|
||||
connectFn: () => Promise<void>,
|
||||
opts: RunWithReconnectOpts = {},
|
||||
): Promise<void> {
|
||||
const { initialDelayMs = 2000, maxDelayMs = 60_000 } = opts;
|
||||
const jitterRatio = Math.max(0, opts.jitterRatio ?? 0);
|
||||
const random = opts.random ?? Math.random;
|
||||
let retryDelay = initialDelayMs;
|
||||
let attempt = 0;
|
||||
|
||||
while (!opts.abortSignal?.aborted) {
|
||||
let shouldIncreaseDelay = false;
|
||||
let outcome: ReconnectOutcome = "resolved";
|
||||
let error: unknown;
|
||||
try {
|
||||
await connectFn();
|
||||
retryDelay = initialDelayMs;
|
||||
} catch (err) {
|
||||
if (opts.abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
outcome = "rejected";
|
||||
error = err;
|
||||
opts.onError?.(err);
|
||||
shouldIncreaseDelay = true;
|
||||
}
|
||||
if (opts.abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
const delayMs = withJitter(retryDelay, jitterRatio, random);
|
||||
const shouldReconnect =
|
||||
opts.shouldReconnect?.({
|
||||
attempt,
|
||||
delayMs,
|
||||
outcome,
|
||||
error,
|
||||
}) ?? true;
|
||||
if (!shouldReconnect) {
|
||||
return;
|
||||
}
|
||||
opts.onReconnect?.(delayMs);
|
||||
await sleepAbortable(delayMs, opts.abortSignal);
|
||||
if (shouldIncreaseDelay) {
|
||||
retryDelay = Math.min(retryDelay * 2, maxDelayMs);
|
||||
}
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
|
||||
function withJitter(baseMs: number, jitterRatio: number, random: () => number): number {
|
||||
if (jitterRatio <= 0) {
|
||||
return baseMs;
|
||||
}
|
||||
const normalized = Math.max(0, Math.min(1, random()));
|
||||
const spread = baseMs * jitterRatio;
|
||||
return Math.max(1, Math.round(baseMs - spread + normalized * spread * 2));
|
||||
}
|
||||
|
||||
function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
231
openclaw/extensions/mattermost/src/mattermost/send.ts
Normal file
231
openclaw/extensions/mattermost/src/mattermost/send.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
createMattermostDirectChannel,
|
||||
createMattermostPost,
|
||||
fetchMattermostMe,
|
||||
fetchMattermostUserByUsername,
|
||||
normalizeMattermostBaseUrl,
|
||||
uploadMattermostFile,
|
||||
type MattermostUser,
|
||||
} from "./client.js";
|
||||
|
||||
export type MattermostSendOpts = {
|
||||
botToken?: string;
|
||||
baseUrl?: string;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
replyToId?: string;
|
||||
};
|
||||
|
||||
export type MattermostSendResult = {
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
type MattermostTarget =
|
||||
| { kind: "channel"; id: string }
|
||||
| { kind: "user"; id?: string; username?: string };
|
||||
|
||||
const botUserCache = new Map<string, MattermostUser>();
|
||||
const userByNameCache = new Map<string, MattermostUser>();
|
||||
|
||||
const getCore = () => getMattermostRuntime();
|
||||
|
||||
function cacheKey(baseUrl: string, token: string): string {
|
||||
return `${baseUrl}::${token}`;
|
||||
}
|
||||
|
||||
function normalizeMessage(text: string, mediaUrl?: string): string {
|
||||
const trimmed = text.trim();
|
||||
const media = mediaUrl?.trim();
|
||||
return [trimmed, media].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value);
|
||||
}
|
||||
|
||||
function parseMattermostTarget(raw: string): MattermostTarget {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Recipient is required for Mattermost sends");
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.startsWith("channel:")) {
|
||||
const id = trimmed.slice("channel:".length).trim();
|
||||
if (!id) {
|
||||
throw new Error("Channel id is required for Mattermost sends");
|
||||
}
|
||||
return { kind: "channel", id };
|
||||
}
|
||||
if (lower.startsWith("user:")) {
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
if (!id) {
|
||||
throw new Error("User id is required for Mattermost sends");
|
||||
}
|
||||
return { kind: "user", id };
|
||||
}
|
||||
if (lower.startsWith("mattermost:")) {
|
||||
const id = trimmed.slice("mattermost:".length).trim();
|
||||
if (!id) {
|
||||
throw new Error("User id is required for Mattermost sends");
|
||||
}
|
||||
return { kind: "user", id };
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const username = trimmed.slice(1).trim();
|
||||
if (!username) {
|
||||
throw new Error("Username is required for Mattermost sends");
|
||||
}
|
||||
return { kind: "user", username };
|
||||
}
|
||||
return { kind: "channel", id: trimmed };
|
||||
}
|
||||
|
||||
async function resolveBotUser(baseUrl: string, token: string): Promise<MattermostUser> {
|
||||
const key = cacheKey(baseUrl, token);
|
||||
const cached = botUserCache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const client = createMattermostClient({ baseUrl, botToken: token });
|
||||
const user = await fetchMattermostMe(client);
|
||||
botUserCache.set(key, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function resolveUserIdByUsername(params: {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
username: string;
|
||||
}): Promise<string> {
|
||||
const { baseUrl, token, username } = params;
|
||||
const key = `${cacheKey(baseUrl, token)}::${username.toLowerCase()}`;
|
||||
const cached = userByNameCache.get(key);
|
||||
if (cached?.id) {
|
||||
return cached.id;
|
||||
}
|
||||
const client = createMattermostClient({ baseUrl, botToken: token });
|
||||
const user = await fetchMattermostUserByUsername(client, username);
|
||||
userByNameCache.set(key, user);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async function resolveTargetChannelId(params: {
|
||||
target: MattermostTarget;
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
}): Promise<string> {
|
||||
if (params.target.kind === "channel") {
|
||||
return params.target.id;
|
||||
}
|
||||
const userId = params.target.id
|
||||
? params.target.id
|
||||
: await resolveUserIdByUsername({
|
||||
baseUrl: params.baseUrl,
|
||||
token: params.token,
|
||||
username: params.target.username ?? "",
|
||||
});
|
||||
const botUser = await resolveBotUser(params.baseUrl, params.token);
|
||||
const client = createMattermostClient({
|
||||
baseUrl: params.baseUrl,
|
||||
botToken: params.token,
|
||||
});
|
||||
const channel = await createMattermostDirectChannel(client, [botUser.id, userId]);
|
||||
return channel.id;
|
||||
}
|
||||
|
||||
export async function sendMessageMattermost(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: MattermostSendOpts = {},
|
||||
): Promise<MattermostSendResult> {
|
||||
const core = getCore();
|
||||
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||
const cfg = core.config.loadConfig();
|
||||
const account = resolveMattermostAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = opts.botToken?.trim() || account.botToken?.trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
`Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`,
|
||||
);
|
||||
}
|
||||
const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl);
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
`Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`,
|
||||
);
|
||||
}
|
||||
|
||||
const target = parseMattermostTarget(to);
|
||||
const channelId = await resolveTargetChannelId({
|
||||
target,
|
||||
baseUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
const client = createMattermostClient({ baseUrl, botToken: token });
|
||||
let message = text?.trim() ?? "";
|
||||
let fileIds: string[] | undefined;
|
||||
let uploadError: Error | undefined;
|
||||
const mediaUrl = opts.mediaUrl?.trim();
|
||||
if (mediaUrl) {
|
||||
try {
|
||||
const media = await core.media.loadWebMedia(mediaUrl);
|
||||
const fileInfo = await uploadMattermostFile(client, {
|
||||
channelId,
|
||||
buffer: media.buffer,
|
||||
fileName: media.fileName ?? "upload",
|
||||
contentType: media.contentType ?? undefined,
|
||||
});
|
||||
fileIds = [fileInfo.id];
|
||||
} catch (err) {
|
||||
uploadError = err instanceof Error ? err : new Error(String(err));
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
logger.debug?.(
|
||||
`mattermost send: media upload failed, falling back to URL text: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
message = normalizeMessage(message, isHttpUrl(mediaUrl) ? mediaUrl : "");
|
||||
}
|
||||
}
|
||||
|
||||
if (message) {
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
message = core.channel.text.convertMarkdownTables(message, tableMode);
|
||||
}
|
||||
|
||||
if (!message && (!fileIds || fileIds.length === 0)) {
|
||||
if (uploadError) {
|
||||
throw new Error(`Mattermost media upload failed: ${uploadError.message}`);
|
||||
}
|
||||
throw new Error("Mattermost message is empty");
|
||||
}
|
||||
|
||||
const post = await createMattermostPost(client, {
|
||||
channelId,
|
||||
message,
|
||||
rootId: opts.replyToId,
|
||||
fileIds,
|
||||
});
|
||||
|
||||
core.channel.activity.record({
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
return {
|
||||
messageId: post.id ?? "unknown",
|
||||
channelId,
|
||||
};
|
||||
}
|
||||
46
openclaw/extensions/mattermost/src/normalize.ts
Normal file
46
openclaw/extensions/mattermost/src/normalize.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export function normalizeMattermostMessagingTarget(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.startsWith("channel:")) {
|
||||
const id = trimmed.slice("channel:".length).trim();
|
||||
return id ? `channel:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("group:")) {
|
||||
const id = trimmed.slice("group:".length).trim();
|
||||
return id ? `channel:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("user:")) {
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("mattermost:")) {
|
||||
const id = trimmed.slice("mattermost:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const id = trimmed.slice(1).trim();
|
||||
return id ? `@${id}` : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("#")) {
|
||||
const id = trimmed.slice(1).trim();
|
||||
return id ? `channel:${id}` : undefined;
|
||||
}
|
||||
return `channel:${trimmed}`;
|
||||
}
|
||||
|
||||
export function looksLikeMattermostTargetId(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (/^(user|channel|group|mattermost):/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (/^[@#]/.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
return /^[a-z0-9]{8,}$/i.test(trimmed);
|
||||
}
|
||||
1
openclaw/extensions/mattermost/src/onboarding-helpers.ts
Normal file
1
openclaw/extensions/mattermost/src/onboarding-helpers.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { promptAccountId } from "openclaw/plugin-sdk";
|
||||
178
openclaw/extensions/mattermost/src/onboarding.ts
Normal file
178
openclaw/extensions/mattermost/src/onboarding.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { ChannelOnboardingAdapter, OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import {
|
||||
listMattermostAccountIds,
|
||||
resolveDefaultMattermostAccountId,
|
||||
resolveMattermostAccount,
|
||||
} from "./mattermost/accounts.js";
|
||||
import { promptAccountId } from "./onboarding-helpers.js";
|
||||
|
||||
const channel = "mattermost" as const;
|
||||
|
||||
async function noteMattermostSetup(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"1) Mattermost System Console -> Integrations -> Bot Accounts",
|
||||
"2) Create a bot + copy its token",
|
||||
"3) Use your server base URL (e.g., https://chat.example.com)",
|
||||
"Tip: the bot must be a member of any channel you want it to monitor.",
|
||||
"Docs: https://docs.openclaw.ai/channels/mattermost",
|
||||
].join("\n"),
|
||||
"Mattermost bot token",
|
||||
);
|
||||
}
|
||||
|
||||
async function promptMattermostCredentials(prompter: WizardPrompter): Promise<{
|
||||
botToken: string;
|
||||
baseUrl: string;
|
||||
}> {
|
||||
const botToken = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
const baseUrl = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost base URL",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
return { botToken, baseUrl };
|
||||
}
|
||||
|
||||
export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listMattermostAccountIds(cfg).some((accountId) => {
|
||||
const account = resolveMattermostAccount({ cfg, accountId });
|
||||
return Boolean(account.botToken && account.baseUrl);
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`Mattermost: ${configured ? "configured" : "needs token + url"}`],
|
||||
selectionHint: configured ? "configured" : "needs setup",
|
||||
quickstartScore: configured ? 2 : 1,
|
||||
};
|
||||
},
|
||||
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
|
||||
const override = accountOverrides.mattermost?.trim();
|
||||
const defaultAccountId = resolveDefaultMattermostAccountId(cfg);
|
||||
let accountId = override ? normalizeAccountId(override) : defaultAccountId;
|
||||
if (shouldPromptAccountIds && !override) {
|
||||
accountId = await promptAccountId({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "Mattermost",
|
||||
currentId: accountId,
|
||||
listAccountIds: listMattermostAccountIds,
|
||||
defaultAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg;
|
||||
const resolvedAccount = resolveMattermostAccount({
|
||||
cfg: next,
|
||||
accountId,
|
||||
});
|
||||
const accountConfigured = Boolean(resolvedAccount.botToken && resolvedAccount.baseUrl);
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const canUseEnv =
|
||||
allowEnv &&
|
||||
Boolean(process.env.MATTERMOST_BOT_TOKEN?.trim()) &&
|
||||
Boolean(process.env.MATTERMOST_URL?.trim());
|
||||
const hasConfigValues =
|
||||
Boolean(resolvedAccount.config.botToken) || Boolean(resolvedAccount.config.baseUrl);
|
||||
|
||||
let botToken: string | null = null;
|
||||
let baseUrl: string | null = null;
|
||||
|
||||
if (!accountConfigured) {
|
||||
await noteMattermostSetup(prompter);
|
||||
}
|
||||
|
||||
if (canUseEnv && !hasConfigValues) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message: "MATTERMOST_BOT_TOKEN + MATTERMOST_URL detected. Use env vars?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (keepEnv) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
mattermost: {
|
||||
...next.channels?.mattermost,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const entered = await promptMattermostCredentials(prompter);
|
||||
botToken = entered.botToken;
|
||||
baseUrl = entered.baseUrl;
|
||||
}
|
||||
} else if (accountConfigured) {
|
||||
const keep = await prompter.confirm({
|
||||
message: "Mattermost credentials already configured. Keep them?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keep) {
|
||||
const entered = await promptMattermostCredentials(prompter);
|
||||
botToken = entered.botToken;
|
||||
baseUrl = entered.baseUrl;
|
||||
}
|
||||
} else {
|
||||
const entered = await promptMattermostCredentials(prompter);
|
||||
botToken = entered.botToken;
|
||||
baseUrl = entered.baseUrl;
|
||||
}
|
||||
|
||||
if (botToken || baseUrl) {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
mattermost: {
|
||||
...next.channels?.mattermost,
|
||||
enabled: true,
|
||||
...(botToken ? { botToken } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
mattermost: {
|
||||
...next.channels?.mattermost,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.mattermost?.accounts,
|
||||
[accountId]: {
|
||||
...next.channels?.mattermost?.accounts?.[accountId],
|
||||
enabled: next.channels?.mattermost?.accounts?.[accountId]?.enabled ?? true,
|
||||
...(botToken ? { botToken } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { cfg: next, accountId };
|
||||
},
|
||||
disable: (cfg: OpenClawConfig) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
mattermost: { ...cfg.channels?.mattermost, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
14
openclaw/extensions/mattermost/src/runtime.ts
Normal file
14
openclaw/extensions/mattermost/src/runtime.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setMattermostRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getMattermostRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Mattermost runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
62
openclaw/extensions/mattermost/src/types.ts
Normal file
62
openclaw/extensions/mattermost/src/types.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "openclaw/plugin-sdk";
|
||||
|
||||
export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
|
||||
|
||||
export type MattermostAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/**
|
||||
* Break-glass override: allow mutable identity matching (@username/display name) in allowlists.
|
||||
* Default behavior is ID-only matching.
|
||||
*/
|
||||
dangerouslyAllowNameMatching?: boolean;
|
||||
/** Allow channel-initiated config writes (default: true). */
|
||||
configWrites?: boolean;
|
||||
/** If false, do not start this Mattermost account. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Bot token for Mattermost. */
|
||||
botToken?: string;
|
||||
/** Base URL for the Mattermost server (e.g., https://chat.example.com). */
|
||||
baseUrl?: string;
|
||||
/**
|
||||
* Controls when channel messages trigger replies.
|
||||
* - "oncall": only respond when mentioned
|
||||
* - "onmessage": respond to every channel message
|
||||
* - "onchar": respond when a trigger character prefixes the message
|
||||
*/
|
||||
chatmode?: MattermostChatMode;
|
||||
/** Prefix characters that trigger onchar mode (default: [">", "!"]). */
|
||||
oncharPrefixes?: string[];
|
||||
/** Require @mention to respond in channels. Default: true. */
|
||||
requireMention?: boolean;
|
||||
/** Direct message policy (pairing/allowlist/open/disabled). */
|
||||
dmPolicy?: DmPolicy;
|
||||
/** Allowlist for direct messages (user ids or @usernames). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Allowlist for group messages (user ids or @usernames). */
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
/** Group message policy (allowlist/open/disabled). */
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
/** Disable block streaming for this account. */
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
/** Action toggles for this account. */
|
||||
actions?: {
|
||||
/** Enable message reaction actions. Default: true. */
|
||||
reactions?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type MattermostConfig = {
|
||||
/** Optional per-account Mattermost configuration (multi-account). */
|
||||
accounts?: Record<string, MattermostAccountConfig>;
|
||||
} & MattermostAccountConfig;
|
||||
Reference in New Issue
Block a user