feat(mattermost): narrow replyToMode to channel threads

This commit is contained in:
Muhammed Mukhthar CM
2026-03-10 17:20:00 +00:00
parent 3b139bf112
commit adf21fb410
10 changed files with 204 additions and 42 deletions

View File

@@ -57,7 +57,9 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
<<<<<<< HEAD
- 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.
- 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.
### Breaking

View File

@@ -129,6 +129,35 @@ Notes:
- `onchar` still responds to explicit @mentions.
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
## Threading and sessions
Use `channels.mattermost.replyToMode` to control whether channel and group replies stay in the
main channel or start a thread under the triggering post.
- `off` (default): only reply in a thread when the inbound post is already in one.
- `first`: for top-level channel/group posts, start a thread under that post and route the
conversation to a thread-scoped session.
- `all`: same behavior as `first` for Mattermost today.
- Direct messages ignore this setting and stay non-threaded.
Config example:
```json5
{
channels: {
mattermost: {
replyToMode: "all",
},
},
}
```
Notes:
- Thread-scoped sessions use the triggering post id as the thread root.
- `first` and `all` are currently equivalent because once Mattermost has a thread root,
follow-up chunks and media continue in that same thread.
## Access control (DMs)
- Default: `channels.mattermost.dmPolicy = "pairing"` (unknown senders get a pairing code).

View File

@@ -65,6 +65,38 @@ describe("mattermostPlugin", () => {
});
});
describe("threading", () => {
it("uses replyToMode for channel messages and keeps direct messages off", () => {
const resolveReplyToMode = mattermostPlugin.threading?.resolveReplyToMode;
if (!resolveReplyToMode) {
return;
}
const cfg: OpenClawConfig = {
channels: {
mattermost: {
replyToMode: "all",
},
},
};
expect(
resolveReplyToMode({
cfg,
accountId: "default",
chatType: "channel",
}),
).toBe("all");
expect(
resolveReplyToMode({
cfg,
accountId: "default",
chatType: "direct",
}),
).toBe("off");
});
});
describe("messageActions", () => {
beforeEach(() => {
resetMattermostReactionBotUserCacheForTests();

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { MattermostConfigSchema } from "./config-schema.js";
describe("MattermostConfigSchema SecretInput", () => {
describe("MattermostConfigSchema", () => {
it("accepts SecretRef botToken at top-level", () => {
const result = MattermostConfigSchema.safeParse({
botToken: { source: "env", provider: "default", id: "MATTERMOST_BOT_TOKEN" },
@@ -21,4 +21,29 @@ describe("MattermostConfigSchema SecretInput", () => {
});
expect(result.success).toBe(true);
});
it("accepts replyToMode", () => {
const result = MattermostConfigSchema.safeParse({
replyToMode: "all",
});
expect(result.success).toBe(true);
});
it("rejects unsupported direct-message reply threading config", () => {
const result = MattermostConfigSchema.safeParse({
dm: {
replyToMode: "all",
},
});
expect(result.success).toBe(false);
});
it("rejects unsupported per-chat-type reply threading config", () => {
const result = MattermostConfigSchema.safeParse({
replyToModeByChatType: {
direct: "all",
},
});
expect(result.success).toBe(false);
});
});

View File

@@ -46,18 +46,6 @@ const MattermostAccountSchemaBase = z
replyToMode: z.enum(["off", "first", "all"]).optional(),
responsePrefix: z.string().optional(),
replyToMode: z.enum(["off", "first", "all"]).optional(),
replyToModeByChatType: z
.object({
direct: z.enum(["off", "first", "all"]).optional(),
channel: z.enum(["off", "first", "all"]).optional(),
group: z.enum(["off", "first", "all"]).optional(),
})
.optional(),
dm: z
.object({
replyToMode: z.enum(["off", "first", "all"]).optional(),
})
.optional(),
actions: z
.object({
reactions: z.boolean().optional(),

View File

@@ -1,6 +1,10 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
import { describe, expect, it } from "vitest";
import { resolveDefaultMattermostAccountId } from "./accounts.js";
import {
resolveDefaultMattermostAccountId,
resolveMattermostAccount,
resolveMattermostReplyToMode,
} from "./accounts.js";
describe("resolveDefaultMattermostAccountId", () => {
it("prefers channels.mattermost.defaultAccount when it matches a configured account", () => {
@@ -50,3 +54,37 @@ describe("resolveDefaultMattermostAccountId", () => {
expect(resolveDefaultMattermostAccountId(cfg)).toBe("default");
});
});
describe("resolveMattermostReplyToMode", () => {
it("uses the configured mode for channel and group messages", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
replyToMode: "all",
},
},
};
const account = resolveMattermostAccount({ cfg, accountId: "default" });
expect(resolveMattermostReplyToMode(account, "channel")).toBe("all");
expect(resolveMattermostReplyToMode(account, "group")).toBe("all");
});
it("keeps direct messages off even when replyToMode is enabled", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
replyToMode: "all",
},
},
};
const account = resolveMattermostAccount({ cfg, accountId: "default" });
expect(resolveMattermostReplyToMode(account, "direct")).toBe("off");
});
it("defaults to off when replyToMode is unset", () => {
const account = resolveMattermostAccount({ cfg: {}, accountId: "default" });
expect(resolveMattermostReplyToMode(account, "channel")).toBe("off");
});
});

View File

@@ -137,21 +137,15 @@ export function resolveMattermostAccount(params: {
/**
* Resolve the effective replyToMode for a given chat type.
* Checks replyToModeByChatType overrides first, then dm.replyToMode for DMs,
* then falls back to the global replyToMode (defaulting to "off" for DMs).
* Mattermost auto-threading only applies to channel and group messages.
*/
export function resolveMattermostReplyToMode(
account: ResolvedMattermostAccount,
kind: MattermostChatTypeKey,
): MattermostReplyToMode {
if (account.config.replyToModeByChatType?.[kind] !== undefined) {
return account.config.replyToModeByChatType[kind] ?? "off";
if (kind === "direct") {
return "off";
}
if (kind === "direct" && account.config.dm?.replyToMode !== undefined) {
return account.config.dm.replyToMode ?? "off";
}
// DMs default to "off" unless explicitly configured
if (kind === "direct") return "off";
return account.config.replyToMode ?? "off";
}

View File

@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import { resolveMattermostAccount } from "./accounts.js";
import {
evaluateMattermostMentionGate,
resolveMattermostEffectiveReplyToId,
resolveMattermostReplyRootId,
type MattermostMentionGateInput,
type MattermostRequireMentionResolverInput,
@@ -154,3 +155,46 @@ describe("resolveMattermostReplyRootId", () => {
expect(resolveMattermostReplyRootId({})).toBeUndefined();
});
});
describe("resolveMattermostEffectiveReplyToId", () => {
it("keeps an existing thread root", () => {
expect(
resolveMattermostEffectiveReplyToId({
kind: "channel",
postId: "post-123",
replyToMode: "all",
threadRootId: "thread-root-456",
}),
).toBe("thread-root-456");
});
it("starts a thread for top-level channel messages when replyToMode is all", () => {
expect(
resolveMattermostEffectiveReplyToId({
kind: "channel",
postId: "post-123",
replyToMode: "all",
}),
).toBe("post-123");
});
it("starts a thread for top-level group messages when replyToMode is first", () => {
expect(
resolveMattermostEffectiveReplyToId({
kind: "group",
postId: "post-123",
replyToMode: "first",
}),
).toBe("post-123");
});
it("keeps direct messages non-threaded", () => {
expect(
resolveMattermostEffectiveReplyToId({
kind: "direct",
postId: "post-123",
replyToMode: "all",
}),
).toBeUndefined();
});
});

View File

@@ -274,6 +274,26 @@ export function resolveMattermostReplyRootId(params: {
}
return params.replyToId?.trim() || undefined;
}
export function resolveMattermostEffectiveReplyToId(params: {
kind: ChatType;
postId?: string | null;
replyToMode: "off" | "first" | "all";
threadRootId?: string | null;
}): string | undefined {
const threadRootId = params.threadRootId?.trim();
if (threadRootId) {
return threadRootId;
}
if (params.kind === "direct") {
return undefined;
}
const postId = params.postId?.trim();
if (!postId) {
return undefined;
}
return params.replyToMode === "all" || params.replyToMode === "first" ? postId : undefined;
}
type MattermostMediaInfo = {
path: string;
contentType?: string;
@@ -1386,11 +1406,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const baseSessionKey = route.sessionKey;
const threadRootId = post.root_id?.trim() || undefined;
const replyToMode = resolveMattermostReplyToMode(account, kind);
const effectiveReplyToId =
threadRootId ??
(kind !== "direct" && (replyToMode === "all" || replyToMode === "first")
? post.id
: undefined);
const effectiveReplyToId = resolveMattermostEffectiveReplyToId({
kind,
postId: post.id,
replyToMode,
threadRootId,
});
const threadKeys = resolveThreadSessionKeys({
baseSessionKey,
threadId: effectiveReplyToId,

View File

@@ -60,24 +60,13 @@ export type MattermostAccountConfig = {
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
/**
* Controls whether bot replies are sent as thread replies.
* Controls whether channel and group replies are sent as thread replies.
* - "off" (default): only thread-reply when incoming message is already a thread reply
* - "first": same as "all" (reply in thread under the triggering message)
* - "first": reply in a thread under the triggering message
* - "all": always reply in a thread; uses existing thread root or starts a new thread under the message
* Applies to channel and group messages. For direct messages, use replyToModeByChatType or dm.replyToMode.
* Direct messages always behave as "off".
*/
replyToMode?: MattermostReplyToMode;
/**
* Per-chat-type override for replyToMode.
* Keys: "direct", "channel", "group".
* Example: { direct: "all", channel: "all", group: "off" }
*/
replyToModeByChatType?: Partial<Record<MattermostChatTypeKey, MattermostReplyToMode>>;
/** Direct message threading config. */
dm?: {
/** Controls thread reply behavior for direct messages. Default: "off". */
replyToMode?: MattermostReplyToMode;
};
/** Action toggles for this account. */
actions?: {
/** Enable message reaction actions. Default: true. */