@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/index.ts +17 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +28 -0
- package/src/channel.test.ts +235 -0
- package/src/channel.ts +433 -0
- package/src/config-schema.ts +61 -0
- package/src/group-mentions.ts +15 -0
- package/src/mattermost/accounts.ts +128 -0
- package/src/mattermost/client.test.ts +19 -0
- package/src/mattermost/client.ts +230 -0
- package/src/mattermost/index.ts +9 -0
- package/src/mattermost/monitor-helpers.ts +93 -0
- package/src/mattermost/monitor-onchar.ts +25 -0
- package/src/mattermost/monitor-websocket.test.ts +232 -0
- package/src/mattermost/monitor-websocket.ts +221 -0
- package/src/mattermost/monitor.ts +1030 -0
- package/src/mattermost/probe.ts +73 -0
- package/src/mattermost/reactions.test-helpers.ts +83 -0
- package/src/mattermost/reactions.test.ts +103 -0
- package/src/mattermost/reactions.ts +124 -0
- package/src/mattermost/reconnect.test.ts +192 -0
- package/src/mattermost/reconnect.ts +103 -0
- package/src/mattermost/send.ts +231 -0
- package/src/normalize.ts +46 -0
- package/src/onboarding-helpers.ts +1 -0
- package/src/onboarding.ts +186 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +57 -0
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelAccountSnapshot,
|
|
3
|
+
ChatType,
|
|
4
|
+
OpenClawConfig,
|
|
5
|
+
ReplyPayload,
|
|
6
|
+
RuntimeEnv,
|
|
7
|
+
} from "openclaw/plugin-sdk";
|
|
8
|
+
import {
|
|
9
|
+
buildAgentMediaPayload,
|
|
10
|
+
createReplyPrefixOptions,
|
|
11
|
+
createTypingCallbacks,
|
|
12
|
+
logInboundDrop,
|
|
13
|
+
logTypingFailure,
|
|
14
|
+
buildPendingHistoryContextFromMap,
|
|
15
|
+
clearHistoryEntriesIfEnabled,
|
|
16
|
+
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
17
|
+
recordPendingHistoryEntryIfEnabled,
|
|
18
|
+
resolveControlCommandGate,
|
|
19
|
+
resolveChannelMediaMaxBytes,
|
|
20
|
+
type HistoryEntry,
|
|
21
|
+
} from "openclaw/plugin-sdk";
|
|
22
|
+
import { getMattermostRuntime } from "../runtime.js";
|
|
23
|
+
import { resolveMattermostAccount } from "./accounts.js";
|
|
24
|
+
import {
|
|
25
|
+
createMattermostClient,
|
|
26
|
+
fetchMattermostChannel,
|
|
27
|
+
fetchMattermostMe,
|
|
28
|
+
fetchMattermostUser,
|
|
29
|
+
normalizeMattermostBaseUrl,
|
|
30
|
+
sendMattermostTyping,
|
|
31
|
+
type MattermostChannel,
|
|
32
|
+
type MattermostPost,
|
|
33
|
+
type MattermostUser,
|
|
34
|
+
} from "./client.js";
|
|
35
|
+
import {
|
|
36
|
+
createDedupeCache,
|
|
37
|
+
formatInboundFromLabel,
|
|
38
|
+
resolveThreadSessionKeys,
|
|
39
|
+
} from "./monitor-helpers.js";
|
|
40
|
+
import { resolveOncharPrefixes, stripOncharPrefix } from "./monitor-onchar.js";
|
|
41
|
+
import {
|
|
42
|
+
createMattermostConnectOnce,
|
|
43
|
+
type MattermostEventPayload,
|
|
44
|
+
type MattermostWebSocketFactory,
|
|
45
|
+
} from "./monitor-websocket.js";
|
|
46
|
+
import { runWithReconnect } from "./reconnect.js";
|
|
47
|
+
import { sendMessageMattermost } from "./send.js";
|
|
48
|
+
|
|
49
|
+
export type MonitorMattermostOpts = {
|
|
50
|
+
botToken?: string;
|
|
51
|
+
baseUrl?: string;
|
|
52
|
+
accountId?: string;
|
|
53
|
+
config?: OpenClawConfig;
|
|
54
|
+
runtime?: RuntimeEnv;
|
|
55
|
+
abortSignal?: AbortSignal;
|
|
56
|
+
statusSink?: (patch: Partial<ChannelAccountSnapshot>) => void;
|
|
57
|
+
webSocketFactory?: MattermostWebSocketFactory;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type FetchLike = (input: URL | RequestInfo, init?: RequestInit) => Promise<Response>;
|
|
61
|
+
type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
|
|
62
|
+
|
|
63
|
+
type MattermostReaction = {
|
|
64
|
+
user_id?: string;
|
|
65
|
+
post_id?: string;
|
|
66
|
+
emoji_name?: string;
|
|
67
|
+
create_at?: number;
|
|
68
|
+
};
|
|
69
|
+
const RECENT_MATTERMOST_MESSAGE_TTL_MS = 5 * 60_000;
|
|
70
|
+
const RECENT_MATTERMOST_MESSAGE_MAX = 2000;
|
|
71
|
+
const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
|
|
72
|
+
const USER_CACHE_TTL_MS = 10 * 60_000;
|
|
73
|
+
|
|
74
|
+
const recentInboundMessages = createDedupeCache({
|
|
75
|
+
ttlMs: RECENT_MATTERMOST_MESSAGE_TTL_MS,
|
|
76
|
+
maxSize: RECENT_MATTERMOST_MESSAGE_MAX,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
function resolveRuntime(opts: MonitorMattermostOpts): RuntimeEnv {
|
|
80
|
+
return (
|
|
81
|
+
opts.runtime ?? {
|
|
82
|
+
log: console.log,
|
|
83
|
+
error: console.error,
|
|
84
|
+
exit: (code: number): never => {
|
|
85
|
+
throw new Error(`exit ${code}`);
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeMention(text: string, mention: string | undefined): string {
|
|
92
|
+
if (!mention) {
|
|
93
|
+
return text.trim();
|
|
94
|
+
}
|
|
95
|
+
const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
96
|
+
const re = new RegExp(`@${escaped}\\b`, "gi");
|
|
97
|
+
return text.replace(re, " ").replace(/\s+/g, " ").trim();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isSystemPost(post: MattermostPost): boolean {
|
|
101
|
+
const type = post.type?.trim();
|
|
102
|
+
return Boolean(type);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function channelKind(channelType?: string | null): ChatType {
|
|
106
|
+
if (!channelType) {
|
|
107
|
+
return "channel";
|
|
108
|
+
}
|
|
109
|
+
const normalized = channelType.trim().toUpperCase();
|
|
110
|
+
if (normalized === "D") {
|
|
111
|
+
return "direct";
|
|
112
|
+
}
|
|
113
|
+
if (normalized === "G") {
|
|
114
|
+
return "group";
|
|
115
|
+
}
|
|
116
|
+
return "channel";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function channelChatType(kind: ChatType): "direct" | "group" | "channel" {
|
|
120
|
+
if (kind === "direct") {
|
|
121
|
+
return "direct";
|
|
122
|
+
}
|
|
123
|
+
if (kind === "group") {
|
|
124
|
+
return "group";
|
|
125
|
+
}
|
|
126
|
+
return "channel";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizeAllowEntry(entry: string): string {
|
|
130
|
+
const trimmed = entry.trim();
|
|
131
|
+
if (!trimmed) {
|
|
132
|
+
return "";
|
|
133
|
+
}
|
|
134
|
+
if (trimmed === "*") {
|
|
135
|
+
return "*";
|
|
136
|
+
}
|
|
137
|
+
return trimmed
|
|
138
|
+
.replace(/^(mattermost|user):/i, "")
|
|
139
|
+
.replace(/^@/, "")
|
|
140
|
+
.toLowerCase();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function normalizeAllowList(entries: Array<string | number>): string[] {
|
|
144
|
+
const normalized = entries.map((entry) => normalizeAllowEntry(String(entry))).filter(Boolean);
|
|
145
|
+
return Array.from(new Set(normalized));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isSenderAllowed(params: {
|
|
149
|
+
senderId: string;
|
|
150
|
+
senderName?: string;
|
|
151
|
+
allowFrom: string[];
|
|
152
|
+
}): boolean {
|
|
153
|
+
const allowFrom = params.allowFrom;
|
|
154
|
+
if (allowFrom.length === 0) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if (allowFrom.includes("*")) {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
const normalizedSenderId = normalizeAllowEntry(params.senderId);
|
|
161
|
+
const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
|
|
162
|
+
return allowFrom.some(
|
|
163
|
+
(entry) =>
|
|
164
|
+
entry === normalizedSenderId || (normalizedSenderName && entry === normalizedSenderName),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
type MattermostMediaInfo = {
|
|
169
|
+
path: string;
|
|
170
|
+
contentType?: string;
|
|
171
|
+
kind: MediaKind;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): string {
|
|
175
|
+
if (mediaList.length === 0) {
|
|
176
|
+
return "";
|
|
177
|
+
}
|
|
178
|
+
if (mediaList.length === 1) {
|
|
179
|
+
const kind = mediaList[0].kind === "unknown" ? "document" : mediaList[0].kind;
|
|
180
|
+
return `<media:${kind}>`;
|
|
181
|
+
}
|
|
182
|
+
const allImages = mediaList.every((media) => media.kind === "image");
|
|
183
|
+
const label = allImages ? "image" : "file";
|
|
184
|
+
const suffix = mediaList.length === 1 ? label : `${label}s`;
|
|
185
|
+
const tag = allImages ? "<media:image>" : "<media:document>";
|
|
186
|
+
return `${tag} (${mediaList.length} ${suffix})`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function buildMattermostWsUrl(baseUrl: string): string {
|
|
190
|
+
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
|
191
|
+
if (!normalized) {
|
|
192
|
+
throw new Error("Mattermost baseUrl is required");
|
|
193
|
+
}
|
|
194
|
+
const wsBase = normalized.replace(/^http/i, "ws");
|
|
195
|
+
return `${wsBase}/api/v4/websocket`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}): Promise<void> {
|
|
199
|
+
const core = getMattermostRuntime();
|
|
200
|
+
const runtime = resolveRuntime(opts);
|
|
201
|
+
const cfg = opts.config ?? core.config.loadConfig();
|
|
202
|
+
const account = resolveMattermostAccount({
|
|
203
|
+
cfg,
|
|
204
|
+
accountId: opts.accountId,
|
|
205
|
+
});
|
|
206
|
+
const botToken = opts.botToken?.trim() || account.botToken?.trim();
|
|
207
|
+
if (!botToken) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl);
|
|
213
|
+
if (!baseUrl) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const client = createMattermostClient({ baseUrl, botToken });
|
|
220
|
+
const botUser = await fetchMattermostMe(client);
|
|
221
|
+
const botUserId = botUser.id;
|
|
222
|
+
const botUsername = botUser.username?.trim() || undefined;
|
|
223
|
+
runtime.log?.(`mattermost connected as ${botUsername ? `@${botUsername}` : botUserId}`);
|
|
224
|
+
|
|
225
|
+
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
|
|
226
|
+
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
|
|
227
|
+
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
|
228
|
+
const logVerboseMessage = (message: string) => {
|
|
229
|
+
if (!core.logging.shouldLogVerbose()) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
logger.debug?.(message);
|
|
233
|
+
};
|
|
234
|
+
const mediaMaxBytes =
|
|
235
|
+
resolveChannelMediaMaxBytes({
|
|
236
|
+
cfg,
|
|
237
|
+
resolveChannelLimitMb: () => undefined,
|
|
238
|
+
accountId: account.accountId,
|
|
239
|
+
}) ?? 8 * 1024 * 1024;
|
|
240
|
+
const historyLimit = Math.max(
|
|
241
|
+
0,
|
|
242
|
+
cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
|
243
|
+
);
|
|
244
|
+
const channelHistories = new Map<string, HistoryEntry[]>();
|
|
245
|
+
|
|
246
|
+
const fetchWithAuth: FetchLike = (input, init) => {
|
|
247
|
+
const headers = new Headers(init?.headers);
|
|
248
|
+
headers.set("Authorization", `Bearer ${client.token}`);
|
|
249
|
+
return fetch(input, { ...init, headers });
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const resolveMattermostMedia = async (
|
|
253
|
+
fileIds?: string[] | null,
|
|
254
|
+
): Promise<MattermostMediaInfo[]> => {
|
|
255
|
+
const ids = (fileIds ?? []).map((id) => id?.trim()).filter(Boolean);
|
|
256
|
+
if (ids.length === 0) {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
const out: MattermostMediaInfo[] = [];
|
|
260
|
+
for (const fileId of ids) {
|
|
261
|
+
try {
|
|
262
|
+
const fetched = await core.channel.media.fetchRemoteMedia({
|
|
263
|
+
url: `${client.apiBaseUrl}/files/${fileId}`,
|
|
264
|
+
fetchImpl: fetchWithAuth,
|
|
265
|
+
filePathHint: fileId,
|
|
266
|
+
maxBytes: mediaMaxBytes,
|
|
267
|
+
});
|
|
268
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
269
|
+
fetched.buffer,
|
|
270
|
+
fetched.contentType ?? undefined,
|
|
271
|
+
"inbound",
|
|
272
|
+
mediaMaxBytes,
|
|
273
|
+
);
|
|
274
|
+
const contentType = saved.contentType ?? fetched.contentType ?? undefined;
|
|
275
|
+
out.push({
|
|
276
|
+
path: saved.path,
|
|
277
|
+
contentType,
|
|
278
|
+
kind: core.media.mediaKindFromMime(contentType),
|
|
279
|
+
});
|
|
280
|
+
} catch (err) {
|
|
281
|
+
logger.debug?.(`mattermost: failed to download file ${fileId}: ${String(err)}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const sendTypingIndicator = async (channelId: string, parentId?: string) => {
|
|
288
|
+
await sendMattermostTyping(client, { channelId, parentId });
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
|
|
292
|
+
const cached = channelCache.get(channelId);
|
|
293
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
294
|
+
return cached.value;
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
const info = await fetchMattermostChannel(client, channelId);
|
|
298
|
+
channelCache.set(channelId, {
|
|
299
|
+
value: info,
|
|
300
|
+
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
|
|
301
|
+
});
|
|
302
|
+
return info;
|
|
303
|
+
} catch (err) {
|
|
304
|
+
logger.debug?.(`mattermost: channel lookup failed: ${String(err)}`);
|
|
305
|
+
channelCache.set(channelId, {
|
|
306
|
+
value: null,
|
|
307
|
+
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
|
|
308
|
+
});
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const resolveUserInfo = async (userId: string): Promise<MattermostUser | null> => {
|
|
314
|
+
const cached = userCache.get(userId);
|
|
315
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
316
|
+
return cached.value;
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
const info = await fetchMattermostUser(client, userId);
|
|
320
|
+
userCache.set(userId, {
|
|
321
|
+
value: info,
|
|
322
|
+
expiresAt: Date.now() + USER_CACHE_TTL_MS,
|
|
323
|
+
});
|
|
324
|
+
return info;
|
|
325
|
+
} catch (err) {
|
|
326
|
+
logger.debug?.(`mattermost: user lookup failed: ${String(err)}`);
|
|
327
|
+
userCache.set(userId, {
|
|
328
|
+
value: null,
|
|
329
|
+
expiresAt: Date.now() + USER_CACHE_TTL_MS,
|
|
330
|
+
});
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const handlePost = async (
|
|
336
|
+
post: MattermostPost,
|
|
337
|
+
payload: MattermostEventPayload,
|
|
338
|
+
messageIds?: string[],
|
|
339
|
+
) => {
|
|
340
|
+
const channelId = post.channel_id ?? payload.data?.channel_id ?? payload.broadcast?.channel_id;
|
|
341
|
+
if (!channelId) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const allMessageIds = messageIds?.length ? messageIds : post.id ? [post.id] : [];
|
|
346
|
+
if (allMessageIds.length === 0) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const dedupeEntries = allMessageIds.map((id) =>
|
|
350
|
+
recentInboundMessages.check(`${account.accountId}:${id}`),
|
|
351
|
+
);
|
|
352
|
+
if (dedupeEntries.length > 0 && dedupeEntries.every(Boolean)) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const senderId = post.user_id ?? payload.broadcast?.user_id;
|
|
357
|
+
if (!senderId) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (senderId === botUserId) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (isSystemPost(post)) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const channelInfo = await resolveChannelInfo(channelId);
|
|
368
|
+
const channelType = payload.data?.channel_type ?? channelInfo?.type ?? undefined;
|
|
369
|
+
const kind = channelKind(channelType);
|
|
370
|
+
const chatType = channelChatType(kind);
|
|
371
|
+
|
|
372
|
+
const senderName =
|
|
373
|
+
payload.data?.sender_name?.trim() ||
|
|
374
|
+
(await resolveUserInfo(senderId))?.username?.trim() ||
|
|
375
|
+
senderId;
|
|
376
|
+
const rawText = post.message?.trim() || "";
|
|
377
|
+
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
|
378
|
+
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
|
379
|
+
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
|
380
|
+
const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []);
|
|
381
|
+
const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []);
|
|
382
|
+
const storeAllowFrom = normalizeAllowList(
|
|
383
|
+
await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
|
|
384
|
+
);
|
|
385
|
+
const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom]));
|
|
386
|
+
const effectiveGroupAllowFrom = Array.from(
|
|
387
|
+
new Set([
|
|
388
|
+
...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
|
|
389
|
+
...storeAllowFrom,
|
|
390
|
+
]),
|
|
391
|
+
);
|
|
392
|
+
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
|
393
|
+
cfg,
|
|
394
|
+
surface: "mattermost",
|
|
395
|
+
});
|
|
396
|
+
const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg);
|
|
397
|
+
const isControlCommand = allowTextCommands && hasControlCommand;
|
|
398
|
+
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
|
399
|
+
const senderAllowedForCommands = isSenderAllowed({
|
|
400
|
+
senderId,
|
|
401
|
+
senderName,
|
|
402
|
+
allowFrom: effectiveAllowFrom,
|
|
403
|
+
});
|
|
404
|
+
const groupAllowedForCommands = isSenderAllowed({
|
|
405
|
+
senderId,
|
|
406
|
+
senderName,
|
|
407
|
+
allowFrom: effectiveGroupAllowFrom,
|
|
408
|
+
});
|
|
409
|
+
const commandGate = resolveControlCommandGate({
|
|
410
|
+
useAccessGroups,
|
|
411
|
+
authorizers: [
|
|
412
|
+
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
|
413
|
+
{
|
|
414
|
+
configured: effectiveGroupAllowFrom.length > 0,
|
|
415
|
+
allowed: groupAllowedForCommands,
|
|
416
|
+
},
|
|
417
|
+
],
|
|
418
|
+
allowTextCommands,
|
|
419
|
+
hasControlCommand,
|
|
420
|
+
});
|
|
421
|
+
const commandAuthorized =
|
|
422
|
+
kind === "direct"
|
|
423
|
+
? dmPolicy === "open" || senderAllowedForCommands
|
|
424
|
+
: commandGate.commandAuthorized;
|
|
425
|
+
|
|
426
|
+
if (kind === "direct") {
|
|
427
|
+
if (dmPolicy === "disabled") {
|
|
428
|
+
logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (dmPolicy !== "open" && !senderAllowedForCommands) {
|
|
432
|
+
if (dmPolicy === "pairing") {
|
|
433
|
+
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
|
434
|
+
channel: "mattermost",
|
|
435
|
+
id: senderId,
|
|
436
|
+
meta: { name: senderName },
|
|
437
|
+
});
|
|
438
|
+
logVerboseMessage(`mattermost: pairing request sender=${senderId} created=${created}`);
|
|
439
|
+
if (created) {
|
|
440
|
+
try {
|
|
441
|
+
await sendMessageMattermost(
|
|
442
|
+
`user:${senderId}`,
|
|
443
|
+
core.channel.pairing.buildPairingReply({
|
|
444
|
+
channel: "mattermost",
|
|
445
|
+
idLine: `Your Mattermost user id: ${senderId}`,
|
|
446
|
+
code,
|
|
447
|
+
}),
|
|
448
|
+
{ accountId: account.accountId },
|
|
449
|
+
);
|
|
450
|
+
opts.statusSink?.({ lastOutboundAt: Date.now() });
|
|
451
|
+
} catch (err) {
|
|
452
|
+
logVerboseMessage(`mattermost: pairing reply failed for ${senderId}: ${String(err)}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
} else {
|
|
456
|
+
logVerboseMessage(`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`);
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
} else {
|
|
461
|
+
if (groupPolicy === "disabled") {
|
|
462
|
+
logVerboseMessage("mattermost: drop group message (groupPolicy=disabled)");
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (groupPolicy === "allowlist") {
|
|
466
|
+
if (effectiveGroupAllowFrom.length === 0) {
|
|
467
|
+
logVerboseMessage("mattermost: drop group message (no group allowlist)");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (!groupAllowedForCommands) {
|
|
471
|
+
logVerboseMessage(`mattermost: drop group sender=${senderId} (not in groupAllowFrom)`);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (kind !== "direct" && commandGate.shouldBlock) {
|
|
478
|
+
logInboundDrop({
|
|
479
|
+
log: logVerboseMessage,
|
|
480
|
+
channel: "mattermost",
|
|
481
|
+
reason: "control command (unauthorized)",
|
|
482
|
+
target: senderId,
|
|
483
|
+
});
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const teamId = payload.data?.team_id ?? channelInfo?.team_id ?? undefined;
|
|
488
|
+
const channelName = payload.data?.channel_name ?? channelInfo?.name ?? "";
|
|
489
|
+
const channelDisplay =
|
|
490
|
+
payload.data?.channel_display_name ?? channelInfo?.display_name ?? channelName;
|
|
491
|
+
const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`;
|
|
492
|
+
|
|
493
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
494
|
+
cfg,
|
|
495
|
+
channel: "mattermost",
|
|
496
|
+
accountId: account.accountId,
|
|
497
|
+
teamId,
|
|
498
|
+
peer: {
|
|
499
|
+
kind,
|
|
500
|
+
id: kind === "direct" ? senderId : channelId,
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const baseSessionKey = route.sessionKey;
|
|
505
|
+
const threadRootId = post.root_id?.trim() || undefined;
|
|
506
|
+
const threadKeys = resolveThreadSessionKeys({
|
|
507
|
+
baseSessionKey,
|
|
508
|
+
threadId: threadRootId,
|
|
509
|
+
parentSessionKey: threadRootId ? baseSessionKey : undefined,
|
|
510
|
+
});
|
|
511
|
+
const sessionKey = threadKeys.sessionKey;
|
|
512
|
+
const historyKey = kind === "direct" ? null : sessionKey;
|
|
513
|
+
|
|
514
|
+
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId);
|
|
515
|
+
const wasMentioned =
|
|
516
|
+
kind !== "direct" &&
|
|
517
|
+
((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) ||
|
|
518
|
+
core.channel.mentions.matchesMentionPatterns(rawText, mentionRegexes));
|
|
519
|
+
const pendingBody =
|
|
520
|
+
rawText ||
|
|
521
|
+
(post.file_ids?.length
|
|
522
|
+
? `[Mattermost ${post.file_ids.length === 1 ? "file" : "files"}]`
|
|
523
|
+
: "");
|
|
524
|
+
const pendingSender = senderName;
|
|
525
|
+
const recordPendingHistory = () => {
|
|
526
|
+
const trimmed = pendingBody.trim();
|
|
527
|
+
recordPendingHistoryEntryIfEnabled({
|
|
528
|
+
historyMap: channelHistories,
|
|
529
|
+
limit: historyLimit,
|
|
530
|
+
historyKey: historyKey ?? "",
|
|
531
|
+
entry:
|
|
532
|
+
historyKey && trimmed
|
|
533
|
+
? {
|
|
534
|
+
sender: pendingSender,
|
|
535
|
+
body: trimmed,
|
|
536
|
+
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
|
537
|
+
messageId: post.id ?? undefined,
|
|
538
|
+
}
|
|
539
|
+
: null,
|
|
540
|
+
});
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const oncharEnabled = account.chatmode === "onchar" && kind !== "direct";
|
|
544
|
+
const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : [];
|
|
545
|
+
const oncharResult = oncharEnabled
|
|
546
|
+
? stripOncharPrefix(rawText, oncharPrefixes)
|
|
547
|
+
: { triggered: false, stripped: rawText };
|
|
548
|
+
const oncharTriggered = oncharResult.triggered;
|
|
549
|
+
|
|
550
|
+
const shouldRequireMention =
|
|
551
|
+
kind !== "direct" &&
|
|
552
|
+
core.channel.groups.resolveRequireMention({
|
|
553
|
+
cfg,
|
|
554
|
+
channel: "mattermost",
|
|
555
|
+
accountId: account.accountId,
|
|
556
|
+
groupId: channelId,
|
|
557
|
+
});
|
|
558
|
+
const shouldBypassMention =
|
|
559
|
+
isControlCommand && shouldRequireMention && !wasMentioned && commandAuthorized;
|
|
560
|
+
const effectiveWasMentioned = wasMentioned || shouldBypassMention || oncharTriggered;
|
|
561
|
+
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
|
|
562
|
+
|
|
563
|
+
if (oncharEnabled && !oncharTriggered && !wasMentioned && !isControlCommand) {
|
|
564
|
+
recordPendingHistory();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (kind !== "direct" && shouldRequireMention && canDetectMention) {
|
|
569
|
+
if (!effectiveWasMentioned) {
|
|
570
|
+
recordPendingHistory();
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const mediaList = await resolveMattermostMedia(post.file_ids);
|
|
575
|
+
const mediaPlaceholder = buildMattermostAttachmentPlaceholder(mediaList);
|
|
576
|
+
const bodySource = oncharTriggered ? oncharResult.stripped : rawText;
|
|
577
|
+
const baseText = [bodySource, mediaPlaceholder].filter(Boolean).join("\n").trim();
|
|
578
|
+
const bodyText = normalizeMention(baseText, botUsername);
|
|
579
|
+
if (!bodyText) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
core.channel.activity.record({
|
|
584
|
+
channel: "mattermost",
|
|
585
|
+
accountId: account.accountId,
|
|
586
|
+
direction: "inbound",
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
const fromLabel = formatInboundFromLabel({
|
|
590
|
+
isGroup: kind !== "direct",
|
|
591
|
+
groupLabel: channelDisplay || roomLabel,
|
|
592
|
+
groupId: channelId,
|
|
593
|
+
groupFallback: roomLabel || "Channel",
|
|
594
|
+
directLabel: senderName,
|
|
595
|
+
directId: senderId,
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
const preview = bodyText.replace(/\s+/g, " ").slice(0, 160);
|
|
599
|
+
const inboundLabel =
|
|
600
|
+
kind === "direct"
|
|
601
|
+
? `Mattermost DM from ${senderName}`
|
|
602
|
+
: `Mattermost message in ${roomLabel} from ${senderName}`;
|
|
603
|
+
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
|
604
|
+
sessionKey,
|
|
605
|
+
contextKey: `mattermost:message:${channelId}:${post.id ?? "unknown"}`,
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`;
|
|
609
|
+
const body = core.channel.reply.formatInboundEnvelope({
|
|
610
|
+
channel: "Mattermost",
|
|
611
|
+
from: fromLabel,
|
|
612
|
+
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
|
613
|
+
body: textWithId,
|
|
614
|
+
chatType,
|
|
615
|
+
sender: { name: senderName, id: senderId },
|
|
616
|
+
});
|
|
617
|
+
let combinedBody = body;
|
|
618
|
+
if (historyKey) {
|
|
619
|
+
combinedBody = buildPendingHistoryContextFromMap({
|
|
620
|
+
historyMap: channelHistories,
|
|
621
|
+
historyKey,
|
|
622
|
+
limit: historyLimit,
|
|
623
|
+
currentMessage: combinedBody,
|
|
624
|
+
formatEntry: (entry) =>
|
|
625
|
+
core.channel.reply.formatInboundEnvelope({
|
|
626
|
+
channel: "Mattermost",
|
|
627
|
+
from: fromLabel,
|
|
628
|
+
timestamp: entry.timestamp,
|
|
629
|
+
body: `${entry.body}${
|
|
630
|
+
entry.messageId ? ` [id:${entry.messageId} channel:${channelId}]` : ""
|
|
631
|
+
}`,
|
|
632
|
+
chatType,
|
|
633
|
+
senderLabel: entry.sender,
|
|
634
|
+
}),
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const to = kind === "direct" ? `user:${senderId}` : `channel:${channelId}`;
|
|
639
|
+
const mediaPayload = buildAgentMediaPayload(mediaList);
|
|
640
|
+
const inboundHistory =
|
|
641
|
+
historyKey && historyLimit > 0
|
|
642
|
+
? (channelHistories.get(historyKey) ?? []).map((entry) => ({
|
|
643
|
+
sender: entry.sender,
|
|
644
|
+
body: entry.body,
|
|
645
|
+
timestamp: entry.timestamp,
|
|
646
|
+
}))
|
|
647
|
+
: undefined;
|
|
648
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
649
|
+
Body: combinedBody,
|
|
650
|
+
BodyForAgent: bodyText,
|
|
651
|
+
InboundHistory: inboundHistory,
|
|
652
|
+
RawBody: bodyText,
|
|
653
|
+
CommandBody: bodyText,
|
|
654
|
+
From:
|
|
655
|
+
kind === "direct"
|
|
656
|
+
? `mattermost:${senderId}`
|
|
657
|
+
: kind === "group"
|
|
658
|
+
? `mattermost:group:${channelId}`
|
|
659
|
+
: `mattermost:channel:${channelId}`,
|
|
660
|
+
To: to,
|
|
661
|
+
SessionKey: sessionKey,
|
|
662
|
+
ParentSessionKey: threadKeys.parentSessionKey,
|
|
663
|
+
AccountId: route.accountId,
|
|
664
|
+
ChatType: chatType,
|
|
665
|
+
ConversationLabel: fromLabel,
|
|
666
|
+
GroupSubject: kind !== "direct" ? channelDisplay || roomLabel : undefined,
|
|
667
|
+
GroupChannel: channelName ? `#${channelName}` : undefined,
|
|
668
|
+
GroupSpace: teamId,
|
|
669
|
+
SenderName: senderName,
|
|
670
|
+
SenderId: senderId,
|
|
671
|
+
Provider: "mattermost" as const,
|
|
672
|
+
Surface: "mattermost" as const,
|
|
673
|
+
MessageSid: post.id ?? undefined,
|
|
674
|
+
MessageSids: allMessageIds.length > 1 ? allMessageIds : undefined,
|
|
675
|
+
MessageSidFirst: allMessageIds.length > 1 ? allMessageIds[0] : undefined,
|
|
676
|
+
MessageSidLast:
|
|
677
|
+
allMessageIds.length > 1 ? allMessageIds[allMessageIds.length - 1] : undefined,
|
|
678
|
+
ReplyToId: threadRootId,
|
|
679
|
+
MessageThreadId: threadRootId,
|
|
680
|
+
Timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
|
681
|
+
WasMentioned: kind !== "direct" ? effectiveWasMentioned : undefined,
|
|
682
|
+
CommandAuthorized: commandAuthorized,
|
|
683
|
+
OriginatingChannel: "mattermost" as const,
|
|
684
|
+
OriginatingTo: to,
|
|
685
|
+
...mediaPayload,
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
if (kind === "direct") {
|
|
689
|
+
const sessionCfg = cfg.session;
|
|
690
|
+
const storePath = core.channel.session.resolveStorePath(sessionCfg?.store, {
|
|
691
|
+
agentId: route.agentId,
|
|
692
|
+
});
|
|
693
|
+
await core.channel.session.updateLastRoute({
|
|
694
|
+
storePath,
|
|
695
|
+
sessionKey: route.mainSessionKey,
|
|
696
|
+
deliveryContext: {
|
|
697
|
+
channel: "mattermost",
|
|
698
|
+
to,
|
|
699
|
+
accountId: route.accountId,
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
|
705
|
+
logVerboseMessage(
|
|
706
|
+
`mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
const textLimit = core.channel.text.resolveTextChunkLimit(
|
|
710
|
+
cfg,
|
|
711
|
+
"mattermost",
|
|
712
|
+
account.accountId,
|
|
713
|
+
{
|
|
714
|
+
fallbackLimit: account.textChunkLimit ?? 4000,
|
|
715
|
+
},
|
|
716
|
+
);
|
|
717
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
718
|
+
cfg,
|
|
719
|
+
channel: "mattermost",
|
|
720
|
+
accountId: account.accountId,
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
|
724
|
+
cfg,
|
|
725
|
+
agentId: route.agentId,
|
|
726
|
+
channel: "mattermost",
|
|
727
|
+
accountId: account.accountId,
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
const typingCallbacks = createTypingCallbacks({
|
|
731
|
+
start: () => sendTypingIndicator(channelId, threadRootId),
|
|
732
|
+
onStartError: (err) => {
|
|
733
|
+
logTypingFailure({
|
|
734
|
+
log: (message) => logger.debug?.(message),
|
|
735
|
+
channel: "mattermost",
|
|
736
|
+
target: channelId,
|
|
737
|
+
error: err,
|
|
738
|
+
});
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
742
|
+
core.channel.reply.createReplyDispatcherWithTyping({
|
|
743
|
+
...prefixOptions,
|
|
744
|
+
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
|
745
|
+
deliver: async (payload: ReplyPayload) => {
|
|
746
|
+
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
|
747
|
+
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
|
748
|
+
if (mediaUrls.length === 0) {
|
|
749
|
+
const chunkMode = core.channel.text.resolveChunkMode(
|
|
750
|
+
cfg,
|
|
751
|
+
"mattermost",
|
|
752
|
+
account.accountId,
|
|
753
|
+
);
|
|
754
|
+
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode);
|
|
755
|
+
for (const chunk of chunks.length > 0 ? chunks : [text]) {
|
|
756
|
+
if (!chunk) {
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
await sendMessageMattermost(to, chunk, {
|
|
760
|
+
accountId: account.accountId,
|
|
761
|
+
replyToId: threadRootId,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
} else {
|
|
765
|
+
let first = true;
|
|
766
|
+
for (const mediaUrl of mediaUrls) {
|
|
767
|
+
const caption = first ? text : "";
|
|
768
|
+
first = false;
|
|
769
|
+
await sendMessageMattermost(to, caption, {
|
|
770
|
+
accountId: account.accountId,
|
|
771
|
+
mediaUrl,
|
|
772
|
+
replyToId: threadRootId,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
runtime.log?.(`delivered reply to ${to}`);
|
|
777
|
+
},
|
|
778
|
+
onError: (err, info) => {
|
|
779
|
+
runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`);
|
|
780
|
+
},
|
|
781
|
+
onReplyStart: typingCallbacks.onReplyStart,
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
await core.channel.reply.dispatchReplyFromConfig({
|
|
785
|
+
ctx: ctxPayload,
|
|
786
|
+
cfg,
|
|
787
|
+
dispatcher,
|
|
788
|
+
replyOptions: {
|
|
789
|
+
...replyOptions,
|
|
790
|
+
disableBlockStreaming:
|
|
791
|
+
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
|
792
|
+
onModelSelected,
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
markDispatchIdle();
|
|
796
|
+
if (historyKey) {
|
|
797
|
+
clearHistoryEntriesIfEnabled({
|
|
798
|
+
historyMap: channelHistories,
|
|
799
|
+
historyKey,
|
|
800
|
+
limit: historyLimit,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const handleReactionEvent = async (payload: MattermostEventPayload) => {
|
|
806
|
+
const reactionData = payload.data?.reaction;
|
|
807
|
+
if (!reactionData) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
let reaction: MattermostReaction | null = null;
|
|
811
|
+
if (typeof reactionData === "string") {
|
|
812
|
+
try {
|
|
813
|
+
reaction = JSON.parse(reactionData) as MattermostReaction;
|
|
814
|
+
} catch {
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
} else if (typeof reactionData === "object") {
|
|
818
|
+
reaction = reactionData as MattermostReaction;
|
|
819
|
+
}
|
|
820
|
+
if (!reaction) {
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const userId = reaction.user_id?.trim();
|
|
825
|
+
const postId = reaction.post_id?.trim();
|
|
826
|
+
const emojiName = reaction.emoji_name?.trim();
|
|
827
|
+
if (!userId || !postId || !emojiName) {
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Skip reactions from the bot itself
|
|
832
|
+
if (userId === botUserId) {
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const isRemoved = payload.event === "reaction_removed";
|
|
837
|
+
const action = isRemoved ? "removed" : "added";
|
|
838
|
+
|
|
839
|
+
const senderInfo = await resolveUserInfo(userId);
|
|
840
|
+
const senderName = senderInfo?.username?.trim() || userId;
|
|
841
|
+
|
|
842
|
+
// Resolve the channel from broadcast or post to route to the correct agent session
|
|
843
|
+
const channelId = payload.broadcast?.channel_id;
|
|
844
|
+
if (!channelId) {
|
|
845
|
+
// Without a channel id we cannot verify DM/group policies — drop to be safe
|
|
846
|
+
logVerboseMessage(
|
|
847
|
+
`mattermost: drop reaction (no channel_id in broadcast, cannot enforce policy)`,
|
|
848
|
+
);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
const channelInfo = await resolveChannelInfo(channelId);
|
|
852
|
+
if (!channelInfo?.type) {
|
|
853
|
+
// Cannot determine channel type — drop to avoid policy bypass
|
|
854
|
+
logVerboseMessage(`mattermost: drop reaction (cannot resolve channel type for ${channelId})`);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const kind = channelKind(channelInfo.type);
|
|
858
|
+
|
|
859
|
+
// Enforce DM/group policy and allowlist checks (same as normal messages)
|
|
860
|
+
if (kind === "direct") {
|
|
861
|
+
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
|
862
|
+
if (dmPolicy === "disabled") {
|
|
863
|
+
logVerboseMessage(`mattermost: drop reaction (dmPolicy=disabled sender=${userId})`);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
// For pairing/allowlist modes, only allow reactions from approved senders
|
|
867
|
+
if (dmPolicy !== "open") {
|
|
868
|
+
const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []);
|
|
869
|
+
const storeAllowFrom = normalizeAllowList(
|
|
870
|
+
await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
|
|
871
|
+
);
|
|
872
|
+
const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom]));
|
|
873
|
+
const allowed = isSenderAllowed({
|
|
874
|
+
senderId: userId,
|
|
875
|
+
senderName,
|
|
876
|
+
allowFrom: effectiveAllowFrom,
|
|
877
|
+
});
|
|
878
|
+
if (!allowed) {
|
|
879
|
+
logVerboseMessage(
|
|
880
|
+
`mattermost: drop reaction (dmPolicy=${dmPolicy} sender=${userId} not allowed)`,
|
|
881
|
+
);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
} else if (kind) {
|
|
886
|
+
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
|
887
|
+
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
|
888
|
+
if (groupPolicy === "disabled") {
|
|
889
|
+
logVerboseMessage(`mattermost: drop reaction (groupPolicy=disabled channel=${channelId})`);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (groupPolicy === "allowlist") {
|
|
893
|
+
const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []);
|
|
894
|
+
const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []);
|
|
895
|
+
const storeAllowFrom = normalizeAllowList(
|
|
896
|
+
await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
|
|
897
|
+
);
|
|
898
|
+
const effectiveGroupAllowFrom = Array.from(
|
|
899
|
+
new Set([
|
|
900
|
+
...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
|
|
901
|
+
...storeAllowFrom,
|
|
902
|
+
]),
|
|
903
|
+
);
|
|
904
|
+
// Drop when allowlist is empty (same as normal message handler)
|
|
905
|
+
const allowed =
|
|
906
|
+
effectiveGroupAllowFrom.length > 0 &&
|
|
907
|
+
isSenderAllowed({
|
|
908
|
+
senderId: userId,
|
|
909
|
+
senderName,
|
|
910
|
+
allowFrom: effectiveGroupAllowFrom,
|
|
911
|
+
});
|
|
912
|
+
if (!allowed) {
|
|
913
|
+
logVerboseMessage(`mattermost: drop reaction (groupPolicy=allowlist sender=${userId})`);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const teamId = channelInfo?.team_id ?? undefined;
|
|
920
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
921
|
+
cfg,
|
|
922
|
+
channel: "mattermost",
|
|
923
|
+
accountId: account.accountId,
|
|
924
|
+
teamId,
|
|
925
|
+
peer: {
|
|
926
|
+
kind,
|
|
927
|
+
id: kind === "direct" ? userId : channelId,
|
|
928
|
+
},
|
|
929
|
+
});
|
|
930
|
+
const sessionKey = route.sessionKey;
|
|
931
|
+
|
|
932
|
+
const eventText = `Mattermost reaction ${action}: :${emojiName}: by @${senderName} on post ${postId} in channel ${channelId}`;
|
|
933
|
+
|
|
934
|
+
core.system.enqueueSystemEvent(eventText, {
|
|
935
|
+
sessionKey,
|
|
936
|
+
contextKey: `mattermost:reaction:${postId}:${emojiName}:${userId}:${action}`,
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
logVerboseMessage(
|
|
940
|
+
`mattermost reaction: ${action} :${emojiName}: by ${senderName} on ${postId}`,
|
|
941
|
+
);
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
|
|
945
|
+
cfg,
|
|
946
|
+
channel: "mattermost",
|
|
947
|
+
});
|
|
948
|
+
const debouncer = core.channel.debounce.createInboundDebouncer<{
|
|
949
|
+
post: MattermostPost;
|
|
950
|
+
payload: MattermostEventPayload;
|
|
951
|
+
}>({
|
|
952
|
+
debounceMs: inboundDebounceMs,
|
|
953
|
+
buildKey: (entry) => {
|
|
954
|
+
const channelId =
|
|
955
|
+
entry.post.channel_id ??
|
|
956
|
+
entry.payload.data?.channel_id ??
|
|
957
|
+
entry.payload.broadcast?.channel_id;
|
|
958
|
+
if (!channelId) {
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
const threadId = entry.post.root_id?.trim();
|
|
962
|
+
const threadKey = threadId ? `thread:${threadId}` : "channel";
|
|
963
|
+
return `mattermost:${account.accountId}:${channelId}:${threadKey}`;
|
|
964
|
+
},
|
|
965
|
+
shouldDebounce: (entry) => {
|
|
966
|
+
if (entry.post.file_ids && entry.post.file_ids.length > 0) {
|
|
967
|
+
return false;
|
|
968
|
+
}
|
|
969
|
+
const text = entry.post.message?.trim() ?? "";
|
|
970
|
+
if (!text) {
|
|
971
|
+
return false;
|
|
972
|
+
}
|
|
973
|
+
return !core.channel.text.hasControlCommand(text, cfg);
|
|
974
|
+
},
|
|
975
|
+
onFlush: async (entries) => {
|
|
976
|
+
const last = entries.at(-1);
|
|
977
|
+
if (!last) {
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
if (entries.length === 1) {
|
|
981
|
+
await handlePost(last.post, last.payload);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
const combinedText = entries
|
|
985
|
+
.map((entry) => entry.post.message?.trim() ?? "")
|
|
986
|
+
.filter(Boolean)
|
|
987
|
+
.join("\n");
|
|
988
|
+
const mergedPost: MattermostPost = {
|
|
989
|
+
...last.post,
|
|
990
|
+
message: combinedText,
|
|
991
|
+
file_ids: [],
|
|
992
|
+
};
|
|
993
|
+
const ids = entries.map((entry) => entry.post.id).filter(Boolean);
|
|
994
|
+
await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined);
|
|
995
|
+
},
|
|
996
|
+
onError: (err) => {
|
|
997
|
+
runtime.error?.(`mattermost debounce flush failed: ${String(err)}`);
|
|
998
|
+
},
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
const wsUrl = buildMattermostWsUrl(baseUrl);
|
|
1002
|
+
let seq = 1;
|
|
1003
|
+
const connectOnce = createMattermostConnectOnce({
|
|
1004
|
+
wsUrl,
|
|
1005
|
+
botToken,
|
|
1006
|
+
abortSignal: opts.abortSignal,
|
|
1007
|
+
statusSink: opts.statusSink,
|
|
1008
|
+
runtime,
|
|
1009
|
+
webSocketFactory: opts.webSocketFactory,
|
|
1010
|
+
nextSeq: () => seq++,
|
|
1011
|
+
onPosted: async (post, payload) => {
|
|
1012
|
+
await debouncer.enqueue({ post, payload });
|
|
1013
|
+
},
|
|
1014
|
+
onReaction: async (payload) => {
|
|
1015
|
+
await handleReactionEvent(payload);
|
|
1016
|
+
},
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
await runWithReconnect(connectOnce, {
|
|
1020
|
+
abortSignal: opts.abortSignal,
|
|
1021
|
+
jitterRatio: 0.2,
|
|
1022
|
+
onError: (err) => {
|
|
1023
|
+
runtime.error?.(`mattermost connection failed: ${String(err)}`);
|
|
1024
|
+
opts.statusSink?.({ lastError: String(err), connected: false });
|
|
1025
|
+
},
|
|
1026
|
+
onReconnect: (delayMs) => {
|
|
1027
|
+
runtime.log?.(`mattermost reconnecting in ${Math.round(delayMs / 1000)}s`);
|
|
1028
|
+
},
|
|
1029
|
+
});
|
|
1030
|
+
}
|