feat: per-channel responsePrefix override (#9001)

* feat: per-channel responsePrefix override

Add responsePrefix field to all channel config types and Zod schemas,
enabling per-channel and per-account outbound response prefix overrides.

Resolution cascade (most specific wins):
  L1: channels.<ch>.accounts.<id>.responsePrefix
  L2: channels.<ch>.responsePrefix
  L3: (reserved for channels.defaults)
  L4: messages.responsePrefix (existing global)

Semantics:
  - undefined -> inherit from parent level
  - empty string -> explicitly no prefix (stops cascade)
  - "auto" -> derive [identity.name] from routed agent

Changes:
  - Core logic: resolveResponsePrefix() in identity.ts accepts
    optional channel/accountId and walks the cascade
  - resolveEffectiveMessagesConfig() passes channel context through
  - Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack,
    Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs
  - Zod schemas: responsePrefix added for config validation
  - All channel handlers wired: telegram, discord, slack, signal,
    imessage, line, heartbeat runner, route-reply, native commands
  - 23 new tests covering backward compat, channel/account levels,
    full cascade, auto keyword, empty string stops, unknown fallthrough

Fully backward compatible - no existing config is affected.
Fixes #8857

* fix: address CI lint + review feedback

- Replace Record<string, any> with proper typed helpers (no-explicit-any)
- Add curly braces to single-line if returns (eslint curly)
- Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types
- Extract getChannelConfig() helper for type-safe dynamic key access

* fix: finish responsePrefix overrides (#9001) (thanks @mudrii)

* fix: normalize prefix wiring and types (#9001) (thanks @mudrii)

---------

Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
This commit is contained in:
mudrii
2026-02-05 05:16:34 +08:00
committed by GitHub
parent 43590d8287
commit 5d82c82313
63 changed files with 838 additions and 120 deletions

View File

@@ -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<boolean>;
type DispatchTelegramMessageParams = {
context: TelegramMessageContext;
bot: Bot;
cfg: OpenClawConfig;
runtime: RuntimeEnv;
replyToMode: ReplyToMode;
streamMode: TelegramStreamMode;
textLimit: number;
telegramCfg: TelegramAccountConfig;
opts: Pick<TelegramBotOptions, "token">;
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();