Telegram: silent streaming updates to reduce notification spam (#940)

This commit is contained in:
Steven Enamakel
2026-04-26 10:36:15 -07:00
committed by GitHub
parent 9f76b62757
commit 2e4f1925f6
9 changed files with 137 additions and 21 deletions
+19
View File
@@ -0,0 +1,19 @@
# Telegram Channel
The Telegram channel allows OpenHuman to interact with users via a Telegram bot.
## Silent Streaming
While the bot is thinking or streaming a reply, updates are sent silently by default to minimize notification spam on the user's device. This means:
- The initial "thinking..." placeholder is sent without a notification sound.
- Intermediate streaming updates (edits to the message) do not trigger new notifications.
- Standalone messages and final fallback messages (if a message needs to be re-sent instead of edited) will still trigger a notification normally.
This behavior can be controlled via the `silent_streaming` option in the `[channels.telegram]` section of `config.toml`. It defaults to `true`.
```toml
[channels.telegram]
bot_token = "YOUR_BOT_TOKEN"
allowed_users = ["your_username"]
silent_streaming = true # Set to false to receive notifications for every update
```
+7 -1
View File
@@ -53,7 +53,11 @@ pub async fn doctor_channels(config: Config) -> Result<()> {
tg.allowed_users.clone(),
tg.mention_only,
)
.with_streaming(tg.stream_mode, tg.draft_update_interval_ms),
.with_streaming(
tg.stream_mode,
tg.draft_update_interval_ms,
tg.silent_streaming,
),
),
));
}
@@ -319,6 +323,7 @@ mod tests {
allowed_users: vec!["user1".into()],
stream_mode: StreamMode::default(),
draft_update_interval_ms: 2000,
silent_streaming: true,
mention_only: false,
});
let _ = doctor_channels(config).await;
@@ -375,6 +380,7 @@ mod tests {
allowed_users: vec![],
stream_mode: StreamMode::default(),
draft_update_interval_ms: 2000,
silent_streaming: true,
mention_only: false,
});
config.channels_config.discord = Some(DiscordConfig {
+9 -2
View File
@@ -234,15 +234,21 @@ pub async fn connect_channel(
let allowed_users_count = allowed_users.len();
let mut persisted = config.clone();
let (stream_mode, draft_update_interval_ms, mention_only) =
let (stream_mode, draft_update_interval_ms, silent_streaming, mention_only) =
if let Some(existing) = persisted.channels_config.telegram.as_ref() {
(
existing.stream_mode,
existing.draft_update_interval_ms,
existing.silent_streaming,
existing.mention_only,
)
} else {
(crate::openhuman::config::StreamMode::default(), 1000, false)
(
crate::openhuman::config::StreamMode::default(),
1000,
true,
false,
)
};
persisted.channels_config.telegram = Some(TelegramConfig {
@@ -250,6 +256,7 @@ pub async fn connect_channel(
allowed_users,
stream_mode,
draft_update_interval_ms,
silent_streaming,
mention_only,
});
@@ -54,6 +54,7 @@ pub struct TelegramChannel {
typing_handle: Mutex<Option<TelegramTypingTask>>,
stream_mode: StreamMode,
draft_update_interval_ms: u64,
silent_streaming: bool,
last_draft_edit: Mutex<std::collections::HashMap<String, std::time::Instant>>,
mention_only: bool,
bot_username: Mutex<Option<String>>,
@@ -81,6 +82,7 @@ impl TelegramChannel {
client: reqwest::Client::new(),
stream_mode: StreamMode::Off,
draft_update_interval_ms: 1000,
silent_streaming: true,
last_draft_edit: Mutex::new(std::collections::HashMap::new()),
typing_handle: Mutex::new(None),
mention_only,
@@ -94,9 +96,11 @@ impl TelegramChannel {
mut self,
stream_mode: StreamMode,
draft_update_interval_ms: u64,
silent_streaming: bool,
) -> Self {
self.stream_mode = stream_mode;
self.draft_update_interval_ms = draft_update_interval_ms;
self.silent_streaming = silent_streaming;
self
}
@@ -913,6 +917,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
chat_id: &str,
thread_id: Option<&str>,
reply_to_message_id: Option<i64>,
disable_notification: bool,
) -> anyhow::Result<()> {
let chunks = split_message_for_telegram(message);
@@ -932,7 +937,8 @@ Allowlist Telegram username (without '@') or numeric user ID.",
let mut markdown_body = serde_json::json!({
"chat_id": chat_id,
"text": text,
"parse_mode": "Markdown"
"parse_mode": "Markdown",
"disable_notification": disable_notification,
});
// Add message_thread_id for forum topic support
@@ -969,6 +975,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
let mut plain_body = serde_json::json!({
"chat_id": chat_id,
"text": text,
"disable_notification": disable_notification,
});
// Add message_thread_id for forum topic support
@@ -1533,6 +1540,7 @@ impl Channel for TelegramChannel {
let mut body = serde_json::json!({
"chat_id": chat_id,
"text": initial_text,
"disable_notification": self.silent_streaming,
});
if let Some(tid) = thread_id {
body["message_thread_id"] = serde_json::Value::String(tid.to_string());
@@ -1613,6 +1621,7 @@ impl Channel for TelegramChannel {
"chat_id": chat_id,
"message_id": message_id_parsed,
"text": display_text,
"disable_notification": self.silent_streaming,
});
let resp = self
@@ -1656,7 +1665,13 @@ impl Channel for TelegramChannel {
Err(e) => {
tracing::warn!("Invalid Telegram message_id '{message_id}': {e}");
return self
.send_text_chunks(text, &chat_id, thread_id.as_deref(), parent_message_id)
.send_text_chunks(
text,
&chat_id,
thread_id.as_deref(),
parent_message_id,
false,
)
.await;
}
};
@@ -1674,7 +1689,13 @@ impl Channel for TelegramChannel {
// Fall back to chunked send
return self
.send_text_chunks(text, &chat_id, thread_id.as_deref(), parent_message_id)
.send_text_chunks(
text,
&chat_id,
thread_id.as_deref(),
parent_message_id,
false,
)
.await;
}
@@ -1683,7 +1704,13 @@ impl Channel for TelegramChannel {
Err(e) => {
tracing::warn!("Invalid Telegram message_id '{message_id}': {e}");
return self
.send_text_chunks(text, &chat_id, thread_id.as_deref(), parent_message_id)
.send_text_chunks(
text,
&chat_id,
thread_id.as_deref(),
parent_message_id,
false,
)
.await;
}
};
@@ -1727,8 +1754,14 @@ impl Channel for TelegramChannel {
// Edit failed entirely — fall back to new message
tracing::warn!("Telegram finalize_draft edit failed; falling back to sendMessage");
self.send_text_chunks(text, &chat_id, thread_id.as_deref(), parent_message_id)
.await
self.send_text_chunks(
text,
&chat_id,
thread_id.as_deref(),
parent_message_id,
false,
)
.await
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
@@ -1777,8 +1810,14 @@ impl Channel for TelegramChannel {
if !attachments.is_empty() {
if !text_without_markers.is_empty() {
self.send_text_chunks(&text_without_markers, chat_id, thread_id, parent_message_id)
.await?;
self.send_text_chunks(
&text_without_markers,
chat_id,
thread_id,
parent_message_id,
false,
)
.await?;
}
for attachment in &attachments {
@@ -1794,8 +1833,14 @@ impl Channel for TelegramChannel {
return Ok(());
}
self.send_text_chunks(&reactionless_content, chat_id, thread_id, parent_message_id)
.await
self.send_text_chunks(
&reactionless_content,
chat_id,
thread_id,
parent_message_id,
false,
)
.await
}
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
@@ -74,9 +74,10 @@ fn supports_draft_updates_respects_stream_mode() {
assert!(!off.supports_draft_updates());
let partial = TelegramChannel::new("fake-token".into(), vec!["*".into()], false)
.with_streaming(StreamMode::Partial, 750);
.with_streaming(StreamMode::Partial, 750, true);
assert!(partial.supports_draft_updates());
assert_eq!(partial.draft_update_interval_ms, 750);
assert!(partial.silent_streaming);
}
#[tokio::test]
@@ -91,8 +92,11 @@ async fn send_draft_returns_none_when_stream_mode_off() {
#[tokio::test]
async fn update_draft_rate_limit_short_circuits_network() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false)
.with_streaming(StreamMode::Partial, 60_000);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false).with_streaming(
StreamMode::Partial,
60_000,
true,
);
ch.last_draft_edit
.lock()
.insert("123".to_string(), std::time::Instant::now());
@@ -103,8 +107,11 @@ async fn update_draft_rate_limit_short_circuits_network() {
#[tokio::test]
async fn update_draft_utf8_truncation_is_safe_for_multibyte_text() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false)
.with_streaming(StreamMode::Partial, 0);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false).with_streaming(
StreamMode::Partial,
0,
true,
);
let long_emoji_text = "😀".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 20);
// Invalid message_id returns early after building display_text.
@@ -117,8 +124,11 @@ async fn update_draft_utf8_truncation_is_safe_for_multibyte_text() {
#[tokio::test]
async fn finalize_draft_invalid_message_id_falls_back_to_chunk_send() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false)
.with_streaming(StreamMode::Partial, 0);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false).with_streaming(
StreamMode::Partial,
0,
true,
);
let long_text = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 64);
// For oversized text + invalid draft message_id, finalize_draft should
@@ -1382,6 +1392,23 @@ fn track_update_id_large_volume_beyond_cache_does_not_panic() {
);
}
#[test]
fn silent_streaming_is_configurable() {
let silent = TelegramChannel::new("fake-token".into(), vec!["*".into()], false).with_streaming(
StreamMode::Partial,
1000,
true,
);
assert!(silent.silent_streaming);
let noisy = TelegramChannel::new("fake-token".into(), vec!["*".into()], false).with_streaming(
StreamMode::Partial,
1000,
false,
);
assert!(!noisy.silent_streaming);
}
// ── Reply-target parsing unit tests ────────────────────────────
#[test]
+5 -1
View File
@@ -241,7 +241,11 @@ pub async fn start_channels(config: Config) -> Result<()> {
tg.allowed_users.clone(),
tg.mention_only,
)
.with_streaming(tg.stream_mode, tg.draft_update_interval_ms),
.with_streaming(
tg.stream_mode,
tg.draft_update_interval_ms,
tg.silent_streaming,
),
));
} else {
tracing::info!(
+1
View File
@@ -73,6 +73,7 @@ mod tests {
allowed_users: vec!["alice".into()],
stream_mode: StreamMode::default(),
draft_update_interval_ms: 1000,
silent_streaming: true,
mention_only: false,
};
+6
View File
@@ -102,6 +102,10 @@ pub(crate) fn default_draft_update_interval_ms() -> u64 {
1000
}
fn default_silent_streaming() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TelegramConfig {
pub bot_token: String,
@@ -110,6 +114,8 @@ pub struct TelegramConfig {
pub stream_mode: StreamMode,
#[serde(default = "default_draft_update_interval_ms")]
pub draft_update_interval_ms: u64,
#[serde(default = "default_silent_streaming")]
pub silent_streaming: bool,
#[serde(default)]
pub mention_only: bool,
}
@@ -44,6 +44,7 @@ fn has_listening_integrations_detects_telegram() {
allowed_users: vec![],
stream_mode: StreamMode::Off,
draft_update_interval_ms: 1000,
silent_streaming: true,
mention_only: false,
});
assert!(cfg.has_listening_integrations());