1use std::collections::HashMap;
72use std::sync::{Arc, Mutex};
73use std::time::{Duration, Instant};
74
75use futures_util::stream::{SplitSink, SplitStream};
76use futures_util::{SinkExt, StreamExt};
77use tokio::sync::mpsc;
78use tokio_tungstenite::{
79 connect_async_tls_with_config, tungstenite::Message, MaybeTlsStream, WebSocketStream,
80};
81
82use crate::backend::reactive::handler::get_global_handler;
83use crate::backend::reactive::types::InjectionRequest;
84use crate::messaging::{BridgeHealth, BridgeStatus, OutboundMsg};
85
86use super::rest;
87use super::types::{AckFrame, EventsApiPayload, SlackEvent, SocketEnvelope};
88
89const RECONNECT_DELAY_SECS: u64 = 5;
90const MAX_RECONNECT_DELAY_SECS: u64 = 60;
91
92const DEAD_CONN_TIMEOUT_SECS: u64 = 300;
96
97const CHANNEL_MIN_SPACING: Duration = Duration::from_millis(1100);
100
101const HELLO_TIMEOUT_SECS: u64 = 10;
103
104type WsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
105type WsWrite = SplitSink<WsStream, Message>;
106type WsRead = SplitStream<WsStream>;
107
108struct WsHalf {
109 write: WsWrite,
110 read: WsRead,
111}
112
113pub async fn run_bridge(
116 app_token: String,
117 bot_token: String,
118 channel_id: String,
119 target_agent: Option<String>,
120 http: reqwest::Client,
121 outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
122 health: Arc<Mutex<BridgeHealth>>,
123) {
124 let outbound_http = http.clone();
125 let default_channel = channel_id.clone();
126 tokio::join!(
127 run_socket_loop(app_token, channel_id, target_agent, http, health),
128 run_outbound_loop(outbound_http, bot_token, default_channel, outbound_rx),
129 );
130}
131
132async fn run_socket_loop(
140 app_token: String,
141 channel_id: String,
142 target_agent: Option<String>,
143 http: reqwest::Client,
144 health: Arc<Mutex<BridgeHealth>>,
145) {
146 let mut delay_secs = RECONNECT_DELAY_SECS;
147
148 loop {
149 {
150 let mut h = health.lock().unwrap();
151 h.status = BridgeStatus::Connecting;
152 }
153
154 let session_start = Instant::now();
155
156 match connect_and_run(&app_token, &channel_id, &target_agent, &http, &health).await {
157 Ok(()) => {
158 tracing::info!("slack_bridge: session ended cleanly");
159 }
160 Err(e) => {
161 tracing::warn!("slack_bridge: session error: {e}");
162 let mut h = health.lock().unwrap();
163 h.status = BridgeStatus::Error;
164 h.error = Some(e);
165 h.reconnect_count += 1;
166 }
167 }
168
169 if session_start.elapsed().as_secs() > 30 {
170 delay_secs = RECONNECT_DELAY_SECS;
171 }
172
173 {
174 let mut h = health.lock().unwrap();
175 if h.status != BridgeStatus::Error {
176 h.status = BridgeStatus::Disconnected;
177 }
178 }
179
180 tokio::time::sleep(Duration::from_secs(delay_secs)).await;
181 delay_secs = (delay_secs * 2).min(MAX_RECONNECT_DELAY_SECS);
182 }
183}
184
185async fn connect_and_run(
186 app_token: &str,
187 channel_id: &str,
188 target_agent: &Option<String>,
189 http: &reqwest::Client,
190 health: &Arc<Mutex<BridgeHealth>>,
191) -> Result<(), String> {
192 let mut primary = open_and_await_hello(app_token, http).await?;
193
194 {
195 let mut h = health.lock().unwrap();
196 h.status = BridgeStatus::Connected;
197 h.error = None;
198 h.last_event_at = Some(unix_secs());
199 }
200 tracing::info!("slack_bridge: connected (hello received)");
201
202 run_session(app_token, channel_id, target_agent, http, health, &mut primary).await
203}
204
205async fn open_and_await_hello(app_token: &str, http: &reqwest::Client) -> Result<WsHalf, String> {
209 let url = rest::open_connection(http, app_token).await?;
210 tracing::debug!(
211 "slack_bridge: opened connection ({})",
212 rest::redact_ticket(&url)
213 );
214
215 let request = tokio_tungstenite::tungstenite::http::Request::builder()
216 .uri(&url)
217 .body(())
218 .map_err(|e| format!("slack_bridge: build ws request: {e}"))?;
219
220 let (ws_stream, _) = connect_async_tls_with_config(request, None, false, None)
221 .await
222 .map_err(|e| format!("slack_bridge: ws connect: {e}"))?;
223
224 let (write, mut read) = ws_stream.split();
225
226 match tokio::time::timeout(Duration::from_secs(HELLO_TIMEOUT_SECS), read.next()).await {
227 Ok(Some(Ok(Message::Text(text)))) => match serde_json::from_str::<SocketEnvelope>(&text) {
228 Ok(SocketEnvelope::Hello(_)) => Ok(WsHalf { write, read }),
229 Ok(_) => Err("slack_bridge: expected hello as first frame, got a different envelope type".to_string()),
230 Err(e) => Err(format!("slack_bridge: parse first frame: {e}")),
231 },
232 Ok(Some(Ok(_))) => Err("slack_bridge: expected hello as first frame, got a non-text frame".to_string()),
233 Ok(Some(Err(e))) => Err(format!("slack_bridge: ws recv error awaiting hello: {e}")),
234 Ok(None) => Err("slack_bridge: socket closed before hello".to_string()),
235 Err(_) => Err("slack_bridge: timed out waiting for hello".to_string()),
236 }
237}
238
239async fn next_frame(
244 old: &mut Option<WsHalf>,
245) -> Option<Result<Message, tokio_tungstenite::tungstenite::Error>> {
246 match old {
247 Some(half) => half.read.next().await,
248 None => std::future::pending().await,
249 }
250}
251
252enum FrameOutcome {
253 Continue,
254 WarningReceived,
255 Closed,
256}
257
258async fn run_session(
266 app_token: &str,
267 channel_id: &str,
268 target_agent: &Option<String>,
269 http: &reqwest::Client,
270 health: &Arc<Mutex<BridgeHealth>>,
271 primary: &mut WsHalf,
272) -> Result<(), String> {
273 let mut old: Option<WsHalf> = None;
274 let mut cutover_pending = false;
275
276 let mut cutover_connect: std::pin::Pin<
277 Box<dyn std::future::Future<Output = Result<WsHalf, String>> + Send>,
278 > = Box::pin(std::future::pending());
279
280 let mut dead_conn_sleep = Box::pin(tokio::time::sleep(Duration::from_secs(
281 DEAD_CONN_TIMEOUT_SECS,
282 )));
283
284 loop {
285 tokio::select! {
286 _ = &mut dead_conn_sleep => {
288 return Err(format!(
289 "no frames received for {DEAD_CONN_TIMEOUT_SECS}s — treating connection as dead"
290 ));
291 }
292
293 result = &mut cutover_connect, if cutover_pending => {
295 cutover_pending = false;
296 cutover_connect = Box::pin(std::future::pending());
297 match result {
298 Ok(new_half) => {
299 tracing::info!("slack_bridge: cutover connection ready (hello received) — promoting to primary");
300 let retiring = std::mem::replace(primary, new_half);
301 old = Some(retiring);
302 dead_conn_sleep = Box::pin(tokio::time::sleep(Duration::from_secs(DEAD_CONN_TIMEOUT_SECS)));
303 let mut h = health.lock().unwrap();
304 h.last_event_at = Some(unix_secs());
305 }
306 Err(e) => {
307 tracing::warn!("slack_bridge: cutover connect failed: {e} — staying on current primary until it closes");
312 }
313 }
314 }
315
316 frame = primary.read.next() => {
318 match frame {
319 None => return Ok(()),
320 Some(Err(e)) => return Err(format!("ws recv (primary): {e}")),
321 Some(Ok(msg)) => {
322 dead_conn_sleep = Box::pin(tokio::time::sleep(Duration::from_secs(DEAD_CONN_TIMEOUT_SECS)));
323 let outcome = handle_frame(msg, &mut primary.write, channel_id, target_agent, health).await?;
324 match outcome {
325 FrameOutcome::Continue => {}
326 FrameOutcome::Closed => return Ok(()),
327 FrameOutcome::WarningReceived => {
328 if cutover_pending || old.is_some() {
348 tracing::debug!(
349 "slack_bridge: disconnect warning while a cutover is already in flight or draining — ignoring"
350 );
351 } else {
352 tracing::info!("slack_bridge: disconnect warning received — starting make-before-break reconnect");
353 cutover_pending = true;
354 let app_token = app_token.to_string();
355 let http = http.clone();
356 cutover_connect = Box::pin(async move { open_and_await_hello(&app_token, &http).await });
357 }
358 }
359 }
360 }
361 }
362 }
363
364 frame = next_frame(&mut old), if old.is_some() => {
367 match frame {
368 None | Some(Err(_)) => {
369 tracing::debug!("slack_bridge: old socket closed/errored after cutover — discarding");
370 old = None;
371 }
372 Some(Ok(msg)) => {
373 if let Some(half) = old.as_mut() {
377 match handle_frame(msg, &mut half.write, channel_id, target_agent, health).await {
378 Ok(FrameOutcome::Closed) => {
379 tracing::info!("slack_bridge: old socket closed cleanly after cutover");
380 old = None;
381 }
382 Ok(_) => {}
383 Err(e) => {
384 tracing::debug!("slack_bridge: old socket error after cutover (harmless — draining): {e}");
385 old = None;
386 }
387 }
388 }
389 }
390 }
391 }
392 }
393 }
394}
395
396async fn handle_frame(
400 msg: Message,
401 write: &mut WsWrite,
402 channel_id: &str,
403 target_agent: &Option<String>,
404 health: &Arc<Mutex<BridgeHealth>>,
405) -> Result<FrameOutcome, String> {
406 match msg {
407 Message::Text(text) => {
408 let envelope: SocketEnvelope = match serde_json::from_str(&text) {
409 Ok(e) => e,
410 Err(e) => {
411 tracing::warn!("slack_bridge: parse error: {e}");
412 return Ok(FrameOutcome::Continue);
413 }
414 };
415
416 match envelope {
417 SocketEnvelope::Hello(_) => {
418 tracing::debug!("slack_bridge: hello frame on an already-active socket");
419 Ok(FrameOutcome::Continue)
420 }
421 SocketEnvelope::EventsApi(ev) => {
422 ack_envelope(write, &ev.envelope_id).await?;
423 route_event(ev.payload, channel_id, target_agent, health);
424 Ok(FrameOutcome::Continue)
425 }
426 SocketEnvelope::SlashCommands(ev) => {
427 ack_envelope(write, &ev.envelope_id).await?;
431 tracing::debug!("slack_bridge: received slash_commands envelope (not handled — out of scope for this PR)");
432 Ok(FrameOutcome::Continue)
433 }
434 SocketEnvelope::Disconnect(d) => {
435 if d.reason.as_deref() == Some("warning") {
436 Ok(FrameOutcome::WarningReceived)
437 } else {
438 tracing::info!("slack_bridge: disconnect frame (reason={:?})", d.reason);
439 Ok(FrameOutcome::Closed)
440 }
441 }
442 SocketEnvelope::Unknown => {
443 tracing::debug!("slack_bridge: unhandled envelope type");
444 Ok(FrameOutcome::Continue)
445 }
446 }
447 }
448 Message::Ping(data) => {
449 let _ = write.send(Message::Pong(data)).await;
450 Ok(FrameOutcome::Continue)
451 }
452 Message::Close(_) => Ok(FrameOutcome::Closed),
453 _ => Ok(FrameOutcome::Continue),
454 }
455}
456
457async fn ack_envelope(write: &mut WsWrite, envelope_id: &str) -> Result<(), String> {
461 let ack = AckFrame {
462 envelope_id: envelope_id.to_string(),
463 };
464 let ack_json = serde_json::to_string(&ack).unwrap_or_default();
465 write
466 .send(Message::Text(ack_json.into()))
467 .await
468 .map_err(|e| format!("slack_bridge: ack send: {e}"))
469}
470
471fn should_route(event: &SlackEvent, channel_id: &str) -> bool {
475 if event.bot_id.is_some() {
476 return false; }
478 if event.type_ != "message" && event.type_ != "app_mention" {
479 return false;
480 }
481 event.channel.as_deref() == Some(channel_id) }
483
484fn route_event(
485 payload: Option<EventsApiPayload>,
486 channel_id: &str,
487 target_agent: &Option<String>,
488 health: &Arc<Mutex<BridgeHealth>>,
489) {
490 let Some(event) = payload.and_then(|p| p.event) else {
491 return;
492 };
493
494 if !should_route(&event, channel_id) {
495 return;
496 }
497
498 {
499 let mut h = health.lock().unwrap();
500 h.last_event_at = Some(unix_secs());
501 }
502
503 let Some(target) = target_agent else {
504 tracing::debug!("slack_bridge: event in #{channel_id} (no target agent configured)");
505 return;
506 };
507
508 let user = event.user.as_deref().unwrap_or("unknown");
509 let text = event.text.as_deref().unwrap_or("");
510 let envelope = format!("[Slack #{channel_id} @{user}]: {text}");
511
512 let handler = get_global_handler();
513 let req = InjectionRequest {
514 target_agent: target.clone(),
515 message: envelope,
516 source_agent: Some("slack".to_string()),
517 request_id: event.ts.clone(),
518 priority: None,
519 jekt_tier: None,
520 delivery_tier: Some("wan".to_string()),
521 forward_hops: 0,
522 ..Default::default()
523 };
524 let result = handler.inject_message(req);
525 if result.success {
526 tracing::debug!("slack_bridge: injected msg from {user} to agent {target}");
527 } else {
528 tracing::warn!(
529 "slack_bridge: inject to agent {target} failed: {:?}",
530 result.error
531 );
532 }
533}
534
535async fn run_outbound_loop(
538 http: reqwest::Client,
539 bot_token: String,
540 default_channel_id: String,
541 mut outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
542) {
543 let mut channel_last_sent: HashMap<String, Instant> = HashMap::new();
547 let mut channel_backoff_until: HashMap<String, Instant> = HashMap::new();
548 let mut channel_backoff_secs: HashMap<String, u64> = HashMap::new();
549
550 while let Some(msg) = outbound_rx.recv().await {
551 handle_outbound(
552 &http,
553 &bot_token,
554 &default_channel_id,
555 msg,
556 &mut channel_last_sent,
557 &mut channel_backoff_until,
558 &mut channel_backoff_secs,
559 )
560 .await;
561 }
562}
563
564fn resolve_channel_id(channel_id: &str, default_channel_id: &str) -> Option<String> {
567 if !channel_id.is_empty() {
568 return Some(channel_id.to_string());
569 }
570 if default_channel_id.is_empty() {
571 return None;
572 }
573 Some(default_channel_id.to_string())
574}
575
576fn next_backoff_secs(current: u64) -> u64 {
580 (current * 2).min(MAX_RECONNECT_DELAY_SECS)
581}
582
583async fn handle_outbound(
584 http: &reqwest::Client,
585 bot_token: &str,
586 default_channel_id: &str,
587 msg: OutboundMsg,
588 channel_last_sent: &mut HashMap<String, Instant>,
589 channel_backoff_until: &mut HashMap<String, Instant>,
590 channel_backoff_secs: &mut HashMap<String, u64>,
591) {
592 let Some(channel_id) = resolve_channel_id(&msg.channel_id, default_channel_id) else {
593 tracing::warn!(
594 "slack_bridge: send dropped — no channel_id (empty channel_id and no messaging:slack:channel configured)"
595 );
596 return;
597 };
598
599 if let Some(until) = channel_backoff_until.get(&channel_id).copied() {
600 let now = Instant::now();
601 if now < until {
602 tokio::time::sleep(until - now).await;
603 }
604 channel_backoff_until.remove(&channel_id);
605 }
606
607 if let Some(last) = channel_last_sent.get(&channel_id) {
608 let elapsed = last.elapsed();
609 if elapsed < CHANNEL_MIN_SPACING {
610 tokio::time::sleep(CHANNEL_MIN_SPACING - elapsed).await;
611 }
612 }
613
614 match rest::post_message(http, bot_token, &channel_id, &msg).await {
615 Ok(()) => {
616 channel_last_sent.insert(channel_id.clone(), Instant::now());
617 channel_backoff_secs.remove(&channel_id);
618 }
619 Err(e) if e.retry_after.is_some() => {
620 let retry_after = e.retry_after.unwrap();
622 tracing::warn!(
623 "slack_bridge: rate limited sending to #{channel_id}, retry_after={retry_after}s — waiting then retrying once"
624 );
625 tokio::time::sleep(Duration::from_secs(retry_after)).await;
626
627 match rest::post_message(http, bot_token, &channel_id, &msg).await {
628 Ok(()) => {
629 channel_last_sent.insert(channel_id.clone(), Instant::now());
630 channel_backoff_secs.remove(&channel_id);
631 }
632 Err(e2) if e2.retry_after.is_some() => {
633 tracing::warn!(
636 "slack_bridge: retry after rate limit still rate-limited for #{channel_id}: {e2}"
637 );
638 let current = channel_backoff_secs
639 .get(&channel_id)
640 .copied()
641 .unwrap_or(RECONNECT_DELAY_SECS);
642 channel_backoff_until.insert(channel_id.clone(), Instant::now() + Duration::from_secs(current));
643 channel_backoff_secs.insert(channel_id, next_backoff_secs(current));
644 }
645 Err(e2) => {
646 tracing::warn!(
651 "slack_bridge: retry after rate limit failed for #{channel_id} for an unrelated reason: {e2}"
652 );
653 }
654 }
655 }
656 Err(e) => {
657 tracing::warn!("slack_bridge: send to #{channel_id} failed: {e}");
658 }
659 }
660}
661
662fn unix_secs() -> u64 {
663 agentmux_common::time::now_secs_u64()
664}
665
666#[cfg(test)]
667mod tests {
668 use super::*;
669 use super::super::types::SlackEvent;
670
671 fn event(type_: &str, channel: Option<&str>, bot_id: Option<&str>) -> SlackEvent {
672 SlackEvent {
673 type_: type_.to_string(),
674 channel: channel.map(str::to_string),
675 user: Some("U1".to_string()),
676 text: Some("hi".to_string()),
677 ts: Some("123.4".to_string()),
678 bot_id: bot_id.map(str::to_string),
679 }
680 }
681
682 #[test]
683 fn should_route_accepts_message_in_configured_channel() {
684 let e = event("message", Some("C1"), None);
685 assert!(should_route(&e, "C1"));
686 }
687
688 #[test]
689 fn should_route_accepts_app_mention() {
690 let e = event("app_mention", Some("C1"), None);
691 assert!(should_route(&e, "C1"));
692 }
693
694 #[test]
695 fn should_route_rejects_other_channel() {
696 let e = event("message", Some("C2"), None);
697 assert!(!should_route(&e, "C1"));
698 }
699
700 #[test]
701 fn should_route_rejects_missing_channel() {
702 let e = event("message", None, None);
703 assert!(!should_route(&e, "C1"));
704 }
705
706 #[test]
707 fn should_route_rejects_bot_authored_events() {
708 let e = event("message", Some("C1"), Some("B1"));
709 assert!(!should_route(&e, "C1"));
710 }
711
712 #[test]
713 fn should_route_rejects_unrelated_event_types() {
714 let e = event("channel_join", Some("C1"), None);
715 assert!(!should_route(&e, "C1"));
716 }
717
718 #[test]
719 fn resolve_channel_id_uses_explicit_channel_when_present() {
720 assert_eq!(resolve_channel_id("C1", "C_default"), Some("C1".to_string()));
721 }
722
723 #[test]
724 fn resolve_channel_id_falls_back_to_default_when_empty() {
725 assert_eq!(resolve_channel_id("", "C_default"), Some("C_default".to_string()));
726 }
727
728 #[test]
729 fn resolve_channel_id_none_when_both_empty() {
730 assert_eq!(resolve_channel_id("", ""), None);
731 }
732
733 #[test]
734 fn next_backoff_secs_doubles() {
735 assert_eq!(next_backoff_secs(5), 10);
736 assert_eq!(next_backoff_secs(10), 20);
737 }
738
739 #[test]
740 fn next_backoff_secs_caps_at_max() {
741 assert_eq!(next_backoff_secs(45), MAX_RECONNECT_DELAY_SECS);
742 assert_eq!(next_backoff_secs(60), MAX_RECONNECT_DELAY_SECS);
743 }
744
745 #[test]
748 fn duplicate_warning_while_cutover_pending_is_a_noop_decision() {
749 let cutover_pending = true;
754 let should_start_new_cutover = !cutover_pending;
755 assert!(!should_start_new_cutover);
756 }
757
758 #[test]
759 fn fresh_warning_when_not_pending_starts_cutover() {
760 let cutover_pending = false;
761 let should_start_new_cutover = !cutover_pending;
762 assert!(should_start_new_cutover);
763 }
764}