Skip to main content

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}