From f321732dfe6cb2618deeb9660706b3da5cfdc24c Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 4 Feb 2026 15:08:19 -0500 Subject: [PATCH] fix: finish responsePrefix overrides (#9001) (thanks @mudrii) --- CHANGELOG.md | 1 + docs/concepts/messages.md | 2 +- docs/gateway/configuration.md | 19 ++++++++++ extensions/bluebubbles/src/monitor.ts | 10 +++++ extensions/feishu/src/config-schema.ts | 1 + extensions/googlechat/src/monitor.ts | 14 ++++++- extensions/matrix/src/config-schema.ts | 1 + .../matrix/src/matrix/monitor/handler.ts | 7 +++- extensions/matrix/src/types.ts | 2 + extensions/mattermost/src/channel.test.ts | 24 ++++++++++++ extensions/mattermost/src/config-schema.ts | 1 + .../mattermost/src/mattermost/monitor.ts | 7 +++- extensions/mattermost/src/types.ts | 2 + .../src/monitor-handler/message-handler.ts | 1 + extensions/msteams/src/reply-dispatcher.ts | 3 ++ .../nextcloud-talk/src/config-schema.ts | 1 + extensions/nextcloud-talk/src/inbound.ts | 11 ++++++ extensions/nextcloud-talk/src/types.ts | 2 + extensions/tlon/src/config-schema.ts | 2 + extensions/tlon/src/monitor/index.ts | 15 ++++++-- extensions/twitch/src/config-schema.ts | 2 + extensions/twitch/src/monitor.ts | 12 ++++++ extensions/twitch/src/types.ts | 2 + extensions/zalo/src/config-schema.ts | 1 + extensions/zalo/src/monitor.ts | 12 ++++++ extensions/zalo/src/types.ts | 2 + extensions/zalouser/src/config-schema.ts | 1 + extensions/zalouser/src/monitor.ts | 14 ++++++- extensions/zalouser/src/types.ts | 2 + src/auto-reply/reply/route-reply.ts | 5 ++- src/discord/monitor/native-command.ts | 17 ++++++--- src/feishu/message.ts | 13 +++++++ src/gateway/server-methods/chat.ts | 4 +- src/line/config-schema.ts | 2 + src/line/monitor.ts | 18 ++++++--- src/line/types.ts | 4 ++ src/slack/monitor/slash.ts | 20 +++++++--- src/telegram/bot-native-commands.ts | 16 +++++--- ...efixes-body-same-phone-marker-from.test.ts | 38 +++++++++++++++++++ src/web/auto-reply/monitor/process-message.ts | 2 + 40 files changed, 279 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d6cdb262e..91b823c02b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz. - Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov. - Docs: mirror the landing page revamp for zh-CN (features, quickstart, docs directory, network model, credits). (#8994) Thanks @joshp123. +- Messages: add per-channel and per-account responsePrefix overrides across channels. (#9001) Thanks @mudrii. - Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config. - Cron: default isolated jobs to announce delivery; accept ISO 8601 `schedule.at` in tool inputs. - Cron: hard-migrate isolated jobs to announce/none delivery; drop legacy post-to-main/payload delivery fields and `atMs` inputs. diff --git a/docs/concepts/messages.md b/docs/concepts/messages.md index 9e64526b5b..6ebb0be813 100644 --- a/docs/concepts/messages.md +++ b/docs/concepts/messages.md @@ -148,7 +148,7 @@ Details: [Thinking + reasoning directives](/tools/thinking) and [Token use](/tok Outbound message formatting is centralized in `messages`: -- `messages.responsePrefix` (outbound prefix) and `channels.whatsapp.messagePrefix` (WhatsApp inbound prefix) +- `messages.responsePrefix`, `channels..responsePrefix`, and `channels..accounts..responsePrefix` (outbound prefix cascade), plus `channels.whatsapp.messagePrefix` (WhatsApp inbound prefix) - Reply threading via `replyToMode` and per-channel defaults Details: [Configuration](/gateway/configuration#messages) and channel docs. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 75cd808771..fe8ff4d5f2 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1517,6 +1517,25 @@ See [Messages](/concepts/messages) for queueing, sessions, and streaming context `responsePrefix` is applied to **all outbound replies** (tool summaries, block streaming, final replies) across channels unless already present. +Overrides can be configured per channel and per account: + +- `channels..responsePrefix` +- `channels..accounts..responsePrefix` + +Resolution order (most specific wins): + +1. `channels..accounts..responsePrefix` +2. `channels..responsePrefix` +3. `messages.responsePrefix` + +Semantics: + +- `undefined` falls through to the next level. +- `""` explicitly disables the prefix and stops the cascade. +- `"auto"` derives `[{identity.name}]` for the routed agent. + +Overrides apply to all channels, including extensions, and to every outbound reply kind. + If `messages.responsePrefix` is unset, no prefix is applied by default. WhatsApp self-chat replies are the exception: they default to `[{identity.name}]` when set, otherwise `[openclaw]`, so same-phone conversations stay legible. diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index eafb6170e1..126c850526 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -1,6 +1,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { + createReplyPrefixContext, logAckFailure, logInboundDrop, logTypingFailure, @@ -2173,10 +2174,18 @@ async function processMessage( }, typingRestartDelayMs); }; try { + const prefixContext = createReplyPrefixContext({ + cfg: config, + agentId: route.agentId, + channel: "bluebubbles", + accountId: account.accountId, + }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config, dispatcherOptions: { + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, deliver: async (payload, info) => { const rawReplyToId = typeof payload.replyToId === "string" ? payload.replyToId.trim() : ""; @@ -2288,6 +2297,7 @@ async function processMessage( }, }, replyOptions: { + onModelSelected: prefixContext.onModelSelected, disableBlockStreaming: typeof account.config.blockStreaming === "boolean" ? !account.config.blockStreaming diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index 3c8903c81e..68e1975805 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -37,6 +37,7 @@ const FeishuAccountSchema = z blockStreaming: z.boolean().optional(), streaming: z.boolean().optional(), mediaMaxMb: z.number().optional(), + responsePrefix: z.string().optional(), groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(), }) .strict(); diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index b5167878b8..c58454a263 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -1,6 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; -import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk"; +import { createReplyPrefixContext, resolveMentionGatingWithBypass } from "openclaw/plugin-sdk"; import type { GoogleChatAnnotation, GoogleChatAttachment, @@ -725,10 +725,19 @@ async function processMessageWithPipeline(params: { } } + const prefixContext = createReplyPrefixContext({ + cfg: config, + agentId: route.agentId, + channel: "googlechat", + accountId: route.accountId, + }); + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config, dispatcherOptions: { + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, deliver: async (payload) => { await deliverGoogleChatReply({ payload, @@ -749,6 +758,9 @@ async function processMessageWithPipeline(params: { ); }, }, + replyOptions: { + onModelSelected: prefixContext.onModelSelected, + }, }); } diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index 5d08fc73b1..4fa99e882f 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -51,6 +51,7 @@ export const MatrixConfigSchema = z.object({ threadReplies: z.enum(["off", "inbound", "always"]).optional(), textChunkLimit: z.number().optional(), chunkMode: z.enum(["length", "newline"]).optional(), + responsePrefix: z.string().optional(), mediaMaxMb: z.number().optional(), autoJoin: z.enum(["always", "allowlist", "off"]).optional(), autoJoinAllowlist: z.array(allowFromEntry).optional(), diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index d88ad35232..cfc1555dca 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -579,7 +579,12 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam channel: "matrix", accountId: route.accountId, }); - const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); + const prefixContext = createReplyPrefixContext({ + cfg, + agentId: route.agentId, + channel: "matrix", + accountId: route.accountId, + }); const typingCallbacks = createTypingCallbacks({ start: () => sendTypingMatrix(roomId, true, undefined, client), stop: () => sendTypingMatrix(roomId, false, undefined, client), diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index f16ebfa196..c316c24bd5 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -71,6 +71,8 @@ export type MatrixConfig = { textChunkLimit?: number; /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ chunkMode?: "length" | "newline"; + /** Outbound response prefix override for this channel/account. */ + responsePrefix?: string; /** Max outbound media size in MB. */ mediaMaxMb?: number; /** Auto-join invites (always|allowlist|off). Default: always. */ diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index 118d6dfb67..87dcb27df1 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { createReplyPrefixContext } from "openclaw/plugin-sdk"; import { mattermostPlugin } from "./channel.js"; describe("mattermostPlugin", () => { @@ -44,5 +46,27 @@ describe("mattermostPlugin", () => { }); expect(formatted).toEqual(["@alice", "user123", "bot999"]); }); + + it("uses account responsePrefix overrides", () => { + const cfg: OpenClawConfig = { + channels: { + mattermost: { + responsePrefix: "[Channel]", + accounts: { + default: { responsePrefix: "[Account]" }, + }, + }, + }, + }; + + const prefixContext = createReplyPrefixContext({ + cfg, + agentId: "main", + channel: "mattermost", + accountId: "default", + }); + + expect(prefixContext.responsePrefix).toBe("[Account]"); + }); }); }); diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index 4f184f3802..4d0fcecdc0 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -27,6 +27,7 @@ const MattermostAccountSchemaBase = z chunkMode: z.enum(["length", "newline"]).optional(), blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + responsePrefix: z.string().optional(), }) .strict(); diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 8d10b13f6b..cf9dcdd13d 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -760,7 +760,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} accountId: account.accountId, }); - const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); + const prefixContext = createReplyPrefixContext({ + cfg, + agentId: route.agentId, + channel: "mattermost", + accountId: account.accountId, + }); const typingCallbacks = createTypingCallbacks({ start: () => sendTypingIndicator(channelId, threadRootId), diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index 0af8cd33ac..4b047819da 100644 --- a/extensions/mattermost/src/types.ts +++ b/extensions/mattermost/src/types.ts @@ -42,6 +42,8 @@ export type MattermostAccountConfig = { blockStreaming?: boolean; /** Merge streamed block replies before sending. */ blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + /** Outbound response prefix override for this channel/account. */ + responsePrefix?: string; }; export type MattermostConfig = { diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 8d9965579c..c4b84a352f 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -493,6 +493,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({ cfg, agentId: route.agentId, + accountId: route.accountId, runtime, log, adapter, diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index 517f849412..516e3c218a 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -26,6 +26,7 @@ import { getMSTeamsRuntime } from "./runtime.js"; export function createMSTeamsReplyDispatcher(params: { cfg: OpenClawConfig; agentId: string; + accountId?: string; runtime: RuntimeEnv; log: MSTeamsMonitorLogger; adapter: MSTeamsAdapter; @@ -58,6 +59,8 @@ export function createMSTeamsReplyDispatcher(params: { const prefixContext = createReplyPrefixContext({ cfg: params.cfg, agentId: params.agentId, + channel: "msteams", + accountId: params.accountId, }); const chunkMode = core.channel.text.resolveChunkMode(params.cfg, "msteams"); diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts index 95d8142db1..73369b1eb2 100644 --- a/extensions/nextcloud-talk/src/config-schema.ts +++ b/extensions/nextcloud-talk/src/config-schema.ts @@ -47,6 +47,7 @@ export const NextcloudTalkAccountSchemaBase = z chunkMode: z.enum(["length", "newline"]).optional(), blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + responsePrefix: z.string().optional(), mediaMaxMb: z.number().positive().optional(), }) .strict(); diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 1964d1a8a8..ae190193c4 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -1,4 +1,5 @@ import { + createReplyPrefixContext, logInboundDrop, resolveControlCommandGate, type OpenClawConfig, @@ -285,10 +286,19 @@ export async function handleNextcloudTalkInbound(params: { }, }); + const prefixContext = createReplyPrefixContext({ + cfg: config as OpenClawConfig, + agentId: route.agentId, + channel: CHANNEL_ID, + accountId: account.accountId, + }); + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config as OpenClawConfig, dispatcherOptions: { + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, deliver: async (payload) => { await deliverNextcloudTalkReply({ payload: payload as { @@ -308,6 +318,7 @@ export async function handleNextcloudTalkInbound(params: { }, replyOptions: { skillFilter: roomConfig?.skills, + onModelSelected: prefixContext.onModelSelected, disableBlockStreaming: typeof account.config.blockStreaming === "boolean" ? !account.config.blockStreaming diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts index 45cd1a5a9b..59ce8c0973 100644 --- a/extensions/nextcloud-talk/src/types.ts +++ b/extensions/nextcloud-talk/src/types.ts @@ -68,6 +68,8 @@ export type NextcloudTalkAccountConfig = { blockStreaming?: boolean; /** Merge streamed block replies before sending. */ blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + /** Outbound response prefix override for this channel/account. */ + responsePrefix?: string; /** Media upload max size in MB. */ mediaMaxMb?: number; }; diff --git a/extensions/tlon/src/config-schema.ts b/extensions/tlon/src/config-schema.ts index 831e786574..338881106c 100644 --- a/extensions/tlon/src/config-schema.ts +++ b/extensions/tlon/src/config-schema.ts @@ -23,6 +23,7 @@ export const TlonAccountSchema = z.object({ dmAllowlist: z.array(ShipSchema).optional(), autoDiscoverChannels: z.boolean().optional(), showModelSignature: z.boolean().optional(), + responsePrefix: z.string().optional(), }); export const TlonConfigSchema = z.object({ @@ -35,6 +36,7 @@ export const TlonConfigSchema = z.object({ dmAllowlist: z.array(ShipSchema).optional(), autoDiscoverChannels: z.boolean().optional(), showModelSignature: z.boolean().optional(), + responsePrefix: z.string().optional(), authorization: TlonAuthorizationSchema.optional(), defaultAuthorizedShips: z.array(ShipSchema).optional(), accounts: z.record(z.string(), TlonAccountSchema).optional(), diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index 05b486dcf9..84dd3ab9ae 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -1,4 +1,5 @@ import type { RuntimeEnv, ReplyPayload, OpenClawConfig } from "openclaw/plugin-sdk"; +import { createReplyPrefixContext } from "openclaw/plugin-sdk"; import { format } from "node:util"; import { getTlonRuntime } from "../runtime.js"; import { normalizeShip, parseChannelNest } from "../targets.js"; @@ -355,17 +356,20 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { let replyText = payload.text; @@ -408,6 +412,9 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { await deliverTwitchReply({ payload, @@ -121,6 +130,9 @@ async function processTwitchMessage(params: { }); }, }, + replyOptions: { + onModelSelected: prefixContext.onModelSelected, + }, }); } diff --git a/extensions/twitch/src/types.ts b/extensions/twitch/src/types.ts index 150ebd1078..25aaf3bd80 100644 --- a/extensions/twitch/src/types.ts +++ b/extensions/twitch/src/types.ts @@ -55,6 +55,8 @@ export interface TwitchAccountConfig { allowedRoles?: TwitchRole[]; /** Require @mention to trigger bot responses */ requireMention?: boolean; + /** Outbound response prefix override for this channel/account. */ + responsePrefix?: string; /** Twitch client secret (required for token refresh via RefreshingAuthProvider) */ clientSecret?: string; /** Refresh token (required for automatic token refresh) */ diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts index 229607de29..db4fba2781 100644 --- a/extensions/zalo/src/config-schema.ts +++ b/extensions/zalo/src/config-schema.ts @@ -16,6 +16,7 @@ const zaloAccountSchema = z.object({ allowFrom: z.array(allowFromEntry).optional(), mediaMaxMb: z.number().optional(), proxy: z.string().optional(), + responsePrefix: z.string().optional(), }); export const ZaloConfigSchema = zaloAccountSchema.extend({ diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index cd8c34f125..27a4cbb5e9 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,5 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk"; +import { createReplyPrefixContext } from "openclaw/plugin-sdk"; import type { ResolvedZaloAccount } from "./accounts.js"; import { ZaloApiError, @@ -583,11 +584,19 @@ async function processMessageWithPipeline(params: { channel: "zalo", accountId: account.accountId, }); + const prefixContext = createReplyPrefixContext({ + cfg: config, + agentId: route.agentId, + channel: "zalo", + accountId: account.accountId, + }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config, dispatcherOptions: { + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, deliver: async (payload) => { await deliverZaloReply({ payload, @@ -606,6 +615,9 @@ async function processMessageWithPipeline(params: { runtime.error?.(`[${account.accountId}] Zalo ${info.kind} reply failed: ${String(err)}`); }, }, + replyOptions: { + onModelSelected: prefixContext.onModelSelected, + }, }); } diff --git a/extensions/zalo/src/types.ts b/extensions/zalo/src/types.ts index 6b17da99fc..bcc43138f9 100644 --- a/extensions/zalo/src/types.ts +++ b/extensions/zalo/src/types.ts @@ -21,6 +21,8 @@ export type ZaloAccountConfig = { mediaMaxMb?: number; /** Proxy URL for API requests. */ proxy?: string; + /** Outbound response prefix override for this channel/account. */ + responsePrefix?: string; }; export type ZaloConfig = { diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts index 6ff9489f7c..2e060ff005 100644 --- a/extensions/zalouser/src/config-schema.ts +++ b/extensions/zalouser/src/config-schema.ts @@ -19,6 +19,7 @@ const zalouserAccountSchema = z.object({ groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(), groups: z.object({}).catchall(groupConfigSchema).optional(), messagePrefix: z.string().optional(), + responsePrefix: z.string().optional(), }); export const ZalouserConfigSchema = zalouserAccountSchema.extend({ diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 3d94585146..f518b16796 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -1,6 +1,6 @@ import type { ChildProcess } from "node:child_process"; import type { OpenClawConfig, MarkdownTableMode, RuntimeEnv } from "openclaw/plugin-sdk"; -import { mergeAllowlist, summarizeMapping } from "openclaw/plugin-sdk"; +import { createReplyPrefixContext, mergeAllowlist, summarizeMapping } from "openclaw/plugin-sdk"; import type { ResolvedZalouserAccount, ZcaFriend, ZcaGroup, ZcaMessage } from "./types.js"; import { getZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser } from "./send.js"; @@ -334,10 +334,19 @@ async function processMessage( }, }); + const prefixContext = createReplyPrefixContext({ + cfg: config, + agentId: route.agentId, + channel: "zalouser", + accountId: account.accountId, + }); + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config, dispatcherOptions: { + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, deliver: async (payload) => { await deliverZalouserReply({ payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string }, @@ -360,6 +369,9 @@ async function processMessage( runtime.error(`[${account.accountId}] Zalouser ${info.kind} reply failed: ${String(err)}`); }, }, + replyOptions: { + onModelSelected: prefixContext.onModelSelected, + }, }); } diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts index e157cb1d7b..e6557cb0e7 100644 --- a/extensions/zalouser/src/types.ts +++ b/extensions/zalouser/src/types.ts @@ -80,6 +80,7 @@ export type ZalouserAccountConfig = { { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } } >; messagePrefix?: string; + responsePrefix?: string; }; export type ZalouserConfig = { @@ -95,6 +96,7 @@ export type ZalouserConfig = { { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } } >; messagePrefix?: string; + responsePrefix?: string; accounts?: Record; }; diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 0a237dcbc4..c540f268d7 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -13,7 +13,7 @@ import type { ReplyPayload } from "../types.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; import { normalizeChannelId } from "../../channels/plugins/index.js"; -import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; +import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; import { normalizeReplyPayload } from "./normalize-reply.js"; export type RouteReplyParams = { @@ -56,6 +56,7 @@ export type RouteReplyResult = { */ export async function routeReply(params: RouteReplyParams): Promise { const { payload, channel, to, accountId, threadId, cfg, abortSignal } = params; + const normalizedChannel = normalizeMessageChannel(channel); // Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts` const responsePrefix = params.sessionKey @@ -65,7 +66,7 @@ export async function routeReply(params: RouteReplyParams): Promise { try { @@ -831,6 +837,7 @@ async function dispatchDiscordCommandInteraction(params: { typeof discordConfig?.blockStreaming === "boolean" ? !discordConfig.blockStreaming : undefined, + onModelSelected: prefixContext.onModelSelected, }, }); } diff --git a/src/feishu/message.ts b/src/feishu/message.ts index a3724588ce..c75c0a1628 100644 --- a/src/feishu/message.ts +++ b/src/feishu/message.ts @@ -1,6 +1,8 @@ import type { Client } from "@larksuiteoapi/node-sdk"; import type { OpenClawConfig } from "../config/config.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; +import { resolveSessionAgentId } from "../agents/agent-scope.js"; +import { createReplyPrefixContext } from "../channels/reply-prefix.js"; import { loadConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -302,10 +304,20 @@ export async function processFeishuMessage( WasMentioned: isGroup ? wasMentioned : undefined, }; + const agentId = resolveSessionAgentId({ sessionKey: ctx.SessionKey, config: cfg }); + const prefixContext = createReplyPrefixContext({ + cfg, + agentId, + channel: "feishu", + accountId, + }); + await dispatchReplyWithBufferedBlockDispatcher({ ctx, cfg, dispatcherOptions: { + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, deliver: async (payload, info) => { const hasMedia = payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0); if (!payload.text && !hasMedia) { @@ -391,6 +403,7 @@ export async function processFeishuMessage( }, replyOptions: { disableBlockStreaming: !feishuCfg.blockStreaming, + onModelSelected: prefixContext.onModelSelected, onPartialReply: streamingSession ? async (payload) => { if (!streamingSession.isActive() || !payload.text) { diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index b49af56de3..2533c0cfbb 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -482,7 +482,9 @@ export const chatHandlers: GatewayRequestHandlers = { }; const finalReplyParts: string[] = []; const dispatcher = createReplyDispatcher({ - responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix, + responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId, { + channel: INTERNAL_MESSAGE_CHANNEL, + }).responsePrefix, responsePrefixContextProvider: () => prefixContext, onError: (err) => { context.logGateway.warn(`webchat dispatch failed: ${formatForLog(err)}`); diff --git a/src/line/config-schema.ts b/src/line/config-schema.ts index 7e7a2be039..55804f81e5 100644 --- a/src/line/config-schema.ts +++ b/src/line/config-schema.ts @@ -25,6 +25,7 @@ const LineAccountConfigSchema = z groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), groupPolicy: GroupPolicySchema.optional().default("allowlist"), + responsePrefix: z.string().optional(), mediaMaxMb: z.number().optional(), webhookPath: z.string().optional(), groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(), @@ -43,6 +44,7 @@ export const LineConfigSchema = z groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), groupPolicy: GroupPolicySchema.optional().default("allowlist"), + responsePrefix: z.string().optional(), mediaMaxMb: z.number().optional(), webhookPath: z.string().optional(), accounts: z.record(z.string(), LineAccountConfigSchema.optional()).optional(), diff --git a/src/line/monitor.ts b/src/line/monitor.ts index d0e025a08c..2416e6a284 100644 --- a/src/line/monitor.ts +++ b/src/line/monitor.ts @@ -3,9 +3,9 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; import type { LineChannelData, ResolvedLineAccount } from "./types.js"; -import { resolveEffectiveMessagesConfig } from "../agents/identity.js"; import { chunkMarkdownText } from "../auto-reply/chunk.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; +import { createReplyPrefixContext } from "../channels/reply-prefix.js"; import { danger, logVerbose } from "../globals.js"; import { normalizePluginHttpPath } from "../plugins/http-path.js"; import { registerPluginHttpRoute } from "../plugins/http-registry.js"; @@ -192,15 +192,19 @@ export async function monitorLineProvider( try { const textLimit = 5000; // LINE max message length let replyTokenUsed = false; // Track if we've used the one-time reply token + const prefixContext = createReplyPrefixContext({ + cfg: config, + agentId: route.agentId, + channel: "line", + accountId: route.accountId, + }); const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config, dispatcherOptions: { - responsePrefix: resolveEffectiveMessagesConfig(config, route.agentId, { - channel: "line", - accountId: route.accountId, - }).responsePrefix, + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, deliver: async (payload, _info) => { const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {}; @@ -252,7 +256,9 @@ export async function monitorLineProvider( runtime.error?.(danger(`line ${info.kind} reply failed: ${String(err)}`)); }, }, - replyOptions: {}, + replyOptions: { + onModelSelected: prefixContext.onModelSelected, + }, }); if (!queuedFinal) { diff --git a/src/line/types.ts b/src/line/types.ts index 252fcb949e..dbd157cad7 100644 --- a/src/line/types.ts +++ b/src/line/types.ts @@ -21,6 +21,8 @@ export interface LineConfig { groupAllowFrom?: Array; dmPolicy?: "open" | "allowlist" | "pairing" | "disabled"; groupPolicy?: "open" | "allowlist" | "disabled"; + /** Outbound response prefix override for this channel/account. */ + responsePrefix?: string; mediaMaxMb?: number; webhookPath?: string; accounts?: Record; @@ -38,6 +40,8 @@ export interface LineAccountConfig { groupAllowFrom?: Array; dmPolicy?: "open" | "allowlist" | "pairing" | "disabled"; groupPolicy?: "open" | "allowlist" | "disabled"; + /** Outbound response prefix override for this account. */ + responsePrefix?: string; mediaMaxMb?: number; webhookPath?: string; groups?: Record; diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index 1527e4c715..0c5d9864a9 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -2,7 +2,6 @@ import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@sla import type { ChatCommandDefinition, CommandArgs } from "../../auto-reply/commands-registry.js"; import type { ResolvedSlackAccount } from "../accounts.js"; import type { SlackMonitorContext } from "./context.js"; -import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; import { resolveChunkMode } from "../../auto-reply/chunk.js"; import { buildCommandTextFromArgs, @@ -17,6 +16,7 @@ import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js"; import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { resolveConversationLabel } from "../../channels/conversation-label.js"; +import { createReplyPrefixContext } from "../../channels/reply-prefix.js"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js"; import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { danger, logVerbose } from "../../globals.js"; @@ -434,14 +434,19 @@ export function registerSlackMonitorSlashCommands(params: { OriginatingTo: `user:${command.user_id}`, }); + const prefixContext = createReplyPrefixContext({ + cfg, + agentId: route.agentId, + channel: "slack", + accountId: route.accountId, + }); + const { counts } = await dispatchReplyWithDispatcher({ ctx: ctxPayload, cfg, dispatcherOptions: { - responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId, { - channel: "slack", - accountId: route.accountId, - }).responsePrefix, + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, deliver: async (payload) => { await deliverSlackSlashReplies({ replies: [payload], @@ -460,7 +465,10 @@ export function registerSlackMonitorSlashCommands(params: { runtime.error?.(danger(`slack slash ${info.kind} reply failed: ${String(err)}`)); }, }, - replyOptions: { skillFilter: channelConfig?.skills }, + replyOptions: { + skillFilter: channelConfig?.skills, + onModelSelected: prefixContext.onModelSelected, + }, }); if (counts.final + counts.tool + counts.block === 0) { await deliverSlackSlashReplies({ diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 1de6ac198a..82fa31d612 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -10,7 +10,6 @@ import type { } from "../config/types.js"; import type { RuntimeEnv } from "../runtime.js"; import type { TelegramContext } from "./bot/types.js"; -import { resolveEffectiveMessagesConfig } from "../agents/identity.js"; import { resolveChunkMode } from "../auto-reply/chunk.js"; import { buildCommandTextFromArgs, @@ -24,6 +23,7 @@ import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js"; +import { createReplyPrefixContext } from "../channels/reply-prefix.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.js"; import { @@ -547,14 +547,19 @@ export const registerTelegramNativeCommands = ({ skippedNonSilent: 0, }; + const prefixContext = createReplyPrefixContext({ + cfg, + agentId: route.agentId, + channel: "telegram", + accountId: route.accountId, + }); + await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg, dispatcherOptions: { - responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId, { - channel: "telegram", - accountId: route.accountId, - }).responsePrefix, + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, deliver: async (payload, _info) => { const result = await deliverReplies({ replies: [payload], @@ -585,6 +590,7 @@ export const registerTelegramNativeCommands = ({ replyOptions: { skillFilter, disableBlockStreaming, + onModelSelected: prefixContext.onModelSelected, }, }); if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) { diff --git a/src/web/auto-reply.web-auto-reply.prefixes-body-same-phone-marker-from.test.ts b/src/web/auto-reply.web-auto-reply.prefixes-body-same-phone-marker-from.test.ts index 2616c98c70..705b907b9a 100644 --- a/src/web/auto-reply.web-auto-reply.prefixes-body-same-phone-marker-from.test.ts +++ b/src/web/auto-reply.web-auto-reply.prefixes-body-same-phone-marker-from.test.ts @@ -258,6 +258,44 @@ describe("web auto-reply", () => { expect(reply).toHaveBeenCalledWith("🦞 hello there"); resetLoadConfigMock(); }); + it("applies channel responsePrefix override to replies", async () => { + setLoadConfigMock(() => ({ + channels: { whatsapp: { allowFrom: ["*"], responsePrefix: "[WA]" } }, + messages: { + messagePrefix: undefined, + responsePrefix: "[Global]", + }, + })); + + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const reply = vi.fn(); + const listenerFactory = async (opts: { + onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + const resolver = vi.fn().mockResolvedValue({ text: "hello there" }); + + await monitorWebChannel(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hi", + from: "+1555", + to: "+2666", + id: "msg1", + sendComposing: vi.fn(), + reply, + sendMedia: vi.fn(), + }); + + expect(reply).toHaveBeenCalledWith("[WA] hello there"); + resetLoadConfigMock(); + }); it("defaults responsePrefix for self-chat replies when unset", async () => { setLoadConfigMock(() => ({ agents: { diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index e8651529d1..cb24c706a2 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -258,6 +258,8 @@ export async function processMessage(params: { const prefixContext = createReplyPrefixContext({ cfg: params.cfg, agentId: params.route.agentId, + channel: "whatsapp", + accountId: params.route.accountId, }); const isSelfChat = params.msg.chatType !== "group" &&