diff --git a/CHANGELOG.md b/CHANGELOG.md index df03ad8fc5d..5a2873ccd64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280. - Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility. - Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus. +- Telegram/error replies: add a default-off `channels.telegram.silentErrorReplies` setting so bot error replies can be delivered silently across regular replies, native commands, and fallback sends. (#19776) Thanks @ImLukeF. - Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. - Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898. - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 156d9296ae7..64fe301658a 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -298,6 +298,43 @@ describe("dispatchTelegramMessage draft streaming", () => { ); }); + it("sends error replies silently when silentErrorReplies is enabled", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" }); + return { queuedFinal: true }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ + context: createContext(), + telegramCfg: { silentErrorReplies: true }, + }); + + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + silent: true, + replies: [expect.objectContaining({ isError: true })], + }), + ); + }); + + it("keeps error replies notifying by default", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" }); + return { queuedFinal: true }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ context: createContext() }); + + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + silent: false, + replies: [expect.objectContaining({ isError: true })], + }), + ); + }); + it("keeps block streaming enabled when session reasoning level is on", async () => { loadSessionStore.mockReturnValue({ s1: { reasoningLevel: "on" }, diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index a9c0e625508..61fc9f92fbf 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -465,6 +465,7 @@ export const dispatchTelegramMessage = async ({ linkPreview: telegramCfg.linkPreview, replyQuoteText, }; + const silentErrorReplies = telegramCfg.silentErrorReplies === true; const applyTextToPayload = (payload: ReplyPayload, text: string): ReplyPayload => { if (payload.text === text) { return payload; @@ -476,6 +477,7 @@ export const dispatchTelegramMessage = async ({ ...deliveryBaseOptions, replies: [payload], onVoiceRecording: sendRecordVoice, + silent: silentErrorReplies && payload.isError === true, }); if (result.delivered) { deliveryState.markDelivered(); @@ -809,6 +811,7 @@ export const dispatchTelegramMessage = async ({ const result = await deliverReplies({ replies: [{ text: fallbackText }], ...deliveryBaseOptions, + silent: silentErrorReplies && dispatchError != null, }); sentFallback = result.delivered; } diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index db3fdc23bba..6160afccf01 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -187,18 +187,20 @@ function registerAndResolveStatusHandler(params: { cfg: OpenClawConfig; allowFrom?: string[]; groupAllowFrom?: string[]; + telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; }): { handler: TelegramCommandHandler; sendMessage: ReturnType; } { - const { cfg, allowFrom, groupAllowFrom, resolveTelegramGroupConfig } = params; + const { cfg, allowFrom, groupAllowFrom, telegramCfg, resolveTelegramGroupConfig } = params; return registerAndResolveCommandHandlerBase({ commandName: "status", cfg, allowFrom: allowFrom ?? ["*"], groupAllowFrom: groupAllowFrom ?? [], useAccessGroups: true, + telegramCfg, resolveTelegramGroupConfig, }); } @@ -209,6 +211,7 @@ function registerAndResolveCommandHandlerBase(params: { allowFrom: string[]; groupAllowFrom: string[]; useAccessGroups: boolean; + telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; }): { handler: TelegramCommandHandler; @@ -220,6 +223,7 @@ function registerAndResolveCommandHandlerBase(params: { allowFrom, groupAllowFrom, useAccessGroups, + telegramCfg, resolveTelegramGroupConfig, } = params; const commandHandlers = new Map(); @@ -239,6 +243,7 @@ function registerAndResolveCommandHandlerBase(params: { allowFrom, groupAllowFrom, useAccessGroups, + telegramCfg, resolveTelegramGroupConfig, }), }); @@ -254,6 +259,7 @@ function registerAndResolveCommandHandler(params: { allowFrom?: string[]; groupAllowFrom?: string[]; useAccessGroups?: boolean; + telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; }): { handler: TelegramCommandHandler; @@ -265,6 +271,7 @@ function registerAndResolveCommandHandler(params: { allowFrom, groupAllowFrom, useAccessGroups, + telegramCfg, resolveTelegramGroupConfig, } = params; return registerAndResolveCommandHandlerBase({ @@ -273,6 +280,7 @@ function registerAndResolveCommandHandler(params: { allowFrom: allowFrom ?? [], groupAllowFrom: groupAllowFrom ?? [], useAccessGroups: useAccessGroups ?? true, + telegramCfg, resolveTelegramGroupConfig, }); } @@ -443,6 +451,31 @@ describe("registerTelegramNativeCommands — session metadata", () => { expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); }); + it("sends native command error replies silently when silentErrorReplies is enabled", async () => { + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( + async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { + await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" }); + return dispatchReplyResult; + }, + ); + + const { handler } = registerAndResolveStatusHandler({ + cfg: {}, + telegramCfg: { silentErrorReplies: true }, + }); + await handler(buildStatusCommandContext()); + + const deliveredCall = deliveryMocks.deliverReplies.mock.calls[0]?.[0] as + | DeliverRepliesParams + | undefined; + expect(deliveredCall).toEqual( + expect.objectContaining({ + silent: true, + replies: [expect.objectContaining({ isError: true })], + }), + ); + }); + it("routes Telegram native commands through configured ACP topic bindings", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index f6ebfe0dfe8..bc843293fc5 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -290,4 +290,56 @@ describe("registerTelegramNativeCommands", () => { ); expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found."); }); + + it("sends plugin command error replies silently when silentErrorReplies is enabled", async () => { + const commandHandlers = new Map Promise>(); + + pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([ + { + name: "plug", + description: "Plugin command", + }, + ] as never); + pluginCommandMocks.matchPluginCommand.mockReturnValue({ + command: { key: "plug", requireAuth: false }, + args: undefined, + } as never); + pluginCommandMocks.executePluginCommand.mockResolvedValue({ + text: "plugin failed", + isError: true, + } as never); + + registerTelegramNativeCommands({ + ...buildParams({}), + bot: { + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue(undefined), + }, + command: vi.fn((name: string, cb: (ctx: unknown) => Promise) => { + commandHandlers.set(name, cb); + }), + } as unknown as Parameters[0]["bot"], + telegramCfg: { silentErrorReplies: true } as TelegramAccountConfig, + }); + + const handler = commandHandlers.get("plug"); + expect(handler).toBeTruthy(); + await handler?.({ + match: "", + message: { + message_id: 1, + date: Math.floor(Date.now() / 1000), + chat: { id: 123, type: "private" }, + from: { id: 456, username: "alice" }, + }, + }); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + silent: true, + replies: [expect.objectContaining({ isError: true })], + }), + ); + }); }); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 7dd91f6ad63..64874d1f8eb 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -363,6 +363,7 @@ export const registerTelegramNativeCommands = ({ shouldSkipUpdate, opts, }: RegisterTelegramNativeCommandsParams) => { + const silentErrorReplies = telegramCfg.silentErrorReplies === true; const boundRoute = nativeEnabled && nativeSkillsEnabled ? resolveAgentRoute({ cfg, channel: "telegram", accountId }) @@ -734,7 +735,6 @@ export const registerTelegramNativeCommands = ({ typeof telegramCfg.blockStreaming === "boolean" ? !telegramCfg.blockStreaming : undefined; - const deliveryState = { delivered: false, skippedNonSilent: 0, @@ -766,6 +766,7 @@ export const registerTelegramNativeCommands = ({ const result = await deliverReplies({ replies: [payload], ...deliveryBaseOptions, + silent: silentErrorReplies && payload.isError === true, }); if (result.delivered) { deliveryState.delivered = true; @@ -885,6 +886,7 @@ export const registerTelegramNativeCommands = ({ await deliverReplies({ replies: [result], ...deliveryBaseOptions, + silent: silentErrorReplies && result.isError === true, }); } }); diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 84d66fec12b..2dfc1c8e956 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -103,6 +103,7 @@ async function deliverTextReply(params: { replyMarkup?: ReturnType; replyQuoteText?: string; linkPreview?: boolean; + silent?: boolean; replyToId?: number; replyToMode: ReplyToMode; progress: DeliveryProgress; @@ -129,6 +130,7 @@ async function deliverTextReply(params: { textMode: "html", plainText: chunk.text, linkPreview: params.linkPreview, + silent: params.silent, replyMarkup, }, ); @@ -149,6 +151,7 @@ async function sendPendingFollowUpText(params: { text: string; replyMarkup?: ReturnType; linkPreview?: boolean; + silent?: boolean; replyToId?: number; replyToMode: ReplyToMode; progress: DeliveryProgress; @@ -167,6 +170,7 @@ async function sendPendingFollowUpText(params: { textMode: "html", plainText: chunk.text, linkPreview: params.linkPreview, + silent: params.silent, replyMarkup, }); }, @@ -196,6 +200,7 @@ async function sendTelegramVoiceFallbackText(opts: { replyToId?: number; thread?: TelegramThreadSpec | null; linkPreview?: boolean; + silent?: boolean; replyMarkup?: ReturnType; replyQuoteText?: string; }): Promise { @@ -213,6 +218,7 @@ async function sendTelegramVoiceFallbackText(opts: { textMode: "html", plainText: chunk.text, linkPreview: opts.linkPreview, + silent: opts.silent, replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined, }); if (firstDeliveredMessageId == null) { @@ -237,6 +243,7 @@ async function deliverMediaReply(params: { chunkText: ChunkTextFn; onVoiceRecording?: () => Promise | void; linkPreview?: boolean; + silent?: boolean; replyQuoteText?: string; replyMarkup?: ReturnType; replyToId?: number; @@ -282,6 +289,7 @@ async function deliverMediaReply(params: { ...buildTelegramSendParams({ replyToMessageId, thread: params.thread, + silent: params.silent, }), }; if (isGif) { @@ -375,6 +383,7 @@ async function deliverMediaReply(params: { replyToId: voiceFallbackReplyTo, thread: params.thread, linkPreview: params.linkPreview, + silent: params.silent, replyMarkup: params.replyMarkup, replyQuoteText: params.replyQuoteText, }); @@ -404,6 +413,7 @@ async function deliverMediaReply(params: { replyToId: undefined, thread: params.thread, linkPreview: params.linkPreview, + silent: params.silent, replyMarkup: params.replyMarkup, }); } @@ -451,6 +461,7 @@ async function deliverMediaReply(params: { text: pendingFollowUpText, replyMarkup: params.replyMarkup, linkPreview: params.linkPreview, + silent: params.silent, replyToId: params.replyToId, replyToMode: params.replyToMode, progress: params.progress, @@ -557,6 +568,8 @@ export async function deliverReplies(params: { onVoiceRecording?: () => Promise | void; /** Controls whether link previews are shown. Default: true (previews enabled). */ linkPreview?: boolean; + /** When true, messages are sent with disable_notification. */ + silent?: boolean; /** Optional quote text for Telegram reply_parameters. */ replyQuoteText?: string; }): Promise<{ delivered: boolean }> { @@ -637,6 +650,7 @@ export async function deliverReplies(params: { replyMarkup, replyQuoteText: params.replyQuoteText, linkPreview: params.linkPreview, + silent: params.silent, replyToId, replyToMode: params.replyToMode, progress, @@ -654,6 +668,7 @@ export async function deliverReplies(params: { chunkText, onVoiceRecording: params.onVoiceRecording, linkPreview: params.linkPreview, + silent: params.silent, replyQuoteText: params.replyQuoteText, replyMarkup, replyToId, diff --git a/extensions/telegram/src/bot/delivery.send.ts b/extensions/telegram/src/bot/delivery.send.ts index f541495aa76..d8768899c28 100644 --- a/extensions/telegram/src/bot/delivery.send.ts +++ b/extensions/telegram/src/bot/delivery.send.ts @@ -76,6 +76,7 @@ export async function sendTelegramWithThreadFallback(params: { export function buildTelegramSendParams(opts?: { replyToMessageId?: number; thread?: TelegramThreadSpec | null; + silent?: boolean; }): Record { const threadParams = buildTelegramThreadParams(opts?.thread); const params: Record = {}; @@ -85,6 +86,9 @@ export function buildTelegramSendParams(opts?: { if (threadParams) { params.message_thread_id = threadParams.message_thread_id; } + if (opts?.silent === true) { + params.disable_notification = true; + } return params; } @@ -100,12 +104,14 @@ export async function sendTelegramText( textMode?: "markdown" | "html"; plainText?: string; linkPreview?: boolean; + silent?: boolean; replyMarkup?: ReturnType; }, ): Promise { const baseParams = buildTelegramSendParams({ replyToMessageId: opts?.replyToMessageId, thread: opts?.thread, + silent: opts?.silent, }); // Add link_preview_options when link preview is disabled. const linkPreviewEnabled = opts?.linkPreview ?? true; diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index a1dce34dceb..d9dbbf7e99b 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -211,6 +211,30 @@ describe("deliverReplies", () => { ); }); + it("sets disable_notification when silent is true", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 5, + chat: { id: "123" }, + }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + replies: [{ text: "hello" }], + runtime, + bot, + silent: true, + }); + + expect(sendMessage).toHaveBeenCalledWith( + "123", + expect.any(String), + expect.objectContaining({ + disable_notification: true, + }), + ); + }); + it("emits internal message:sent when session hook context is available", async () => { const runtime = createRuntime(false); const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } }); @@ -645,6 +669,36 @@ describe("deliverReplies", () => { ); }); + it("keeps disable_notification on voice fallback text when silent is true", async () => { + const runtime = createRuntime(); + const sendVoice = vi.fn().mockRejectedValue(createVoiceMessagesForbiddenError()); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 5, + chat: { id: "123" }, + }); + const bot = createBot({ sendVoice, sendMessage }); + + mockMediaLoad("note.ogg", "audio/ogg", "voice"); + + await deliverWith({ + replies: [ + { mediaUrl: "https://example.com/note.ogg", text: "Hello there", audioAsVoice: true }, + ], + runtime, + bot, + silent: true, + }); + + expect(sendVoice).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith( + "123", + expect.stringContaining("Hello there"), + expect.objectContaining({ + disable_notification: true, + }), + ); + }); + it("voice fallback applies reply-to only on first chunk when replyToMode is first", async () => { const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({ voiceError: createVoiceMessagesForbiddenError(), diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 627dccb5049..3054b3f2ed2 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1530,6 +1530,8 @@ export const FIELD_HELP: Record = { "Override Node autoSelectFamily for Telegram (true=enable, false=disable).", "channels.telegram.timeoutSeconds": "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", + "channels.telegram.silentErrorReplies": + "When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false.", "channels.telegram.threadBindings.enabled": "Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set.", "channels.telegram.threadBindings.idleHours": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 9541ad3b10a..2e9ebe1189c 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -737,6 +737,7 @@ export const FIELD_LABELS: Record = { "channels.telegram.retry.jitter": "Telegram Retry Jitter", "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", + "channels.telegram.silentErrorReplies": "Telegram Silent Error Replies", "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", "channels.telegram.execApprovals": "Telegram Exec Approvals", "channels.telegram.execApprovals.enabled": "Telegram Exec Approvals Enabled", diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index fe1c5be3962..aa40cec7077 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -188,6 +188,8 @@ export type TelegramAccountConfig = { healthMonitor?: ChannelHealthMonitorConfig; /** Controls whether link previews are shown in outbound messages. Default: true. */ linkPreview?: boolean; + /** Send Telegram bot error replies silently (no notification sound). Default: false. */ + silentErrorReplies?: boolean; /** * Per-channel outbound response prefix override. * diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index da81ef61a4f..e65030d8f38 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -277,6 +277,7 @@ export const TelegramAccountSchemaBase = z heartbeat: ChannelHeartbeatVisibilitySchema, healthMonitor: ChannelHealthMonitorSchema, linkPreview: z.boolean().optional(), + silentErrorReplies: z.boolean().optional(), responsePrefix: z.string().optional(), ackReaction: z.string().optional(), })