Skip to content

Commit 74b9f22

Browse files
fix: add Telegram error suppression controls (#51914) (thanks @chinar-amrutkar)
* feat(telegram): add error policy for suppressing repetitive error messages Introduces per-account error policy configuration that can suppress repetitive error messages (e.g., 429 rate limit, ECONNRESET) to prevent noisy error floods in Telegram channels. Closes #34498 * fix(telegram): track error cooldown per message * fix(telegram): prune expired error cooldowns * fix: add Telegram error suppression controls (#51914) (thanks @chinar-amrutkar) --------- Co-authored-by: chinar-amrutkar <[email protected]> Co-authored-by: Ayaan Zaidi <[email protected]>
1 parent ef28698 commit 74b9f22

7 files changed

Lines changed: 326 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
1313
- Feishu/comments: add a dedicated Drive comment-event flow with comment-thread context resolution, in-thread replies, and `feishu_drive` comment actions for document collaboration workflows. (#58497) thanks @wittam-01.
1414
- Tasks/chat: add `/tasks` as a chat-native background task board for the current session, with recent task details and agent-local fallback counts when no linked tasks are visible. Related #54226. Thanks @vincentkoc.
1515
- Agents/failover: cap prompt-side and assistant-side same-provider auth-profile retries for rate-limit failures before cross-provider model fallback, add the `auth.cooldowns.rateLimitedProfileRotations` knob, and document the new fallback behavior. (#58707) Thanks @Forgely3D
16+
- Telegram/errors: add configurable `errorPolicy` and `errorCooldownMs` controls so Telegram can suppress repeated delivery errors per account, chat, and topic without muting distinct failures. (#51914) Thanks @chinar-amrutkar
1617

1718
### Fixes
1819

extensions/telegram/src/bot-message-context.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ export type TelegramMessageContext = {
6161
groupConfig?: ReturnType<
6262
BuildTelegramMessageContextParams["resolveTelegramGroupConfig"]
6363
>["groupConfig"];
64+
topicConfig?: ReturnType<
65+
BuildTelegramMessageContextParams["resolveTelegramGroupConfig"]
66+
>["topicConfig"];
6467
resolvedThreadId?: number;
6568
threadSpec: ReturnType<typeof resolveTelegramThreadSpec>;
6669
replyThreadId?: number;
@@ -491,6 +494,7 @@ export const buildTelegramMessageContext = async ({
491494
chatId,
492495
isGroup,
493496
groupConfig,
497+
topicConfig,
494498
resolvedThreadId,
495499
threadSpec,
496500
replyThreadId,

extensions/telegram/src/bot-message-dispatch.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js";
3939
import type { TelegramStreamMode } from "./bot/types.js";
4040
import type { TelegramInlineButtons } from "./button-types.js";
4141
import { createTelegramDraftStream } from "./draft-stream.js";
42+
import {
43+
buildTelegramErrorScopeKey,
44+
isSilentErrorPolicy,
45+
resolveTelegramErrorPolicy,
46+
shouldSuppressTelegramError,
47+
} from "./error-policy.js";
4248
import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
4349
import { renderTelegramHtmlText } from "./format.js";
4450
import {
@@ -166,6 +172,7 @@ export const dispatchTelegramMessage = async ({
166172
chatId,
167173
isGroup,
168174
groupConfig,
175+
topicConfig,
169176
threadSpec,
170177
historyKey,
171178
historyLimit,
@@ -726,6 +733,28 @@ export const dispatchTelegramMessage = async ({
726733
}
727734
},
728735
onError: (err, info) => {
736+
const errorPolicy = resolveTelegramErrorPolicy({
737+
accountConfig: telegramCfg,
738+
groupConfig,
739+
topicConfig,
740+
});
741+
if (isSilentErrorPolicy(errorPolicy.policy)) {
742+
return;
743+
}
744+
if (
745+
errorPolicy.policy === "once" &&
746+
shouldSuppressTelegramError({
747+
scopeKey: buildTelegramErrorScopeKey({
748+
accountId: route.accountId,
749+
chatId,
750+
threadId: threadSpec.id,
751+
}),
752+
cooldownMs: errorPolicy.cooldownMs,
753+
errorMessage: String(err),
754+
})
755+
) {
756+
return;
757+
}
729758
deliveryState.markNonSilentFailure();
730759
runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`));
731760
},
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
buildTelegramErrorScopeKey,
4+
resolveTelegramErrorPolicy,
5+
resetTelegramErrorPolicyStoreForTest,
6+
shouldSuppressTelegramError,
7+
} from "./error-policy.js";
8+
9+
describe("telegram error policy", () => {
10+
beforeEach(() => {
11+
vi.useFakeTimers();
12+
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
13+
resetTelegramErrorPolicyStoreForTest();
14+
});
15+
16+
afterEach(() => {
17+
resetTelegramErrorPolicyStoreForTest();
18+
vi.useRealTimers();
19+
});
20+
21+
it("resolves policy and cooldown from the most specific config", () => {
22+
expect(
23+
resolveTelegramErrorPolicy({
24+
accountConfig: { errorPolicy: "once", errorCooldownMs: 1000 },
25+
groupConfig: { errorCooldownMs: 2000 },
26+
topicConfig: { errorPolicy: "silent" },
27+
}),
28+
).toEqual({
29+
policy: "silent",
30+
cooldownMs: 2000,
31+
});
32+
});
33+
34+
it("suppresses only repeated matching errors within the same scope", () => {
35+
const scopeKey = buildTelegramErrorScopeKey({
36+
accountId: "work",
37+
chatId: 42,
38+
threadId: 7,
39+
});
40+
41+
expect(
42+
shouldSuppressTelegramError({
43+
scopeKey,
44+
cooldownMs: 1000,
45+
errorMessage: "429",
46+
}),
47+
).toBe(false);
48+
expect(
49+
shouldSuppressTelegramError({
50+
scopeKey,
51+
cooldownMs: 1000,
52+
errorMessage: "429",
53+
}),
54+
).toBe(true);
55+
expect(
56+
shouldSuppressTelegramError({
57+
scopeKey,
58+
cooldownMs: 1000,
59+
errorMessage: "403",
60+
}),
61+
).toBe(false);
62+
});
63+
64+
it("keeps cooldowns per error message within the same scope", () => {
65+
const scopeKey = buildTelegramErrorScopeKey({
66+
accountId: "work",
67+
chatId: 42,
68+
});
69+
70+
expect(
71+
shouldSuppressTelegramError({
72+
scopeKey,
73+
cooldownMs: 1000,
74+
errorMessage: "A",
75+
}),
76+
).toBe(false);
77+
expect(
78+
shouldSuppressTelegramError({
79+
scopeKey,
80+
cooldownMs: 1000,
81+
errorMessage: "B",
82+
}),
83+
).toBe(false);
84+
expect(
85+
shouldSuppressTelegramError({
86+
scopeKey,
87+
cooldownMs: 1000,
88+
errorMessage: "A",
89+
}),
90+
).toBe(true);
91+
});
92+
93+
it("prunes expired cooldowns within a single scope", () => {
94+
const scopeKey = buildTelegramErrorScopeKey({
95+
accountId: "work",
96+
chatId: 42,
97+
});
98+
99+
expect(
100+
shouldSuppressTelegramError({
101+
scopeKey,
102+
cooldownMs: 1000,
103+
errorMessage: "A",
104+
}),
105+
).toBe(false);
106+
vi.advanceTimersByTime(1001);
107+
expect(
108+
shouldSuppressTelegramError({
109+
scopeKey,
110+
cooldownMs: 1000,
111+
errorMessage: "B",
112+
}),
113+
).toBe(false);
114+
expect(
115+
shouldSuppressTelegramError({
116+
scopeKey,
117+
cooldownMs: 1000,
118+
errorMessage: "A",
119+
}),
120+
).toBe(false);
121+
});
122+
123+
it("does not leak suppression across accounts or threads", () => {
124+
const workMain = buildTelegramErrorScopeKey({
125+
accountId: "work",
126+
chatId: 42,
127+
});
128+
const personalMain = buildTelegramErrorScopeKey({
129+
accountId: "personal",
130+
chatId: 42,
131+
});
132+
const workTopic = buildTelegramErrorScopeKey({
133+
accountId: "work",
134+
chatId: 42,
135+
threadId: 9,
136+
});
137+
138+
expect(
139+
shouldSuppressTelegramError({
140+
scopeKey: workMain,
141+
cooldownMs: 1000,
142+
errorMessage: "429",
143+
}),
144+
).toBe(false);
145+
expect(
146+
shouldSuppressTelegramError({
147+
scopeKey: personalMain,
148+
cooldownMs: 1000,
149+
errorMessage: "429",
150+
}),
151+
).toBe(false);
152+
expect(
153+
shouldSuppressTelegramError({
154+
scopeKey: workTopic,
155+
cooldownMs: 1000,
156+
errorMessage: "429",
157+
}),
158+
).toBe(false);
159+
});
160+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import type {
2+
TelegramAccountConfig,
3+
TelegramDirectConfig,
4+
TelegramGroupConfig,
5+
TelegramTopicConfig,
6+
} from "openclaw/plugin-sdk/config-runtime";
7+
8+
export type TelegramErrorPolicy = "always" | "once" | "silent";
9+
10+
type TelegramErrorConfig =
11+
| TelegramAccountConfig
12+
| TelegramDirectConfig
13+
| TelegramGroupConfig
14+
| TelegramTopicConfig;
15+
16+
const errorCooldownStore = new Map<string, Map<string, number>>();
17+
const DEFAULT_ERROR_COOLDOWN_MS = 14400000;
18+
19+
function pruneExpiredCooldowns(messageStore: Map<string, number>, now: number) {
20+
for (const [message, expiresAt] of messageStore) {
21+
if (expiresAt <= now) {
22+
messageStore.delete(message);
23+
}
24+
}
25+
}
26+
27+
export function resolveTelegramErrorPolicy(params: {
28+
accountConfig?: TelegramAccountConfig;
29+
groupConfig?: TelegramDirectConfig | TelegramGroupConfig;
30+
topicConfig?: TelegramTopicConfig;
31+
}): {
32+
policy: TelegramErrorPolicy;
33+
cooldownMs: number;
34+
} {
35+
const configs: Array<TelegramErrorConfig | undefined> = [
36+
params.accountConfig,
37+
params.groupConfig,
38+
params.topicConfig,
39+
];
40+
let policy: TelegramErrorPolicy = "always";
41+
let cooldownMs = DEFAULT_ERROR_COOLDOWN_MS;
42+
43+
for (const config of configs) {
44+
if (config?.errorPolicy) {
45+
policy = config.errorPolicy;
46+
}
47+
if (typeof config?.errorCooldownMs === "number") {
48+
cooldownMs = config.errorCooldownMs;
49+
}
50+
}
51+
52+
return { policy, cooldownMs };
53+
}
54+
55+
export function buildTelegramErrorScopeKey(params: {
56+
accountId: string;
57+
chatId: string | number;
58+
threadId?: string | number | null;
59+
}): string {
60+
const threadId = params.threadId == null ? "main" : String(params.threadId);
61+
return `${params.accountId}:${String(params.chatId)}:${threadId}`;
62+
}
63+
64+
export function shouldSuppressTelegramError(params: {
65+
scopeKey: string;
66+
cooldownMs: number;
67+
errorMessage?: string;
68+
}): boolean {
69+
const { scopeKey, cooldownMs, errorMessage } = params;
70+
const now = Date.now();
71+
const messageKey = errorMessage ?? "";
72+
const scopeStore = errorCooldownStore.get(scopeKey);
73+
74+
if (scopeStore) {
75+
pruneExpiredCooldowns(scopeStore, now);
76+
if (scopeStore.size === 0) {
77+
errorCooldownStore.delete(scopeKey);
78+
}
79+
}
80+
81+
if (errorCooldownStore.size > 100) {
82+
for (const [scope, messageStore] of errorCooldownStore) {
83+
pruneExpiredCooldowns(messageStore, now);
84+
if (messageStore.size === 0) {
85+
errorCooldownStore.delete(scope);
86+
}
87+
}
88+
}
89+
90+
const expiresAt = scopeStore?.get(messageKey);
91+
if (typeof expiresAt === "number" && expiresAt > now) {
92+
return true;
93+
}
94+
95+
const nextScopeStore = scopeStore ?? new Map<string, number>();
96+
nextScopeStore.set(messageKey, now + cooldownMs);
97+
errorCooldownStore.set(scopeKey, nextScopeStore);
98+
return false;
99+
}
100+
101+
export function isSilentErrorPolicy(policy: TelegramErrorPolicy): boolean {
102+
return policy === "silent";
103+
}
104+
105+
export function resetTelegramErrorPolicyStoreForTest() {
106+
errorCooldownStore.clear();
107+
}

src/config/types.telegram.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,10 @@ export type TelegramAccountConfig = {
203203
linkPreview?: boolean;
204204
/** Send Telegram bot error replies silently (no notification sound). Default: false. */
205205
silentErrorReplies?: boolean;
206+
/** Controls outbound error reporting: always, once per cooldown window, or silent. */
207+
errorPolicy?: "always" | "once" | "silent";
208+
/** Cooldown window for `errorPolicy: "once"` in milliseconds. */
209+
errorCooldownMs?: number;
206210
/**
207211
* Per-channel outbound response prefix override.
208212
*
@@ -238,6 +242,10 @@ export type TelegramTopicConfig = {
238242
disableAudioPreflight?: boolean;
239243
/** Route this topic to a specific agent (overrides group-level and binding routing). */
240244
agentId?: string;
245+
/** Controls outbound error reporting for this topic. */
246+
errorPolicy?: "always" | "once" | "silent";
247+
/** Cooldown window for `errorPolicy: "once"` in milliseconds. */
248+
errorCooldownMs?: number;
241249
};
242250

243251
export type TelegramGroupConfig = {
@@ -259,6 +267,10 @@ export type TelegramGroupConfig = {
259267
systemPrompt?: string;
260268
/** If true, skip automatic voice-note transcription for mention detection in this group. */
261269
disableAudioPreflight?: boolean;
270+
/** Controls outbound error reporting for this group. */
271+
errorPolicy?: "always" | "once" | "silent";
272+
/** Cooldown window for `errorPolicy: "once"` in milliseconds. */
273+
errorCooldownMs?: number;
262274
};
263275

264276
/** Config for LLM-based auto-topic labeling. */
@@ -288,6 +300,10 @@ export type TelegramDirectConfig = {
288300
allowFrom?: Array<string | number>;
289301
/** Optional system prompt snippet for this DM. */
290302
systemPrompt?: string;
303+
/** Controls outbound error reporting for this DM. */
304+
errorPolicy?: "always" | "once" | "silent";
305+
/** Cooldown window for `errorPolicy: "once"` in milliseconds. */
306+
errorCooldownMs?: number;
291307
/** Auto-rename DM forum topics on first message using LLM. Default: true. */
292308
autoTopicLabel?: AutoTopicLabelConfig;
293309
};

0 commit comments

Comments
 (0)