Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/config/load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1902,6 +1902,7 @@ impl Config {
enabled: instance.enabled && token.is_some(),
token: token.unwrap_or_default(),
dm_allowed_users: instance.dm_allowed_users,
native_streaming: instance.native_streaming,
}
})
.collect::<Vec<_>>();
Expand All @@ -1919,6 +1920,7 @@ impl Config {
token: token.unwrap_or_default(),
instances,
dm_allowed_users: t.dm_allowed_users,
native_streaming: t.native_streaming,
})
}),
email: toml.messaging.email.and_then(|email| {
Expand Down
12 changes: 11 additions & 1 deletion src/config/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,25 +201,34 @@ pub struct TelegramPermissions {
pub chat_filter: Option<Vec<i64>>,
/// User IDs allowed in private chats.
pub dm_allowed_users: Vec<i64>,
/// Whether to use native streaming via sendMessageDraft API
pub native_streaming: bool,
}

impl TelegramPermissions {
/// Build from the current config's telegram settings and bindings.
pub fn from_config(telegram: &TelegramConfig, bindings: &[Binding]) -> Self {
Self::from_bindings_for_adapter(telegram.dm_allowed_users.clone(), bindings, None)
Self::from_bindings_for_adapter(
telegram.dm_allowed_users.clone(),
telegram.native_streaming,
bindings,
None,
)
}

/// Build permissions for a named Telegram adapter instance.
pub fn from_instance_config(instance: &TelegramInstanceConfig, bindings: &[Binding]) -> Self {
Self::from_bindings_for_adapter(
instance.dm_allowed_users.clone(),
instance.native_streaming,
bindings,
Some(instance.name.as_str()),
)
}

fn from_bindings_for_adapter(
seed_dm_allowed_users: Vec<String>,
native_streaming: bool,
bindings: &[Binding],
adapter_selector: Option<&str>,
) -> Self {
Expand Down Expand Up @@ -261,6 +270,7 @@ impl TelegramPermissions {
Self {
chat_filter,
dm_allowed_users,
native_streaming,
}
}
}
Expand Down
8 changes: 8 additions & 0 deletions src/config/toml_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,8 @@ pub(super) struct TomlTelegramConfig {
pub(super) instances: Vec<TomlTelegramInstanceConfig>,
#[serde(default)]
pub(super) dm_allowed_users: Vec<String>,
#[serde(default = "default_native_streaming")]
pub(super) native_streaming: bool,
}

#[derive(Deserialize)]
Expand All @@ -576,6 +578,8 @@ pub(super) struct TomlTelegramInstanceConfig {
pub(super) token: Option<String>,
#[serde(default)]
pub(super) dm_allowed_users: Vec<String>,
#[serde(default = "default_native_streaming")]
pub(super) native_streaming: bool,
}

#[derive(Deserialize)]
Expand Down Expand Up @@ -726,6 +730,10 @@ pub(super) fn default_email_max_attachment_bytes() -> usize {
10 * 1024 * 1024
}

pub(super) fn default_native_streaming() -> bool {
true
}

#[derive(Deserialize)]
pub(super) struct TomlBinding {
pub(super) agent_id: String,
Expand Down
7 changes: 7 additions & 0 deletions src/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2110,6 +2110,9 @@ impl SystemSecrets for SlackConfig {

#[derive(Clone)]
pub struct TelegramConfig {
/// Whether to use native streaming via sendMessageDraft API (Bot API 9.5+)
/// Only works for private chats. Defaults to true.
pub native_streaming: bool,
pub enabled: bool,
pub token: String,
/// Additional named Telegram bot instances for this platform.
Expand All @@ -2125,6 +2128,8 @@ pub struct TelegramInstanceConfig {
pub token: String,
/// User IDs allowed to DM this bot instance.
pub dm_allowed_users: Vec<String>,
/// Whether to use native streaming via sendMessageDraft API
pub native_streaming: bool,
}

impl std::fmt::Debug for TelegramInstanceConfig {
Expand All @@ -2134,6 +2139,7 @@ impl std::fmt::Debug for TelegramInstanceConfig {
.field("enabled", &self.enabled)
.field("token", &"[REDACTED]")
.field("dm_allowed_users", &self.dm_allowed_users)
.field("native_streaming", &self.native_streaming)
.finish()
}
}
Expand All @@ -2145,6 +2151,7 @@ impl std::fmt::Debug for TelegramConfig {
.field("token", &"[REDACTED]")
.field("instances", &self.instances)
.field("dm_allowed_users", &self.dm_allowed_users)
.field("native_streaming", &self.native_streaming)
.finish()
}
}
Expand Down
180 changes: 178 additions & 2 deletions src/messaging/telegram.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use teloxide::types::{
ParseMode, ReactionType, ReplyParameters, UpdateKind, UserId,
};
use teloxide::{ApiError, Bot, RequestError};
use reqwest::Client;

use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, LazyLock};
Expand All @@ -38,13 +39,21 @@ pub struct TelegramAdapter {
typing_tasks: Arc<RwLock<HashMap<String, JoinHandle<()>>>>,
/// Shutdown signal for the polling loop.
shutdown_tx: Arc<RwLock<Option<mpsc::Sender<()>>>>,
/// HTTP client for raw Telegram API calls (sendMessageDraft).
http_client: Client,
/// Bot token for raw API calls.
token: String,
}

/// Tracks an in-progress streaming message edit.
struct ActiveStream {
chat_id: ChatId,
message_id: MessageId,
last_edit: Instant,
/// Draft ID for native streaming (sendMessageDraft API).
draft_id: Option<i32>,
/// Whether this is a private chat (draft API only works for private chats).
is_private: bool,
}

/// Telegram's per-message character limit.
Expand All @@ -56,6 +65,9 @@ const FORMATTED_SPLIT_LENGTH: usize = MAX_MESSAGE_LENGTH / 2;
/// Minimum interval between streaming edits to avoid rate limits.
const STREAM_EDIT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(1000);

/// Minimum interval between draft updates (100-200ms recommended by Telegram).
const DRAFT_UPDATE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(150);

impl TelegramAdapter {
pub fn new(
runtime_key: impl Into<String>,
Expand All @@ -74,9 +86,96 @@ impl TelegramAdapter {
active_messages: Arc::new(RwLock::new(HashMap::new())),
typing_tasks: Arc::new(RwLock::new(HashMap::new())),
shutdown_tx: Arc::new(RwLock::new(None)),
http_client: Client::new(),
token,
}
}

/// Send a message draft using Telegram's native streaming API (Bot API 9.5+).
///
/// This method uses the `sendMessageDraft` endpoint which provides smooth
/// animated text updates in the Telegram client. Only works for private chats.
///
/// # Arguments
/// * `chat_id` - The target chat ID (must be a private chat)
/// * `draft_id` - Unique identifier for this draft (same ID = animated updates)
/// * `text` - The text content to display
/// * `parse_mode` - Optional parse mode (HTML, Markdown, MarkdownV2)
///
/// # Returns
/// `true` on success, `false` on failure
pub async fn send_message_draft(
&self,
chat_id: i64,
draft_id: i32,
text: &str,
parse_mode: Option<&str>,
) -> bool {
let url = format!(
"https://api.telegram.org/bot{}/sendMessageDraft",
self.token
);

let mut body = serde_json::json!({
"chat_id": chat_id,
"draft_id": draft_id,
"text": text,
});

if let Some(mode) = parse_mode {
body["parse_mode"] = serde_json::Value::String(mode.to_string());
}

match self.http_client
.post(&url)
.json(&body)
.send()
.await
{
Ok(response) => {
if response.status().is_success() {
true
} else {
tracing::debug!(
status = %response.status(),
"sendMessageDraft returned non-success status"
);
false
}
}
Err(error) => {
tracing::debug!(%error, "sendMessageDraft request failed");
false
}
}
}

/// Generate a unique draft ID for a streaming session.
fn generate_draft_id(&self) -> i32 {
// Use timestamp in milliseconds XORed with a random component
// Ensure it's non-zero (required by Telegram API)
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i32;
// Ensure non-zero by ORing with 1
timestamp | 1
}

/// Check if native streaming should be used for this message.
/// Returns true if native_streaming is enabled and the chat is private.
fn should_use_native_streaming(&self, message: &InboundMessage) -> bool {
let permissions = self.permissions.load();
if !permissions.native_streaming {
return false;
}
// Check if this is a private chat
message.metadata
.get("telegram_chat_type")
.and_then(|v| v.as_str())
.is_some_and(|t| t == "private")
}

fn extract_chat_id(&self, message: &InboundMessage) -> anyhow::Result<ChatId> {
let id = message
.metadata
Expand Down Expand Up @@ -428,6 +527,32 @@ impl Messaging for TelegramAdapter {
OutboundResponse::StreamStart => {
self.stop_typing(&message.conversation_id).await;

let is_private = self.should_use_native_streaming(&message);
let chat_id_value = chat_id.0;

if is_private {
// Use native sendMessageDraft API for private chats
let draft_id = self.generate_draft_id();

// Send initial draft
if self.send_message_draft(chat_id_value, draft_id, "...", None).await {
self.active_messages.write().await.insert(
message.conversation_id.clone(),
ActiveStream {
chat_id,
message_id: MessageId(0), // Not used for draft streaming
last_edit: Instant::now(),
draft_id: Some(draft_id),
is_private: true,
},
);
return Ok(());
}
// Fall through to edit-based streaming if draft fails
tracing::debug!("sendMessageDraft failed, falling back to edit-based streaming");
}

// Fallback: traditional edit-based streaming
let placeholder = self
.bot
.send_message(chat_id, "...")
Expand All @@ -441,13 +566,22 @@ impl Messaging for TelegramAdapter {
chat_id,
message_id: placeholder.id,
last_edit: Instant::now(),
draft_id: None,
is_private: false,
},
);
}
OutboundResponse::StreamChunk(text) => {
let mut active = self.active_messages.write().await;
if let Some(stream) = active.get_mut(&message.conversation_id) {
if stream.last_edit.elapsed() < STREAM_EDIT_INTERVAL {
// Use faster interval for draft streaming
let min_interval = if stream.is_private && stream.draft_id.is_some() {
DRAFT_UPDATE_INTERVAL
} else {
STREAM_EDIT_INTERVAL
};

if stream.last_edit.elapsed() < min_interval {
return Ok(());
}

Expand All @@ -458,6 +592,20 @@ impl Messaging for TelegramAdapter {
text
};

// Use native draft streaming if available
if stream.is_private {
if let Some(draft_id) = stream.draft_id {
let html = markdown_to_telegram_html(&display_text);
// Try HTML first, fall back to plain text
if !self.send_message_draft(stream.chat_id.0, draft_id, &html, Some("HTML")).await {
self.send_message_draft(stream.chat_id.0, draft_id, &display_text, None).await;
}
stream.last_edit = Instant::now();
return Ok(());
}
}

// Fallback: traditional edit-based streaming
let html = markdown_to_telegram_html(&display_text);
if let Err(html_error) = self
.bot
Expand All @@ -480,6 +628,18 @@ impl Messaging for TelegramAdapter {
}
}
OutboundResponse::StreamEnd => {
// For draft streaming, we need to send the final message
// The draft will be automatically discarded when we send a real message
let active = self.active_messages.read().await;
if let Some(stream) = active.get(&message.conversation_id) {
if stream.is_private && stream.draft_id.is_some() {
// Draft streaming: the final message will replace the draft
// No explicit cleanup needed - just remove from active
tracing::trace!("Draft streaming ended for conversation {}", message.conversation_id);
}
}
drop(active);

self.active_messages
.write()
.await
Expand Down Expand Up @@ -1016,10 +1176,26 @@ fn split_message(text: &str, max_len: usize) -> Vec<String> {
break;
}

// Find split point: prefer newline, then space, then hard-cut.
// When hard-cutting, ensure we don't split mid-character (UTF-8 safety).
let split_at = remaining[..max_len]
.rfind('\n')
.or_else(|| remaining[..max_len].rfind(' '))
.unwrap_or(max_len);
.unwrap_or_else(|| {
// Hard-cut: find the last valid char boundary before max_len
let mut pos = max_len;
while pos > 0 && !remaining.is_char_boundary(pos) {
pos -= 1;
}
pos
});
Comment on lines 1181 to +1191

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.

This still slices remaining[..max_len] before ensuring max_len is a char boundary, so it can still panic on UTF-8. Flooring once and using that window avoids the panic and makes the hard-cut loop unnecessary.

Suggested change
let split_at = remaining[..max_len]
.rfind('\n')
.or_else(|| remaining[..max_len].rfind(' '))
.unwrap_or(max_len);
.unwrap_or_else(|| {
// Hard-cut: find the last valid char boundary before max_len
let mut pos = max_len;
while pos > 0 && !remaining.is_char_boundary(pos) {
pos -= 1;
}
pos
});
let window_end = remaining.floor_char_boundary(max_len);
let split_at = remaining[..window_end]
.rfind('\n')
.or_else(|| remaining[..window_end].rfind(' '))
.unwrap_or(window_end);


// Avoid empty chunks if split_at is 0 (e.g., first char is multi-byte)
let split_at = if split_at == 0 {
remaining.char_indices().nth(1).map(|(i, _)| i).unwrap_or(remaining.len())
} else {
split_at
};
Comment on lines +1179 to +1198

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.

⚠️ Potential issue | 🔴 Critical

split_message() can still panic on a multi-byte boundary.

Lines 1181-1183 slice remaining[..max_len] before max_len is snapped to a valid char boundary. If the limit lands inside a UTF-8 code point, this panics exactly like the truncation bug this PR is trying to eliminate.

🛠️ Suggested fix
-        let split_at = remaining[..max_len]
-            .rfind('\n')
-            .or_else(|| remaining[..max_len].rfind(' '))
+        let safe_max_len = remaining.floor_char_boundary(max_len);
+        let split_at = remaining[..safe_max_len]
+            .rfind('\n')
+            .or_else(|| remaining[..safe_max_len].rfind(' '))
             .unwrap_or_else(|| {
-                // Hard-cut: find the last valid char boundary before max_len
-                let mut pos = max_len;
-                while pos > 0 && !remaining.is_char_boundary(pos) {
-                    pos -= 1;
-                }
-                pos
+                safe_max_len
             });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/telegram.rs` around lines 1179 - 1198, split_message currently
slices remaining[..max_len] which can panic if max_len falls inside a UTF-8 code
point; before taking that slice compute a safe_boundary <= max_len (e.g., if
remaining.is_char_boundary(max_len) use max_len, otherwise walk backward until
is_char_boundary(pos)) and use remaining[..safe_boundary] for the rfind()
checks; keep rest of the logic (fallback hard-cut, then avoid empty chunks) but
reference the safe_boundary when searching so split_message, remaining, max_len,
and split_at no longer trigger a slice panic.


chunks.push(remaining[..split_at].to_string());
remaining = remaining[split_at..].trim_start();
Expand Down
3 changes: 2 additions & 1 deletion src/tools/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,8 @@ fn render_snapshot_node(node: &SnapshotNode, depth: usize, output: &mut String)
output.push_str(" \"");
// Truncate very long names for context efficiency.
let display_name = if node.name.len() > 200 {
format!("{}...", &node.name[..200])
let truncated = node.name.floor_char_boundary(200);
format!("{}...", &node.name[..truncated])
Comment on lines 532 to +534

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.

⚠️ Potential issue | 🔴 Critical

Apply the UTF-8-safe truncation to value too.

This fixes the panic for node.name, but Line 579 still does &value[..100]. A long non-ASCII form value can still panic browser_snapshot, so the worker-crash path remains. Please route both fields through the same boundary-aware truncation helper.

🛠️ Suggested follow-up
+fn truncate_utf8(text: &str, max_bytes: usize) -> String {
+    if text.len() <= max_bytes {
+        return text.to_string();
+    }
+    let end = text.floor_char_boundary(max_bytes);
+    format!("{}...", &text[..end])
+}
+
 fn render_snapshot_node(node: &SnapshotNode, depth: usize, output: &mut String) {
@@
-        let display_name = if node.name.len() > 200 {
-            let truncated = node.name.floor_char_boundary(200);
-            format!("{}...", &node.name[..truncated])
-        } else {
-            node.name.clone()
-        };
+        let display_name = truncate_utf8(&node.name, 200);
@@
-        let display_value = if value.len() > 100 {
-            format!("{}...", &value[..100])
-        } else {
-            value.clone()
-        };
+        let display_value = truncate_utf8(value, 100);

As per coding guidelines, src/tools/**/*.rs: Tool errors are returned as structured results, not panics. The LLM sees the error and can recover (error-as-result for tools pattern).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let display_name = if node.name.len() > 200 {
format!("{}...", &node.name[..200])
let truncated = node.name.floor_char_boundary(200);
format!("{}...", &node.name[..truncated])
fn truncate_utf8(text: &str, max_bytes: usize) -> String {
if text.len() <= max_bytes {
return text.to_string();
}
let end = text.floor_char_boundary(max_bytes);
format!("{}...", &text[..end])
}
fn render_snapshot_node(node: &SnapshotNode, depth: usize, output: &mut String) {
let display_name = truncate_utf8(&node.name, 200);
let display_value = truncate_utf8(value, 100);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/browser.rs` around lines 532 - 534, The code only applies
UTF-8-safe truncation to node.name but still slices value with &value[..100],
which can panic on non-ASCII; create or reuse a boundary-aware truncation helper
(e.g., trunc_utf8_boundary or truncate_to_char_boundary) and use it for both
node.name (display_name) and value (where &value[..100] is used), returning a
safe String (append "..." when truncated) and replacing direct slices in
browser_snapshot/path handling so no panic occurs; ensure the helper is called
wherever display_name or value truncation happens.

} else {
node.name.clone()
};
Expand Down