Skip to content

Commit 27a6a43

Browse files
committed
feat(telegram): wire heartbeat alerts to forum topic
Route heartbeat alert messages to a configured Telegram forum topic via heartbeat_topic_id config. Adds AlertCallback to HeartbeatRunner that the daemon wires to send messages with message_thread_id(). - AlertCallback type on HeartbeatRunner (invoked for non-OK responses) - create_heartbeat_alert_callback() in server telegram module - load_paired_chat_id() helper for notification routing - Daemon wires callback when heartbeat_topic_id is configured
1 parent 2eeef7d commit 27a6a43

4 files changed

Lines changed: 70 additions & 2 deletions

File tree

crates/cli/src/cli/daemon.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,25 @@ async fn run_daemon_services(
275275
runner.enable_gen_dispatch();
276276
tracing::info!("Heartbeat gen dispatch enabled (localgpt-gen found)");
277277
}
278+
279+
// Wire Telegram alert callback if heartbeat_topic_id is configured
280+
if let Some(ref tg) = heartbeat_config.telegram
281+
&& tg.enabled
282+
&& let Some(topic_id) = tg.heartbeat_topic_id
283+
&& !tg.api_token.is_empty()
284+
&& !tg.api_token.starts_with("${")
285+
&& let Some(callback) = localgpt_server::telegram::create_heartbeat_alert_callback(
286+
&tg.api_token,
287+
topic_id,
288+
)
289+
{
290+
runner.set_alert_callback(callback);
291+
tracing::info!(
292+
"Heartbeat Telegram alert callback enabled (topic_id: {})",
293+
topic_id
294+
);
295+
}
296+
278297
tracing::info!("Heartbeat runner created");
279298
if let Err(e) = runner.run().await {
280299
tracing::error!("Heartbeat runner error: {}", e);

crates/core/src/heartbeat/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ pub mod gen_dispatch;
33
mod runner;
44

55
pub use events::{HeartbeatEvent, HeartbeatStatus, emit_heartbeat_event, get_last_heartbeat_event};
6-
pub use runner::{HeartbeatRunner, ToolFactory};
6+
pub use runner::{AlertCallback, HeartbeatRunner, ToolFactory};

crates/core/src/heartbeat/runner.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,14 @@ pub struct HeartbeatRunner {
4343
/// When true, heartbeat checks HEARTBEAT.md for gen experiments before normal processing
4444
/// and dispatches them via `localgpt-gen headless` subprocess.
4545
gen_dispatch_enabled: bool,
46+
/// Optional callback invoked when heartbeat produces an alert (non-OK, non-skipped response).
47+
/// Used by the daemon to forward alerts to Telegram.
48+
on_alert: Option<AlertCallback>,
4649
}
4750

51+
/// Callback type for heartbeat alert notifications
52+
pub type AlertCallback = Box<dyn Fn(&str) + Send + Sync>;
53+
4854
impl HeartbeatRunner {
4955
/// Create a new HeartbeatRunner with the default agent ID ("main")
5056
pub fn new(config: &Config) -> Result<Self> {
@@ -129,6 +135,7 @@ impl HeartbeatRunner {
129135
workspace_lock,
130136
tool_factory,
131137
gen_dispatch_enabled: false,
138+
on_alert: None,
132139
})
133140
}
134141

@@ -141,6 +148,12 @@ impl HeartbeatRunner {
141148
self.gen_dispatch_enabled = true;
142149
}
143150

151+
/// Set a callback invoked when heartbeat produces an alert (non-OK, non-skipped).
152+
/// Used by the daemon to forward alerts to Telegram with topic routing.
153+
pub fn set_alert_callback(&mut self, callback: AlertCallback) {
154+
self.on_alert = Some(callback);
155+
}
156+
144157
async fn first_delay(&self) -> Duration {
145158
// Read last heartbeat event to calibrate first tick time
146159
if let Ok(json) = fs::read_to_string(self.config.paths.last_heartbeat())
@@ -267,6 +280,10 @@ impl HeartbeatRunner {
267280
debug!(name: "Heartbeat", "OK");
268281
} else {
269282
warn!(name: "Heartbeat", "response not OK: {}", response);
283+
// Notify via alert callback (e.g., Telegram with topic routing)
284+
if let Some(ref callback) = self.on_alert {
285+
callback(&response);
286+
}
270287
}
271288

272289
if status == HeartbeatStatus::SkippedMayTry {

crates/server/src/telegram.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use std::path::PathBuf;
1010
use std::sync::Arc;
1111
use std::time::Instant;
1212
use teloxide::prelude::*;
13-
use teloxide::types::{MessageId, ParseMode};
13+
use teloxide::types::{ChatId, MessageId, ParseMode, ThreadId};
1414
use tokio::sync::Mutex;
1515
use tracing::{debug, error, info, warn};
1616

@@ -65,6 +65,38 @@ fn load_paired_user() -> Option<PairedUser> {
6565
serde_json::from_str(&content).ok()
6666
}
6767

68+
/// Load the paired user's chat ID for sending notifications.
69+
/// In Telegram, private chat ID equals user ID for DMs.
70+
pub fn load_paired_chat_id() -> Option<i64> {
71+
load_paired_user().map(|u| u.user_id as i64)
72+
}
73+
74+
/// Create a heartbeat alert callback that sends messages to a Telegram topic.
75+
/// Returns None if no paired user or missing config.
76+
pub fn create_heartbeat_alert_callback(
77+
api_token: &str,
78+
topic_id: i32,
79+
) -> Option<localgpt_core::heartbeat::AlertCallback> {
80+
let chat_id = load_paired_chat_id()?;
81+
let bot = Bot::new(api_token);
82+
83+
Some(Box::new(move |text: &str| {
84+
let bot = bot.clone();
85+
let msg = if text.len() > 4000 {
86+
format!("{}...", &text[..text.floor_char_boundary(4000)])
87+
} else {
88+
text.to_string()
89+
};
90+
tokio::spawn(async move {
91+
let mut req = bot.send_message(ChatId(chat_id), &msg);
92+
req = req.message_thread_id(ThreadId(MessageId(topic_id)));
93+
if let Err(e) = req.await {
94+
tracing::warn!("Failed to send heartbeat alert to Telegram topic: {}", e);
95+
}
96+
});
97+
}))
98+
}
99+
68100
fn save_paired_user(user: &PairedUser) -> Result<()> {
69101
let path = pairing_file_path()?;
70102
let content = serde_json::to_string_pretty(user)?;

0 commit comments

Comments
 (0)