feat(mattermost): add replyToMode support (off | first | all) (#29587)

Merged via squash.

Prepared head SHA: 4a67791f53
Co-authored-by: teconomix <6959299+teconomix@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
This commit is contained in:
Teconomix
2026-03-12 13:33:12 +01:00
committed by GitHub
parent 8e0e4f736a
commit 171d2df9e0
12 changed files with 477 additions and 40 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

@@ -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<ResolvedMattermostAccount> = {
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"] },

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

@@ -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

@@ -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 }))

View File

@@ -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<typeof setMattermostRuntime>[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",
}),
);

View File

@@ -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<string>;
resolveSessionKey?: (params: {
channelId: string;
userId: string;
post: MattermostPost;
}) => Promise<string>;
handleInteraction?: (opts: {
payload: MattermostInteractionPayload;
userName: string;
@@ -398,6 +402,7 @@ export function createMattermostInteractionHandler(params: {
actionName: string;
originalMessage: string;
context: Record<string, unknown>;
post: MattermostPost;
}) => Promise<MattermostInteractionResponse | null>;
dispatchButtonClick?: (opts: {
channelId: string;
@@ -406,6 +411,7 @@ export function createMattermostInteractionHandler(params: {
actionId: string;
actionName: string;
postId: string;
post: MattermostPost;
}) => Promise<void>;
log?: (message: string) => void;
}): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
@@ -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<string, unknown>;
}>(`/posts/${payload.post_id}`);
originalPost = await client.request<MattermostPost>(`/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)}`);

View File

@@ -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,
});
});
});

View File

@@ -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<typeof core.channel.routing.resolveAgentRoute>;
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<string> => {
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<string, unknown>;
post: MattermostPost;
}): Promise<MattermostInteractionResponse | null> {
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,
}),
});

View File

@@ -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. */