Skip to main content

agentmux_srv/messaging/slack/
types.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Slack Socket Mode + Web API wire types.
5//!
6//! Block Kit is represented as raw `serde_json::Value` rather than a typed
7//! enum tree (spec §4.4, §7, §11.2) — Slack's block/element/object surface
8//! is large and evolving; a raw escape hatch is the deliberate v1 choice.
9
10use serde::{Deserialize, Serialize};
11
12// ── Socket Mode inbound envelope ────────────────────────────────────────────
13
14/// Tagged on the wire `"type"` field. `SlashCommands` is parsed for forward
15/// compatibility (an envelope of this kind can arrive if the app has any
16/// slash command registered) but its payload is not modeled/acted on — slash
17/// command handling is explicitly out of scope for this PR (spec §2.5, §10
18/// Phase 4).
19#[derive(Debug, Deserialize)]
20#[serde(tag = "type", rename_all = "snake_case")]
21pub enum SocketEnvelope {
22    /// Content unused today — only the variant tag matters (first-frame
23    /// check in `socket.rs::open_and_await_hello`, reconnect-ack log line in
24    /// `handle_frame`).
25    #[allow(dead_code)]
26    Hello(HelloFrame),
27    EventsApi(EventsApiEnvelope),
28    Disconnect(DisconnectFrame),
29    SlashCommands(SlashCommandsEnvelope),
30    #[serde(other)]
31    Unknown,
32}
33
34#[derive(Debug, Deserialize)]
35pub struct HelloFrame {
36    #[serde(default)]
37    #[allow(dead_code)]
38    pub num_connections: Option<u32>,
39}
40
41#[derive(Debug, Deserialize)]
42pub struct EventsApiEnvelope {
43    pub envelope_id: String,
44    #[serde(default)]
45    pub payload: Option<EventsApiPayload>,
46}
47
48#[derive(Debug, Deserialize)]
49pub struct EventsApiPayload {
50    #[serde(default)]
51    pub event: Option<SlackEvent>,
52}
53
54#[derive(Debug, Clone, Deserialize)]
55pub struct SlackEvent {
56    #[serde(rename = "type")]
57    pub type_: String,
58    #[serde(default)]
59    pub channel: Option<String>,
60    #[serde(default)]
61    pub user: Option<String>,
62    #[serde(default)]
63    pub text: Option<String>,
64    #[serde(default)]
65    pub ts: Option<String>,
66    /// Present on bot-authored events, including this bridge's own posts —
67    /// used for self-message loop prevention (spec §9.4).
68    #[serde(default)]
69    pub bot_id: Option<String>,
70}
71
72#[derive(Debug, Deserialize)]
73pub struct DisconnectFrame {
74    #[serde(default)]
75    pub reason: Option<String>,
76}
77
78/// Present for wire-compatibility only — not routed/acted on (see module doc).
79#[derive(Debug, Deserialize)]
80#[allow(dead_code)]
81pub struct SlashCommandsEnvelope {
82    pub envelope_id: String,
83    #[serde(default)]
84    pub payload: Option<serde_json::Value>,
85}
86
87// ── Socket Mode outbound (ACK) ──────────────────────────────────────────────
88
89/// Sent back on the same socket a `events_api`/`slash_commands` envelope
90/// arrived on, within 3 seconds of receipt (spec §2.2 — hard correctness
91/// requirement).
92#[derive(Debug, Serialize)]
93pub struct AckFrame {
94    pub envelope_id: String,
95}
96
97// ── Web API: apps.connections.open ──────────────────────────────────────────
98
99#[derive(Debug, Deserialize)]
100pub struct OpenConnectionResponse {
101    pub ok: bool,
102    #[serde(default)]
103    pub url: Option<String>,
104    #[serde(default)]
105    pub error: Option<String>,
106}
107
108// ── Web API: chat.postMessage ───────────────────────────────────────────────
109
110#[derive(Debug, Serialize)]
111pub struct PostMessageBody {
112    pub channel: String,
113    /// Always sent, even alongside `blocks` — Slack uses it as the
114    /// notification/accessibility fallback (spec §2.4).
115    pub text: String,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub blocks: Option<Vec<serde_json::Value>>,
118}
119
120#[derive(Debug, Deserialize)]
121pub struct PostMessageResponse {
122    pub ok: bool,
123    #[serde(default)]
124    pub error: Option<String>,
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn parses_hello_frame() {
133        let json = r#"{"type": "hello", "num_connections": 1, "connection_info": {"app_id": "A1"}}"#;
134        let env: SocketEnvelope = serde_json::from_str(json).unwrap();
135        assert!(matches!(env, SocketEnvelope::Hello(_)));
136    }
137
138    #[test]
139    fn parses_disconnect_warning_frame() {
140        let json = r#"{"type": "disconnect", "reason": "warning"}"#;
141        let env: SocketEnvelope = serde_json::from_str(json).unwrap();
142        match env {
143            SocketEnvelope::Disconnect(d) => assert_eq!(d.reason.as_deref(), Some("warning")),
144            other => panic!("expected Disconnect, got {other:?}"),
145        }
146    }
147
148    #[test]
149    fn parses_events_api_envelope_with_message_event() {
150        let json = r#"{
151            "envelope_id": "95869",
152            "type": "events_api",
153            "accepts_response_payload": false,
154            "payload": {
155                "event": {"type": "message", "channel": "C1", "user": "U1", "text": "hi", "ts": "123.4"}
156            }
157        }"#;
158        let env: SocketEnvelope = serde_json::from_str(json).unwrap();
159        match env {
160            SocketEnvelope::EventsApi(e) => {
161                assert_eq!(e.envelope_id, "95869");
162                let event = e.payload.unwrap().event.unwrap();
163                assert_eq!(event.type_, "message");
164                assert_eq!(event.channel.as_deref(), Some("C1"));
165                assert_eq!(event.text.as_deref(), Some("hi"));
166                assert!(event.bot_id.is_none());
167            }
168            other => panic!("expected EventsApi, got {other:?}"),
169        }
170    }
171
172    #[test]
173    fn parses_events_api_envelope_with_bot_id() {
174        let json = r#"{
175            "envelope_id": "1",
176            "type": "events_api",
177            "payload": {"event": {"type": "message", "channel": "C1", "bot_id": "B1"}}
178        }"#;
179        let env: SocketEnvelope = serde_json::from_str(json).unwrap();
180        match env {
181            SocketEnvelope::EventsApi(e) => {
182                let event = e.payload.unwrap().event.unwrap();
183                assert_eq!(event.bot_id.as_deref(), Some("B1"));
184            }
185            other => panic!("expected EventsApi, got {other:?}"),
186        }
187    }
188
189    #[test]
190    fn unknown_envelope_type_does_not_error() {
191        let json = r#"{"type": "some_future_type", "foo": "bar"}"#;
192        let env: SocketEnvelope = serde_json::from_str(json).unwrap();
193        assert!(matches!(env, SocketEnvelope::Unknown));
194    }
195
196    #[test]
197    fn ack_frame_serializes_envelope_id_only() {
198        let ack = AckFrame { envelope_id: "95869".to_string() };
199        let json = serde_json::to_string(&ack).unwrap();
200        assert_eq!(json, r#"{"envelope_id":"95869"}"#);
201    }
202
203    #[test]
204    fn post_message_body_omits_blocks_when_none() {
205        let body = PostMessageBody {
206            channel: "C1".to_string(),
207            text: "hi".to_string(),
208            blocks: None,
209        };
210        let json = serde_json::to_string(&body).unwrap();
211        assert!(!json.contains("blocks"));
212    }
213}