diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d6cdb262..91b823c02 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 9e64526b5..6ebb0be81 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 75cd80877..fe8ff4d5f 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 eafb6170e..0d87cbfea 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 { + createReplyPrefixOptions, logAckFailure, logInboundDrop, logTypingFailure, @@ -2173,10 +2174,17 @@ async function processMessage( }, typingRestartDelayMs); }; try { + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg: config, + agentId: route.agentId, + channel: "bluebubbles", + accountId: account.accountId, + }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config, dispatcherOptions: { + ...prefixOptions, deliver: async (payload, info) => { const rawReplyToId = typeof payload.replyToId === "string" ? payload.replyToId.trim() : ""; @@ -2288,6 +2296,7 @@ async function processMessage( }, }, replyOptions: { + 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 3c8903c81..68e197580 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 b5167878b..7ff5e92cd 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 { createReplyPrefixOptions, resolveMentionGatingWithBypass } from "openclaw/plugin-sdk"; import type { GoogleChatAnnotation, GoogleChatAttachment, @@ -725,10 +725,18 @@ async function processMessageWithPipeline(params: { } } + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg: config, + agentId: route.agentId, + channel: "googlechat", + accountId: route.accountId, + }); + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config, dispatcherOptions: { + ...prefixOptions, deliver: async (payload) => { await deliverGoogleChatReply({ payload, @@ -749,6 +757,9 @@ async function processMessageWithPipeline(params: { ); }, }, + replyOptions: { + onModelSelected, + }, }); } diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index 5d08fc73b..4fa99e882 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 d88ad3523..a9a4e373c 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -1,6 +1,6 @@ import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk"; import { - createReplyPrefixContext, + createReplyPrefixOptions, createTypingCallbacks, formatAllowlistMatchMeta, logInboundDrop, @@ -579,7 +579,12 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam channel: "matrix", accountId: route.accountId, }); - const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId: route.agentId, + channel: "matrix", + accountId: route.accountId, + }); const typingCallbacks = createTypingCallbacks({ start: () => sendTypingMatrix(roomId, true, undefined, client), stop: () => sendTypingMatrix(roomId, false, undefined, client), @@ -604,8 +609,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }); const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ - responsePrefix: prefixContext.responsePrefix, - responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + ...prefixOptions, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload) => { await deliverMatrixReplies({ @@ -635,7 +639,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam replyOptions: { ...replyOptions, skillFilter: roomConfig?.skills, - onModelSelected: prefixContext.onModelSelected, + onModelSelected, }, }); markDispatchIdle(); diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index f16ebfa19..c316c24bd 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 118d6dfb6..1799c538f 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -1,3 +1,5 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { createReplyPrefixOptions } from "openclaw/plugin-sdk"; import { describe, expect, it } from "vitest"; import { mattermostPlugin } from "./channel.js"; @@ -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 = createReplyPrefixOptions({ + 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 4f184f380..4d0fcecdc 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 8d10b13f6..93ab3067e 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -5,7 +5,7 @@ import type { RuntimeEnv, } from "openclaw/plugin-sdk"; import { - createReplyPrefixContext, + createReplyPrefixOptions, createTypingCallbacks, logInboundDrop, logTypingFailure, @@ -760,7 +760,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} accountId: account.accountId, }); - const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId: route.agentId, + channel: "mattermost", + accountId: account.accountId, + }); const typingCallbacks = createTypingCallbacks({ start: () => sendTypingIndicator(channelId, threadRootId), @@ -775,8 +780,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }); const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ - responsePrefix: prefixContext.responsePrefix, - responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + ...prefixOptions, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload: ReplyPayload) => { const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); @@ -825,7 +829,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} ...replyOptions, disableBlockStreaming: typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, - onModelSelected: prefixContext.onModelSelected, + onModelSelected, }, }); markDispatchIdle(); diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index 0af8cd33a..4b047819d 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 8d9965579..c4b84a352 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 517f84941..fef1cf480 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -1,5 +1,5 @@ import { - createReplyPrefixContext, + createReplyPrefixOptions, createTypingCallbacks, logTypingFailure, resolveChannelMediaMaxBytes, @@ -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; @@ -55,16 +56,17 @@ export function createMSTeamsReplyDispatcher(params: { }); }, }); - const prefixContext = createReplyPrefixContext({ + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ cfg: params.cfg, agentId: params.agentId, + channel: "msteams", + accountId: params.accountId, }); const chunkMode = core.channel.text.resolveChunkMode(params.cfg, "msteams"); const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ - responsePrefix: prefixContext.responsePrefix, - responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + ...prefixOptions, humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), deliver: async (payload) => { const tableMode = core.channel.text.resolveMarkdownTableMode({ @@ -124,7 +126,7 @@ export function createMSTeamsReplyDispatcher(params: { return { dispatcher, - replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected }, + replyOptions: { ...replyOptions, onModelSelected }, markDispatchIdle, }; } diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts index 95d8142db..73369b1eb 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 1964d1a8a..16c477bf6 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -1,4 +1,5 @@ import { + createReplyPrefixOptions, logInboundDrop, resolveControlCommandGate, type OpenClawConfig, @@ -285,10 +286,18 @@ export async function handleNextcloudTalkInbound(params: { }, }); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + 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: { + ...prefixOptions, deliver: async (payload) => { await deliverNextcloudTalkReply({ payload: payload as { @@ -308,6 +317,7 @@ export async function handleNextcloudTalkInbound(params: { }, replyOptions: { skillFilter: roomConfig?.skills, + 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 45cd1a5a9..59ce8c097 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 831e78657..338881106 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 05b486dcf..7a696fab3 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -1,5 +1,6 @@ import type { RuntimeEnv, ReplyPayload, OpenClawConfig } from "openclaw/plugin-sdk"; import { format } from "node:util"; +import { createReplyPrefixOptions } from "openclaw/plugin-sdk"; import { getTlonRuntime } from "../runtime.js"; import { normalizeShip, parseChannelNest } from "../targets.js"; import { resolveTlonAccount } from "../types.js"; @@ -28,6 +29,29 @@ type ChannelAuthorization = { allowedShips?: string[]; }; +type UrbitMemo = { + author?: string; + content?: unknown; + sent?: number; +}; + +type UrbitUpdate = { + id?: string | number; + response?: { + add?: { memo?: UrbitMemo }; + post?: { + id?: string | number; + "r-post"?: { + set?: { essay?: UrbitMemo }; + reply?: { + id?: string | number; + "r-reply"?: { set?: { memo?: UrbitMemo } }; + }; + }; + }; + }; +}; + function resolveChannelAuthorization( cfg: OpenClawConfig, channelNest: string, @@ -120,15 +144,14 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { + const handleIncomingDM = async (update: UrbitUpdate) => { try { const memo = update?.response?.add?.memo; if (!memo) { return; } - const messageId = update.id as string | undefined; + const messageId = update.id != null ? String(update.id) : undefined; if (!processedTracker.mark(messageId)) { return; } @@ -160,25 +183,24 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise async (update: any) => { + const handleIncomingGroupMessage = (channelNest: string) => async (update: UrbitUpdate) => { try { const parsed = parseChannelNest(channelNest); if (!parsed) { return; } - const essay = update?.response?.post?.["r-post"]?.set?.essay; - const memo = update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.memo; + const post = update?.response?.post?.["r-post"]; + const essay = post?.set?.essay; + const memo = post?.reply?.["r-reply"]?.set?.memo; if (!essay && !memo) { return; } const content = memo || essay; const isThreadReply = Boolean(memo); - const messageId = isThreadReply - ? update?.response?.post?.["r-post"]?.reply?.id - : update?.response?.post?.id; + const rawMessageId = isThreadReply ? post?.reply?.id : update?.response?.post?.id; + const messageId = rawMessageId != null ? String(rawMessageId) : undefined; if (!processedTracker.mark(messageId)) { return; @@ -355,17 +377,19 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { let replyText = payload.text; @@ -408,6 +432,9 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { await deliverTwitchReply({ payload, @@ -121,6 +129,9 @@ async function processTwitchMessage(params: { }); }, }, + replyOptions: { + onModelSelected, + }, }); } diff --git a/extensions/twitch/src/types.ts b/extensions/twitch/src/types.ts index 150ebd107..25aaf3bd8 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 229607de2..db4fba278 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 cd8c34f12..6399d4b2b 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 { createReplyPrefixOptions } from "openclaw/plugin-sdk"; import type { ResolvedZaloAccount } from "./accounts.js"; import { ZaloApiError, @@ -583,11 +584,18 @@ async function processMessageWithPipeline(params: { channel: "zalo", accountId: account.accountId, }); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg: config, + agentId: route.agentId, + channel: "zalo", + accountId: account.accountId, + }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config, dispatcherOptions: { + ...prefixOptions, deliver: async (payload) => { await deliverZaloReply({ payload, @@ -606,6 +614,9 @@ async function processMessageWithPipeline(params: { runtime.error?.(`[${account.accountId}] Zalo ${info.kind} reply failed: ${String(err)}`); }, }, + replyOptions: { + onModelSelected, + }, }); } diff --git a/extensions/zalo/src/types.ts b/extensions/zalo/src/types.ts index 6b17da99f..bcc43138f 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 6ff9489f7..2e060ff00 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 3d9458514..b74303554 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 { createReplyPrefixOptions, 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,18 @@ async function processMessage( }, }); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg: config, + agentId: route.agentId, + channel: "zalouser", + accountId: account.accountId, + }); + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config, dispatcherOptions: { + ...prefixOptions, deliver: async (payload) => { await deliverZalouserReply({ payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string }, @@ -360,6 +368,9 @@ async function processMessage( runtime.error(`[${account.accountId}] Zalouser ${info.kind} reply failed: ${String(err)}`); }, }, + replyOptions: { + onModelSelected, + }, }); } diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts index e157cb1d7..e6557cb0e 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/agents/identity.per-channel-prefix.test.ts b/src/agents/identity.per-channel-prefix.test.ts new file mode 100644 index 000000000..4cd4f913d --- /dev/null +++ b/src/agents/identity.per-channel-prefix.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveResponsePrefix, resolveEffectiveMessagesConfig } from "./identity.js"; + +const makeConfig = (cfg: T) => cfg; + +describe("resolveResponsePrefix with per-channel override", () => { + // ─── Backward compatibility ───────────────────────────────────────── + + describe("backward compatibility (no channel param)", () => { + it("returns undefined when no prefix configured anywhere", () => { + const cfg: OpenClawConfig = {}; + expect(resolveResponsePrefix(cfg, "main")).toBeUndefined(); + }); + + it("returns global prefix when set", () => { + const cfg: OpenClawConfig = { messages: { responsePrefix: "[Bot] " } }; + expect(resolveResponsePrefix(cfg, "main")).toBe("[Bot] "); + }); + + it("resolves 'auto' to identity name at global level", () => { + const cfg: OpenClawConfig = { + agents: { + list: [{ id: "main", identity: { name: "TestBot" } }], + }, + messages: { responsePrefix: "auto" }, + }; + expect(resolveResponsePrefix(cfg, "main")).toBe("[TestBot]"); + }); + + it("returns empty string when global prefix is explicitly empty", () => { + const cfg: OpenClawConfig = { messages: { responsePrefix: "" } }; + expect(resolveResponsePrefix(cfg, "main")).toBe(""); + }); + }); + + // ─── Channel-level prefix ────────────────────────────────────────── + + describe("channel-level prefix", () => { + it("returns channel prefix when set, ignoring global", () => { + const cfg = makeConfig({ + messages: { responsePrefix: "[Global] " }, + channels: { + whatsapp: { responsePrefix: "[WA] " }, + }, + } satisfies OpenClawConfig); + expect(resolveResponsePrefix(cfg, "main", { channel: "whatsapp" })).toBe("[WA] "); + }); + + it("falls through to global when channel prefix is undefined", () => { + const cfg = makeConfig({ + messages: { responsePrefix: "[Global] " }, + channels: { + whatsapp: {}, + }, + } satisfies OpenClawConfig); + expect(resolveResponsePrefix(cfg, "main", { channel: "whatsapp" })).toBe("[Global] "); + }); + + it("channel empty string stops cascade (no global prefix applied)", () => { + const cfg = makeConfig({ + messages: { responsePrefix: "[Global] " }, + channels: { + telegram: { responsePrefix: "" }, + }, + } satisfies OpenClawConfig); + expect(resolveResponsePrefix(cfg, "main", { channel: "telegram" })).toBe(""); + }); + + it("resolves 'auto' at channel level to identity name", () => { + const cfg = makeConfig({ + agents: { + list: [{ id: "main", identity: { name: "MyBot" } }], + }, + channels: { + whatsapp: { responsePrefix: "auto" }, + }, + } satisfies OpenClawConfig); + expect(resolveResponsePrefix(cfg, "main", { channel: "whatsapp" })).toBe("[MyBot]"); + }); + + it("different channels get different prefixes", () => { + const cfg = makeConfig({ + channels: { + whatsapp: { responsePrefix: "[WA Bot] " }, + telegram: { responsePrefix: "" }, + discord: { responsePrefix: "🤖 " }, + }, + } satisfies OpenClawConfig); + expect(resolveResponsePrefix(cfg, "main", { channel: "whatsapp" })).toBe("[WA Bot] "); + expect(resolveResponsePrefix(cfg, "main", { channel: "telegram" })).toBe(""); + expect(resolveResponsePrefix(cfg, "main", { channel: "discord" })).toBe("🤖 "); + }); + + it("returns undefined when channel not in config", () => { + const cfg = makeConfig({ + channels: { + whatsapp: { responsePrefix: "[WA] " }, + }, + } satisfies OpenClawConfig); + expect(resolveResponsePrefix(cfg, "main", { channel: "telegram" })).toBeUndefined(); + }); + }); + + // ─── Account-level prefix ───────────────────────────────────────── + + describe("account-level prefix", () => { + it("returns account prefix when set, ignoring channel and global", () => { + const cfg = makeConfig({ + messages: { responsePrefix: "[Global] " }, + channels: { + whatsapp: { + responsePrefix: "[WA] ", + accounts: { + business: { responsePrefix: "[Biz] " }, + }, + }, + }, + } satisfies OpenClawConfig); + expect( + resolveResponsePrefix(cfg, "main", { channel: "whatsapp", accountId: "business" }), + ).toBe("[Biz] "); + }); + + it("falls through to channel prefix when account prefix is undefined", () => { + const cfg = makeConfig({ + channels: { + whatsapp: { + responsePrefix: "[WA] ", + accounts: { + business: {}, + }, + }, + }, + } satisfies OpenClawConfig); + expect( + resolveResponsePrefix(cfg, "main", { channel: "whatsapp", accountId: "business" }), + ).toBe("[WA] "); + }); + + it("falls through to global when both account and channel are undefined", () => { + const cfg = makeConfig({ + messages: { responsePrefix: "[Global] " }, + channels: { + whatsapp: { + accounts: { + business: {}, + }, + }, + }, + } satisfies OpenClawConfig); + expect( + resolveResponsePrefix(cfg, "main", { channel: "whatsapp", accountId: "business" }), + ).toBe("[Global] "); + }); + + it("account empty string stops cascade", () => { + const cfg = makeConfig({ + messages: { responsePrefix: "[Global] " }, + channels: { + whatsapp: { + responsePrefix: "[WA] ", + accounts: { + business: { responsePrefix: "" }, + }, + }, + }, + } satisfies OpenClawConfig); + expect( + resolveResponsePrefix(cfg, "main", { channel: "whatsapp", accountId: "business" }), + ).toBe(""); + }); + + it("resolves 'auto' at account level to identity name", () => { + const cfg = makeConfig({ + agents: { + list: [{ id: "main", identity: { name: "BizBot" } }], + }, + channels: { + whatsapp: { + accounts: { + business: { responsePrefix: "auto" }, + }, + }, + }, + } satisfies OpenClawConfig); + expect( + resolveResponsePrefix(cfg, "main", { channel: "whatsapp", accountId: "business" }), + ).toBe("[BizBot]"); + }); + + it("different accounts on same channel get different prefixes", () => { + const cfg = makeConfig({ + channels: { + whatsapp: { + responsePrefix: "[WA] ", + accounts: { + business: { responsePrefix: "[Biz] " }, + personal: { responsePrefix: "[Personal] " }, + }, + }, + }, + } satisfies OpenClawConfig); + expect( + resolveResponsePrefix(cfg, "main", { channel: "whatsapp", accountId: "business" }), + ).toBe("[Biz] "); + expect( + resolveResponsePrefix(cfg, "main", { channel: "whatsapp", accountId: "personal" }), + ).toBe("[Personal] "); + }); + + it("unknown accountId falls through to channel level", () => { + const cfg = makeConfig({ + channels: { + whatsapp: { + responsePrefix: "[WA] ", + accounts: { + business: { responsePrefix: "[Biz] " }, + }, + }, + }, + } satisfies OpenClawConfig); + expect( + resolveResponsePrefix(cfg, "main", { channel: "whatsapp", accountId: "unknown" }), + ).toBe("[WA] "); + }); + }); + + // ─── Full cascade ───────────────────────────────────────────────── + + describe("full 4-level cascade", () => { + const fullCfg = makeConfig({ + agents: { + list: [{ id: "main", identity: { name: "TestBot" } }], + }, + messages: { responsePrefix: "[L4-Global] " }, + channels: { + whatsapp: { + responsePrefix: "[L2-Channel] ", + accounts: { + business: { responsePrefix: "[L1-Account] " }, + default: {}, + }, + }, + telegram: {}, + }, + } satisfies OpenClawConfig); + + it("L1: account prefix wins when all levels set", () => { + expect( + resolveResponsePrefix(fullCfg, "main", { channel: "whatsapp", accountId: "business" }), + ).toBe("[L1-Account] "); + }); + + it("L2: channel prefix when account undefined", () => { + expect( + resolveResponsePrefix(fullCfg, "main", { channel: "whatsapp", accountId: "default" }), + ).toBe("[L2-Channel] "); + }); + + it("L4: global prefix when channel has no prefix", () => { + expect(resolveResponsePrefix(fullCfg, "main", { channel: "telegram" })).toBe("[L4-Global] "); + }); + + it("undefined: no prefix at any level", () => { + const cfg = makeConfig({ + channels: { telegram: {} }, + } satisfies OpenClawConfig); + expect(resolveResponsePrefix(cfg, "main", { channel: "telegram" })).toBeUndefined(); + }); + }); + + // ─── resolveEffectiveMessagesConfig integration ──────────────────── + + describe("resolveEffectiveMessagesConfig with channel context", () => { + it("passes channel context through to responsePrefix resolution", () => { + const cfg = makeConfig({ + messages: { responsePrefix: "[Global] " }, + channels: { + whatsapp: { responsePrefix: "[WA] " }, + }, + } satisfies OpenClawConfig); + const result = resolveEffectiveMessagesConfig(cfg, "main", { + channel: "whatsapp", + }); + expect(result.responsePrefix).toBe("[WA] "); + }); + + it("uses global when no channel context provided", () => { + const cfg = makeConfig({ + messages: { responsePrefix: "[Global] " }, + channels: { + whatsapp: { responsePrefix: "[WA] " }, + }, + } satisfies OpenClawConfig); + const result = resolveEffectiveMessagesConfig(cfg, "main"); + expect(result.responsePrefix).toBe("[Global] "); + }); + }); +}); diff --git a/src/agents/identity.ts b/src/agents/identity.ts index 72305fc19..1ce3831ad 100644 --- a/src/agents/identity.ts +++ b/src/agents/identity.ts @@ -53,7 +53,49 @@ export function resolveMessagePrefix( return resolveIdentityNamePrefix(cfg, agentId) ?? opts?.fallback ?? "[openclaw]"; } -export function resolveResponsePrefix(cfg: OpenClawConfig, agentId: string): string | undefined { +/** Helper to extract a channel config value by dynamic key. */ +function getChannelConfig( + cfg: OpenClawConfig, + channel: string, +): Record | undefined { + const channels = cfg.channels as Record | undefined; + const value = channels?.[channel]; + return typeof value === "object" && value !== null + ? (value as Record) + : undefined; +} + +export function resolveResponsePrefix( + cfg: OpenClawConfig, + agentId: string, + opts?: { channel?: string; accountId?: string }, +): string | undefined { + // L1: Channel account level + if (opts?.channel && opts?.accountId) { + const channelCfg = getChannelConfig(cfg, opts.channel); + const accounts = channelCfg?.accounts as Record> | undefined; + const accountPrefix = accounts?.[opts.accountId]?.responsePrefix as string | undefined; + if (accountPrefix !== undefined) { + if (accountPrefix === "auto") { + return resolveIdentityNamePrefix(cfg, agentId); + } + return accountPrefix; + } + } + + // L2: Channel level + if (opts?.channel) { + const channelCfg = getChannelConfig(cfg, opts.channel); + const channelPrefix = channelCfg?.responsePrefix as string | undefined; + if (channelPrefix !== undefined) { + if (channelPrefix === "auto") { + return resolveIdentityNamePrefix(cfg, agentId); + } + return channelPrefix; + } + } + + // L4: Global level const configured = cfg.messages?.responsePrefix; if (configured !== undefined) { if (configured === "auto") { @@ -67,14 +109,22 @@ export function resolveResponsePrefix(cfg: OpenClawConfig, agentId: string): str export function resolveEffectiveMessagesConfig( cfg: OpenClawConfig, agentId: string, - opts?: { hasAllowFrom?: boolean; fallbackMessagePrefix?: string }, + opts?: { + hasAllowFrom?: boolean; + fallbackMessagePrefix?: string; + channel?: string; + accountId?: string; + }, ): { messagePrefix: string; responsePrefix?: string } { return { messagePrefix: resolveMessagePrefix(cfg, agentId, { hasAllowFrom: opts?.hasAllowFrom, fallback: opts?.fallbackMessagePrefix, }), - responsePrefix: resolveResponsePrefix(cfg, agentId), + responsePrefix: resolveResponsePrefix(cfg, agentId, { + channel: opts?.channel, + accountId: opts?.accountId, + }), }; } diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index df21524d8..c540f268d 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,6 +66,7 @@ export async function routeReply(params: RouteReplyParams): Promise void; }; +export type ReplyPrefixOptions = Pick< + ReplyPrefixContextBundle, + "responsePrefix" | "responsePrefixContextProvider" | "onModelSelected" +>; + export function createReplyPrefixContext(params: { cfg: OpenClawConfig; agentId: string; + channel?: string; + accountId?: string; }): ReplyPrefixContextBundle { const { cfg, agentId } = params; const prefixContext: ResponsePrefixContext = { @@ -34,8 +41,22 @@ export function createReplyPrefixContext(params: { return { prefixContext, - responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix, + responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId, { + channel: params.channel, + accountId: params.accountId, + }).responsePrefix, responsePrefixContextProvider: () => prefixContext, onModelSelected, }; } + +export function createReplyPrefixOptions(params: { + cfg: OpenClawConfig; + agentId: string; + channel?: string; + accountId?: string; +}): ReplyPrefixOptions { + const { responsePrefix, responsePrefixContextProvider, onModelSelected } = + createReplyPrefixContext(params); + return { responsePrefix, responsePrefixContextProvider, onModelSelected }; +} diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index ba427d414..044207101 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -155,6 +155,8 @@ export type DiscordAccountConfig = { intents?: DiscordIntentsConfig; /** PluralKit identity resolution for proxied messages. */ pluralkit?: DiscordPluralKitConfig; + /** Outbound response prefix override for this channel/account. */ + responsePrefix?: string; }; export type DiscordConfig = { diff --git a/src/config/types.feishu.ts b/src/config/types.feishu.ts index a021029da..1cb2288ee 100644 --- a/src/config/types.feishu.ts +++ b/src/config/types.feishu.ts @@ -84,6 +84,8 @@ export type FeishuAccountConfig = { retry?: OutboundRetryConfig; /** Heartbeat visibility settings for this channel. */ heartbeat?: ChannelHeartbeatVisibilityConfig; + /** Outbound response prefix override for this channel/account. */ + responsePrefix?: string; }; export type FeishuConfig = { diff --git a/src/config/types.googlechat.ts b/src/config/types.googlechat.ts index 5fceff49e..2ffa7a0ea 100644 --- a/src/config/types.googlechat.ts +++ b/src/config/types.googlechat.ts @@ -98,6 +98,8 @@ export type GoogleChatAccountConfig = { * If configured, falls back to message mode with a warning. */ typingIndicator?: "none" | "message" | "reaction"; + /** Outbound response prefix override for this channel/account. */ + responsePrefix?: string; }; export type GoogleChatConfig = { diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index 0be92fcb7..e8b4b69e2 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -71,6 +71,8 @@ export type IMessageAccountConfig = { >; /** Heartbeat visibility settings for this channel. */ heartbeat?: ChannelHeartbeatVisibilityConfig; + /** Outbound response prefix override for this channel/account. */ + responsePrefix?: string; }; export type IMessageConfig = { diff --git a/src/config/types.msteams.ts b/src/config/types.msteams.ts index df16b2712..0eb5e03fd 100644 --- a/src/config/types.msteams.ts +++ b/src/config/types.msteams.ts @@ -106,4 +106,6 @@ export type MSTeamsConfig = { sharePointSiteId?: string; /** Heartbeat visibility settings for this channel. */ heartbeat?: ChannelHeartbeatVisibilityConfig; + /** Outbound response prefix override for this channel/account. */ + responsePrefix?: string; }; diff --git a/src/config/types.signal.ts b/src/config/types.signal.ts index 014f62841..e8e1a6967 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -84,6 +84,8 @@ export type SignalAccountConfig = { reactionLevel?: SignalReactionLevel; /** Heartbeat visibility settings for this channel. */ heartbeat?: ChannelHeartbeatVisibilityConfig; + /** Outbound response prefix override for this channel/account. */ + responsePrefix?: string; }; export type SignalConfig = { diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index cbf912e80..4408aeb09 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -142,6 +142,8 @@ export type SlackAccountConfig = { channels?: Record; /** Heartbeat visibility settings for this channel. */ heartbeat?: ChannelHeartbeatVisibilityConfig; + /** Outbound response prefix override for this channel/account. */ + responsePrefix?: string; }; export type SlackConfig = { diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 9a96bce45..1f5c0972e 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -130,6 +130,14 @@ export type TelegramAccountConfig = { heartbeat?: ChannelHeartbeatVisibilityConfig; /** Controls whether link previews are shown in outbound messages. Default: true. */ linkPreview?: boolean; + /** + * Per-channel outbound response prefix override. + * + * When set, this takes precedence over the global `messages.responsePrefix`. + * Use `""` to explicitly disable a global prefix for this channel. + * Use `"auto"` to derive `[{identity.name}]` from the routed agent. + */ + responsePrefix?: string; }; export type TelegramTopicConfig = { diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 85718ec19..dc44dc808 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -30,6 +30,14 @@ export type WhatsAppConfig = { * Default: `[{agents.list[].identity.name}]` (or `[openclaw]`) when allowFrom is empty, else `""`. */ messagePrefix?: string; + /** + * Per-channel outbound response prefix override. + * + * When set, this takes precedence over the global `messages.responsePrefix`. + * Use `""` to explicitly disable a global prefix for this channel. + * Use `"auto"` to derive `[{identity.name}]` from the routed agent. + */ + responsePrefix?: string; /** Direct message access policy (default: pairing). */ dmPolicy?: DmPolicy; /** @@ -109,6 +117,8 @@ export type WhatsAppAccountConfig = { sendReadReceipts?: boolean; /** Inbound message prefix override for this account (WhatsApp only). */ messagePrefix?: string; + /** Per-account outbound response prefix override (takes precedence over channel and global). */ + responsePrefix?: string; /** Override auth directory (Baileys multi-file auth state). */ authDir?: string; /** Direct message access policy (default: pairing). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 98bbc1b57..427baa1ee 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -137,6 +137,7 @@ export const TelegramAccountSchemaBase = z reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, linkPreview: z.boolean().optional(), + responsePrefix: z.string().optional(), }) .strict(); @@ -321,6 +322,7 @@ export const DiscordAccountSchema = z }) .strict() .optional(), + responsePrefix: z.string().optional(), }) .strict(); @@ -391,6 +393,7 @@ export const GoogleChatAccountSchema = z .optional(), dm: GoogleChatDmSchema.optional(), typingIndicator: z.enum(["none", "message", "reaction"]).optional(), + responsePrefix: z.string().optional(), }) .strict(); @@ -505,6 +508,7 @@ export const SlackAccountSchema = z dm: SlackDmSchema.optional(), channels: z.record(z.string(), SlackChannelSchema.optional()).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, + responsePrefix: z.string().optional(), }) .strict(); @@ -588,6 +592,7 @@ export const SignalAccountSchemaBase = z .optional(), reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, + responsePrefix: z.string().optional(), }) .strict(); @@ -652,6 +657,7 @@ export const IMessageAccountSchemaBase = z ) .optional(), heartbeat: ChannelHeartbeatVisibilitySchema, + responsePrefix: z.string().optional(), }) .strict(); @@ -731,6 +737,7 @@ export const BlueBubblesAccountSchemaBase = z blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), groups: z.record(z.string(), BlueBubblesGroupConfigSchema.optional()).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, + responsePrefix: z.string().optional(), }) .strict(); @@ -813,6 +820,7 @@ export const MSTeamsConfigSchema = z /** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2") */ sharePointSiteId: z.string().optional(), heartbeat: ChannelHeartbeatVisibilitySchema, + responsePrefix: z.string().optional(), }) .strict() .superRefine((value, ctx) => { diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 9a5492504..0defe17e5 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -20,6 +20,7 @@ export const WhatsAppAccountSchema = z enabled: z.boolean().optional(), sendReadReceipts: z.boolean().optional(), messagePrefix: z.string().optional(), + responsePrefix: z.string().optional(), /** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */ authDir: z.string().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), @@ -84,6 +85,7 @@ export const WhatsAppConfigSchema = z sendReadReceipts: z.boolean().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), messagePrefix: z.string().optional(), + responsePrefix: z.string().optional(), selfChatMode: z.boolean().optional(), allowFrom: z.array(z.string()).optional(), groupAllowFrom: z.array(z.string()).optional(), diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 11c706e4e..08ff191ec 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -20,7 +20,7 @@ import { shouldAckReaction as shouldAckReactionGate, } from "../../channels/ack-reactions.js"; import { logTypingFailure, logAckFailure } from "../../channels/logging.js"; -import { createReplyPrefixContext } from "../../channels/reply-prefix.js"; +import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { recordInboundSession } from "../../channels/session.js"; import { createTypingCallbacks } from "../../channels/typing.js"; import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; @@ -334,7 +334,12 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) ? deliverTarget.slice("channel:".length) : message.channelId; - const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId: route.agentId, + channel: "discord", + accountId: route.accountId, + }); const tableMode = resolveMarkdownTableMode({ cfg, channel: "discord", @@ -342,8 +347,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) }); const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: prefixContext.responsePrefix, - responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + ...prefixOptions, humanDelay: resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload: ReplyPayload) => { const replyToId = replyReference.use(); @@ -389,9 +393,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) typeof discordConfig?.blockStreaming === "boolean" ? !discordConfig.blockStreaming : undefined, - onModelSelected: (ctx) => { - prefixContext.onModelSelected(ctx); - }, + onModelSelected, }, }); markDispatchIdle(); diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index a56b53293..79246921e 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -19,7 +19,7 @@ import type { } from "../../auto-reply/commands-registry.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig, loadConfig } from "../../config/config.js"; -import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js"; +import { resolveHumanDelayConfig } from "../../agents/identity.js"; import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js"; import { buildCommandTextFromArgs, @@ -33,6 +33,7 @@ import { import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; +import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { readChannelAllowFromStore, @@ -790,12 +791,19 @@ async function dispatchDiscordCommandInteraction(params: { CommandSource: "native" as const, }); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId: route.agentId, + channel: "discord", + accountId: route.accountId, + }); + let didReply = false; await dispatchReplyWithDispatcher({ ctx: ctxPayload, cfg, dispatcherOptions: { - responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, + ...prefixOptions, humanDelay: resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload) => { try { @@ -828,6 +836,7 @@ async function dispatchDiscordCommandInteraction(params: { typeof discordConfig?.blockStreaming === "boolean" ? !discordConfig.blockStreaming : undefined, + onModelSelected, }, }); } diff --git a/src/feishu/message.ts b/src/feishu/message.ts index a3724588c..a8814ddf7 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 { resolveSessionAgentId } from "../agents/agent-scope.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; +import { createReplyPrefixOptions } 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,19 @@ export async function processFeishuMessage( WasMentioned: isGroup ? wasMentioned : undefined, }; + const agentId = resolveSessionAgentId({ config: cfg }); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId, + channel: "feishu", + accountId, + }); + await dispatchReplyWithBufferedBlockDispatcher({ ctx, cfg, dispatcherOptions: { + ...prefixOptions, deliver: async (payload, info) => { const hasMedia = payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0); if (!payload.text && !hasMedia) { @@ -391,6 +402,7 @@ export async function processFeishuMessage( }, replyOptions: { disableBlockStreaming: !feishuCfg.blockStreaming, + 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 b49af56de..c07b0ec30 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -5,15 +5,11 @@ import path from "node:path"; import type { MsgContext } from "../../auto-reply/templating.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; -import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../../agents/identity.js"; import { resolveThinkingDefault } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; -import { - extractShortModelName, - type ResponsePrefixContext, -} from "../../auto-reply/reply/response-prefix-template.js"; +import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import { @@ -477,13 +473,14 @@ export const chatHandlers: GatewayRequestHandlers = { sessionKey: p.sessionKey, config: cfg, }); - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(cfg, agentId), - }; + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId, + channel: INTERNAL_MESSAGE_CHANNEL, + }); const finalReplyParts: string[] = []; const dispatcher = createReplyDispatcher({ - responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix, - responsePrefixContextProvider: () => prefixContext, + ...prefixOptions, onError: (err) => { context.logGateway.warn(`webchat dispatch failed: ${formatForLog(err)}`); }, @@ -512,12 +509,7 @@ export const chatHandlers: GatewayRequestHandlers = { onAgentRunStart: () => { agentRunStarted = true; }, - onModelSelected: (ctx) => { - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; - }, + onModelSelected, }, }) .then(() => { diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index bb2123e0c..a25b4644e 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -25,7 +25,7 @@ import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/re import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; import { resolveControlCommandGate } from "../../channels/command-gating.js"; import { logInboundDrop } from "../../channels/logging.js"; -import { createReplyPrefixContext } from "../../channels/reply-prefix.js"; +import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { recordInboundSession } from "../../channels/session.js"; import { loadConfig } from "../../config/config.js"; import { @@ -610,11 +610,15 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P ); } - const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId: route.agentId, + channel: "imessage", + accountId: route.accountId, + }); const dispatcher = createReplyDispatcher({ - responsePrefix: prefixContext.responsePrefix, - responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + ...prefixOptions, humanDelay: resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload) => { await deliverReplies({ @@ -642,7 +646,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P typeof accountInfo.config.blockStreaming === "boolean" ? !accountInfo.config.blockStreaming : undefined, - onModelSelected: prefixContext.onModelSelected, + onModelSelected, }, }); if (!queuedFinal) { diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 4390298f8..e9648edba 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -543,7 +543,10 @@ export async function runHeartbeatOnce(opts: { }) : { showOk: false, showAlerts: true, useIndicator: true }; const { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery }); - const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix; + const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId, { + channel: delivery.channel !== "none" ? delivery.channel : undefined, + accountId: delivery.accountId, + }).responsePrefix; // Check if this is an exec event with pending exec completion system events. // If so, use a specialized prompt that instructs the model to relay the result diff --git a/src/line/config-schema.ts b/src/line/config-schema.ts index 7e7a2be03..55804f81e 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 8880e4a77..170225c74 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 { createReplyPrefixOptions } 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,12 +192,18 @@ 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 { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg: config, + agentId: route.agentId, + channel: "line", + accountId: route.accountId, + }); const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config, dispatcherOptions: { - responsePrefix: resolveEffectiveMessagesConfig(config, route.agentId).responsePrefix, + ...prefixOptions, deliver: async (payload, _info) => { const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {}; @@ -249,7 +255,9 @@ export async function monitorLineProvider( runtime.error?.(danger(`line ${info.kind} reply failed: ${String(err)}`)); }, }, - replyOptions: {}, + replyOptions: { + onModelSelected, + }, }); if (!queuedFinal) { diff --git a/src/line/types.ts b/src/line/types.ts index 252fcb949..dbd157cad 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/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index f742547ed..05128012e 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -148,7 +148,7 @@ export { shouldAckReactionForWhatsApp, } from "../channels/ack-reactions.js"; export { createTypingCallbacks } from "../channels/typing.js"; -export { createReplyPrefixContext } from "../channels/reply-prefix.js"; +export { createReplyPrefixContext, createReplyPrefixOptions } from "../channels/reply-prefix.js"; export { logAckFailure, logInboundDrop, logTypingFailure } from "../channels/logging.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export type { NormalizedLocation } from "../channels/location.js"; diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 34e21dc41..d60658d33 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -19,7 +19,7 @@ import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.j import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; import { resolveControlCommandGate } from "../../channels/command-gating.js"; import { logInboundDrop, logTypingFailure } from "../../channels/logging.js"; -import { createReplyPrefixContext } from "../../channels/reply-prefix.js"; +import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { recordInboundSession } from "../../channels/session.js"; import { createTypingCallbacks } from "../../channels/typing.js"; import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; @@ -171,7 +171,12 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { logVerbose(`signal inbound: from=${ctxPayload.From} len=${body.length} preview="${preview}"`); } - const prefixContext = createReplyPrefixContext({ cfg: deps.cfg, agentId: route.agentId }); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg: deps.cfg, + agentId: route.agentId, + channel: "signal", + accountId: route.accountId, + }); const typingCallbacks = createTypingCallbacks({ start: async () => { @@ -195,8 +200,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { }); const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: prefixContext.responsePrefix, - responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + ...prefixOptions, humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId), deliver: async (payload) => { await deps.deliverReplies({ @@ -224,9 +228,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { ...replyOptions, disableBlockStreaming: typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined, - onModelSelected: (ctx) => { - prefixContext.onModelSelected(ctx); - }, + onModelSelected, }, }); markDispatchIdle(); diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index 0028b1c3b..8a988ca35 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -5,7 +5,7 @@ import { clearHistoryEntriesIfEnabled } from "../../../auto-reply/reply/history. import { createReplyDispatcherWithTyping } from "../../../auto-reply/reply/reply-dispatcher.js"; import { removeAckReactionAfterReply } from "../../../channels/ack-reactions.js"; import { logAckFailure, logTypingFailure } from "../../../channels/logging.js"; -import { createReplyPrefixContext } from "../../../channels/reply-prefix.js"; +import { createReplyPrefixOptions } from "../../../channels/reply-prefix.js"; import { createTypingCallbacks } from "../../../channels/typing.js"; import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js"; @@ -95,11 +95,15 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag }, }); - const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId: route.agentId, + channel: "slack", + accountId: route.accountId, + }); const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: prefixContext.responsePrefix, - responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + ...prefixOptions, humanDelay: resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload) => { const replyThreadTs = replyPlan.nextThreadTs(); @@ -134,9 +138,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag typeof account.config.blockStreaming === "boolean" ? !account.config.blockStreaming : undefined, - onModelSelected: (ctx) => { - prefixContext.onModelSelected(ctx); - }, + onModelSelected, }, }); markDispatchIdle(); diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index 0f6475fb6..b59952b6d 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 { createReplyPrefixOptions } 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,11 +434,18 @@ export function registerSlackMonitorSlashCommands(params: { OriginatingTo: `user:${command.user_id}`, }); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId: route.agentId, + channel: "slack", + accountId: route.accountId, + }); + const { counts } = await dispatchReplyWithDispatcher({ ctx: ctxPayload, cfg, dispatcherOptions: { - responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, + ...prefixOptions, deliver: async (payload) => { await deliverSlackSlashReplies({ replies: [payload], @@ -457,7 +464,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, + }, }); if (counts.final + counts.tool + counts.block === 0) { await deliverSlackSlashReplies({ diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index d1bcf0088..b4f284aee 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -695,3 +695,7 @@ export const buildTelegramMessageContext = async ({ accountId: account.accountId, }; }; + +export type TelegramMessageContext = NonNullable< + Awaited> +>; diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts index b24f29f5c..f6d4c5d7f 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/src/telegram/bot-message-dispatch.test.ts @@ -1,3 +1,4 @@ +import type { Bot } from "grammy"; import { beforeEach, describe, expect, it, vi } from "vitest"; const createTelegramDraftStream = vi.hoisted(() => vi.fn()); @@ -72,16 +73,25 @@ describe("dispatchTelegramMessage draft streaming", () => { removeAckAfterReply: false, }; + const bot = { api: { sendMessageDraft: vi.fn() } } as unknown as Bot; + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: () => { + throw new Error("exit"); + }, + }; + await dispatchTelegramMessage({ context, - bot: { api: {} }, + bot, cfg: {}, - runtime: {}, + runtime, replyToMode: "first", streamMode: "partial", textLimit: 4096, telegramCfg: {}, - opts: {}, + opts: { token: "token" }, resolveBotTopicsEnabled, }); diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 5117732f1..a9b493a83 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -1,5 +1,10 @@ +import type { Bot } from "grammy"; +import type { OpenClawConfig, ReplyToMode, TelegramAccountConfig } from "../config/types.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { TelegramMessageContext } from "./bot-message-context.js"; +import type { TelegramBotOptions } from "./bot.js"; +import type { TelegramStreamMode, TelegramContext } from "./bot/types.js"; import { resolveAgentDir } from "../agents/agent-scope.js"; -// @ts-nocheck import { findModelInCatalog, loadModelCatalog, @@ -12,9 +17,8 @@ import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { removeAckReactionAfterReply } from "../channels/ack-reactions.js"; import { logAckFailure, logTypingFailure } from "../channels/logging.js"; -import { createReplyPrefixContext } from "../channels/reply-prefix.js"; +import { createReplyPrefixOptions } from "../channels/reply-prefix.js"; import { createTypingCallbacks } from "../channels/typing.js"; -import { OpenClawConfig } from "../config/config.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { danger, logVerbose } from "../globals.js"; import { deliverReplies } from "./bot/delivery.js"; @@ -38,6 +42,21 @@ async function resolveStickerVisionSupport(cfg: OpenClawConfig, agentId: string) } } +type ResolveBotTopicsEnabled = (ctx: TelegramContext) => boolean | Promise; + +type DispatchTelegramMessageParams = { + context: TelegramMessageContext; + bot: Bot; + cfg: OpenClawConfig; + runtime: RuntimeEnv; + replyToMode: ReplyToMode; + streamMode: TelegramStreamMode; + textLimit: number; + telegramCfg: TelegramAccountConfig; + opts: Pick; + resolveBotTopicsEnabled: ResolveBotTopicsEnabled; +}; + export const dispatchTelegramMessage = async ({ context, bot, @@ -49,8 +68,7 @@ export const dispatchTelegramMessage = async ({ telegramCfg, opts, resolveBotTopicsEnabled, - // oxlint-disable-next-line typescript/no-explicit-any -}: any) => { +}: DispatchTelegramMessageParams) => { const { ctxPayload, primaryCtx, @@ -157,7 +175,12 @@ export const dispatchTelegramMessage = async ({ Boolean(draftStream) || (typeof telegramCfg.blockStreaming === "boolean" ? !telegramCfg.blockStreaming : undefined); - const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId: route.agentId, + channel: "telegram", + accountId: route.accountId, + }); const tableMode = resolveMarkdownTableMode({ cfg, channel: "telegram", @@ -202,16 +225,20 @@ export const dispatchTelegramMessage = async ({ } // Cache the description for future encounters - cacheSticker({ - fileId: sticker.fileId, - fileUniqueId: sticker.fileUniqueId, - emoji: sticker.emoji, - setName: sticker.setName, - description, - cachedAt: new Date().toISOString(), - receivedFrom: ctxPayload.From, - }); - logVerbose(`telegram: cached sticker description for ${sticker.fileUniqueId}`); + if (sticker.fileId) { + cacheSticker({ + fileId: sticker.fileId, + fileUniqueId: sticker.fileUniqueId, + emoji: sticker.emoji, + setName: sticker.setName, + description, + cachedAt: new Date().toISOString(), + receivedFrom: ctxPayload.From, + }); + logVerbose(`telegram: cached sticker description for ${sticker.fileUniqueId}`); + } else { + logVerbose(`telegram: skipped sticker cache (missing fileId)`); + } } } @@ -228,8 +255,7 @@ export const dispatchTelegramMessage = async ({ ctx: ctxPayload, cfg, dispatcherOptions: { - responsePrefix: prefixContext.responsePrefix, - responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + ...prefixOptions, deliver: async (payload, info) => { if (info.kind === "final") { await flushDraft(); @@ -278,9 +304,7 @@ export const dispatchTelegramMessage = async ({ skillFilter, disableBlockStreaming, onPartialReply: draftStream ? (payload) => updateDraftFromPartial(payload.text) : undefined, - onModelSelected: (ctx) => { - prefixContext.onModelSelected(ctx); - }, + onModelSelected, }, }); draftStream?.stop(); diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index b48e6284d..8a7abe7e9 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 { createReplyPrefixOptions } from "../channels/reply-prefix.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.js"; import { @@ -547,11 +547,18 @@ export const registerTelegramNativeCommands = ({ skippedNonSilent: 0, }; + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId: route.agentId, + channel: "telegram", + accountId: route.accountId, + }); + await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg, dispatcherOptions: { - responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, + ...prefixOptions, deliver: async (payload, _info) => { const result = await deliverReplies({ replies: [payload], @@ -582,6 +589,7 @@ export const registerTelegramNativeCommands = ({ replyOptions: { skillFilter, disableBlockStreaming, + 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 2616c98c7..705b907b9 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 e8651529d..a8a63aedb 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -18,7 +18,7 @@ import { import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../../../auto-reply/reply/provider-dispatcher.js"; import { toLocationContext } from "../../../channels/location.js"; -import { createReplyPrefixContext } from "../../../channels/reply-prefix.js"; +import { createReplyPrefixOptions } from "../../../channels/reply-prefix.js"; import { resolveMarkdownTableMode } from "../../../config/markdown-tables.js"; import { readSessionUpdatedAt, @@ -255,16 +255,18 @@ export async function processMessage(params: { ? await resolveWhatsAppCommandAuthorized({ cfg: params.cfg, msg: params.msg }) : undefined; const configuredResponsePrefix = params.cfg.messages?.responsePrefix; - const prefixContext = createReplyPrefixContext({ + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ cfg: params.cfg, agentId: params.route.agentId, + channel: "whatsapp", + accountId: params.route.accountId, }); const isSelfChat = params.msg.chatType !== "group" && Boolean(params.msg.selfE164) && normalizeE164(params.msg.from) === normalizeE164(params.msg.selfE164 ?? ""); const responsePrefix = - prefixContext.responsePrefix ?? + prefixOptions.responsePrefix ?? (configuredResponsePrefix === undefined && isSelfChat ? (resolveIdentityNamePrefix(params.cfg, params.route.agentId) ?? "[openclaw]") : undefined); @@ -339,8 +341,8 @@ export async function processMessage(params: { cfg: params.cfg, replyResolver: params.replyResolver, dispatcherOptions: { + ...prefixOptions, responsePrefix, - responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, onHeartbeatStrip: () => { if (!didLogHeartbeatStrip) { didLogHeartbeatStrip = true; @@ -400,7 +402,7 @@ export async function processMessage(params: { typeof params.cfg.channels?.whatsapp?.blockStreaming === "boolean" ? !params.cfg.channels.whatsapp.blockStreaming : undefined, - onModelSelected: prefixContext.onModelSelected, + onModelSelected, }, });