From c4cee72938e2c4aa9e45266d238cfc8a203c24d5 Mon Sep 17 00:00:00 2001 From: dave Date: Thu, 26 Mar 2026 20:19:57 +0000 Subject: [PATCH] storkit: merge 396_story_whatsapp_bot_startup_announcement_after_restart --- server/src/main.rs | 37 +++++++++++++++ server/src/rebuild.rs | 104 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/server/src/main.rs b/server/src/main.rs index 44da66eb..8df77ce6 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -379,6 +379,43 @@ async fn main() -> Result<(), std::io::Error> { let whatsapp_ctx_for_shutdown: Option> = whatsapp_ctx.clone(); + // ── Startup announcements (WhatsApp & Slack) ────────────────────────── + // + // Send "{bot_name} is online." to all known contacts so users know the bot + // is ready. This mirrors the Matrix bot's startup announcement and fires + // on every fresh process start — including after a rebuild/re-exec. + // + // • WhatsApp: send to all phone numbers present in persisted history. + // • Slack: send to all configured channel IDs (channel_ids from bot.toml). + // • Matrix: handled by spawn_bot() below; no action needed here. + if let Some(ref ctx) = whatsapp_ctx { + let transport = Arc::clone(&ctx.transport); + let bot_name = ctx.bot_name.clone(); + let history = Arc::clone(&ctx.history); + tokio::spawn(async move { + let senders: Vec = history.lock().await.keys().cloned().collect(); + if senders.is_empty() { + return; + } + let notifier = + crate::rebuild::BotShutdownNotifier::new(transport, senders, bot_name); + notifier.notify_startup().await; + }); + } + if let Some(ref ctx) = slack_ctx { + let transport = Arc::clone(&ctx.transport) as Arc; + let bot_name = ctx.bot_name.clone(); + let channels: Vec = ctx.channel_ids.iter().cloned().collect(); + tokio::spawn(async move { + if channels.is_empty() { + return; + } + let notifier = + crate::rebuild::BotShutdownNotifier::new(transport, channels, bot_name); + notifier.notify_startup().await; + }); + } + // Watch channel: signals the Matrix bot task to send a shutdown announcement. // `None` initial value means "server is running". let (matrix_shutdown_tx, matrix_shutdown_rx) = diff --git a/server/src/rebuild.rs b/server/src/rebuild.rs index db6f19a2..9ea3eb2d 100644 --- a/server/src/rebuild.rs +++ b/server/src/rebuild.rs @@ -43,6 +43,19 @@ impl BotShutdownNotifier { } } + /// Send a startup announcement to all configured channels. + /// + /// Called once per process start so users know the bot is online. + /// Errors are logged and ignored — startup is never blocked by a failed send. + pub async fn notify_startup(&self) { + let msg = format!("{} is online.", self.bot_name); + for channel in &self.channels { + if let Err(e) = self.transport.send_message(channel, &msg, &msg).await { + slog!("[startup] Failed to send startup message to {channel}: {e}"); + } + } + } + /// Send a shutdown message to all configured channels. /// /// Errors from individual sends are logged and ignored so that a single @@ -345,4 +358,95 @@ mod tests { notifier.notify(ShutdownReason::Manual).await; assert!(transport.messages().is_empty()); } + + // -- notify_startup ------------------------------------------------------- + + #[tokio::test] + async fn notify_startup_sends_online_message_to_all_channels() { + let transport = Arc::new(CapturingTransport::new()); + let notifier = BotShutdownNotifier::new( + Arc::clone(&transport) as Arc, + vec!["#channel1".to_string(), "#channel2".to_string()], + "Timmy".to_string(), + ); + + notifier.notify_startup().await; + + let msgs = transport.messages(); + assert_eq!(msgs.len(), 2); + assert_eq!(msgs[0].0, "#channel1"); + assert_eq!(msgs[1].0, "#channel2"); + assert!( + msgs[0].1.contains("online"), + "expected 'online' in startup message: {}", + msgs[0].1 + ); + assert!( + msgs[0].1.contains("Timmy"), + "expected bot name in startup message: {}", + msgs[0].1 + ); + } + + #[tokio::test] + async fn notify_startup_message_uses_bot_name() { + let transport = Arc::new(CapturingTransport::new()); + let notifier = BotShutdownNotifier::new( + Arc::clone(&transport) as Arc, + vec!["#general".to_string()], + "HAL".to_string(), + ); + + notifier.notify_startup().await; + + let msgs = transport.messages(); + assert_eq!(msgs[0].1, "HAL is online."); + } + + #[tokio::test] + async fn notify_startup_with_no_channels_is_noop() { + let transport = Arc::new(CapturingTransport::new()); + let notifier = BotShutdownNotifier::new( + Arc::clone(&transport) as Arc, + vec![], + "Timmy".to_string(), + ); + notifier.notify_startup().await; + assert!(transport.messages().is_empty()); + } + + #[tokio::test] + async fn notify_startup_is_best_effort_failing_send_does_not_panic() { + let transport = Arc::new(CapturingTransport::failing()); + let notifier = BotShutdownNotifier::new( + Arc::clone(&transport) as Arc, + vec!["#channel".to_string()], + "Timmy".to_string(), + ); + // Should complete without panicking. + notifier.notify_startup().await; + } + + #[tokio::test] + async fn notify_startup_message_differs_from_shutdown_message() { + let transport_start = Arc::new(CapturingTransport::new()); + let notifier_start = BotShutdownNotifier::new( + Arc::clone(&transport_start) as Arc, + vec!["C1".to_string()], + "Bot".to_string(), + ); + notifier_start.notify_startup().await; + + let transport_stop = Arc::new(CapturingTransport::new()); + let notifier_stop = BotShutdownNotifier::new( + Arc::clone(&transport_stop) as Arc, + vec!["C1".to_string()], + "Bot".to_string(), + ); + notifier_stop.notify(ShutdownReason::Manual).await; + + let startup_msg = &transport_start.messages()[0].1; + let shutdown_msg = &transport_stop.messages()[0].1; + assert_ne!(startup_msg, shutdown_msg, "startup and shutdown messages must differ"); + } }