mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 19:37:28 +00:00
feat(mattermost): narrow replyToMode to channel threads
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user