Skip to main content

agentmux_srv/server/
messaging_handlers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! HTTP handlers for the messaging bridge layer.
5//!
6//! Routes:
7//!   GET  /api/messaging/status          — health of all active bridges
8//!   POST /api/messaging/discord/send    — send a message via the Discord bridge
9//!   POST /api/messaging/telegram/send   — send a message via the Telegram bridge
10//!   POST /api/messaging/slack/send      — send a message via the Slack bridge
11//!   POST /api/messaging/whatsapp/send   — send a message via the WhatsApp bridge
12//!
13//! The WhatsApp *inbound* webhook receiver (`GET`/`POST /webhook/whatsapp`)
14//! is not here — it's unauthenticated by necessity (Meta can't supply
15//! `X-AuthKey`) and lives in `messaging::whatsapp::webhook`, registered
16//! directly on `server/mod.rs`'s top-level router alongside `health`, not in
17//! this authed handler module. See `server/mod.rs` and
18//! `docs/specs/SPEC_MESSAGING_INTEGRATION_WHATSAPP_2026_07_07.md` §8.2.
19
20use axum::{
21    extract::State,
22    http::StatusCode,
23    response::{IntoResponse, Json},
24};
25use serde::Deserialize;
26use serde_json::json;
27
28use super::AppState;
29use crate::messaging::{EmbedField, MsgEmbed, MessagingBridge, OutboundMsg};
30use crate::messaging::discord::DiscordBridge;
31use crate::messaging::slack::SlackBridge;
32use crate::messaging::telegram::TelegramBridge;
33use crate::messaging::whatsapp::WhatsAppBridge;
34
35/// GET /api/messaging/status
36pub(super) async fn handle_status(State(_state): State<AppState>) -> impl IntoResponse {
37    let mut bridges = vec![];
38    if let Some(b) = DiscordBridge::get() {
39        bridges.push((b as &dyn MessagingBridge).health());
40    }
41    if let Some(b) = TelegramBridge::get() {
42        bridges.push((b as &dyn MessagingBridge).health());
43    }
44    if let Some(b) = SlackBridge::get() {
45        bridges.push((b as &dyn MessagingBridge).health());
46    }
47    if let Some(b) = WhatsAppBridge::get() {
48        bridges.push((b as &dyn MessagingBridge).health());
49    }
50    Json(json!({ "bridges": bridges }))
51}
52
53/// POST /api/messaging/discord/send
54#[derive(Deserialize)]
55pub(super) struct DiscordSendRequest {
56    /// Message text (may be empty when using embed only).
57    #[serde(default)]
58    pub text: String,
59    /// Override channel. Empty → use the bridge's default channel.
60    #[serde(default)]
61    pub channel_id: String,
62    /// Optional embed title.
63    pub title: Option<String>,
64    /// If present, creates a rich embed with this as the description.
65    pub embed_description: Option<String>,
66    /// Embed footer text (default: "via AgentMux").
67    pub footer: Option<String>,
68    /// Embed fields.
69    #[serde(default)]
70    pub fields: Vec<EmbedFieldRequest>,
71}
72
73#[derive(Deserialize)]
74pub(super) struct EmbedFieldRequest {
75    pub name: String,
76    pub value: String,
77    #[serde(default)]
78    pub inline: bool,
79}
80
81pub(super) async fn handle_discord_send(
82    State(_state): State<AppState>,
83    Json(req): Json<DiscordSendRequest>,
84) -> impl IntoResponse {
85    let bridge = match DiscordBridge::get() {
86        Some(b) => b,
87        None => {
88            return (
89                StatusCode::SERVICE_UNAVAILABLE,
90                Json(json!({"error": "discord bridge not initialized — set messaging:discord:enabled in settings"})),
91            )
92                .into_response();
93        }
94    };
95
96    let embed = req.embed_description.map(|desc| MsgEmbed {
97        title: req.title,
98        description: desc,
99        color: Some(5_763_719), // Discord green #57F287
100        fields: req
101            .fields
102            .into_iter()
103            .map(|f| EmbedField {
104                name: f.name,
105                value: f.value,
106                inline: f.inline,
107            })
108            .collect(),
109        footer: Some(req.footer.unwrap_or_else(|| "via AgentMux".to_string())),
110    });
111
112    let msg = OutboundMsg {
113        text: req.text,
114        channel_id: req.channel_id,
115        reply_to: None,
116        embed,
117        edit_message_id: None,
118        blocks: None,
119    };
120
121    match bridge.send(msg) {
122        Ok(()) => Json(json!({ "ok": true })).into_response(),
123        Err(e) => (
124            StatusCode::INTERNAL_SERVER_ERROR,
125            Json(json!({ "error": e })),
126        )
127            .into_response(),
128    }
129}
130
131/// POST /api/messaging/whatsapp/send
132///
133/// No embed field — WhatsApp's Cloud API has no rich embed format (spec
134/// §5.3, §8.1). `to` maps onto `OutboundMsg.channel_id`, which WhatsApp
135/// repurposes as "recipient phone number" since the platform has no
136/// channel/room concept (see that field's doc comment in `messaging/mod.rs`).
137#[derive(Deserialize)]
138pub(super) struct WhatsAppSendRequest {
139    /// Recipient phone number, E.164 format preferred (e.g. "+14155552671").
140    #[serde(default)]
141    pub to: String,
142    #[serde(default)]
143    pub text: String,
144}
145
146pub(super) async fn handle_whatsapp_send(
147    State(_state): State<AppState>,
148    Json(req): Json<WhatsAppSendRequest>,
149) -> impl IntoResponse {
150    let bridge = match WhatsAppBridge::get() {
151        Some(b) => b,
152        None => {
153            return (
154                StatusCode::SERVICE_UNAVAILABLE,
155                Json(json!({"error": "whatsapp bridge not initialized — set messaging:whatsapp:enabled in settings"})),
156            )
157                .into_response();
158        }
159    };
160
161    if req.to.trim().is_empty() {
162        return (
163            StatusCode::BAD_REQUEST,
164            Json(json!({"error": "whatsapp send: \"to\" (recipient phone number) is required"})),
165        )
166            .into_response();
167    }
168
169    let msg = OutboundMsg {
170        text: req.text,
171        channel_id: req.to,
172        reply_to: None,
173        embed: None,
174        edit_message_id: None,
175        blocks: None,
176    };
177
178    match bridge.send(msg) {
179        Ok(()) => Json(json!({ "ok": true })).into_response(),
180        Err(e) => (
181            StatusCode::INTERNAL_SERVER_ERROR,
182            Json(json!({ "error": e })),
183        )
184            .into_response(),
185    }
186}
187
188/// POST /api/messaging/telegram/send
189#[derive(Deserialize)]
190pub(super) struct TelegramSendRequest {
191    #[serde(default)]
192    pub text: String,
193    /// Override chat. Empty → use the bridge's default chat
194    /// (messaging:telegram:default_chat).
195    #[serde(default)]
196    pub chat_id: String,
197    /// If set, edits this existing message instead of sending a new one
198    /// (see spec §2.3 — streaming-output simulation).
199    pub edit_message_id: Option<i64>,
200}
201
202pub(super) async fn handle_telegram_send(
203    State(_state): State<AppState>,
204    Json(req): Json<TelegramSendRequest>,
205) -> impl IntoResponse {
206    let bridge = match TelegramBridge::get() {
207        Some(b) => b,
208        None => {
209            return (
210                StatusCode::SERVICE_UNAVAILABLE,
211                Json(json!({"error": "telegram bridge not initialized — set messaging:telegram:enabled in settings"})),
212            )
213                .into_response();
214        }
215    };
216
217    let msg = OutboundMsg {
218        text: req.text,
219        channel_id: req.chat_id,
220        reply_to: None,
221        embed: None,
222        edit_message_id: req.edit_message_id,
223        blocks: None,
224    };
225
226    match bridge.send(msg) {
227        Ok(()) => Json(json!({ "ok": true })).into_response(),
228        Err(e) => (
229            StatusCode::INTERNAL_SERVER_ERROR,
230            Json(json!({ "error": e })),
231        )
232            .into_response(),
233    }
234}
235
236/// POST /api/messaging/slack/send
237///
238/// Dual path (spec §7): a raw Block Kit `blocks` escape hatch, a title/body
239/// convenience path that synthesizes a minimal 2-block message server-side,
240/// or plain `text` only. Block Kit doesn't map onto the shared `MsgEmbed`
241/// struct (see `OutboundMsg.blocks` doc comment in `messaging/mod.rs`), so
242/// this handler builds `blocks: Vec<serde_json::Value>` directly rather than
243/// reusing the Discord embed request shape.
244#[derive(Deserialize)]
245pub(super) struct SlackSendRequest {
246    /// Plain text — always sent as the top-level "text" field (required by
247    /// Slack as the fallback/notification string even when blocks are
248    /// present).
249    #[serde(default)]
250    pub text: String,
251    /// Override channel. Empty → use the bridge's default channel.
252    #[serde(default)]
253    pub channel_id: String,
254    /// Optional convenience path: a title + body rendered as a simple
255    /// two-block Block Kit message (header + section).
256    pub title: Option<String>,
257    pub body: Option<String>,
258    /// Escape hatch: raw Block Kit `blocks` array, passed through verbatim
259    /// to chat.postMessage. If present, takes precedence over title/body.
260    pub blocks: Option<Vec<serde_json::Value>>,
261}
262
263pub(super) async fn handle_slack_send(
264    State(_state): State<AppState>,
265    Json(req): Json<SlackSendRequest>,
266) -> impl IntoResponse {
267    let bridge = match SlackBridge::get() {
268        Some(b) => b,
269        None => {
270            return (
271                StatusCode::SERVICE_UNAVAILABLE,
272                Json(json!({"error": "slack bridge not initialized — set messaging:slack:enabled in settings"})),
273            )
274                .into_response();
275        }
276    };
277
278    let blocks = if let Some(raw_blocks) = req.blocks {
279        Some(raw_blocks)
280    } else if req.title.is_some() || req.body.is_some() {
281        let mut blocks = vec![];
282        if let Some(title) = &req.title {
283            blocks.push(json!({
284                "type": "header",
285                "text": { "type": "plain_text", "text": title }
286            }));
287        }
288        if let Some(body) = &req.body {
289            blocks.push(json!({
290                "type": "section",
291                "text": { "type": "mrkdwn", "text": body }
292            }));
293        }
294        Some(blocks)
295    } else {
296        None
297    };
298
299    let text = if req.text.is_empty() {
300        // Slack requires non-empty `text` as the fallback string even when
301        // `blocks` carries the real content — synthesize one from
302        // title/body so a title/body-only request doesn't fail Slack-side.
303        match (&req.title, &req.body) {
304            (Some(t), Some(b)) => format!("{t}: {b}"),
305            (Some(t), None) => t.clone(),
306            (None, Some(b)) => b.clone(),
307            (None, None) => String::new(),
308        }
309    } else {
310        req.text
311    };
312
313    let msg = OutboundMsg {
314        text,
315        channel_id: req.channel_id,
316        reply_to: None,
317        embed: None,
318        edit_message_id: None,
319        blocks,
320    };
321
322    match bridge.send(msg) {
323        Ok(()) => Json(json!({ "ok": true })).into_response(),
324        Err(e) => (
325            StatusCode::INTERNAL_SERVER_ERROR,
326            Json(json!({ "error": e })),
327        )
328            .into_response(),
329    }
330}