Skip to content

Commit 171d2df

Browse files
feat(mattermost): add replyToMode support (off | first | all) (#29587)
Merged via squash. Prepared head SHA: 4a67791 Co-authored-by: teconomix <[email protected]> Co-authored-by: mukhtharcm <[email protected]> Reviewed-by: @mukhtharcm
1 parent 8e0e4f7 commit 171d2df

File tree

12 files changed

+477
-40
lines changed

12 files changed

+477
-40
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ Docs: https://docs.openclaw.ai
5757
- Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky.
5858
- Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle.
5959
- Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc.
60+
<<<<<<< HEAD
6061
- LLM Task/Lobster: add an optional `thinking` override so workflow calls can explicitly set embedded reasoning level with shared validation for invalid values and unsupported `xhigh` modes. (#15606) Thanks @xadenryan and @ImLukeF.
62+
- Mattermost/reply threading: add `channels.mattermost.replyToMode` for channel and group messages so top-level posts can start thread-scoped sessions without the manual reply-then-thread workaround. (#29587) Thanks @teconomix.
6163

6264
### Breaking
6365

docs/channels/mattermost.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,35 @@ Notes:
129129
- `onchar` still responds to explicit @mentions.
130130
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
131131

132+
## Threading and sessions
133+
134+
Use `channels.mattermost.replyToMode` to control whether channel and group replies stay in the
135+
main channel or start a thread under the triggering post.
136+
137+
- `off` (default): only reply in a thread when the inbound post is already in one.
138+
- `first`: for top-level channel/group posts, start a thread under that post and route the
139+
conversation to a thread-scoped session.
140+
- `all`: same behavior as `first` for Mattermost today.
141+
- Direct messages ignore this setting and stay non-threaded.
142+
143+
Config example:
144+
145+
```json5
146+
{
147+
channels: {
148+
mattermost: {
149+
replyToMode: "all",
150+
},
151+
},
152+
}
153+
```
154+
155+
Notes:
156+
157+
- Thread-scoped sessions use the triggering post id as the thread root.
158+
- `first` and `all` are currently equivalent because once Mattermost has a thread root,
159+
follow-up chunks and media continue in that same thread.
160+
132161
## Access control (DMs)
133162

134163
- Default: `channels.mattermost.dmPolicy = "pairing"` (unknown senders get a pairing code).

extensions/mattermost/src/channel.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,38 @@ describe("mattermostPlugin", () => {
6565
});
6666
});
6767

68+
describe("threading", () => {
69+
it("uses replyToMode for channel messages and keeps direct messages off", () => {
70+
const resolveReplyToMode = mattermostPlugin.threading?.resolveReplyToMode;
71+
if (!resolveReplyToMode) {
72+
return;
73+
}
74+
75+
const cfg: OpenClawConfig = {
76+
channels: {
77+
mattermost: {
78+
replyToMode: "all",
79+
},
80+
},
81+
};
82+
83+
expect(
84+
resolveReplyToMode({
85+
cfg,
86+
accountId: "default",
87+
chatType: "channel",
88+
}),
89+
).toBe("all");
90+
expect(
91+
resolveReplyToMode({
92+
cfg,
93+
accountId: "default",
94+
chatType: "direct",
95+
}),
96+
).toBe("off");
97+
});
98+
});
99+
68100
describe("messageActions", () => {
69101
beforeEach(() => {
70102
resetMattermostReactionBotUserCacheForTests();

extensions/mattermost/src/channel.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
deleteAccountFromConfigSection,
1515
migrateBaseNameToDefaultAccount,
1616
normalizeAccountId,
17+
resolveAllowlistProviderRuntimeGroupPolicy,
18+
resolveDefaultGroupPolicy,
1719
setAccountEnabledInConfigSection,
1820
type ChannelMessageActionAdapter,
1921
type ChannelMessageActionName,
@@ -25,6 +27,7 @@ import {
2527
listMattermostAccountIds,
2628
resolveDefaultMattermostAccountId,
2729
resolveMattermostAccount,
30+
resolveMattermostReplyToMode,
2831
type ResolvedMattermostAccount,
2932
} from "./mattermost/accounts.js";
3033
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
@@ -271,13 +274,13 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
271274
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
272275
},
273276
threading: {
274-
resolveReplyToMode: ({ cfg, accountId }) => {
277+
resolveReplyToMode: ({ cfg, accountId, chatType }) => {
275278
const account = resolveMattermostAccount({ cfg, accountId: accountId ?? "default" });
276-
const mode = account.config.replyToMode;
277-
if (mode === "off" || mode === "first") {
278-
return mode;
279-
}
280-
return "all";
279+
const kind =
280+
chatType === "direct" || chatType === "group" || chatType === "channel"
281+
? chatType
282+
: "channel";
283+
return resolveMattermostReplyToMode(account, kind);
281284
},
282285
},
283286
reload: { configPrefixes: ["channels.mattermost"] },

extensions/mattermost/src/config-schema.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import { MattermostConfigSchema } from "./config-schema.js";
33

4-
describe("MattermostConfigSchema SecretInput", () => {
4+
describe("MattermostConfigSchema", () => {
55
it("accepts SecretRef botToken at top-level", () => {
66
const result = MattermostConfigSchema.safeParse({
77
botToken: { source: "env", provider: "default", id: "MATTERMOST_BOT_TOKEN" },
@@ -21,4 +21,29 @@ describe("MattermostConfigSchema SecretInput", () => {
2121
});
2222
expect(result.success).toBe(true);
2323
});
24+
25+
it("accepts replyToMode", () => {
26+
const result = MattermostConfigSchema.safeParse({
27+
replyToMode: "all",
28+
});
29+
expect(result.success).toBe(true);
30+
});
31+
32+
it("rejects unsupported direct-message reply threading config", () => {
33+
const result = MattermostConfigSchema.safeParse({
34+
dm: {
35+
replyToMode: "all",
36+
},
37+
});
38+
expect(result.success).toBe(false);
39+
});
40+
41+
it("rejects unsupported per-chat-type reply threading config", () => {
42+
const result = MattermostConfigSchema.safeParse({
43+
replyToModeByChatType: {
44+
direct: "all",
45+
},
46+
});
47+
expect(result.success).toBe(false);
48+
});
2449
});

extensions/mattermost/src/mattermost/accounts.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
22
import { describe, expect, it } from "vitest";
3-
import { resolveDefaultMattermostAccountId } from "./accounts.js";
3+
import {
4+
resolveDefaultMattermostAccountId,
5+
resolveMattermostAccount,
6+
resolveMattermostReplyToMode,
7+
} from "./accounts.js";
48

59
describe("resolveDefaultMattermostAccountId", () => {
610
it("prefers channels.mattermost.defaultAccount when it matches a configured account", () => {
@@ -50,3 +54,37 @@ describe("resolveDefaultMattermostAccountId", () => {
5054
expect(resolveDefaultMattermostAccountId(cfg)).toBe("default");
5155
});
5256
});
57+
58+
describe("resolveMattermostReplyToMode", () => {
59+
it("uses the configured mode for channel and group messages", () => {
60+
const cfg: OpenClawConfig = {
61+
channels: {
62+
mattermost: {
63+
replyToMode: "all",
64+
},
65+
},
66+
};
67+
68+
const account = resolveMattermostAccount({ cfg, accountId: "default" });
69+
expect(resolveMattermostReplyToMode(account, "channel")).toBe("all");
70+
expect(resolveMattermostReplyToMode(account, "group")).toBe("all");
71+
});
72+
73+
it("keeps direct messages off even when replyToMode is enabled", () => {
74+
const cfg: OpenClawConfig = {
75+
channels: {
76+
mattermost: {
77+
replyToMode: "all",
78+
},
79+
},
80+
};
81+
82+
const account = resolveMattermostAccount({ cfg, accountId: "default" });
83+
expect(resolveMattermostReplyToMode(account, "direct")).toBe("off");
84+
});
85+
86+
it("defaults to off when replyToMode is unset", () => {
87+
const account = resolveMattermostAccount({ cfg: {}, accountId: "default" });
88+
expect(resolveMattermostReplyToMode(account, "channel")).toBe("off");
89+
});
90+
});

extensions/mattermost/src/mattermost/accounts.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
22
import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
33
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js";
4-
import type { MattermostAccountConfig, MattermostChatMode } from "../types.js";
4+
import type {
5+
MattermostAccountConfig,
6+
MattermostChatMode,
7+
MattermostChatTypeKey,
8+
MattermostReplyToMode,
9+
} from "../types.js";
510
import { normalizeMattermostBaseUrl } from "./client.js";
611

712
export type MattermostTokenSource = "env" | "config" | "none";
@@ -130,6 +135,20 @@ export function resolveMattermostAccount(params: {
130135
};
131136
}
132137

138+
/**
139+
* Resolve the effective replyToMode for a given chat type.
140+
* Mattermost auto-threading only applies to channel and group messages.
141+
*/
142+
export function resolveMattermostReplyToMode(
143+
account: ResolvedMattermostAccount,
144+
kind: MattermostChatTypeKey,
145+
): MattermostReplyToMode {
146+
if (kind === "direct") {
147+
return "off";
148+
}
149+
return account.config.replyToMode ?? "off";
150+
}
151+
133152
export function listEnabledMattermostAccounts(cfg: OpenClawConfig): ResolvedMattermostAccount[] {
134153
return listMattermostAccountIds(cfg)
135154
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))

extensions/mattermost/src/mattermost/interactions.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { type IncomingMessage, type ServerResponse } from "node:http";
22
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
33
import { setMattermostRuntime } from "../runtime.js";
44
import { resolveMattermostAccount } from "./accounts.js";
5-
import type { MattermostClient } from "./client.js";
5+
import type { MattermostClient, MattermostPost } from "./client.js";
66
import {
77
buildButtonAttachments,
88
computeInteractionCallbackUrl,
@@ -738,6 +738,70 @@ describe("createMattermostInteractionHandler", () => {
738738
]);
739739
});
740740

741+
it("forwards fetched post threading metadata to session and button callbacks", async () => {
742+
const enqueueSystemEvent = vi.fn();
743+
setMattermostRuntime({
744+
system: {
745+
enqueueSystemEvent,
746+
},
747+
} as unknown as Parameters<typeof setMattermostRuntime>[0]);
748+
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
749+
const token = generateInteractionToken(context, "acct");
750+
const resolveSessionKey = vi.fn().mockResolvedValue("session:thread:root-9");
751+
const dispatchButtonClick = vi.fn();
752+
const fetchedPost: MattermostPost = {
753+
id: "post-1",
754+
channel_id: "chan-1",
755+
root_id: "root-9",
756+
message: "Choose",
757+
props: {
758+
attachments: [{ actions: [{ id: "approve", name: "Approve" }] }],
759+
},
760+
};
761+
const handler = createMattermostInteractionHandler({
762+
client: {
763+
request: async (_path: string, init?: { method?: string }) =>
764+
init?.method === "PUT" ? { id: "post-1" } : fetchedPost,
765+
} as unknown as MattermostClient,
766+
botUserId: "bot",
767+
accountId: "acct",
768+
resolveSessionKey,
769+
dispatchButtonClick,
770+
});
771+
772+
const req = createReq({
773+
body: {
774+
user_id: "user-1",
775+
user_name: "alice",
776+
channel_id: "chan-1",
777+
post_id: "post-1",
778+
context: { ...context, _token: token },
779+
},
780+
});
781+
const res = createRes();
782+
783+
await handler(req, res);
784+
785+
expect(res.statusCode).toBe(200);
786+
expect(resolveSessionKey).toHaveBeenCalledWith({
787+
channelId: "chan-1",
788+
userId: "user-1",
789+
post: fetchedPost,
790+
});
791+
expect(enqueueSystemEvent).toHaveBeenCalledWith(
792+
expect.stringContaining('Mattermost button click: action="approve"'),
793+
expect.objectContaining({ sessionKey: "session:thread:root-9" }),
794+
);
795+
expect(dispatchButtonClick).toHaveBeenCalledWith(
796+
expect.objectContaining({
797+
channelId: "chan-1",
798+
userId: "user-1",
799+
postId: "post-1",
800+
post: fetchedPost,
801+
}),
802+
);
803+
});
804+
741805
it("lets a custom interaction handler short-circuit generic completion updates", async () => {
742806
const context = { action_id: "mdlprov", __openclaw_channel_id: "chan-1" };
743807
const token = generateInteractionToken(context, "acct");
@@ -751,6 +815,7 @@ describe("createMattermostInteractionHandler", () => {
751815
request: async (path: string, init?: { method?: string }) => {
752816
requestLog.push({ path, method: init?.method });
753817
return {
818+
id: "post-1",
754819
channel_id: "chan-1",
755820
message: "Choose",
756821
props: {
@@ -790,6 +855,7 @@ describe("createMattermostInteractionHandler", () => {
790855
actionId: "mdlprov",
791856
actionName: "Browse providers",
792857
originalMessage: "Choose",
858+
post: expect.objectContaining({ id: "post-1" }),
793859
userName: "alice",
794860
}),
795861
);

0 commit comments

Comments
 (0)