From 171d2df9e0fe281ce951a2b21f5fdc4e61dbd7eb Mon Sep 17 00:00:00 2001 From: Teconomix Date: Thu, 12 Mar 2026 13:33:12 +0100 Subject: [PATCH] feat(mattermost): add replyToMode support (off | first | all) (#29587) Merged via squash. Prepared head SHA: 4a67791f53b1109959082738429471b7a5bc93b8 Co-authored-by: teconomix <6959299+teconomix@users.noreply.github.com> Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com> Reviewed-by: @mukhtharcm --- CHANGELOG.md | 2 + docs/channels/mattermost.md | 29 ++++ extensions/mattermost/src/channel.test.ts | 32 ++++ extensions/mattermost/src/channel.ts | 15 +- .../mattermost/src/config-schema.test.ts | 27 +++- .../src/mattermost/accounts.test.ts | 40 ++++- .../mattermost/src/mattermost/accounts.ts | 21 ++- .../src/mattermost/interactions.test.ts | 68 ++++++++- .../mattermost/src/mattermost/interactions.ts | 33 +++- .../mattermost/src/mattermost/monitor.test.ts | 93 +++++++++++ .../mattermost/src/mattermost/monitor.ts | 144 +++++++++++++++--- extensions/mattermost/src/types.ts | 13 +- 12 files changed, 477 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48513274800..60f249a91e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index 6a7ee8bb472..1e3e3f4bad2 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -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). diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index c3ff193896f..c188a8e6719 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -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(); diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 42d167948a0..f8116e127b3 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -14,6 +14,8 @@ import { deleteAccountFromConfigSection, migrateBaseNameToDefaultAccount, normalizeAccountId, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, type ChannelMessageActionAdapter, type ChannelMessageActionName, @@ -25,6 +27,7 @@ import { listMattermostAccountIds, resolveDefaultMattermostAccountId, resolveMattermostAccount, + resolveMattermostReplyToMode, type ResolvedMattermostAccount, } from "./mattermost/accounts.js"; import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; @@ -271,13 +274,13 @@ export const mattermostPlugin: ChannelPlugin = { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, threading: { - resolveReplyToMode: ({ cfg, accountId }) => { + resolveReplyToMode: ({ cfg, accountId, chatType }) => { const account = resolveMattermostAccount({ cfg, accountId: accountId ?? "default" }); - const mode = account.config.replyToMode; - if (mode === "off" || mode === "first") { - return mode; - } - return "all"; + const kind = + chatType === "direct" || chatType === "group" || chatType === "channel" + ? chatType + : "channel"; + return resolveMattermostReplyToMode(account, kind); }, }, reload: { configPrefixes: ["channels.mattermost"] }, diff --git a/extensions/mattermost/src/config-schema.test.ts b/extensions/mattermost/src/config-schema.test.ts index c744a6a5e0f..aa8db0f5d02 100644 --- a/extensions/mattermost/src/config-schema.test.ts +++ b/extensions/mattermost/src/config-schema.test.ts @@ -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); + }); }); diff --git a/extensions/mattermost/src/mattermost/accounts.test.ts b/extensions/mattermost/src/mattermost/accounts.test.ts index b3ad8d49e04..0e01d362520 100644 --- a/extensions/mattermost/src/mattermost/accounts.test.ts +++ b/extensions/mattermost/src/mattermost/accounts.test.ts @@ -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"); + }); +}); diff --git a/extensions/mattermost/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts index 1de9a09bca8..ae154ba8923 100644 --- a/extensions/mattermost/src/mattermost/accounts.ts +++ b/extensions/mattermost/src/mattermost/accounts.ts @@ -1,7 +1,12 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js"; -import type { MattermostAccountConfig, MattermostChatMode } from "../types.js"; +import type { + MattermostAccountConfig, + MattermostChatMode, + MattermostChatTypeKey, + MattermostReplyToMode, +} from "../types.js"; import { normalizeMattermostBaseUrl } from "./client.js"; export type MattermostTokenSource = "env" | "config" | "none"; @@ -130,6 +135,20 @@ export function resolveMattermostAccount(params: { }; } +/** + * Resolve the effective replyToMode for a given chat type. + * Mattermost auto-threading only applies to channel and group messages. + */ +export function resolveMattermostReplyToMode( + account: ResolvedMattermostAccount, + kind: MattermostChatTypeKey, +): MattermostReplyToMode { + if (kind === "direct") { + return "off"; + } + return account.config.replyToMode ?? "off"; +} + export function listEnabledMattermostAccounts(cfg: OpenClawConfig): ResolvedMattermostAccount[] { return listMattermostAccountIds(cfg) .map((accountId) => resolveMattermostAccount({ cfg, accountId })) diff --git a/extensions/mattermost/src/mattermost/interactions.test.ts b/extensions/mattermost/src/mattermost/interactions.test.ts index a6379a52664..3f52982cc52 100644 --- a/extensions/mattermost/src/mattermost/interactions.test.ts +++ b/extensions/mattermost/src/mattermost/interactions.test.ts @@ -2,7 +2,7 @@ import { type IncomingMessage, type ServerResponse } from "node:http"; import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { setMattermostRuntime } from "../runtime.js"; import { resolveMattermostAccount } from "./accounts.js"; -import type { MattermostClient } from "./client.js"; +import type { MattermostClient, MattermostPost } from "./client.js"; import { buildButtonAttachments, computeInteractionCallbackUrl, @@ -738,6 +738,70 @@ describe("createMattermostInteractionHandler", () => { ]); }); + it("forwards fetched post threading metadata to session and button callbacks", async () => { + const enqueueSystemEvent = vi.fn(); + setMattermostRuntime({ + system: { + enqueueSystemEvent, + }, + } as unknown as Parameters[0]); + const context = { action_id: "approve", __openclaw_channel_id: "chan-1" }; + const token = generateInteractionToken(context, "acct"); + const resolveSessionKey = vi.fn().mockResolvedValue("session:thread:root-9"); + const dispatchButtonClick = vi.fn(); + const fetchedPost: MattermostPost = { + id: "post-1", + channel_id: "chan-1", + root_id: "root-9", + message: "Choose", + props: { + attachments: [{ actions: [{ id: "approve", name: "Approve" }] }], + }, + }; + const handler = createMattermostInteractionHandler({ + client: { + request: async (_path: string, init?: { method?: string }) => + init?.method === "PUT" ? { id: "post-1" } : fetchedPost, + } as unknown as MattermostClient, + botUserId: "bot", + accountId: "acct", + resolveSessionKey, + dispatchButtonClick, + }); + + const req = createReq({ + body: { + user_id: "user-1", + user_name: "alice", + channel_id: "chan-1", + post_id: "post-1", + context: { ...context, _token: token }, + }, + }); + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(resolveSessionKey).toHaveBeenCalledWith({ + channelId: "chan-1", + userId: "user-1", + post: fetchedPost, + }); + expect(enqueueSystemEvent).toHaveBeenCalledWith( + expect.stringContaining('Mattermost button click: action="approve"'), + expect.objectContaining({ sessionKey: "session:thread:root-9" }), + ); + expect(dispatchButtonClick).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "chan-1", + userId: "user-1", + postId: "post-1", + post: fetchedPost, + }), + ); + }); + it("lets a custom interaction handler short-circuit generic completion updates", async () => { const context = { action_id: "mdlprov", __openclaw_channel_id: "chan-1" }; const token = generateInteractionToken(context, "acct"); @@ -751,6 +815,7 @@ describe("createMattermostInteractionHandler", () => { request: async (path: string, init?: { method?: string }) => { requestLog.push({ path, method: init?.method }); return { + id: "post-1", channel_id: "chan-1", message: "Choose", props: { @@ -790,6 +855,7 @@ describe("createMattermostInteractionHandler", () => { actionId: "mdlprov", actionName: "Browse providers", originalMessage: "Choose", + post: expect.objectContaining({ id: "post-1" }), userName: "alice", }), ); diff --git a/extensions/mattermost/src/mattermost/interactions.ts b/extensions/mattermost/src/mattermost/interactions.ts index 9e888d658cb..f99d0b5d3ac 100644 --- a/extensions/mattermost/src/mattermost/interactions.ts +++ b/extensions/mattermost/src/mattermost/interactions.ts @@ -6,7 +6,7 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/mattermost"; import { getMattermostRuntime } from "../runtime.js"; -import { updateMattermostPost, type MattermostClient } from "./client.js"; +import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js"; const INTERACTION_MAX_BODY_BYTES = 64 * 1024; const INTERACTION_BODY_TIMEOUT_MS = 10_000; @@ -390,7 +390,11 @@ export function createMattermostInteractionHandler(params: { allowedSourceIps?: string[]; trustedProxies?: string[]; allowRealIpFallback?: boolean; - resolveSessionKey?: (channelId: string, userId: string) => Promise; + resolveSessionKey?: (params: { + channelId: string; + userId: string; + post: MattermostPost; + }) => Promise; handleInteraction?: (opts: { payload: MattermostInteractionPayload; userName: string; @@ -398,6 +402,7 @@ export function createMattermostInteractionHandler(params: { actionName: string; originalMessage: string; context: Record; + post: MattermostPost; }) => Promise; dispatchButtonClick?: (opts: { channelId: string; @@ -406,6 +411,7 @@ export function createMattermostInteractionHandler(params: { actionId: string; actionName: string; postId: string; + post: MattermostPost; }) => Promise; log?: (message: string) => void; }): (req: IncomingMessage, res: ServerResponse) => Promise { @@ -503,13 +509,10 @@ export function createMattermostInteractionHandler(params: { const userName = payload.user_name ?? payload.user_id; let originalMessage = ""; + let originalPost: MattermostPost | null = null; let clickedButtonName: string | null = null; try { - const originalPost = await client.request<{ - channel_id?: string | null; - message?: string; - props?: Record; - }>(`/posts/${payload.post_id}`); + originalPost = await client.request(`/posts/${payload.post_id}`); const postChannelId = originalPost.channel_id?.trim(); if (!postChannelId || postChannelId !== payload.channel_id) { log?.( @@ -550,6 +553,14 @@ export function createMattermostInteractionHandler(params: { return; } + if (!originalPost) { + log?.(`mattermost interaction: missing fetched post ${payload.post_id}`); + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Failed to load interaction post" })); + return; + } + log?.( `mattermost interaction: action=${actionId} user=${payload.user_name ?? payload.user_id} ` + `post=${payload.post_id} channel=${payload.channel_id}`, @@ -564,6 +575,7 @@ export function createMattermostInteractionHandler(params: { actionName: clickedButtonName, originalMessage, context: contextWithoutToken, + post: originalPost, }); if (response !== null) { res.statusCode = 200; @@ -590,7 +602,11 @@ export function createMattermostInteractionHandler(params: { `in channel ${payload.channel_id}`; const sessionKey = params.resolveSessionKey - ? await params.resolveSessionKey(payload.channel_id, payload.user_id) + ? await params.resolveSessionKey({ + channelId: payload.channel_id, + userId: payload.user_id, + post: originalPost, + }) : `agent:main:mattermost:${accountId}:${payload.channel_id}`; core.system.enqueueSystemEvent(eventLabel, { @@ -632,6 +648,7 @@ export function createMattermostInteractionHandler(params: { actionId, actionName: clickedButtonName, postId: payload.post_id, + post: originalPost, }); } catch (err) { log?.(`mattermost interaction: dispatchButtonClick failed: ${String(err)}`); diff --git a/extensions/mattermost/src/mattermost/monitor.test.ts b/extensions/mattermost/src/mattermost/monitor.test.ts index d479909ac05..ab993dbb2af 100644 --- a/extensions/mattermost/src/mattermost/monitor.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it, vi } from "vitest"; import { resolveMattermostAccount } from "./accounts.js"; import { evaluateMattermostMentionGate, + resolveMattermostEffectiveReplyToId, resolveMattermostReplyRootId, + resolveMattermostThreadSessionContext, type MattermostMentionGateInput, type MattermostRequireMentionResolverInput, } from "./monitor.js"; @@ -154,3 +156,94 @@ 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(); + }); +}); + +describe("resolveMattermostThreadSessionContext", () => { + it("forks channel sessions by top-level post when replyToMode is all", () => { + expect( + resolveMattermostThreadSessionContext({ + baseSessionKey: "agent:main:mattermost:default:chan-1", + kind: "channel", + postId: "post-123", + replyToMode: "all", + }), + ).toEqual({ + effectiveReplyToId: "post-123", + sessionKey: "agent:main:mattermost:default:chan-1:thread:post-123", + parentSessionKey: "agent:main:mattermost:default:chan-1", + }); + }); + + it("keeps existing thread roots for threaded follow-ups", () => { + expect( + resolveMattermostThreadSessionContext({ + baseSessionKey: "agent:main:mattermost:default:chan-1", + kind: "group", + postId: "post-123", + replyToMode: "first", + threadRootId: "root-456", + }), + ).toEqual({ + effectiveReplyToId: "root-456", + sessionKey: "agent:main:mattermost:default:chan-1:thread:root-456", + parentSessionKey: "agent:main:mattermost:default:chan-1", + }); + }); + + it("keeps direct-message sessions linear", () => { + expect( + resolveMattermostThreadSessionContext({ + baseSessionKey: "agent:main:mattermost:default:user-1", + kind: "direct", + postId: "post-123", + replyToMode: "all", + }), + ).toEqual({ + effectiveReplyToId: undefined, + sessionKey: "agent:main:mattermost:default:user-1", + parentSessionKey: undefined, + }); + }); +}); diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 59bc6b39aee..e655cb68aaa 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -32,7 +32,7 @@ import { type HistoryEntry, } from "openclaw/plugin-sdk/mattermost"; import { getMattermostRuntime } from "../runtime.js"; -import { resolveMattermostAccount } from "./accounts.js"; +import { resolveMattermostAccount, resolveMattermostReplyToMode } from "./accounts.js"; import { createMattermostClient, fetchMattermostChannel, @@ -274,6 +274,51 @@ 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; +} + +export function resolveMattermostThreadSessionContext(params: { + baseSessionKey: string; + kind: ChatType; + postId?: string | null; + replyToMode: "off" | "first" | "all"; + threadRootId?: string | null; +}): { effectiveReplyToId?: string; sessionKey: string; parentSessionKey?: string } { + const effectiveReplyToId = resolveMattermostEffectiveReplyToId({ + kind: params.kind, + postId: params.postId, + replyToMode: params.replyToMode, + threadRootId: params.threadRootId, + }); + const threadKeys = resolveThreadSessionKeys({ + baseSessionKey: params.baseSessionKey, + threadId: effectiveReplyToId, + parentSessionKey: effectiveReplyToId ? params.baseSessionKey : undefined, + }); + return { + effectiveReplyToId, + sessionKey: threadKeys.sessionKey, + parentSessionKey: threadKeys.parentSessionKey, + }; +} type MattermostMediaInfo = { path: string; contentType?: string; @@ -521,7 +566,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} trustedProxies: cfg.gateway?.trustedProxies, allowRealIpFallback: cfg.gateway?.allowRealIpFallback === true, handleInteraction: handleModelPickerInteraction, - resolveSessionKey: async (channelId: string, userId: string) => { + resolveSessionKey: async ({ channelId, userId, post }) => { const channelInfo = await resolveChannelInfo(channelId); const kind = mapMattermostChannelTypeToChatType(channelInfo?.type); const teamId = channelInfo?.team_id ?? undefined; @@ -535,7 +580,14 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} id: kind === "direct" ? userId : channelId, }, }); - return route.sessionKey; + const replyToMode = resolveMattermostReplyToMode(account, kind); + return resolveMattermostThreadSessionContext({ + baseSessionKey: route.sessionKey, + kind, + postId: post.id || undefined, + replyToMode, + threadRootId: post.root_id, + }).sessionKey; }, dispatchButtonClick: async (opts) => { const channelInfo = await resolveChannelInfo(opts.channelId); @@ -554,6 +606,14 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} id: kind === "direct" ? opts.userId : opts.channelId, }, }); + const replyToMode = resolveMattermostReplyToMode(account, kind); + const threadContext = resolveMattermostThreadSessionContext({ + baseSessionKey: route.sessionKey, + kind, + postId: opts.post.id || opts.postId, + replyToMode, + threadRootId: opts.post.root_id, + }); const to = kind === "direct" ? `user:${opts.userId}` : `channel:${opts.channelId}`; const bodyText = `[Button click: user @${opts.userName} selected "${opts.actionName}"]`; const ctxPayload = core.channel.reply.finalizeInboundContext({ @@ -568,7 +628,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} ? `mattermost:group:${opts.channelId}` : `mattermost:channel:${opts.channelId}`, To: to, - SessionKey: route.sessionKey, + SessionKey: threadContext.sessionKey, + ParentSessionKey: threadContext.parentSessionKey, AccountId: route.accountId, ChatType: chatType, ConversationLabel: `mattermost:${opts.userName}`, @@ -580,6 +641,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} Provider: "mattermost" as const, Surface: "mattermost" as const, MessageSid: `interaction:${opts.postId}:${opts.actionId}`, + ReplyToId: threadContext.effectiveReplyToId, + MessageThreadId: threadContext.effectiveReplyToId, WasMentioned: true, CommandAuthorized: false, OriginatingChannel: "mattermost" as const, @@ -604,7 +667,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} accountId: account.accountId, }); const typingCallbacks = createTypingCallbacks({ - start: () => sendTypingIndicator(opts.channelId), + start: () => sendTypingIndicator(opts.channelId, threadContext.effectiveReplyToId), onStartError: (err) => { logTypingFailure({ log: (message) => logger.debug?.(message), @@ -636,6 +699,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} if (!chunk) continue; await sendMessageMattermost(to, chunk, { accountId: account.accountId, + replyToId: resolveMattermostReplyRootId({ + threadRootId: threadContext.effectiveReplyToId, + replyToId: payload.replyToId, + }), }); } } else { @@ -646,6 +713,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} await sendMessageMattermost(to, caption, { accountId: account.accountId, mediaUrl, + replyToId: resolveMattermostReplyRootId({ + threadRootId: threadContext.effectiveReplyToId, + replyToId: payload.replyToId, + }), }); } } @@ -834,6 +905,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} commandText: string; commandAuthorized: boolean; route: ReturnType; + sessionKey: string; + parentSessionKey?: string; channelId: string; senderId: string; senderName: string; @@ -844,6 +917,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} roomLabel: string; teamId?: string; postId: string; + effectiveReplyToId?: string; deliverReplies?: boolean; }): Promise => { const to = params.kind === "direct" ? `user:${params.senderId}` : `channel:${params.channelId}`; @@ -863,7 +937,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} ? `mattermost:group:${params.channelId}` : `mattermost:channel:${params.channelId}`, To: to, - SessionKey: params.route.sessionKey, + SessionKey: params.sessionKey, + ParentSessionKey: params.parentSessionKey, AccountId: params.route.accountId, ChatType: params.chatType, ConversationLabel: fromLabel, @@ -876,6 +951,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} Provider: "mattermost" as const, Surface: "mattermost" as const, MessageSid: `interaction:${params.postId}:${Date.now()}`, + ReplyToId: params.effectiveReplyToId, + MessageThreadId: params.effectiveReplyToId, Timestamp: Date.now(), WasMentioned: true, CommandAuthorized: params.commandAuthorized, @@ -907,7 +984,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const capturedTexts: string[] = []; const typingCallbacks = shouldDeliverReplies ? createTypingCallbacks({ - start: () => sendTypingIndicator(params.channelId), + start: () => sendTypingIndicator(params.channelId, params.effectiveReplyToId), onStartError: (err) => { logTypingFailure({ log: (message) => logger.debug?.(message), @@ -948,6 +1025,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } await sendMessageMattermost(to, chunk, { accountId: account.accountId, + replyToId: resolveMattermostReplyRootId({ + threadRootId: params.effectiveReplyToId, + replyToId: payload.replyToId, + }), }); } return; @@ -960,6 +1041,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} await sendMessageMattermost(to, caption, { accountId: account.accountId, mediaUrl, + replyToId: resolveMattermostReplyRootId({ + threadRootId: params.effectiveReplyToId, + replyToId: payload.replyToId, + }), }); } }, @@ -1000,6 +1085,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }; userName: string; context: Record; + post: MattermostPost; }): Promise { const pickerState = parseMattermostModelPickerContext(params.context); if (!pickerState) { @@ -1088,6 +1174,18 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} id: kind === "direct" ? params.payload.user_id : params.payload.channel_id, }, }); + const replyToMode = resolveMattermostReplyToMode(account, kind); + const threadContext = resolveMattermostThreadSessionContext({ + baseSessionKey: route.sessionKey, + kind, + postId: params.post.id || params.payload.post_id, + replyToMode, + threadRootId: params.post.root_id, + }); + const modelSessionRoute = { + agentId: route.agentId, + sessionKey: threadContext.sessionKey, + }; const data = await buildModelsProviderData(cfg, route.agentId); if (data.providers.length === 0) { @@ -1101,7 +1199,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} if (pickerState.action === "providers" || pickerState.action === "back") { const currentModel = resolveMattermostModelPickerCurrentModel({ cfg, - route, + route: modelSessionRoute, data, }); const view = renderMattermostProviderPickerView({ @@ -1120,7 +1218,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} if (pickerState.action === "list") { const currentModel = resolveMattermostModelPickerCurrentModel({ cfg, - route, + route: modelSessionRoute, data, }); const view = renderMattermostModelsPickerView({ @@ -1151,6 +1249,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} commandText: `/model ${targetModelRef}`, commandAuthorized: auth.commandAuthorized, route, + sessionKey: threadContext.sessionKey, + parentSessionKey: threadContext.parentSessionKey, channelId: params.payload.channel_id, senderId: params.payload.user_id, senderName: params.userName, @@ -1161,11 +1261,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} roomLabel, teamId, postId: params.payload.post_id, + effectiveReplyToId: threadContext.effectiveReplyToId, deliverReplies: true, }); const updatedModel = resolveMattermostModelPickerCurrentModel({ cfg, - route, + route: modelSessionRoute, data, skipCache: true, }); @@ -1385,12 +1486,15 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const baseSessionKey = route.sessionKey; const threadRootId = post.root_id?.trim() || undefined; - const threadKeys = resolveThreadSessionKeys({ + const replyToMode = resolveMattermostReplyToMode(account, kind); + const threadContext = resolveMattermostThreadSessionContext({ baseSessionKey, - threadId: threadRootId, - parentSessionKey: threadRootId ? baseSessionKey : undefined, + kind, + postId: post.id, + replyToMode, + threadRootId, }); - const sessionKey = threadKeys.sessionKey; + const { effectiveReplyToId, sessionKey, parentSessionKey } = threadContext; const historyKey = kind === "direct" ? null : sessionKey; const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId); @@ -1554,7 +1658,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} : `mattermost:channel:${channelId}`, To: to, SessionKey: sessionKey, - ParentSessionKey: threadKeys.parentSessionKey, + ParentSessionKey: parentSessionKey, AccountId: route.accountId, ChatType: chatType, ConversationLabel: fromLabel, @@ -1570,8 +1674,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} MessageSidFirst: allMessageIds.length > 1 ? allMessageIds[0] : undefined, MessageSidLast: allMessageIds.length > 1 ? allMessageIds[allMessageIds.length - 1] : undefined, - ReplyToId: threadRootId, - MessageThreadId: threadRootId, + ReplyToId: effectiveReplyToId, + MessageThreadId: effectiveReplyToId, Timestamp: typeof post.create_at === "number" ? post.create_at : undefined, WasMentioned: kind !== "direct" ? mentionDecision.effectiveWasMentioned : undefined, CommandAuthorized: commandAuthorized, @@ -1623,7 +1727,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }); const typingCallbacks = createTypingCallbacks({ - start: () => sendTypingIndicator(channelId, threadRootId), + start: () => sendTypingIndicator(channelId, effectiveReplyToId), onStartError: (err) => { logTypingFailure({ log: (message) => logger.debug?.(message), @@ -1655,7 +1759,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} await sendMessageMattermost(to, chunk, { accountId: account.accountId, replyToId: resolveMattermostReplyRootId({ - threadRootId, + threadRootId: effectiveReplyToId, replyToId: payload.replyToId, }), }); @@ -1669,7 +1773,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} accountId: account.accountId, mediaUrl, replyToId: resolveMattermostReplyRootId({ - threadRootId, + threadRootId: effectiveReplyToId, replyToId: payload.replyToId, }), }); diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index 86de9c1a714..f4038ac6920 100644 --- a/extensions/mattermost/src/types.ts +++ b/extensions/mattermost/src/types.ts @@ -5,6 +5,9 @@ import type { SecretInput, } from "openclaw/plugin-sdk/mattermost"; +export type MattermostReplyToMode = "off" | "first" | "all"; +export type MattermostChatTypeKey = "direct" | "channel" | "group"; + export type MattermostChatMode = "oncall" | "onmessage" | "onchar"; export type MattermostAccountConfig = { @@ -52,10 +55,16 @@ export type MattermostAccountConfig = { blockStreaming?: boolean; /** Merge streamed block replies before sending. */ blockStreamingCoalesce?: BlockStreamingCoalesceConfig; - /** Control reply threading (off|first|all). Default: "all". */ - replyToMode?: "off" | "first" | "all"; /** Outbound response prefix override for this channel/account. */ responsePrefix?: string; + /** + * 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": 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 + * Direct messages always behave as "off". + */ + replyToMode?: MattermostReplyToMode; /** Action toggles for this account. */ actions?: { /** Enable message reaction actions. Default: true. */