Skip to content

Commit 59fb5fd

Browse files
authored
fix(mattermost): prevent DM replies from creating threads (#72659)
* fix(mattermost): prevent DM replies from creating threads * fix(mattermost): prevent DM replies from creating threads * fix(mattermost): prevent DM replies from creating threads
1 parent 72f7d7e commit 59fb5fd

5 files changed

Lines changed: 113 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Docs: https://docs.openclaw.ai
5454
- Plugins/contracts: resolve runtime manifest-contract plugin owners from one plugin index plus manifest pass instead of rebuilding manifest metadata separately for all owners and enabled owners. Thanks @shakkernerd.
5555
- Plugins/extractors: reuse one manifest registry pass while resolving bundled document and web-content extractor plugins instead of rereading manifests for compatibility and enablement filtering. Thanks @shakkernerd.
5656
- Plugins/registry: resolve lookup-table owner maps for providers, CLI backends, setup providers, command aliases, model catalogs, channel configs, and manifest contracts while preserving setup-only CLI backend ownership. Thanks @shakkernerd.
57+
- Mattermost: keep direct-message replies top-level by suppressing reply roots for DM delivery while preserving channel and group thread roots, and derive inbound chat kind from the trusted channel lookup instead of the websocket event channel type. Carries forward #60115, #55186, #72305, and #72659; refs #59758, #59981, #59791, and #57565. Thanks @vincentkoc, @jwchmodx, and @hnykda.
5758
- Process/Windows: decode command stdout and stderr from raw bytes with console-codepage awareness, while preserving valid UTF-8 output and multibyte characters split across chunks. Fixes #50519. Thanks @iready, @kevinten10, @zhangyongjie1997, @knightplat-blip, @heiqishi666, and @slepybear.
5859
- Bonjour/Windows: hide the bundled mDNS advertiser's Windows ARP shell probe so Gateway startup no longer flashes command-prompt windows. Fixes #70238. Thanks @alexandre-leng, @PratikRai0101, @infinitypacific, and @tomerpeled.
5960
- Agents/bootstrap: dedupe hook-injected bootstrap context files by workspace-relative path and store normalized resolved paths so duplicate relative and absolute hook paths no longer depend on the process cwd. (#59344; fixes #59319; related #56721, #56725, and #57587) Thanks @koen666.

extensions/mattermost/src/mattermost/monitor-gating.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
22
import {
33
evaluateMattermostMentionGate,
44
mapMattermostChannelTypeToChatType,
5+
resolveMattermostTrustedChatKind,
56
} from "./monitor-gating.js";
67

78
describe("mattermost monitor gating", () => {
@@ -13,6 +14,23 @@ describe("mattermost monitor gating", () => {
1314
expect(mapMattermostChannelTypeToChatType(undefined)).toBe("channel");
1415
});
1516

17+
it("derives chat kind from trusted channel lookup before fallback state", () => {
18+
expect(
19+
resolveMattermostTrustedChatKind({
20+
channelType: "O",
21+
fallback: "direct",
22+
}),
23+
).toBe("channel");
24+
expect(
25+
resolveMattermostTrustedChatKind({
26+
channelType: "D",
27+
fallback: "channel",
28+
}),
29+
).toBe("direct");
30+
expect(resolveMattermostTrustedChatKind({ fallback: "group" })).toBe("group");
31+
expect(resolveMattermostTrustedChatKind({})).toBe("channel");
32+
});
33+
1634
it("drops non-mentioned traffic when onchar is enabled but not triggered", () => {
1735
const resolveRequireMention = vi.fn(() => true);
1836

extensions/mattermost/src/mattermost/monitor-gating.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ export function mapMattermostChannelTypeToChatType(channelType?: string | null):
1414
return "channel";
1515
}
1616

17+
export function resolveMattermostTrustedChatKind(params: {
18+
channelType?: string | null;
19+
fallback?: ChatType;
20+
}): ChatType {
21+
const channelType = params.channelType?.trim();
22+
if (channelType) {
23+
return mapMattermostChannelTypeToChatType(channelType);
24+
}
25+
return params.fallback ?? "channel";
26+
}
27+
1728
export type MattermostRequireMentionResolverInput = {
1829
cfg: OpenClawConfig;
1930
channel: "mattermost";

extensions/mattermost/src/mattermost/monitor.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ describe("resolveMattermostReplyRootId with block streaming payloads", () => {
162162
// mode, the deliver callback should still use the existing threadRootId.
163163
expect(
164164
resolveMattermostReplyRootId({
165+
kind: "channel",
165166
threadRootId: "thread-root-1",
166167
replyToId: "streamed-reply-id",
167168
}),
@@ -173,6 +174,7 @@ describe("resolveMattermostReplyRootId with block streaming payloads", () => {
173174
// inbound post id as replyToId from the "all" threading mode.
174175
expect(
175176
resolveMattermostReplyRootId({
177+
kind: "channel",
176178
replyToId: "inbound-post-for-threading",
177179
}),
178180
).toBe("inbound-post-for-threading");
@@ -183,6 +185,7 @@ describe("resolveMattermostReplyRootId", () => {
183185
it("uses replyToId for top-level replies", () => {
184186
expect(
185187
resolveMattermostReplyRootId({
188+
kind: "channel",
186189
replyToId: "inbound-post-123",
187190
}),
188191
).toBe("inbound-post-123");
@@ -191,21 +194,52 @@ describe("resolveMattermostReplyRootId", () => {
191194
it("keeps the thread root when replying inside an existing thread", () => {
192195
expect(
193196
resolveMattermostReplyRootId({
197+
kind: "channel",
194198
threadRootId: "thread-root-456",
195199
replyToId: "child-post-789",
196200
}),
197201
).toBe("thread-root-456");
198202
});
199203

200204
it("falls back to undefined when neither reply target is available", () => {
201-
expect(resolveMattermostReplyRootId({})).toBeUndefined();
205+
expect(resolveMattermostReplyRootId({ kind: "channel" })).toBeUndefined();
206+
});
207+
208+
it("keeps direct-message replies top-level even when a payload reply target exists", () => {
209+
expect(
210+
resolveMattermostReplyRootId({
211+
kind: "direct",
212+
threadRootId: "dm-root-456",
213+
replyToId: "dm-post-123",
214+
}),
215+
).toBeUndefined();
216+
});
217+
218+
it("keeps direct-message replies top-level when only the payload reply target exists", () => {
219+
expect(
220+
resolveMattermostReplyRootId({
221+
kind: "direct",
222+
replyToId: "dm-post-123",
223+
}),
224+
).toBeUndefined();
225+
});
226+
227+
it("keeps group replies on the existing Mattermost thread root", () => {
228+
expect(
229+
resolveMattermostReplyRootId({
230+
kind: "group",
231+
threadRootId: "group-root-456",
232+
replyToId: "group-child-789",
233+
}),
234+
).toBe("group-root-456");
202235
});
203236
});
204237

205238
describe("canFinalizeMattermostPreviewInPlace", () => {
206239
it("allows in-place finalization when the final reply target matches the preview thread", () => {
207240
expect(
208241
canFinalizeMattermostPreviewInPlace({
242+
kind: "channel",
209243
previewRootId: "thread-root-456",
210244
threadRootId: "thread-root-456",
211245
replyToId: "child-post-789",
@@ -216,10 +250,20 @@ describe("canFinalizeMattermostPreviewInPlace", () => {
216250
it("prevents in-place finalization when a top-level preview would become a threaded reply", () => {
217251
expect(
218252
canFinalizeMattermostPreviewInPlace({
253+
kind: "channel",
219254
replyToId: "child-post-789",
220255
}),
221256
).toBe(false);
222257
});
258+
259+
it("uses direct-message root suppression when checking in-place finalization", () => {
260+
expect(
261+
canFinalizeMattermostPreviewInPlace({
262+
kind: "direct",
263+
replyToId: "dm-post-123",
264+
}),
265+
).toBe(true);
266+
});
223267
});
224268

225269
describe("shouldClearMattermostDraftPreview", () => {
@@ -259,6 +303,7 @@ describe("deliverMattermostReplyWithDraftPreview", () => {
259303
await deliverMattermostReplyWithDraftPreview({
260304
payload: { text: " \n > Reasoning:\n> _hidden_" } as never,
261305
info: { kind: "final" },
306+
kind: "channel",
262307
client: createMattermostClientMock(),
263308
draftStream,
264309
effectiveReplyToId: "thread-root-1",
@@ -282,6 +327,7 @@ describe("deliverMattermostReplyWithDraftPreview", () => {
282327
await deliverMattermostReplyWithDraftPreview({
283328
payload: { text: "All good", replyToId: "reply-1" } as never,
284329
info: { kind: "final" },
330+
kind: "channel",
285331
client: createMattermostClientMock(),
286332
draftStream,
287333
resolvePreviewFinalText: (text) => text?.trim(),
@@ -308,6 +354,7 @@ describe("deliverMattermostReplyWithDraftPreview", () => {
308354
mediaUrl: "https://example.com/a.png",
309355
} as never,
310356
info: { kind: "final" },
357+
kind: "channel",
311358
client: createMattermostClientMock(),
312359
draftStream,
313360
effectiveReplyToId: "thread-root-1",
@@ -330,6 +377,7 @@ describe("deliverMattermostReplyWithDraftPreview", () => {
330377
await deliverMattermostReplyWithDraftPreview({
331378
payload: { text: "Error", isError: true } as never,
332379
info: { kind: "final" },
380+
kind: "channel",
333381
client: createMattermostClientMock(),
334382
draftStream,
335383
effectiveReplyToId: "thread-root-1",
@@ -351,6 +399,7 @@ describe("deliverMattermostReplyWithDraftPreview", () => {
351399
await deliverMattermostReplyWithDraftPreview({
352400
payload: { text: "Final answer", replyToId: "child-post-789" } as never,
353401
info: { kind: "final" },
402+
kind: "channel",
354403
client: createMattermostClientMock(),
355404
draftStream,
356405
effectiveReplyToId: "thread-root-456",
@@ -384,6 +433,7 @@ describe("deliverMattermostReplyWithDraftPreview", () => {
384433
deliverMattermostReplyWithDraftPreview({
385434
payload: { text: "Broken", replyToId: "reply-1" } as never,
386435
info: { kind: "final" },
436+
kind: "channel",
387437
client: createMattermostClientMock(),
388438
draftStream,
389439
resolvePreviewFinalText: (text) => text?.trim(),
@@ -484,6 +534,17 @@ describe("resolveMattermostEffectiveReplyToId", () => {
484534
}),
485535
).toBeUndefined();
486536
});
537+
538+
it("suppresses existing direct-message thread roots", () => {
539+
expect(
540+
resolveMattermostEffectiveReplyToId({
541+
kind: "direct",
542+
postId: "post-123",
543+
replyToMode: "all",
544+
threadRootId: "dm-root-456",
545+
}),
546+
).toBeUndefined();
547+
});
487548
});
488549

489550
describe("resolveMattermostThreadSessionContext", () => {
@@ -541,6 +602,7 @@ describe("resolveMattermostThreadSessionContext", () => {
541602
kind: "direct",
542603
postId: "post-123",
543604
replyToMode: "all",
605+
threadRootId: "dm-root-456",
544606
}),
545607
).toEqual({
546608
effectiveReplyToId: undefined,

extensions/mattermost/src/mattermost/monitor.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
import {
4242
evaluateMattermostMentionGate,
4343
mapMattermostChannelTypeToChatType,
44+
resolveMattermostTrustedChatKind,
4445
} from "./monitor-gating.js";
4546
import {
4647
formatInboundFromLabel,
@@ -94,6 +95,7 @@ import { deactivateSlashCommands, getSlashCommandState } from "./slash-state.js"
9495
export {
9596
evaluateMattermostMentionGate,
9697
mapMattermostChannelTypeToChatType,
98+
resolveMattermostTrustedChatKind,
9799
} from "./monitor-gating.js";
98100
export type {
99101
MattermostMentionGateInput,
@@ -231,9 +233,13 @@ function channelChatType(kind: ChatType): "direct" | "group" | "channel" {
231233
}
232234

233235
export function resolveMattermostReplyRootId(params: {
236+
kind: ChatType;
234237
threadRootId?: string;
235238
replyToId?: string;
236239
}): string | undefined {
240+
if (params.kind === "direct") {
241+
return undefined;
242+
}
237243
const threadRootId = normalizeOptionalString(params.threadRootId);
238244
if (threadRootId) {
239245
return threadRootId;
@@ -242,12 +248,14 @@ export function resolveMattermostReplyRootId(params: {
242248
}
243249

244250
export function canFinalizeMattermostPreviewInPlace(params: {
251+
kind: ChatType;
245252
previewRootId?: string;
246253
threadRootId?: string;
247254
replyToId?: string;
248255
}): boolean {
249256
return (
250257
resolveMattermostReplyRootId({
258+
kind: params.kind,
251259
threadRootId: params.threadRootId,
252260
replyToId: params.replyToId,
253261
}) === params.previewRootId?.trim()
@@ -275,6 +283,7 @@ type MattermostDraftPreviewState = {
275283
type MattermostDraftPreviewDeliverParams = {
276284
payload: ReplyPayload;
277285
info: { kind: "tool" | "block" | "final" };
286+
kind: ChatType;
278287
client: MattermostClient;
279288
draftStream: Pick<
280289
ReturnType<typeof createMattermostDraftStream>,
@@ -313,6 +322,7 @@ export async function deliverMattermostReplyWithDraftPreview(
313322
typeof previewFinalText !== "string" ||
314323
payload.isError ||
315324
!canFinalizeMattermostPreviewInPlace({
325+
kind: params.kind,
316326
previewRootId: params.effectiveReplyToId,
317327
threadRootId: params.effectiveReplyToId,
318328
replyToId: payload.replyToId,
@@ -345,13 +355,13 @@ export function resolveMattermostEffectiveReplyToId(params: {
345355
replyToMode: "off" | "first" | "all" | "batched";
346356
threadRootId?: string | null;
347357
}): string | undefined {
358+
if (params.kind === "direct") {
359+
return undefined;
360+
}
348361
const threadRootId = normalizeOptionalString(params.threadRootId);
349362
if (threadRootId && params.replyToMode !== "off") {
350363
return threadRootId;
351364
}
352-
if (params.kind === "direct") {
353-
return undefined;
354-
}
355365
const postId = normalizeOptionalString(params.postId);
356366
if (!postId) {
357367
return undefined;
@@ -707,6 +717,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
707717
accountId: account.accountId,
708718
agentId: route.agentId,
709719
replyToId: resolveMattermostReplyRootId({
720+
kind,
710721
threadRootId: threadContext.effectiveReplyToId,
711722
replyToId: payload.replyToId,
712723
}),
@@ -915,6 +926,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
915926
accountId: account.accountId,
916927
agentId: params.route.agentId,
917928
replyToId: resolveMattermostReplyRootId({
929+
kind: params.kind,
918930
threadRootId: params.effectiveReplyToId,
919931
replyToId: trimmedPayload.replyToId,
920932
}),
@@ -1208,8 +1220,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
12081220
}
12091221

12101222
const channelInfo = await resolveChannelInfo(channelId);
1211-
const channelType = payload.data?.channel_type ?? channelInfo?.type ?? undefined;
1212-
const kind = mapMattermostChannelTypeToChatType(channelType);
1223+
const kind = resolveMattermostTrustedChatKind({
1224+
channelType: channelInfo?.type,
1225+
});
12131226
const chatType = channelChatType(kind);
12141227

12151228
const senderName =
@@ -1695,6 +1708,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
16951708
await deliverMattermostReplyWithDraftPreview({
16961709
payload,
16971710
info,
1711+
kind,
16981712
client,
16991713
draftStream,
17001714
effectiveReplyToId,
@@ -1710,6 +1724,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
17101724
accountId: account.accountId,
17111725
agentId: route.agentId,
17121726
replyToId: resolveMattermostReplyRootId({
1727+
kind,
17131728
threadRootId: effectiveReplyToId,
17141729
replyToId: payload.replyToId,
17151730
}),

0 commit comments

Comments
 (0)