feat(telegram): add configurable silent error replies (#19776)

Port and complete #19776 on top of the current Telegram extension layout.

Adds a default-off `channels.telegram.silentErrorReplies` setting. When enabled, Telegram bot replies marked as errors are delivered silently across the regular bot reply flow, native/slash command replies, and fallback sends.

Thanks @auspic7 

Co-authored-by: Myeongwon Choi <36367286+auspic7@users.noreply.github.com>
Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>
This commit is contained in:
Myeongwon Choi 2026-03-16 20:18:34 +09:00 committed by GitHub
parent fdfa98cda8
commit 6a8f5bc12f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 211 additions and 2 deletions

View File

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

View File

@ -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" },

View File

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

View File

@ -187,18 +187,20 @@ function registerAndResolveStatusHandler(params: {
cfg: OpenClawConfig;
allowFrom?: string[];
groupAllowFrom?: string[];
telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"];
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
}): {
handler: TelegramCommandHandler;
sendMessage: ReturnType<typeof vi.fn>;
} {
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<string, TelegramCommandHandler>();
@ -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(

View File

@ -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<string, (ctx: unknown) => Promise<void>>();
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<void>) => {
commandHandlers.set(name, cb);
}),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[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 })],
}),
);
});
});

View File

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

View File

@ -103,6 +103,7 @@ async function deliverTextReply(params: {
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
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<typeof buildInlineKeyboard>;
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<typeof buildInlineKeyboard>;
replyQuoteText?: string;
}): Promise<number | undefined> {
@ -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> | void;
linkPreview?: boolean;
silent?: boolean;
replyQuoteText?: string;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
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> | 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,

View File

@ -76,6 +76,7 @@ export async function sendTelegramWithThreadFallback<T>(params: {
export function buildTelegramSendParams(opts?: {
replyToMessageId?: number;
thread?: TelegramThreadSpec | null;
silent?: boolean;
}): Record<string, unknown> {
const threadParams = buildTelegramThreadParams(opts?.thread);
const params: Record<string, unknown> = {};
@ -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<typeof buildInlineKeyboard>;
},
): Promise<number> {
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;

View File

@ -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(),

View File

@ -1530,6 +1530,8 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@ -737,6 +737,7 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

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

View File

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