agentmux_srv/messaging/mod.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Messaging bridge framework — common types shared by all platform bridges.
5//!
6//! Each bridge (Discord, Telegram, Slack, …) runs as a background tokio task.
7//! Inbound messages are injected into the reactive bus; outbound messages
8//! arrive via HTTP endpoint or MCP tool.
9
10pub mod discord;
11pub mod slack;
12pub mod telegram;
13pub mod whatsapp;
14
15use serde::{Deserialize, Serialize};
16
17// ── Common message types ───────────────────────────────────────────────────
18
19/// A message received from a messaging platform, normalized for reactive bus injection.
20/// Constructed in Phase 2 when per-message metadata is surfaced to the frontend.
21#[allow(dead_code)]
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct InboundMsg {
24 pub platform: String,
25 pub platform_msg_id: String,
26 pub from_id: String,
27 pub from_name: String,
28 pub channel_id: String,
29 pub text: String,
30 #[serde(default)]
31 pub attachments: Vec<String>,
32 pub received_at: u64,
33}
34
35/// A message to send to a messaging platform.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct OutboundMsg {
38 pub text: String,
39 /// Target channel. Empty → use the bridge's default channel.
40 #[serde(default)]
41 pub channel_id: String,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub reply_to: Option<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub embed: Option<MsgEmbed>,
46 /// Telegram-specific: if set, edit this existing message instead of
47 /// sending a new one (streaming-output simulation, spec §2.3). Ignored
48 /// by platforms that don't support editing (e.g. Discord in v1).
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub edit_message_id: Option<i64>,
51 /// Slack-specific: raw Block Kit `blocks` array, passed through verbatim
52 /// to `chat.postMessage` (escape hatch — see
53 /// `SPEC_MESSAGING_INTEGRATION_SLACK_2026_07_07.md` §7, §11.1). Block
54 /// Kit doesn't map onto `MsgEmbed` (no shared shape with Discord's flat
55 /// embed record), so it gets its own field here rather than a forked
56 /// per-platform outbound type. Harmless no-op for platforms that don't
57 /// support it. `text` is still always sent alongside `blocks` — Slack
58 /// requires it as the notification/accessibility fallback.
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub blocks: Option<Vec<serde_json::Value>>,
61}
62
63/// Rich embed for platforms that support structured output (Discord, Slack).
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct MsgEmbed {
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub title: Option<String>,
68 pub description: String,
69 /// Platform-specific color integer (Discord: 0xRRGGBB).
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub color: Option<u32>,
72 #[serde(default)]
73 pub fields: Vec<EmbedField>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub footer: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct EmbedField {
80 pub name: String,
81 pub value: String,
82 #[serde(default)]
83 pub inline: bool,
84}
85
86// ── Bridge health ──────────────────────────────────────────────────────────
87
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum BridgeStatus {
91 Connected,
92 Connecting,
93 Disconnected,
94 Error,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct BridgeHealth {
99 pub platform: String,
100 pub status: BridgeStatus,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub latency_ms: Option<u32>,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub last_event_at: Option<u64>,
105 pub reconnect_count: u32,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub error: Option<String>,
108}
109
110impl BridgeHealth {
111 pub fn connecting(platform: &str) -> Self {
112 BridgeHealth {
113 platform: platform.to_string(),
114 status: BridgeStatus::Connecting,
115 latency_ms: None,
116 last_event_at: None,
117 reconnect_count: 0,
118 error: None,
119 }
120 }
121}
122
123// ── Common bridge interface ────────────────────────────────────────────────
124
125/// Common interface implemented by every platform bridge (Discord, Telegram, …).
126///
127/// Bridges are singletons accessed via `get()`-style static accessors on the
128/// concrete type (see `DiscordBridge::get()`, `TelegramBridge::get()`) for the
129/// call sites that know their platform statically (e.g. platform-specific HTTP
130/// handlers). This trait exists for call sites that need to treat bridges
131/// polymorphically — today, only `handle_status`'s aggregation loop.
132pub trait MessagingBridge: Send + Sync {
133 /// Current connection/health snapshot. Cheap, non-blocking (reads an
134 /// `Arc<Mutex<BridgeHealth>>` guarded by a short-lived lock).
135 fn health(&self) -> BridgeHealth;
136}