Conversation
…nds, and auto-registration Add 7 new ChannelContent variants (DeleteMessage, Audio, Animation, Sticker, MediaGroup, Poll, PollAnswer) with full send/receive support in the Telegram adapter. New API methods: sendAudio, sendAnimation, sendSticker, sendMediaGroup, sendPoll, deleteMessage, setMyCommands, deleteMyCommands, getMyCommands. Inbound parsing now handles audio files (as Audio, not Voice), animations (checked before video to avoid misclassification), stickers, and poll answers. Bot command menu is auto-registered on start() using built-in defaults (BUILTIN_COMMANDS) when no custom commands are configured, and re-registered on /start or first incoming message via the polling loop. Bridge updated with exhaustive match arms in content_to_text and dispatch_message for all new variants, plus 8 new unit tests.
Agents using Messaging or Automation profiles had no way to send images, files, or other media to channel users because channel_send was only available in the Full profile. This made the agent suggest using the API directly instead of its built-in tools.
The channel awareness section in the system prompt told agents about formatting and character limits but never mentioned channel_send. Agents had the tool available but did not know to use it for sending images or files, proposing raw API calls instead. Add a channel_send hint to the Channel section when the tool is granted, including the recipient ID so the agent can use it immediately. Also add a tool_hint entry so channel_send appears with a description in the Your Tools section.
Add send_channel_poll to KernelHandle trait and kernel implementation. Extend channel_send tool with poll_question, poll_options, poll_is_quiz, poll_correct_option, poll_explanation parameters. Telegram adapter stores poll context (question + options) keyed by poll_id and attaches it to inbound PollAnswer messages so agents see readable answers instead of bare numeric indices.
Reviewer's GuideExtends the Telegram channel adapter and related infrastructure to support richer Telegram features (bot command menu, audio/animation/sticker/media groups/polls, message deletion) and exposes a new channel_send-based poll capability to agents, along with minor refactors and test updates across bridge, runtime, kernel, hands, API, and types crates. Sequence diagram for sending a poll via channel_send toolsequenceDiagram
actor User
participant Agent
participant ToolRunner
participant KernelHandle
participant TelegramAdapter
participant TelegramApi as Telegram_Bot_API
User->>Agent: Ask a question requiring a poll
Agent->>ToolRunner: Call tool_channel_send with poll_question + poll_options
ToolRunner->>ToolRunner: Validate poll_options (>= 2)
ToolRunner->>KernelHandle: send_channel_poll(channel, recipient, question, options, is_quiz, correct_option_id, explanation)
KernelHandle->>TelegramAdapter: send(user, ChannelContent::Poll)
TelegramAdapter->>TelegramAdapter: Build PollParams from ChannelContent::Poll
TelegramAdapter->>TelegramApi: sendPoll(chat_id, question, options, type=regular|quiz, ...)
TelegramApi-->>TelegramAdapter: HTTP response with poll id
TelegramAdapter->>TelegramAdapter: Store PollContext in poll_contexts keyed by poll id
TelegramAdapter-->>KernelHandle: Ok(())
KernelHandle-->>ToolRunner: Ok(())
ToolRunner-->>Agent: "Poll sent to recipient on channel..."
Agent-->>User: Confirms that a poll has been posted
Sequence diagram for handling Telegram poll_answer updatessequenceDiagram
actor TelegramUser
participant TelegramApp as Telegram_Client
participant TelegramApi as Telegram_Bot_API
participant PollLoop as Telegram_polling_loop
participant Bridge as ChannelBridge
participant Agent
TelegramUser->>TelegramApp: Answer poll
TelegramApp->>TelegramApi: submit poll_answer
loop Long polling
PollLoop->>TelegramApi: getUpdates(allowed_updates includes poll_answer)
TelegramApi-->>PollLoop: Update with poll_answer
end
PollLoop->>PollLoop: Extract poll_id, user info, option_ids
PollLoop->>PollLoop: Look up PollContext in poll_contexts by poll_id
PollLoop->>PollLoop: Build ChannelMessage with ChannelContent::PollAnswer
PollLoop->>Bridge: tx.send(ChannelMessage)
Bridge->>Bridge: dispatch_message formats human-readable text
Bridge-->>Agent: Deliver poll answer context
Agent->>Agent: Reason over user choices
Agent-->>TelegramUser: Follow-up message or action based on poll answer
Class diagram for extended Telegram channel types and poll supportclassDiagram
class TelegramAdapter {
-api_base_url: String
-token: SecretString
-commands: Vec~BotCommand~
-poll_contexts: DashMap~String, PollContext~
+with_commands(commands: Vec~BotCommand~) TelegramAdapter
+api_send_audio(chat_id: i64, audio_url: &str, caption: Option~&str~, title: Option~&str~, performer: Option~&str~, thread_id: Option~i64~) Result~(), Error~
+api_send_animation(chat_id: i64, animation_url: &str, caption: Option~&str~, thread_id: Option~i64~) Result~(), Error~
+api_send_sticker(chat_id: i64, file_id: &str, thread_id: Option~i64~) Result~(), Error~
+api_send_media_group(chat_id: i64, items: &[MediaGroupItem], thread_id: Option~i64~) Result~(), Error~
+api_send_poll(params: &PollParams) Result~String, Error~
+api_delete_message(chat_id: i64, message_id: i64) Result~(), Error~
+api_set_my_commands(commands: &[BotCommand]) Result~(), Error~
+api_delete_my_commands() Result~(), Error~
+api_get_my_commands() Vec~BotCommand~
}
class TelegramApiCtx {
-api_base_url: String
-token: SecretString
-client: Client
+get_file_url(file_id: &str) Option~String~
+set_my_commands(commands: &[BotCommand]) async
}
class BotCommand {
+command: String
+description: String
}
class PollContext {
+question: String
+options: Vec~String~
}
class PollParams {
+chat_id: i64
+question: &str
+options: &[String]
+is_quiz: bool
+correct_option_id: Option~u8~
+explanation: Option~&str~
+thread_id: Option~i64~
}
class MediaGroupItem {
<<enum>>
Photo
Video
}
class ChannelContent {
<<enum>>
Text
Image
File
Interactive
ButtonCallback
Voice
Location
DeleteMessage
Audio
Animation
Sticker
MediaGroup
Poll
PollAnswer
}
class ChannelContent_DeleteMessage {
+message_id: String
}
class ChannelContent_Audio {
+url: String
+caption: Option~String~
+duration_seconds: u32
+title: Option~String~
+performer: Option~String~
}
class ChannelContent_Animation {
+url: String
+caption: Option~String~
+duration_seconds: u32
}
class ChannelContent_Sticker {
+file_id: String
}
class ChannelContent_MediaGroup {
+items: Vec~MediaGroupItem~
}
class ChannelContent_Poll {
+question: String
+options: Vec~String~
+is_quiz: bool
+correct_option_id: Option~u8~
+explanation: Option~String~
}
class ChannelContent_PollAnswer {
+poll_id: String
+option_ids: Vec~u8~
}
class LibreFangKernel {
+send_channel_poll(channel: &str, recipient: &str, question: &str, options: &[String], is_quiz: bool, correct_option_id: Option~u8~, explanation: Option~&str~) Result~(), String~
}
class KernelHandle {
<<trait>>
+send_channel_poll(channel: &str, recipient: &str, question: &str, options: &[String], is_quiz: bool, correct_option_id: Option~u8~, explanation: Option~&str~) Result~(), String~
}
TelegramAdapter --> BotCommand : uses
TelegramAdapter --> PollContext : stores
TelegramAdapter --> PollParams : uses
TelegramAdapter --> MediaGroupItem : uses
TelegramApiCtx --> BotCommand : uses
ChannelContent o-- ChannelContent_DeleteMessage
ChannelContent o-- ChannelContent_Audio
ChannelContent o-- ChannelContent_Animation
ChannelContent o-- ChannelContent_Sticker
ChannelContent o-- ChannelContent_MediaGroup
ChannelContent o-- ChannelContent_Poll
ChannelContent o-- ChannelContent_PollAnswer
ChannelContent_MediaGroup --> MediaGroupItem
LibreFangKernel ..|> KernelHandle
LibreFangKernel --> ChannelContent_Poll
LibreFangKernel --> TelegramAdapter
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 3 issues, and left some high level feedback:
- The new
PollContextentries inTelegramAdapterare inserted on every outbound poll but never removed; consider cleaning them up when they are no longer needed (e.g., after receivingpoll_answeror after a timeout) to avoid unbounded growth ofpoll_contexts. - There are now two separate implementations of
setMyCommands(onTelegramApiCtxandTelegramAdapter) that build nearly identical payloads and perform similar HTTP calls; consider refactoring to share a single helper to reduce duplication and keep behavior consistent. - The
channel_sendtool schema and docstring state thatpoll_questionis mutually exclusive withimage_url/file_url/file_path, buttool_channel_senddoes not currently validate or error on such combinations—adding an explicit check would prevent confusing mixed invocations.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The new `PollContext` entries in `TelegramAdapter` are inserted on every outbound poll but never removed; consider cleaning them up when they are no longer needed (e.g., after receiving `poll_answer` or after a timeout) to avoid unbounded growth of `poll_contexts`.
- There are now two separate implementations of `setMyCommands` (on `TelegramApiCtx` and `TelegramAdapter`) that build nearly identical payloads and perform similar HTTP calls; consider refactoring to share a single helper to reduce duplication and keep behavior consistent.
- The `channel_send` tool schema and docstring state that `poll_question` is mutually exclusive with `image_url`/`file_url`/`file_path`, but `tool_channel_send` does not currently validate or error on such combinations—adding an explicit check would prevent confusing mixed invocations.
## Individual Comments
### Comment 1
<location path="crates/librefang-channels/src/telegram.rs" line_range="727-736" />
<code_context>
+ self.token.as_str()
+ );
+ let body = serde_json::json!({});
+ let resp = self.client.post(&url).json(&body).send().await?;
+ if !resp.status().is_success() {
+ let body_text = resp.text().await.unwrap_or_default();
+ warn!("Telegram deleteMyCommands failed: {body_text}");
+ }
+ Ok(())
+ }
+
+ /// Call `getMyCommands` and return the currently registered bot commands.
+ ///
+ /// Returns an empty vec on failure (best-effort).
+ pub async fn api_get_my_commands(&self) -> Vec<BotCommand> {
+ let url = format!(
+ "{}/bot{}/getMyCommands",
+ self.api_base_url,
+ self.token.as_str()
+ );
+ let resp = match self
+ .client
+ .post(&url)
+ .json(&serde_json::json!({}))
+ .send()
+ .await
+ {
</code_context>
<issue_to_address>
**issue (bug_risk):** Non-429 sendMediaGroup failures are logged but treated as success, which can silently drop albums.
In `api_send_media_group`, non-429, non-success responses only emit a warning but still return `Ok(())`, so callers will assume the media group was sent even on 4xx/5xx responses. This can silently drop albums. Please return an error (or otherwise surface failure, e.g., via `Result`/bool) so higher layers can retry, notify the user, or avoid reporting success. The behavior should be consistent with the retry path, which already returns an error on repeated failure.
</issue_to_address>
### Comment 2
<location path="crates/librefang-channels/src/telegram.rs" line_range="764-767" />
<code_context>
+ params: &PollParams<'_>,
+ ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
+ let url = format!("{}/bot{}/sendPoll", self.api_base_url, self.token.as_str());
+ let option_values: Vec<serde_json::Value> = params
+ .options
+ .iter()
+ .map(|o| serde_json::json!({"text": o}))
+ .collect();
+
</code_context>
<issue_to_address>
**issue (bug_risk):** sendPoll builds options as objects with a `text` field, which does not match Telegram's expected API schema.
In `api_send_poll`, `options` is serialized as an array of `{ "text": <option> }` objects, but the Telegram Bot API `sendPoll` method expects an array of strings (e.g., `["A", "B"]`). This mismatch will likely trigger 400 errors. Instead of wrapping each option in `{text: ...}`, pass `params.options` directly (e.g., via `serde_json::json!(params.options)` or as a `Vec<String>`).
</issue_to_address>
### Comment 3
<location path="crates/librefang-runtime/src/tool_runner.rs" line_range="2865-2874" />
<code_context>
+ if let Some(poll_question) = input.get("poll_question").and_then(|v| v.as_str()) {
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Poll handling in channel_send does not enforce mutual exclusivity with other media fields, despite the schema description.
The implementation only checks `poll_options` length and proceeds, so when `poll_question` is set alongside fields like `image_url`, the image is silently ignored while the call still succeeds, which conflicts with the documented mutual exclusivity. Please either add validation that rejects requests combining `poll_question` with other media/file fields, or update the documentation to clarify that non-poll fields are ignored when sending polls.
Suggested implementation:
```rust
"poll_options": { "type": "array", "items": { "type": "string" }, "description": "Answer options for the poll (2-10 items, required with poll_question). When poll_question is set, any other media/file fields in the request are ignored." },
```
To fully align the documentation with the runtime behavior, similar clarifications should be added to:
1. The `poll_question` field description in the same schema object, stating that it is not mutually exclusive in validation and that other media/file fields are ignored when it is provided.
2. Any higher-level API or user-facing documentation that currently describes `poll_question` as mutually exclusive with other media/file fields, to reflect that such fields are accepted but ignored when sending polls.
If you instead prefer enforcing strict mutual exclusivity, the `if let Some(poll_question)` block should be updated to detect the presence of other media/file keys in `input` and return the appropriate validation error type used elsewhere in `channel_send` when conflicting fields are detected.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| let resp = self.client.post(&url).json(&body).send().await?; | ||
| if !resp.status().is_success() { | ||
| let status = resp.status(); | ||
| let body_text = resp.text().await.unwrap_or_default(); | ||
|
|
||
| if status.as_u16() == 429 { | ||
| let retry_after = extract_retry_after(&body_text, RETRY_AFTER_DEFAULT_SECS); | ||
| warn!("Telegram sendMediaGroup rate limited, retrying after {retry_after}s"); | ||
| tokio::time::sleep(Duration::from_secs(retry_after)).await; | ||
|
|
There was a problem hiding this comment.
issue (bug_risk): Non-429 sendMediaGroup failures are logged but treated as success, which can silently drop albums.
In api_send_media_group, non-429, non-success responses only emit a warning but still return Ok(()), so callers will assume the media group was sent even on 4xx/5xx responses. This can silently drop albums. Please return an error (or otherwise surface failure, e.g., via Result/bool) so higher layers can retry, notify the user, or avoid reporting success. The behavior should be consistent with the retry path, which already returns an error on repeated failure.
| let option_values: Vec<serde_json::Value> = params | ||
| .options | ||
| .iter() | ||
| .map(|o| serde_json::json!({"text": o})) |
There was a problem hiding this comment.
issue (bug_risk): sendPoll builds options as objects with a text field, which does not match Telegram's expected API schema.
In api_send_poll, options is serialized as an array of { "text": <option> } objects, but the Telegram Bot API sendPoll method expects an array of strings (e.g., ["A", "B"]). This mismatch will likely trigger 400 errors. Instead of wrapping each option in {text: ...}, pass params.options directly (e.g., via serde_json::json!(params.options) or as a Vec<String>).
| if let Some(poll_question) = input.get("poll_question").and_then(|v| v.as_str()) { | ||
| let poll_options: Vec<String> = input | ||
| .get("poll_options") | ||
| .and_then(|v| v.as_array()) | ||
| .map(|arr| { | ||
| arr.iter() | ||
| .filter_map(|v| v.as_str().map(|s| s.to_string())) | ||
| .collect() | ||
| }) | ||
| .unwrap_or_default(); |
There was a problem hiding this comment.
suggestion (bug_risk): Poll handling in channel_send does not enforce mutual exclusivity with other media fields, despite the schema description.
The implementation only checks poll_options length and proceeds, so when poll_question is set alongside fields like image_url, the image is silently ignored while the call still succeeds, which conflicts with the documented mutual exclusivity. Please either add validation that rejects requests combining poll_question with other media/file fields, or update the documentation to clarify that non-poll fields are ignored when sending polls.
Suggested implementation:
"poll_options": { "type": "array", "items": { "type": "string" }, "description": "Answer options for the poll (2-10 items, required with poll_question). When poll_question is set, any other media/file fields in the request are ignored." },To fully align the documentation with the runtime behavior, similar clarifications should be added to:
- The
poll_questionfield description in the same schema object, stating that it is not mutually exclusive in validation and that other media/file fields are ignored when it is provided. - Any higher-level API or user-facing documentation that currently describes
poll_questionas mutually exclusive with other media/file fields, to reflect that such fields are accepted but ignored when sending polls.
If you instead prefer enforcing strict mutual exclusivity, theif let Some(poll_question)block should be updated to detect the presence of other media/file keys ininputand return the appropriate validation error type used elsewhere inchannel_sendwhen conflicting fields are detected.
There was a problem hiding this comment.
Code Review
This pull request significantly expands the communication channel capabilities, primarily focusing on the Telegram adapter. It introduces support for rich media types such as audio, animations, stickers, media groups, and polls, along with message deletion and bot command menu management. The channel_send tool and system prompt generation have been updated to enable agents to utilize these new features. Review feedback highlights critical error-handling issues in the Telegram adapter where API failures for media groups and polls were being logged but not propagated as errors to the caller.
| return Ok(()); | ||
| } | ||
|
|
||
| warn!("Telegram sendMediaGroup failed ({status}): {body_text}"); |
There was a problem hiding this comment.
The function logs a warning but returns Ok(()) when the Telegram API call fails (and it's not a 429 rate limit). This will lead the caller to believe the media group was successfully sent when it wasn't. It should return an error instead.
| warn!("Telegram sendMediaGroup failed ({status}): {body_text}"); | |
| return Err(format!("Telegram sendMediaGroup failed ({status}): {body_text}").into()); |
| warn!("Telegram sendPoll failed ({status}): {body_text}"); | ||
| return Ok(String::new()); |
There was a problem hiding this comment.
Similar to api_send_media_group, this function logs a warning but returns Ok(String::new()) on failure. This swallows the error and prevents the caller from knowing that the poll failed to send. It should return an error.
| warn!("Telegram sendPoll failed ({status}): {body_text}"); | |
| return Ok(String::new()); | |
| warn!("Telegram sendPoll failed ({status}): {body_text}"); | |
| return Err(format!("Telegram sendPoll failed ({status}): {body_text}").into()); |
Type
Summary
Changes
Attribution
Co-authored-by, commit preservation, or explicit credit in the PR body)Testing
cargo clippy --workspace --all-targets -- -D warningspassescargo test --workspacepassesSecurity
Summary by Sourcery
Extend Telegram channel capabilities, rich media handling, and polling support across the stack, while wiring a new channel_send-based poll API into the runtime and updating tool profiles accordingly.
New Features:
Enhancements:
Tests: