Skip to content

feat(telegram): add forum topic/thread support#366

Open
lutr0 wants to merge 2 commits intospacedriveapp:mainfrom
lutr0:feat/telegram-forum-thread-support
Open

feat(telegram): add forum topic/thread support#366
lutr0 wants to merge 2 commits intospacedriveapp:mainfrom
lutr0:feat/telegram-forum-thread-support

Conversation

@lutr0
Copy link

@lutr0 lutr0 commented Mar 8, 2026

Summary

Telegram supergroups with forum mode enabled use message_thread_id to route messages to specific topics. Without this, all bot replies land in the General topic regardless of where the user sent their message.

Changes

  • Extract thread_id from inbound Telegram messages (including reply chains)
  • Encode thread_id in conversation_id as telegram:<chat>:thread:<id>
  • Pass thread_id through all outbound send paths (text, files, polls, streaming placeholders, ephemeral, scheduled)
  • Store telegram_message_thread_id and telegram_is_topic_message in message metadata for downstream consumption
  • Recover thread_id from conversation_id when metadata is unavailable (e.g. retrigger/branch relay paths)

Behavior

  • Each forum topic gets its own conversation context automatically
  • Replies stay in the correct topic
  • Works for any new topics created in the future (no hardcoded IDs)
  • Private chats and non-forum groups are unaffected (thread_id is None)

Testing

Tested with a Telegram supergroup with forum mode enabled, multiple topics, voice messages, file sends, polls, and streaming responses.

Note

Auto-generated summary: Changes to src/messaging/telegram.rs that add forum topic/thread support for Telegram. The implementation extracts thread IDs from inbound messages (recursively through reply chains), encodes them in conversation IDs for routing, and passes them to all outbound send operations (formatted text, polls, file uploads, streaming). Helper functions handle both direct thread ID extraction and recovery from conversation IDs. All messaging paths (audio, documents, streaming placeholders, ephemeral, scheduled) now preserve thread context. Message metadata now stores the thread ID and topic flag for downstream consumption.

Written by Tembo for commit 21359c6. This will update automatically on new commits.

Telegram supergroups with forum mode enabled use message_thread_id to
route messages to specific topics. Without this, all bot replies land in
the General topic regardless of where the user sent their message.

Changes:
- Extract thread_id from inbound Telegram messages (including reply chains)
- Encode thread_id in conversation_id as telegram:<chat>:thread:<id>
- Pass thread_id through all outbound send paths (text, files, polls,
  streaming placeholders, ephemeral, scheduled)
- Store telegram_message_thread_id and telegram_is_topic_message in
  message metadata for downstream consumption
- Recover thread_id from conversation_id when metadata is unavailable
  (e.g. retrigger/branch relay paths)

This ensures each forum topic gets its own conversation context and
replies stay in the correct topic automatically, including for any
new topics created in the future.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 8, 2026

Walkthrough

Extracts Telegram thread context (ThreadId) from inbound messages and threads it through outbound operations. Conversation ID creation, metadata, and all send paths (text, formatted, files, polls, replies, streaming edits) now accept and propagate an optional ThreadId.

Changes

Cohort / File(s) Summary
Telegram threading & outbound routing
src/messaging/telegram.rs
Added helpers effective_thread_id_for_message, thread_id_from_conversation_id, and extract_thread_id to obtain ThreadId from inbound metadata or conversation_id. Outbound flows (respond, send_plain_text, send_formatted, send_poll, media/file/audio sends, replies, streaming edits, ephemeral/scheduled paths) now accept and propagate Option<ThreadId>. Conversation-id generation includes thread context (telegram:{chat_id}:thread:{thread_id}) when present. Metadata keys telegram_message_thread_id and telegram_is_topic_message are recorded. Function signatures updated to include thread_id: Option<ThreadId> and teloxide ThreadId types are imported and threaded through send paths.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.92% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding forum topic/thread support to Telegram messaging functionality.
Description check ✅ Passed The description is directly related to the changeset, providing clear context about thread ID extraction, encoding, propagation, and testing.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

message
.metadata
.get("telegram_message_thread_id")
.and_then(|v| v.as_i64())
Copy link
Contributor

Choose a reason for hiding this comment

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

v as i32 will silently truncate on overflow. A checked conversion avoids routing replies to the wrong topic if metadata is malformed.

Suggested change
.and_then(|v| v.as_i64())
.and_then(|v| v.as_i64())
.and_then(|v| i32::try_from(v).ok())
.map(|v| ThreadId(MessageId(v)))

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/messaging/telegram.rs (1)

371-433: Thread propagation for file/audio sending looks correct.

Minor style note: tid is used as an abbreviation of thread_id throughout these blocks. Per coding guidelines, consider using the full name for consistency, though the scope is very limited in each usage.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/telegram.rs` around lines 371 - 433, The local pattern variable
`tid` used in the message threading checks should be the full name `thread_id`
for clarity and consistency; replace occurrences like `if let Some(tid) =
thread_id { request = request.message_thread_id(tid); }` with `if let
Some(thread_id) = thread_id { request = request.message_thread_id(thread_id); }`
(do this for both the send_audio and send_document branches where
message_thread_id(...) is set) so the code consistently uses the full identifier
while still passing the value to the message_thread_id(...) call.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/messaging/telegram.rs`:
- Around line 371-433: The local pattern variable `tid` used in the message
threading checks should be the full name `thread_id` for clarity and
consistency; replace occurrences like `if let Some(tid) = thread_id { request =
request.message_thread_id(tid); }` with `if let Some(thread_id) = thread_id {
request = request.message_thread_id(thread_id); }` (do this for both the
send_audio and send_document branches where message_thread_id(...) is set) so
the code consistently uses the full identifier while still passing the value to
the message_thread_id(...) call.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dc941f4a-7d92-4cd7-931f-b9a05a02251f

📥 Commits

Reviewing files that changed from the base of the PR and between 203c9d9 and 21359c6.

📒 Files selected for processing (1)
  • src/messaging/telegram.rs

Comment on lines +59 to +65
fn effective_thread_id_for_message(message: &Message) -> Option<ThreadId> {
message.thread_id.or_else(|| {
message
.reply_to_message()
.and_then(effective_thread_id_for_message)
})
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This recursion is probably fine in practice, but an iterative walk is simpler and avoids any surprises with deeply nested reply_to_message chains.

Suggested change
fn effective_thread_id_for_message(message: &Message) -> Option<ThreadId> {
message.thread_id.or_else(|| {
message
.reply_to_message()
.and_then(effective_thread_id_for_message)
})
}
fn effective_thread_id_for_message(message: &Message) -> Option<ThreadId> {
let mut current = Some(message);
while let Some(message) = current {
if let Some(thread_id) = message.thread_id {
return Some(thread_id);
}
current = message.reply_to_message();
}
None
}

Comment on lines +67 to +73
fn thread_id_from_conversation_id(conversation_id: &str) -> Option<ThreadId> {
let marker = ":thread:";
let idx = conversation_id.rfind(marker)?;
let raw = &conversation_id[idx + marker.len()..];
let parsed = raw.parse::<i32>().ok()?;
Some(ThreadId(MessageId(parsed)))
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Small robustness tweak: parse only the thread ID segment so this keeps working if the conversation id ever grows extra suffix segments.

Suggested change
fn thread_id_from_conversation_id(conversation_id: &str) -> Option<ThreadId> {
let marker = ":thread:";
let idx = conversation_id.rfind(marker)?;
let raw = &conversation_id[idx + marker.len()..];
let parsed = raw.parse::<i32>().ok()?;
Some(ThreadId(MessageId(parsed)))
}
fn thread_id_from_conversation_id(conversation_id: &str) -> Option<ThreadId> {
let marker = ":thread:";
let idx = conversation_id.rfind(marker)?;
let raw = conversation_id[idx + marker.len()..].split(':').next()?;
let parsed = raw.parse::<i32>().ok()?;
Some(ThreadId(MessageId(parsed)))
}

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/messaging/telegram.rs (1)

376-378: Consider using full variable name per coding guidelines.

The abbreviation tid is used throughout for thread_id. While the scope is narrow, the coding guidelines prefer unabbreviated names.

As per coding guidelines: "Don't abbreviate variable names. Use queue not q, message not msg."

♻️ Example change pattern
-if let Some(tid) = thread_id {
-    request = request.message_thread_id(tid);
+if let Some(thread_id) = thread_id {
+    request = request.message_thread_id(thread_id);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/telegram.rs` around lines 376 - 378, Replace the abbreviated
local `tid` with an unabbreviated name to follow naming guidelines: change the
pattern `if let Some(tid) = thread_id { request =
request.message_thread_id(tid); }` to use a full name (e.g., `if let
Some(thread_id_value) = thread_id { request =
request.message_thread_id(thread_id_value); }` or shadow using `if let
Some(thread_id) = thread_id { request = request.message_thread_id(thread_id);
}`) in the same block that calls `request.message_thread_id(...)` in
src/messaging/telegram.rs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/messaging/telegram.rs`:
- Around line 376-378: Replace the abbreviated local `tid` with an unabbreviated
name to follow naming guidelines: change the pattern `if let Some(tid) =
thread_id { request = request.message_thread_id(tid); }` to use a full name
(e.g., `if let Some(thread_id_value) = thread_id { request =
request.message_thread_id(thread_id_value); }` or shadow using `if let
Some(thread_id) = thread_id { request = request.message_thread_id(thread_id);
}`) in the same block that calls `request.message_thread_id(...)` in
src/messaging/telegram.rs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 06a128c0-3ee9-41eb-a001-768a26e7b306

📥 Commits

Reviewing files that changed from the base of the PR and between 21359c6 and 368d684.

📒 Files selected for processing (1)
  • src/messaging/telegram.rs

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.

2 participants