bamboo_server/connect/platform.rs
1//! The `Platform` trait + shared inbound/outbound message types.
2//!
3//! Per epic #447: cc-connect's proven 5-method core, with capabilities
4//! EXPLICIT (not type-asserted) so the bridge/render layer never guesses what
5//! an adapter can do. `ReplyCtx` is platform-opaque (`serde_json::Value`),
6//! carried on every [`InboundMessage`] and handed back to
7//! `Platform::reply`/`Platform::edit` unmodified — mirrors cc-connect's
8//! `replyCtx any` pattern.
9
10use tokio::sync::mpsc;
11
12/// What a platform adapter supports. MVP adapters (Telegram, phase 1) advertise
13/// everything `false` except plain text — buttons/streaming-edit are phase 2.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
15pub struct Capabilities {
16 /// Inline approval/action buttons on a message.
17 pub buttons: bool,
18 /// In-place message editing (`Platform::edit`) — used for streaming
19 /// edit-in-place in a later phase.
20 pub edit_message: bool,
21 /// Sending image attachments.
22 pub images: bool,
23 /// Sending file attachments.
24 pub files: bool,
25}
26
27/// Platform-opaque context handed back unmodified to `Platform::reply`/`edit`.
28/// Concrete shape is decided by each adapter (e.g. Telegram stores `chat_id`).
29#[derive(Debug, Clone, PartialEq)]
30pub struct ReplyCtx(pub serde_json::Value);
31
32/// A reference to a previously-sent message, for `Platform::edit`
33/// (capability-gated on [`Capabilities::edit_message`]).
34#[derive(Debug, Clone, PartialEq)]
35pub struct MessageRef(pub serde_json::Value);
36
37/// A message received from a platform, normalized to the shape the bridge
38/// understands. Fields beyond `text`/`reply_ctx` exist to let the bridge
39/// enforce security policy (allow-list, dedup) generically across every
40/// platform adapter, without knowing platform-specific wire formats.
41#[derive(Debug, Clone)]
42pub struct InboundMessage {
43 /// Platform name (matches `Platform::name`), e.g. `"telegram"`.
44 pub platform: String,
45 /// Platform-scoped chat identifier.
46 pub chat_id: String,
47 /// Platform-scoped sender identifier (checked against `allow_from`).
48 pub user_id: String,
49 /// Platform-scoped, per-platform-unique message id used for dedup (e.g.
50 /// Telegram's `update_id`, stringified).
51 pub message_id: String,
52 /// When the platform says the message was sent — used to drop stale
53 /// backlog delivered right after a restart (older than process start).
54 pub sent_at: chrono::DateTime<chrono::Utc>,
55 /// Message text.
56 pub text: String,
57 /// Opaque context to hand back to `Platform::reply`/`edit`.
58 pub reply_ctx: ReplyCtx,
59}
60
61/// One inline approval/action button (phase 2, issue #458). `callback_data`
62/// is echoed back verbatim on press — the ONLY thing it may carry is a short
63/// ask-nonce + option selector (`"{nonce}:{option_index}"`, see
64/// `connect::approvals`), NEVER raw user text or anything else sensitive.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Button {
67 pub label: String,
68 pub callback_data: String,
69}
70
71impl Button {
72 pub fn new(label: impl Into<String>, callback_data: impl Into<String>) -> Self {
73 Self {
74 label: label.into(),
75 callback_data: callback_data.into(),
76 }
77 }
78}
79
80/// A message to send to a platform.
81#[derive(Debug, Clone)]
82pub struct OutboundMessage {
83 pub text: String,
84 /// Inline keyboard rows (capability-gated on [`Capabilities::buttons`]).
85 /// Adapters that don't advertise `buttons` ignore this field.
86 pub buttons: Option<Vec<Vec<Button>>>,
87}
88
89impl OutboundMessage {
90 pub fn text(text: impl Into<String>) -> Self {
91 Self {
92 text: text.into(),
93 buttons: None,
94 }
95 }
96
97 pub fn with_buttons(mut self, buttons: Vec<Vec<Button>>) -> Self {
98 self.buttons = Some(buttons);
99 self
100 }
101}
102
103/// An inline-button press received from a platform (capability-gated on
104/// [`Capabilities::buttons`]) — the counterpart to a text [`InboundMessage`].
105#[derive(Debug, Clone)]
106pub struct CallbackQuery {
107 /// Platform name (matches `Platform::name`), e.g. `"telegram"`.
108 pub platform: String,
109 /// Platform-scoped chat identifier.
110 pub chat_id: String,
111 /// Platform-scoped sender identifier (checked against `allow_from`,
112 /// exactly like [`InboundMessage::user_id`]).
113 pub user_id: String,
114 /// Platform-scoped callback-query identifier. Every callback query MUST
115 /// be acknowledged via `Platform::answer_callback` exactly once — success
116 /// or not (stale/forged data still gets acked, just silently dropped).
117 pub callback_query_id: String,
118 /// The pressed button's `callback_data`, verbatim.
119 pub data: String,
120 /// Opaque context to hand back to `Platform::reply`/`edit`.
121 pub reply_ctx: ReplyCtx,
122}
123
124/// Everything a platform can push onto its inbound channel: a text message or
125/// a button press. Kept as one enum (rather than two channels) so a platform
126/// whose transport interleaves both in a single feed (Telegram's
127/// `getUpdates`) preserves delivery order end to end.
128#[derive(Debug, Clone)]
129pub enum Inbound {
130 Message(InboundMessage),
131 Callback(CallbackQuery),
132}
133
134/// Error returned by a `Platform` adapter operation.
135#[derive(Debug, thiserror::Error)]
136pub enum PlatformError {
137 #[error("{0}")]
138 Other(String),
139}
140
141impl PlatformError {
142 pub fn other(msg: impl Into<String>) -> Self {
143 Self::Other(msg.into())
144 }
145}
146
147pub type PlatformResult<T> = std::result::Result<T, PlatformError>;
148
149/// An IM-platform adapter. cc-connect's proven 5-method core (`start`,
150/// `reply`, `edit`, `stop`, plus `capabilities`/`name`) — see epic #447.
151#[async_trait::async_trait]
152pub trait Platform: Send + Sync {
153 /// Short identifier, e.g. `"telegram"`. Matches [`InboundMessage::platform`].
154 fn name(&self) -> &str;
155
156 /// What this adapter supports.
157 fn capabilities(&self) -> Capabilities;
158
159 /// Start receiving inbound events (messages and/or button presses),
160 /// sending each onto `inbound`. Runs for the adapter's lifetime (a
161 /// long-poll loop, a WS connection, …); returns only on an unrecoverable
162 /// error or `stop()`.
163 async fn start(&self, inbound: mpsc::Sender<Inbound>) -> PlatformResult<()>;
164
165 /// Send a message in reply to `ctx`.
166 async fn reply(&self, ctx: &ReplyCtx, msg: OutboundMessage) -> PlatformResult<MessageRef>;
167
168 /// Edit a previously-sent message in place. Capability-gated on
169 /// [`Capabilities::edit_message`]; adapters that don't support it may
170 /// return an error — callers must check the capability first.
171 async fn edit(&self, msg_ref: &MessageRef, new: OutboundMessage) -> PlatformResult<()>;
172
173 /// Acknowledge a callback query (capability-gated on
174 /// [`Capabilities::buttons`] — adapters without button support never
175 /// receive one to acknowledge, so the default no-op is correct for them).
176 /// Telegram requires exactly one ack per callback query, success or not;
177 /// `text`, when `Some`, is shown as a brief toast (e.g. an "expired"
178 /// notice for a stale/forged nonce).
179 async fn answer_callback(
180 &self,
181 _callback_query_id: &str,
182 _text: Option<&str>,
183 ) -> PlatformResult<()> {
184 Ok(())
185 }
186
187 /// Stop the adapter (best-effort; graceful shutdown).
188 async fn stop(&self) -> PlatformResult<()>;
189}