Skip to content

fix(channels): route Telegram replies to forum topics - #7612

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
hogeheer499-commits:agent/telegram-topic-replies-7609
Jul 23, 2026
Merged

fix(channels): route Telegram replies to forum topics#7612
wenshao merged 1 commit into
QwenLM:mainfrom
hogeheer499-commits:agent/telegram-topic-replies-7609

Conversation

@hogeheer499-commits

Copy link
Copy Markdown
Contributor

What this PR does

Telegram replies now return to the forum topic that initiated the request. This covers immediate command replies and regular agent output while keeping concurrent topics isolated from each other.

Why it's needed

In Telegram supergroups with Topics enabled, inbound messages already carry their topic ID, but regular outbound replies dropped that context and appeared in #general. Keeping the route scoped to the active inbound request also avoids one topic overwriting another while multiple requests run in the same chat.

Reviewer Test Plan

How to verify

Configure a Telegram supergroup with Topics enabled and send /start or a normal prompt from a non-general topic. The response should appear in the same topic. Requests started in different topics should keep their own routes, and a message from #general should not inherit a previous topic.

Evidence (Before & After)

Before: the focused reproduction extracted topic 42 from the inbound message, but the outbound Telegram API call contained only parse_mode and omitted message_thread_id.

After: all 17 Telegram adapter tests pass. The regression coverage verifies command replies in topic 42, agent replies using the stored session target, a newer inbound topic taking precedence over a stale session route, and #general remaining unthreaded.

Tested on

OS Status
🍏 macOS ⚠️ not tested
🪟 Windows ⚠️ not tested
🐧 Linux ✅ tested

Environment (optional)

Node.js 22.22.1 with the repository lockfile dependencies. Verified with the Telegram adapter unit suite, package ESLint and TypeScript build, plus the repository build and typecheck.

Risk & Scope

  • Main risk or tradeoff: topic routing relies on Node's async context for immediate inbound replies and falls back to the validated session target for delivery outside that context.
  • Not validated / out of scope: end-to-end delivery through a live Telegram bot.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #7609

中文说明

本 PR 的修改

Telegram 回复现在会返回到发起请求的论坛话题中。该修复覆盖即时命令回复和常规代理输出,同时确保并发话题之间的路由相互隔离。

修改原因

在启用 Topics 的 Telegram 超级群组中,入站消息已经包含话题 ID,但常规出站回复会丢失该上下文并出现在 #general 中。将路由限制在当前入站请求的异步上下文中,也可以避免同一聊天内多个请求并发运行时,一个话题覆盖另一个话题。

审阅者测试计划

验证方法

配置一个启用 Topics 的 Telegram 超级群组,并在非 general 话题中发送 /start 或普通提示。回复应出现在同一话题中。从不同话题启动的请求应各自保留路由,而来自 #general 的消息不应继承之前的话题。

修改前后证据

修改前:针对性复现从入站消息中提取了话题 42,但出站 Telegram API 调用只包含 parse_mode,缺少 message_thread_id

修改后:全部 17 个 Telegram 适配器测试通过。回归测试覆盖话题 42 中的命令回复、使用已存储会话目标的代理回复、较新的入站话题优先于过期的会话路由,以及 #general 保持非话题路由。

测试环境

操作系统 状态
macOS ⚠️ 未测试
Windows ⚠️ 未测试
Linux ✅ 已测试

Node.js 22.22.1,使用仓库锁文件依赖。已通过 Telegram 适配器单元测试、包级 ESLint 和 TypeScript 构建,以及仓库构建和类型检查进行验证。

风险与范围

  • 主要风险或权衡:即时入站回复的话题路由依赖 Node 异步上下文;在该上下文之外发送时,会回退到经过验证的会话目标。
  • 未验证 / 范围外:通过真实 Telegram 机器人的端到端消息传递。
  • 破坏性变更 / 迁移说明:无。

关联 Issue

Fixes #7609

@hogeheer499-commits
hogeheer499-commits marked this pull request as ready for review July 23, 2026 15:35
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed bug with a confirmed root cause. Issue #7609 documents clear reproduction steps (create a Telegram supergroup with Topics, send from a non-general topic, reply lands in #general). Two collaborators independently confirmed the root cause: sendMessage() drops threadId while the plumbing (buildEnvelope, sendTelegramMessage, pushProactive) already supports it.

Direction: aligned — Telegram forum topic routing is a straightforward bug fix for an existing integration. The issue is labeled welcome-pr.

Size: not applicable (no core module paths touched). 32 production lines + 106 test lines across 2 files.

Approach: the scope feels right. AsyncLocalStorage for the immediate inbound context plus a session-target fallback for async delivery is a clean two-path solution that avoids changing the abstract sendMessage(chatId, text) signature in ChannelBase. The sendResponseMessage override pattern has precedent in DingtalkAdapter. Every edit in the diff serves the stated goal — no drive-by changes.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的 bug,根因已确认。Issue #7609 记录了清晰的复现步骤(创建启用 Topics 的 Telegram 超级群组,从非 general 话题发送消息,回复出现在 #general)。两位协作者独立确认了根因:sendMessage() 丢失了 threadId,而基础设施(buildEnvelopesendTelegramMessagepushProactive)已经支持它。

方向:对齐——Telegram 论坛话题路由是现有集成的直接 bug 修复。Issue 已标记 welcome-pr

规模:不适用(未触及核心模块路径)。2 个文件,32 行生产代码 + 106 行测试代码。

方案:范围合理。AsyncLocalStorage 用于即时入站上下文,加上会话目标回退用于异步投递,是一个干净的双路径方案,避免了修改 ChannelBase 中的抽象 sendMessage(chatId, text) 签名。sendResponseMessage 覆写模式在 DingtalkAdapter 中有先例。diff 中的每个编辑都服务于既定目标——没有顺手改动。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at bb915afa655bca07f2a2ff7737bad226d25a60fc · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: I would have maintained a chatId → threadId map updated from inbound envelopes and looked up in sendMessage — the direction suggested in the issue triage. For agent responses arriving outside the inbound context, I'd override sendResponseMessage to pull threadId from the session target.

The PR's approach combines both paths and is better than either alone: AsyncLocalStorage wraps the entire handleInbound call so any synchronous reply (commands like /start, error messages) picks up the thread from the async context, while the sendResponseMessage override falls back to the validated session target for async agent delivery. This naturally handles concurrency — two topics in the same chat get isolated stores — without a shared mutable map that could race.

No critical blockers. The implementation is clean:

  • AsyncLocalStorage is the right primitive for per-request context in Node.js — no shared state, no race conditions.
  • The sendResponseMessage fallback validates target.channelName === this.name && target.chatId === chatId before using target.threadId, preventing cross-channel or cross-chat thread leaks.
  • The sendTelegramMessage private method already handled threadIdmessage_thread_id conversion, so no changes needed downstream.
  • Test coverage is thorough: command replies in a topic, agent responses via session target, inbound topic taking precedence over a stale session route, and #general remaining unthreaded.

Typecheck and ESLint pass cleanly on both changed files.

Real-Scenario Testing

This is a Telegram channel adapter — it runs as a bot server, not a CLI prompt, so live end-to-end testing requires a Telegram supergroup with Topics enabled (not available in CI). The unit test suite is the meaningful verification here: it exercises the exact code paths that were broken.

Before (main branch — 14 tests, no topic routing coverage)

 RUN  v3.2.4 /home/github-runner/actions-runner-4/_work/qwen-code/qwen-code/packages/channels/telegram

 ✓ src/TelegramAdapter.test.ts (14 tests) 21ms

 Test Files  1 passed (1)
      Tests  14 passed (14)
   Start at  23:41:17
   Duration  481ms (transform 156ms, setup 0ms, collect 263ms, tests 21ms, environment 0ms, prepare 65ms)

After (this PR — 17 tests, topic routing verified)

 RUN  v3.2.4 /home/github-runner/actions-runner-4/_work/qwen-code/qwen-code/.qwen/worktrees/triage/packages/channels/telegram

 ✓ src/TelegramAdapter.test.ts (17 tests) 24ms

 Test Files  1 passed (1)
      Tests  17 passed (17)
   Start at  23:41:38
   Duration  492ms (transform 167ms, setup 0ms, collect 270ms, tests 24ms, environment 0ms, prepare 70ms)

The 3 new tests cover: command replies routed to topic 42, agent responses using the stored session target, and a newer inbound topic (43) taking precedence over a stale session route (42) while #general remains unthreaded.

中文说明

代码审查

独立方案: 我会维护一个 chatId → threadId 映射,从入站 envelope 更新,在 sendMessage 中查找——即 issue 分诊中建议的方向。对于在入站上下文之外到达的代理响应,我会覆写 sendResponseMessage 从会话目标获取 threadId

PR 的方案 结合了两种路径,比单独任何一种都更好:AsyncLocalStorage 包裹整个 handleInbound 调用,使任何同步回复(如 /start 命令、错误消息)都能从异步上下文获取线程,而 sendResponseMessage 覆写则回退到经过验证的会话目标用于异步代理投递。这自然地处理了并发性——同一聊天中的两个话题获得隔离的存储——而无需可能竞争的共享可变映射。

无关键阻塞问题。实现干净:

  • AsyncLocalStorage 是 Node.js 中每请求上下文的正确原语——无共享状态,无竞态条件。
  • sendResponseMessage 回退在使用 target.threadId 前验证 target.channelName === this.name && target.chatId === chatId,防止跨频道或跨聊天的线程泄漏。
  • sendTelegramMessage 私有方法已经处理了 threadIdmessage_thread_id 转换,下游无需修改。
  • 测试覆盖全面:话题中的命令回复、通过会话目标的代理响应、入站话题优先于过期会话路由、#general 保持非话题路由。

Typecheck 和 ESLint 在两个修改文件上均通过。

真实场景测试

这是 Telegram 频道适配器——作为机器人服务器运行,不是 CLI 提示,因此实时端到端测试需要启用 Topics 的 Telegram 超级群组(CI 中不可用)。单元测试套件是有意义的验证:它执行了被破坏的确切代码路径。

3 个新测试覆盖:话题 42 中的命令回复、使用已存储会话目标的代理响应、较新的入站话题(43)优先于过期的会话路由(42),同时 #general 保持非话题路由。

Qwen Code · qwen3.8-max-preview

Reviewed at bb915afa655bca07f2a2ff7737bad226d25a60fc · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean fix for a confirmed bug, well-tested, would merge without hesitation.

The PR takes the two suggested fix directions from the issue thread and combines them into something better than either alone. AsyncLocalStorage gives concurrency-safe topic routing for the synchronous path (commands, error replies) without a shared mutable map, and the sendResponseMessage override handles the async agent delivery path with proper target validation. 32 production lines, no changes to the base contract, three focused regression tests. Every line in the diff earns its place.

The before/after is clear: main has 14 tests with no topic coverage, the PR adds 3 tests that verify the exact scenario from the bug report (reply to topic 42 lands in topic 42, not #general). All 17 pass, typecheck and lint are clean.

If I had to maintain this in six months, I'd thank the author — the AsyncLocalStorage pattern is self-documenting, the fallback logic is explicit about what it validates, and the tests read like a spec.

中文说明

置信度:5/5 — 已确认 bug 的干净修复,测试充分,毫不犹豫地合并。

PR 将 issue 讨论中两个建议的修复方向结合成比单独任何一个都更好的方案。AsyncLocalStorage 为同步路径(命令、错误回复)提供并发安全的话题路由,无需共享可变映射;sendResponseMessage 覆写处理异步代理投递路径,带有正确的目标验证。32 行生产代码,不修改基础契约,3 个聚焦的回归测试。diff 中的每一行都物有所值。

修改前后对比清晰:main 有 14 个测试无话题覆盖,PR 新增 3 个测试验证了 bug 报告中的确切场景(回复话题 42 应出现在话题 42,而非 #general)。全部 17 个通过,typecheck 和 lint 干净。

如果六个月后我要维护这段代码,我会感谢作者——AsyncLocalStorage 模式是自文档化的,回退逻辑明确说明了它验证什么,测试读起来像规格说明。

Qwen Code · qwen3.8-max-preview

Reviewed at bb915afa655bca07f2a2ff7737bad226d25a60fc · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@wenshao
wenshao added this pull request to the merge queue Jul 23, 2026
Merged via the queue into QwenLM:main with commit 10b5ecb Jul 23, 2026
76 checks passed

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed — no blockers. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

Comment on lines +400 to +404
expect(router.getTarget).toHaveBeenCalledWith('session-1');
expect(bot.api.sendMessage).toHaveBeenCalledWith('2', expect.any(String), {
parse_mode: 'HTML',
message_thread_id: 42,
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The sendResponseMessage override's router-target mismatch branch is untested — no test exercises the path where inboundRoute is undefined and the router target's chatId differs from the argument chatId.

The target.chatId === chatId guard prevents routing a response to a stale topic in a different chat. If a future edit accidentally removed this guard, messages could be sent with a message_thread_id belonging to a different chat — and no test would catch the regression.

Concrete cost: A guard that silently regresses would route messages to wrong forum topics in production with no test signal.

Consider adding a test case where router.getTarget returns a target with a mismatched chatId:

it('omits thread routing when the router target chat does not match', async () => {
  const router = {
    getTarget: vi.fn().mockReturnValue({
      channelName: 'telegram',
      senderId: 'user-1',
      chatId: '3',   // different from the chatId we send to
      threadId: '42',
    }),
  };
  const channel = createChannel({}, router);
  const bot = installFakeBot(channel);

  await channel.sendTestResponse('2', 'mismatched chat', 'session-1');

  expect(bot.api.sendMessage).toHaveBeenCalledWith('2', expect.any(String), {
    parse_mode: 'HTML',
    // no message_thread_id — guard prevented cross-chat routing
  });
});

— qwen3.7-max via Qwen Code /review

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(channels): Telegram replies always go to #general instead of the topic thread

4 participants