@openclaw/mattermost 2026.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/channel.ts ADDED
@@ -0,0 +1,433 @@
1
+ import {
2
+ applyAccountNameToChannelSection,
3
+ buildChannelConfigSchema,
4
+ DEFAULT_ACCOUNT_ID,
5
+ deleteAccountFromConfigSection,
6
+ formatPairingApproveHint,
7
+ migrateBaseNameToDefaultAccount,
8
+ normalizeAccountId,
9
+ setAccountEnabledInConfigSection,
10
+ type ChannelMessageActionAdapter,
11
+ type ChannelMessageActionName,
12
+ type ChannelPlugin,
13
+ } from "openclaw/plugin-sdk";
14
+ import { MattermostConfigSchema } from "./config-schema.js";
15
+ import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
16
+ import {
17
+ listMattermostAccountIds,
18
+ resolveDefaultMattermostAccountId,
19
+ resolveMattermostAccount,
20
+ type ResolvedMattermostAccount,
21
+ } from "./mattermost/accounts.js";
22
+ import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
23
+ import { monitorMattermostProvider } from "./mattermost/monitor.js";
24
+ import { probeMattermost } from "./mattermost/probe.js";
25
+ import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
26
+ import { sendMessageMattermost } from "./mattermost/send.js";
27
+ import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
28
+ import { mattermostOnboardingAdapter } from "./onboarding.js";
29
+ import { getMattermostRuntime } from "./runtime.js";
30
+
31
+ const mattermostMessageActions: ChannelMessageActionAdapter = {
32
+ listActions: ({ cfg }) => {
33
+ const actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined;
34
+ const baseReactions = actionsConfig?.reactions;
35
+ const hasReactionCapableAccount = listMattermostAccountIds(cfg)
36
+ .map((accountId) => resolveMattermostAccount({ cfg, accountId }))
37
+ .filter((account) => account.enabled)
38
+ .filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim()))
39
+ .some((account) => {
40
+ const accountActions = account.config.actions as { reactions?: boolean } | undefined;
41
+ return (accountActions?.reactions ?? baseReactions ?? true) !== false;
42
+ });
43
+
44
+ if (!hasReactionCapableAccount) {
45
+ return [];
46
+ }
47
+
48
+ return ["react"];
49
+ },
50
+ supportsAction: ({ action }) => {
51
+ return action === "react";
52
+ },
53
+ handleAction: async ({ action, params, cfg, accountId }) => {
54
+ if (action !== "react") {
55
+ throw new Error(`Mattermost action ${action} not supported`);
56
+ }
57
+ // Check reactions gate: per-account config takes precedence over base config
58
+ const mmBase = cfg?.channels?.mattermost as Record<string, unknown> | undefined;
59
+ const accounts = mmBase?.accounts as Record<string, Record<string, unknown>> | undefined;
60
+ const resolvedAccountId = accountId ?? resolveDefaultMattermostAccountId(cfg);
61
+ const acctConfig = accounts?.[resolvedAccountId];
62
+ const acctActions = acctConfig?.actions as { reactions?: boolean } | undefined;
63
+ const baseActions = mmBase?.actions as { reactions?: boolean } | undefined;
64
+ const reactionsEnabled = acctActions?.reactions ?? baseActions?.reactions ?? true;
65
+ if (!reactionsEnabled) {
66
+ throw new Error("Mattermost reactions are disabled in config");
67
+ }
68
+
69
+ const postIdRaw =
70
+ typeof (params as any)?.messageId === "string"
71
+ ? (params as any).messageId
72
+ : typeof (params as any)?.postId === "string"
73
+ ? (params as any).postId
74
+ : "";
75
+ const postId = postIdRaw.trim();
76
+ if (!postId) {
77
+ throw new Error("Mattermost react requires messageId (post id)");
78
+ }
79
+
80
+ const emojiRaw = typeof (params as any)?.emoji === "string" ? (params as any).emoji : "";
81
+ const emojiName = emojiRaw.trim().replace(/^:+|:+$/g, "");
82
+ if (!emojiName) {
83
+ throw new Error("Mattermost react requires emoji");
84
+ }
85
+
86
+ const remove = (params as any)?.remove === true;
87
+ if (remove) {
88
+ const result = await removeMattermostReaction({
89
+ cfg,
90
+ postId,
91
+ emojiName,
92
+ accountId: resolvedAccountId,
93
+ });
94
+ if (!result.ok) {
95
+ throw new Error(result.error);
96
+ }
97
+ return {
98
+ content: [
99
+ { type: "text" as const, text: `Removed reaction :${emojiName}: from ${postId}` },
100
+ ],
101
+ details: {},
102
+ };
103
+ }
104
+
105
+ const result = await addMattermostReaction({
106
+ cfg,
107
+ postId,
108
+ emojiName,
109
+ accountId: resolvedAccountId,
110
+ });
111
+ if (!result.ok) {
112
+ throw new Error(result.error);
113
+ }
114
+
115
+ return {
116
+ content: [{ type: "text" as const, text: `Reacted with :${emojiName}: on ${postId}` }],
117
+ details: {},
118
+ };
119
+ },
120
+ };
121
+
122
+ const meta = {
123
+ id: "mattermost",
124
+ label: "Mattermost",
125
+ selectionLabel: "Mattermost (plugin)",
126
+ detailLabel: "Mattermost Bot",
127
+ docsPath: "/channels/mattermost",
128
+ docsLabel: "mattermost",
129
+ blurb: "self-hosted Slack-style chat; install the plugin to enable.",
130
+ systemImage: "bubble.left.and.bubble.right",
131
+ order: 65,
132
+ quickstartAllowFrom: true,
133
+ } as const;
134
+
135
+ function normalizeAllowEntry(entry: string): string {
136
+ return entry
137
+ .trim()
138
+ .replace(/^(mattermost|user):/i, "")
139
+ .replace(/^@/, "")
140
+ .toLowerCase();
141
+ }
142
+
143
+ function formatAllowEntry(entry: string): string {
144
+ const trimmed = entry.trim();
145
+ if (!trimmed) {
146
+ return "";
147
+ }
148
+ if (trimmed.startsWith("@")) {
149
+ const username = trimmed.slice(1).trim();
150
+ return username ? `@${username.toLowerCase()}` : "";
151
+ }
152
+ return trimmed.replace(/^(mattermost|user):/i, "").toLowerCase();
153
+ }
154
+
155
+ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
156
+ id: "mattermost",
157
+ meta: {
158
+ ...meta,
159
+ },
160
+ onboarding: mattermostOnboardingAdapter,
161
+ pairing: {
162
+ idLabel: "mattermostUserId",
163
+ normalizeAllowEntry: (entry) => normalizeAllowEntry(entry),
164
+ notifyApproval: async ({ id }) => {
165
+ console.log(`[mattermost] User ${id} approved for pairing`);
166
+ },
167
+ },
168
+ capabilities: {
169
+ chatTypes: ["direct", "channel", "group", "thread"],
170
+ reactions: true,
171
+ threads: true,
172
+ media: true,
173
+ },
174
+ streaming: {
175
+ blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
176
+ },
177
+ reload: { configPrefixes: ["channels.mattermost"] },
178
+ configSchema: buildChannelConfigSchema(MattermostConfigSchema),
179
+ config: {
180
+ listAccountIds: (cfg) => listMattermostAccountIds(cfg),
181
+ resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }),
182
+ defaultAccountId: (cfg) => resolveDefaultMattermostAccountId(cfg),
183
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
184
+ setAccountEnabledInConfigSection({
185
+ cfg,
186
+ sectionKey: "mattermost",
187
+ accountId,
188
+ enabled,
189
+ allowTopLevel: true,
190
+ }),
191
+ deleteAccount: ({ cfg, accountId }) =>
192
+ deleteAccountFromConfigSection({
193
+ cfg,
194
+ sectionKey: "mattermost",
195
+ accountId,
196
+ clearBaseFields: ["botToken", "baseUrl", "name"],
197
+ }),
198
+ isConfigured: (account) => Boolean(account.botToken && account.baseUrl),
199
+ describeAccount: (account) => ({
200
+ accountId: account.accountId,
201
+ name: account.name,
202
+ enabled: account.enabled,
203
+ configured: Boolean(account.botToken && account.baseUrl),
204
+ botTokenSource: account.botTokenSource,
205
+ baseUrl: account.baseUrl,
206
+ }),
207
+ resolveAllowFrom: ({ cfg, accountId }) =>
208
+ (resolveMattermostAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) =>
209
+ String(entry),
210
+ ),
211
+ formatAllowFrom: ({ allowFrom }) =>
212
+ allowFrom.map((entry) => formatAllowEntry(String(entry))).filter(Boolean),
213
+ },
214
+ security: {
215
+ resolveDmPolicy: ({ cfg, accountId, account }) => {
216
+ const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
217
+ const useAccountPath = Boolean(cfg.channels?.mattermost?.accounts?.[resolvedAccountId]);
218
+ const basePath = useAccountPath
219
+ ? `channels.mattermost.accounts.${resolvedAccountId}.`
220
+ : "channels.mattermost.";
221
+ return {
222
+ policy: account.config.dmPolicy ?? "pairing",
223
+ allowFrom: account.config.allowFrom ?? [],
224
+ policyPath: `${basePath}dmPolicy`,
225
+ allowFromPath: basePath,
226
+ approveHint: formatPairingApproveHint("mattermost"),
227
+ normalizeEntry: (raw) => normalizeAllowEntry(raw),
228
+ };
229
+ },
230
+ collectWarnings: ({ account, cfg }) => {
231
+ const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
232
+ const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
233
+ if (groupPolicy !== "open") {
234
+ return [];
235
+ }
236
+ return [
237
+ `- Mattermost channels: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.mattermost.groupPolicy="allowlist" + channels.mattermost.groupAllowFrom to restrict senders.`,
238
+ ];
239
+ },
240
+ },
241
+ groups: {
242
+ resolveRequireMention: resolveMattermostGroupRequireMention,
243
+ },
244
+ actions: mattermostMessageActions,
245
+ messaging: {
246
+ normalizeTarget: normalizeMattermostMessagingTarget,
247
+ targetResolver: {
248
+ looksLikeId: looksLikeMattermostTargetId,
249
+ hint: "<channelId|user:ID|channel:ID>",
250
+ },
251
+ },
252
+ outbound: {
253
+ deliveryMode: "direct",
254
+ chunker: (text, limit) => getMattermostRuntime().channel.text.chunkMarkdownText(text, limit),
255
+ chunkerMode: "markdown",
256
+ textChunkLimit: 4000,
257
+ resolveTarget: ({ to }) => {
258
+ const trimmed = to?.trim();
259
+ if (!trimmed) {
260
+ return {
261
+ ok: false,
262
+ error: new Error(
263
+ "Delivering to Mattermost requires --to <channelId|@username|user:ID|channel:ID>",
264
+ ),
265
+ };
266
+ }
267
+ return { ok: true, to: trimmed };
268
+ },
269
+ sendText: async ({ to, text, accountId, replyToId }) => {
270
+ const result = await sendMessageMattermost(to, text, {
271
+ accountId: accountId ?? undefined,
272
+ replyToId: replyToId ?? undefined,
273
+ });
274
+ return { channel: "mattermost", ...result };
275
+ },
276
+ sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
277
+ const result = await sendMessageMattermost(to, text, {
278
+ accountId: accountId ?? undefined,
279
+ mediaUrl,
280
+ replyToId: replyToId ?? undefined,
281
+ });
282
+ return { channel: "mattermost", ...result };
283
+ },
284
+ },
285
+ status: {
286
+ defaultRuntime: {
287
+ accountId: DEFAULT_ACCOUNT_ID,
288
+ running: false,
289
+ connected: false,
290
+ lastConnectedAt: null,
291
+ lastDisconnect: null,
292
+ lastStartAt: null,
293
+ lastStopAt: null,
294
+ lastError: null,
295
+ },
296
+ buildChannelSummary: ({ snapshot }) => ({
297
+ configured: snapshot.configured ?? false,
298
+ botTokenSource: snapshot.botTokenSource ?? "none",
299
+ running: snapshot.running ?? false,
300
+ connected: snapshot.connected ?? false,
301
+ lastStartAt: snapshot.lastStartAt ?? null,
302
+ lastStopAt: snapshot.lastStopAt ?? null,
303
+ lastError: snapshot.lastError ?? null,
304
+ baseUrl: snapshot.baseUrl ?? null,
305
+ probe: snapshot.probe,
306
+ lastProbeAt: snapshot.lastProbeAt ?? null,
307
+ }),
308
+ probeAccount: async ({ account, timeoutMs }) => {
309
+ const token = account.botToken?.trim();
310
+ const baseUrl = account.baseUrl?.trim();
311
+ if (!token || !baseUrl) {
312
+ return { ok: false, error: "bot token or baseUrl missing" };
313
+ }
314
+ return await probeMattermost(baseUrl, token, timeoutMs);
315
+ },
316
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
317
+ accountId: account.accountId,
318
+ name: account.name,
319
+ enabled: account.enabled,
320
+ configured: Boolean(account.botToken && account.baseUrl),
321
+ botTokenSource: account.botTokenSource,
322
+ baseUrl: account.baseUrl,
323
+ running: runtime?.running ?? false,
324
+ connected: runtime?.connected ?? false,
325
+ lastConnectedAt: runtime?.lastConnectedAt ?? null,
326
+ lastDisconnect: runtime?.lastDisconnect ?? null,
327
+ lastStartAt: runtime?.lastStartAt ?? null,
328
+ lastStopAt: runtime?.lastStopAt ?? null,
329
+ lastError: runtime?.lastError ?? null,
330
+ probe,
331
+ lastInboundAt: runtime?.lastInboundAt ?? null,
332
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
333
+ }),
334
+ },
335
+ setup: {
336
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
337
+ applyAccountName: ({ cfg, accountId, name }) =>
338
+ applyAccountNameToChannelSection({
339
+ cfg,
340
+ channelKey: "mattermost",
341
+ accountId,
342
+ name,
343
+ }),
344
+ validateInput: ({ accountId, input }) => {
345
+ if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
346
+ return "Mattermost env vars can only be used for the default account.";
347
+ }
348
+ const token = input.botToken ?? input.token;
349
+ const baseUrl = input.httpUrl;
350
+ if (!input.useEnv && (!token || !baseUrl)) {
351
+ return "Mattermost requires --bot-token and --http-url (or --use-env).";
352
+ }
353
+ if (baseUrl && !normalizeMattermostBaseUrl(baseUrl)) {
354
+ return "Mattermost --http-url must include a valid base URL.";
355
+ }
356
+ return null;
357
+ },
358
+ applyAccountConfig: ({ cfg, accountId, input }) => {
359
+ const token = input.botToken ?? input.token;
360
+ const baseUrl = input.httpUrl?.trim();
361
+ const namedConfig = applyAccountNameToChannelSection({
362
+ cfg,
363
+ channelKey: "mattermost",
364
+ accountId,
365
+ name: input.name,
366
+ });
367
+ const next =
368
+ accountId !== DEFAULT_ACCOUNT_ID
369
+ ? migrateBaseNameToDefaultAccount({
370
+ cfg: namedConfig,
371
+ channelKey: "mattermost",
372
+ })
373
+ : namedConfig;
374
+ if (accountId === DEFAULT_ACCOUNT_ID) {
375
+ return {
376
+ ...next,
377
+ channels: {
378
+ ...next.channels,
379
+ mattermost: {
380
+ ...next.channels?.mattermost,
381
+ enabled: true,
382
+ ...(input.useEnv
383
+ ? {}
384
+ : {
385
+ ...(token ? { botToken: token } : {}),
386
+ ...(baseUrl ? { baseUrl } : {}),
387
+ }),
388
+ },
389
+ },
390
+ };
391
+ }
392
+ return {
393
+ ...next,
394
+ channels: {
395
+ ...next.channels,
396
+ mattermost: {
397
+ ...next.channels?.mattermost,
398
+ enabled: true,
399
+ accounts: {
400
+ ...next.channels?.mattermost?.accounts,
401
+ [accountId]: {
402
+ ...next.channels?.mattermost?.accounts?.[accountId],
403
+ enabled: true,
404
+ ...(token ? { botToken: token } : {}),
405
+ ...(baseUrl ? { baseUrl } : {}),
406
+ },
407
+ },
408
+ },
409
+ },
410
+ };
411
+ },
412
+ },
413
+ gateway: {
414
+ startAccount: async (ctx) => {
415
+ const account = ctx.account;
416
+ ctx.setStatus({
417
+ accountId: account.accountId,
418
+ baseUrl: account.baseUrl,
419
+ botTokenSource: account.botTokenSource,
420
+ });
421
+ ctx.log?.info(`[${account.accountId}] starting channel`);
422
+ return monitorMattermostProvider({
423
+ botToken: account.botToken ?? undefined,
424
+ baseUrl: account.baseUrl ?? undefined,
425
+ accountId: account.accountId,
426
+ config: ctx.cfg,
427
+ runtime: ctx.runtime,
428
+ abortSignal: ctx.abortSignal,
429
+ statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
430
+ });
431
+ },
432
+ },
433
+ };
@@ -0,0 +1,61 @@
1
+ import {
2
+ BlockStreamingCoalesceSchema,
3
+ DmPolicySchema,
4
+ GroupPolicySchema,
5
+ MarkdownConfigSchema,
6
+ requireOpenAllowFrom,
7
+ } from "openclaw/plugin-sdk";
8
+ import { z } from "zod";
9
+
10
+ const MattermostAccountSchemaBase = z
11
+ .object({
12
+ name: z.string().optional(),
13
+ capabilities: z.array(z.string()).optional(),
14
+ markdown: MarkdownConfigSchema,
15
+ enabled: z.boolean().optional(),
16
+ configWrites: z.boolean().optional(),
17
+ botToken: z.string().optional(),
18
+ baseUrl: z.string().optional(),
19
+ chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(),
20
+ oncharPrefixes: z.array(z.string()).optional(),
21
+ requireMention: z.boolean().optional(),
22
+ dmPolicy: DmPolicySchema.optional().default("pairing"),
23
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
24
+ groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
25
+ groupPolicy: GroupPolicySchema.optional().default("allowlist"),
26
+ textChunkLimit: z.number().int().positive().optional(),
27
+ chunkMode: z.enum(["length", "newline"]).optional(),
28
+ blockStreaming: z.boolean().optional(),
29
+ blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
30
+ responsePrefix: z.string().optional(),
31
+ actions: z
32
+ .object({
33
+ reactions: z.boolean().optional(),
34
+ })
35
+ .optional(),
36
+ })
37
+ .strict();
38
+
39
+ const MattermostAccountSchema = MattermostAccountSchemaBase.superRefine((value, ctx) => {
40
+ requireOpenAllowFrom({
41
+ policy: value.dmPolicy,
42
+ allowFrom: value.allowFrom,
43
+ ctx,
44
+ path: ["allowFrom"],
45
+ message:
46
+ 'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"',
47
+ });
48
+ });
49
+
50
+ export const MattermostConfigSchema = MattermostAccountSchemaBase.extend({
51
+ accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
52
+ }).superRefine((value, ctx) => {
53
+ requireOpenAllowFrom({
54
+ policy: value.dmPolicy,
55
+ allowFrom: value.allowFrom,
56
+ ctx,
57
+ path: ["allowFrom"],
58
+ message:
59
+ 'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"',
60
+ });
61
+ });
@@ -0,0 +1,15 @@
1
+ import type { ChannelGroupContext } from "openclaw/plugin-sdk";
2
+ import { resolveMattermostAccount } from "./mattermost/accounts.js";
3
+
4
+ export function resolveMattermostGroupRequireMention(
5
+ params: ChannelGroupContext,
6
+ ): boolean | undefined {
7
+ const account = resolveMattermostAccount({
8
+ cfg: params.cfg,
9
+ accountId: params.accountId,
10
+ });
11
+ if (typeof account.requireMention === "boolean") {
12
+ return account.requireMention;
13
+ }
14
+ return true;
15
+ }
@@ -0,0 +1,128 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
3
+ import type { MattermostAccountConfig, MattermostChatMode } from "../types.js";
4
+ import { normalizeMattermostBaseUrl } from "./client.js";
5
+
6
+ export type MattermostTokenSource = "env" | "config" | "none";
7
+ export type MattermostBaseUrlSource = "env" | "config" | "none";
8
+
9
+ export type ResolvedMattermostAccount = {
10
+ accountId: string;
11
+ enabled: boolean;
12
+ name?: string;
13
+ botToken?: string;
14
+ baseUrl?: string;
15
+ botTokenSource: MattermostTokenSource;
16
+ baseUrlSource: MattermostBaseUrlSource;
17
+ config: MattermostAccountConfig;
18
+ chatmode?: MattermostChatMode;
19
+ oncharPrefixes?: string[];
20
+ requireMention?: boolean;
21
+ textChunkLimit?: number;
22
+ blockStreaming?: boolean;
23
+ blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
24
+ };
25
+
26
+ function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
27
+ const accounts = cfg.channels?.mattermost?.accounts;
28
+ if (!accounts || typeof accounts !== "object") {
29
+ return [];
30
+ }
31
+ return Object.keys(accounts).filter(Boolean);
32
+ }
33
+
34
+ export function listMattermostAccountIds(cfg: OpenClawConfig): string[] {
35
+ const ids = listConfiguredAccountIds(cfg);
36
+ if (ids.length === 0) {
37
+ return [DEFAULT_ACCOUNT_ID];
38
+ }
39
+ return ids.toSorted((a, b) => a.localeCompare(b));
40
+ }
41
+
42
+ export function resolveDefaultMattermostAccountId(cfg: OpenClawConfig): string {
43
+ const ids = listMattermostAccountIds(cfg);
44
+ if (ids.includes(DEFAULT_ACCOUNT_ID)) {
45
+ return DEFAULT_ACCOUNT_ID;
46
+ }
47
+ return ids[0] ?? DEFAULT_ACCOUNT_ID;
48
+ }
49
+
50
+ function resolveAccountConfig(
51
+ cfg: OpenClawConfig,
52
+ accountId: string,
53
+ ): MattermostAccountConfig | undefined {
54
+ const accounts = cfg.channels?.mattermost?.accounts;
55
+ if (!accounts || typeof accounts !== "object") {
56
+ return undefined;
57
+ }
58
+ return accounts[accountId] as MattermostAccountConfig | undefined;
59
+ }
60
+
61
+ function mergeMattermostAccountConfig(
62
+ cfg: OpenClawConfig,
63
+ accountId: string,
64
+ ): MattermostAccountConfig {
65
+ const { accounts: _ignored, ...base } = (cfg.channels?.mattermost ??
66
+ {}) as MattermostAccountConfig & { accounts?: unknown };
67
+ const account = resolveAccountConfig(cfg, accountId) ?? {};
68
+ return { ...base, ...account };
69
+ }
70
+
71
+ function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined {
72
+ if (config.chatmode === "oncall") {
73
+ return true;
74
+ }
75
+ if (config.chatmode === "onmessage") {
76
+ return false;
77
+ }
78
+ if (config.chatmode === "onchar") {
79
+ return true;
80
+ }
81
+ return config.requireMention;
82
+ }
83
+
84
+ export function resolveMattermostAccount(params: {
85
+ cfg: OpenClawConfig;
86
+ accountId?: string | null;
87
+ }): ResolvedMattermostAccount {
88
+ const accountId = normalizeAccountId(params.accountId);
89
+ const baseEnabled = params.cfg.channels?.mattermost?.enabled !== false;
90
+ const merged = mergeMattermostAccountConfig(params.cfg, accountId);
91
+ const accountEnabled = merged.enabled !== false;
92
+ const enabled = baseEnabled && accountEnabled;
93
+
94
+ const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
95
+ const envToken = allowEnv ? process.env.MATTERMOST_BOT_TOKEN?.trim() : undefined;
96
+ const envUrl = allowEnv ? process.env.MATTERMOST_URL?.trim() : undefined;
97
+ const configToken = merged.botToken?.trim();
98
+ const configUrl = merged.baseUrl?.trim();
99
+ const botToken = configToken || envToken;
100
+ const baseUrl = normalizeMattermostBaseUrl(configUrl || envUrl);
101
+ const requireMention = resolveMattermostRequireMention(merged);
102
+
103
+ const botTokenSource: MattermostTokenSource = configToken ? "config" : envToken ? "env" : "none";
104
+ const baseUrlSource: MattermostBaseUrlSource = configUrl ? "config" : envUrl ? "env" : "none";
105
+
106
+ return {
107
+ accountId,
108
+ enabled,
109
+ name: merged.name?.trim() || undefined,
110
+ botToken,
111
+ baseUrl,
112
+ botTokenSource,
113
+ baseUrlSource,
114
+ config: merged,
115
+ chatmode: merged.chatmode,
116
+ oncharPrefixes: merged.oncharPrefixes,
117
+ requireMention,
118
+ textChunkLimit: merged.textChunkLimit,
119
+ blockStreaming: merged.blockStreaming,
120
+ blockStreamingCoalesce: merged.blockStreamingCoalesce,
121
+ };
122
+ }
123
+
124
+ export function listEnabledMattermostAccounts(cfg: OpenClawConfig): ResolvedMattermostAccount[] {
125
+ return listMattermostAccountIds(cfg)
126
+ .map((accountId) => resolveMattermostAccount({ cfg, accountId }))
127
+ .filter((account) => account.enabled);
128
+ }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createMattermostClient } from "./client.js";
3
+
4
+ describe("mattermost client", () => {
5
+ it("request returns undefined on 204 responses", async () => {
6
+ const fetchImpl = vi.fn(async () => {
7
+ return new Response(null, { status: 204 });
8
+ });
9
+
10
+ const client = createMattermostClient({
11
+ baseUrl: "https://chat.example.com",
12
+ botToken: "test-token",
13
+ fetchImpl: fetchImpl as any,
14
+ });
15
+
16
+ const result = await client.request<unknown>("/anything", { method: "DELETE" });
17
+ expect(result).toBeUndefined();
18
+ });
19
+ });