Skip to content

fix(telegram): retry sends without stale thread id#30762

Closed
Sid-Qin wants to merge 1 commit into
openclaw:mainfrom
Sid-Qin:fix/30674-telegram-post-image-outbound-recovery-v2
Closed

fix(telegram): retry sends without stale thread id#30762
Sid-Qin wants to merge 1 commit into
openclaw:mainfrom
Sid-Qin:fix/30674-telegram-post-image-outbound-recovery-v2

Conversation

@Sid-Qin

@Sid-Qin Sid-Qin commented Mar 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • retry Telegram bot outbound sends once without message_thread_id when Telegram returns message thread not found
  • apply the threadless retry in both text sends and media sends (photo/video/audio/voice/document/animation)
  • add regression tests for text and media retry paths in src/telegram/bot/delivery.test.ts

Test plan

  • pnpm exec vitest run src/telegram/bot/delivery.test.ts

Closes #30674.

@aisle-research-bot

aisle-research-bot Bot commented Mar 1, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

We found 1 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Telegram topic/thread fallback can misdeliver replies to parent chat when thread ID is stale

1. 🟡 Telegram topic/thread fallback can misdeliver replies to parent chat when thread ID is stale

Property Value
Severity Medium
CWE CWE-200
Location src/telegram/bot/delivery.ts:76-93

Description

The new withTelegramThreadFallback logic retries Telegram sends without message_thread_id when Telegram returns "message thread not found".

message_thread_id is used in this codebase for:

  • Telegram forum topics (scope: "forum"): routes replies into a specific topic within a group (chatId stays the same; message_thread_id selects the topic).
  • Telegram DM topics (scope: "dm"): routes replies inside a private chat topic.

When the thread/topic ID is stale (deleted topic, wrong stored thread ID, etc.), the new fallback will resend the same content to the parent chat (no topic), which can cause unintended message delivery:

  • In a forum group, dropping message_thread_id posts into the main chat/General context rather than the intended topic, potentially exposing content to a broader audience than intended.

Vulnerable behavior:

// if thread not found -> retry without message_thread_id
return await send(removeMessageThreadIdParam(requestParams));

This is a behavior change from “fail closed” (no message delivered) to “fail open” (deliver to a different location) for forum-topic scoped sends.

Recommendation

Do not automatically fall back to a threadless send for contexts where message_thread_id is a routing/segregation boundary (forum topics).

Safer options:

  1. Only allow this fallback for DM-topic sends (thread.scope === "dm"). For forum topics, rethrow and surface the error.
  2. Alternatively, explicitly route to a safe/admin channel or create a new topic rather than posting to the parent chat.

Example (restrict fallback to DMs):

async function sendTelegramWithThreadFallback<T>(params: {
  operation: string;
  runtime: RuntimeEnv;
  requestParams?: Record<string, unknown>;
  allowThreadlessFallback?: boolean;
  send: (effectiveParams?: Record<string, unknown>) => Promise<T>;
}) {
  try {
    return await params.send(params.requestParams);
  } catch (err) {
    if (!params.allowThreadlessFallback) throw err;
    if (!hasMessageThreadIdParam(params.requestParams) || !isTelegramThreadNotFoundError(err)) throw err;
    return await params.send(removeMessageThreadIdParam(params.requestParams));
  }
}// call site (delivery.ts):// allowThreadlessFallback: thread?.scope === "dm"

This preserves the DM reliability improvement while preventing cross-topic misdelivery in forum/group contexts.


Analyzed PR: #30762 at commit 8cc1eeb

Retry Telegram bot outbound sends once without message_thread_id when Telegram returns "message thread not found", covering both text and media paths. This prevents DM/topic thread metadata mismatches from causing silent post-image delivery failures.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 172b1b3d55

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/telegram/bot/delivery.ts Outdated
Comment on lines +86 to +87
if (!hasMessageThreadIdParam(requestParams) || !isTelegramThreadNotFoundError(err)) {
throw err;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict threadless retry to DM topics only

withTelegramThreadFallback retries without message_thread_id for any thread-not-found error, but deliverReplies is used for forum chats too (resolveTelegramThreadSpec can set scope: "forum"). In a forum supergroup, removing message_thread_id reroutes the send to the General topic, so a stale/deleted topic can cause replies to be posted in the wrong thread instead of failing closed; this is a cross-topic message leak introduced by the new fallback path.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Mar 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a retry mechanism for Telegram bot outbound sends (text and all media types) that automatically drops the stale message_thread_id when Telegram responds with "400: Bad Request: message thread not found". The implementation is clean and well-structured, with two new layered helpers (withTelegramThreadFallback and sendTelegramWithThreadFallback) and regression tests covering text and photo retry paths.

Key findings:

  • False danger-level error logged on every successful retrysendTelegramWithThreadFallback wraps each attempt in withTelegramApiErrorLogging, which logs the "thread not found" error via runtime.error(danger(...)) before withTelegramThreadFallback gets a chance to retry. For text sends the shouldLog guard only excludes parse/empty-text errors, so "thread not found" always passes through; for media sends there is no shouldLog at all. The message ultimately delivers successfully, but each successful retry emits a spurious danger-level error that can trigger false alerts in error monitoring.
  • New tests do not cover the false-error side-effect — neither retry test asserts expect(runtime.error).not.toHaveBeenCalled(), so the spurious log goes undetected.
  • The regex THREAD_NOT_FOUND_RE works correctly for real GrammyError instances because formatErrorMessage extracts err.message, which contains the full "(400: Bad Request: message thread not found)" string.

Confidence Score: 3/5

  • The retry logic is functionally correct and messages are delivered, but the architecture causes false danger-level error logs on every successful thread-fallback retry, which can pollute error monitoring.
  • The core retry logic is sound: the regex matches real GrammyError messages, hasMessageThreadIdParam and removeMessageThreadIdParam are correct, and the retry only triggers when appropriate. However, the nesting of withTelegramApiErrorLogging inside withTelegramThreadFallback's send callback means the "thread not found" error is always logged as a danger before the retry succeeds. This is a real behavioural issue (false error alerts), and the new tests do not detect it, warranting a score below fully safe.
  • Pay close attention to src/telegram/bot/delivery.ts around the sendTelegramWithThreadFallback function, specifically how withTelegramApiErrorLogging and withTelegramThreadFallback are layered.

Last reviewed commit: 8cc1eeb

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +96 to +115
async function sendTelegramWithThreadFallback<T>(params: {
operation: string;
runtime: RuntimeEnv;
requestParams?: Record<string, unknown>;
shouldLog?: (err: unknown) => boolean;
send: (effectiveParams?: Record<string, unknown>) => Promise<T>;
}): Promise<T> {
return await withTelegramThreadFallback({
requestParams: params.requestParams,
runtime: params.runtime,
operation: params.operation,
send: async (effectiveParams) =>
await withTelegramApiErrorLogging({
operation: params.operation,
runtime: params.runtime,
...(params.shouldLog ? { shouldLog: params.shouldLog } : {}),
fn: () => params.send(effectiveParams),
}),
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thread-not-found error is logged as a danger before the retry succeeds

In sendTelegramWithThreadFallback, the send callback wraps every call in withTelegramApiErrorLogging. When the first attempt fails with "message thread not found", withTelegramApiErrorLogging evaluates shouldLog(err) — which returns true for this error in both the HTML path (only excludes parse/empty-text errors) and for media sends (no shouldLog at all, so it defaults to always logging). This causes runtime.error to be called with danger("telegram sendMessage/sendPhoto/… failed: 400: Bad Request: message thread not found") before withTelegramThreadFallback even gets the chance to retry. The message ultimately delivers successfully, but a false danger-level error is emitted every time the retry path is taken.

This can trigger spurious error alerts in monitoring for a condition that is fully handled. Consider excluding the thread-not-found error from logging on the first attempt (e.g., by adding THREAD_NOT_FOUND_RE to the shouldLog guards in the callers, or adjusting the layering so the error is only logged if the retry also fails).

As written, the retry tests also do not assert expect(runtime.error).not.toHaveBeenCalled(), so this false-error behaviour is not caught by the new test suite.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/telegram/bot/delivery.ts
Line: 96-115

Comment:
**Thread-not-found error is logged as a `danger` before the retry succeeds**

In `sendTelegramWithThreadFallback`, the `send` callback wraps every call in `withTelegramApiErrorLogging`. When the first attempt fails with "message thread not found", `withTelegramApiErrorLogging` evaluates `shouldLog(err)` — which returns `true` for this error in both the HTML path (only excludes parse/empty-text errors) and for media sends (no `shouldLog` at all, so it defaults to always logging). This causes `runtime.error` to be called with `danger("telegram sendMessage/sendPhoto/… failed: 400: Bad Request: message thread not found")` before `withTelegramThreadFallback` even gets the chance to retry. The message ultimately delivers successfully, but a false `danger`-level error is emitted every time the retry path is taken.

This can trigger spurious error alerts in monitoring for a condition that is fully handled. Consider excluding the thread-not-found error from logging on the first attempt (e.g., by adding `THREAD_NOT_FOUND_RE` to the `shouldLog` guards in the callers, or adjusting the layering so the error is only logged if the retry also fails).

As written, the retry tests also do not assert `expect(runtime.error).not.toHaveBeenCalled()`, so this false-error behaviour is not caught by the new test suite.

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: telegram Channel integration: telegram size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Telegramm

2 participants