Skip to main content

okbot_core/
channel.rs

1//! The chat platform port.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use crate::caps::ChannelCaps;
7use crate::id::{ChannelId, ConversationKey, UserId};
8use crate::message::{InboundEvent, RenderedMessage, ReplyTarget, SentRef};
9use crate::prompt::{Prompt, PromptAnswer};
10use crate::webhook::WebhookHandler;
11use crate::{BoxError, CancellationToken, async_trait};
12
13/// An error from a channel adapter.
14#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum ChannelError {
17    /// The platform does not support the operation (see [`ChannelCaps`]).
18    #[error("not supported by this platform: {0}")]
19    Unsupported(&'static str),
20    /// Credentials were rejected. The channel stops; the operator must fix the configuration.
21    #[error("authentication failed: {0}")]
22    Auth(String),
23    /// The platform asked to slow down.
24    #[error("rate limited")]
25    RateLimited {
26        /// How long to wait, when the platform says.
27        retry_after: Option<Duration>,
28    },
29    /// A temporary failure; retrying later may succeed.
30    #[error("temporary failure: {0}")]
31    Transient(#[source] BoxError),
32    /// A permanent failure for this request.
33    #[error("{0}")]
34    Failed(#[source] BoxError),
35}
36
37impl ChannelError {
38    /// Whether retrying the same request later may succeed.
39    pub fn is_retryable(&self) -> bool {
40        matches!(self, Self::RateLimited { .. } | Self::Transient(_))
41    }
42}
43
44/// A command for the platform's command menu (`/` on Discord and Telegram).
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct CommandInfo {
47    /// Without `/`: a-z, 0-9 and `_`, at most 32.
48    pub name: String,
49    /// One line, at most 100 characters.
50    pub description: String,
51    /// The description in other languages: (language code such as `en`, `ja`, text).
52    pub descriptions: Vec<(String, String)>,
53    /// Positional parameters, required ones first.
54    pub params: Vec<CommandParam>,
55}
56
57/// A parameter of a [`CommandInfo`].
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct CommandParam {
60    /// Its name.
61    pub name: String,
62    /// Must be given.
63    pub required: bool,
64    /// May contain spaces (the rest of the line).
65    pub rest: bool,
66}
67
68/// The runtime stopped accepting events (it is shutting down).
69#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
70#[error("the runtime is no longer accepting events")]
71pub struct SinkClosed;
72
73/// Receives normalized events from channels. Implemented by the runtime.
74#[async_trait]
75pub trait InboundSink: Send + Sync {
76    /// Hands one event to the runtime. Returns quickly; processing happens elsewhere.
77    async fn deliver(&self, event: InboundEvent) -> Result<(), SinkClosed>;
78
79    /// Hands over the answer to a [`Prompt`]. Returns quickly. Sinks without prompts ignore it.
80    async fn answer(&self, _answer: PromptAnswer) -> Result<(), SinkClosed> {
81        Ok(())
82    }
83
84    /// A prompt the runtime still waits on in `conversation` (its channel and room), for channels
85    /// that no longer hold it (they restarted since showing it). Interactive replies (ADR 0015)
86    /// outlive restarts; other prompts do not.
87    async fn prompt(&self, _id: &str, _conversation: &ConversationKey) -> Option<Prompt> {
88        None
89    }
90}
91
92/// A chat platform adapter (design ยง3.2).
93///
94/// A channel receives events (through [`Channel::run`] or a [`WebhookHandler`]) and sends replies.
95/// It knows nothing about agents or knowledge.
96#[async_trait]
97pub trait Channel: Send + Sync {
98    /// The configured instance, for example `zalo:shop`.
99    fn id(&self) -> &ChannelId;
100
101    /// What the platform supports.
102    fn caps(&self) -> &ChannelCaps;
103
104    /// Receives events until `shutdown` is cancelled, delivering them to `sink`.
105    ///
106    /// Polling and socket platforms loop here and reconnect on their own. Webhook platforms may
107    /// only do start-up work (such as registering the webhook) and then wait for `shutdown`.
108    /// Returning an error stops this channel only.
109    async fn run(
110        &self,
111        sink: Arc<dyn InboundSink>,
112        shutdown: CancellationToken,
113    ) -> Result<(), ChannelError>;
114
115    /// Sends one message.
116    async fn send(
117        &self,
118        to: &ReplyTarget,
119        message: &RenderedMessage,
120    ) -> Result<SentRef, ChannelError>;
121
122    /// Replaces the text of a sent message. Only called when [`ChannelCaps::edit`] is true.
123    async fn edit(&self, _sent: &SentRef, _message: &RenderedMessage) -> Result<(), ChannelError> {
124        Err(ChannelError::Unsupported("edit"))
125    }
126
127    /// Shows a typing indicator once. The runtime repeats it while an agent works.
128    async fn typing(&self, _to: &ReplyTarget) -> Result<(), ChannelError> {
129        Ok(())
130    }
131
132    /// Shows a prompt (a message with buttons) in a conversation. Answers come back through
133    /// [`InboundSink::answer`]. Platforms without buttons return [`ChannelError::Unsupported`].
134    async fn prompt(&self, _to: &ReplyTarget, _prompt: &Prompt) -> Result<SentRef, ChannelError> {
135        Err(ChannelError::Unsupported("prompt"))
136    }
137
138    /// Replaces a prompt's text and removes its buttons (answered, cancelled or expired). With
139    /// `keep_buttons`, only the text changes and the prompt can be answered again.
140    async fn close_prompt(
141        &self,
142        _sent: &SentRef,
143        _message: &RenderedMessage,
144        _keep_buttons: bool,
145    ) -> Result<(), ChannelError> {
146        Err(ChannelError::Unsupported("prompt"))
147    }
148
149    /// Replaces a prompt's message with `prompt`: its text, its cards and whatever can still be
150    /// answered (often nothing). Needed for prompts with cards, which [`Channel::close_prompt`]
151    /// cannot rebuild without the channel's memory of them. Channels without cards keep the
152    /// default, which closes the prompt with its text and cards as one message.
153    async fn update_prompt(&self, sent: &SentRef, prompt: &Prompt) -> Result<(), ChannelError> {
154        let mut text = prompt.text.clone();
155        for card in &prompt.cards {
156            for part in [
157                card.title.as_deref().unwrap_or_default(),
158                card.text.as_str(),
159            ] {
160                if !part.trim().is_empty() {
161                    if !text.is_empty() {
162                        text.push_str("\n\n");
163                    }
164                    text.push_str(part);
165                }
166            }
167        }
168        let keep = prompt.is_answerable();
169        self.close_prompt(sent, &RenderedMessage { text }, keep)
170            .await
171    }
172
173    /// The direct chat with `user`, opened if needed, for messages the bot starts (notifications)
174    /// to someone who has not written to it yet. Platforms whose bots cannot start a direct chat,
175    /// or need an extra permission for it, return [`ChannelError::Unsupported`]; the runtime then
176    /// only uses direct chats it has already seen.
177    async fn open_direct(&self, _user: &UserId) -> Result<ConversationKey, ChannelError> {
178        Err(ChannelError::Unsupported("open_direct"))
179    }
180
181    /// Lists the bot's commands in the platform's command menu. Called once, before
182    /// [`Channel::run`], and only when the bot has commands of its own. Commands always arrive as
183    /// text messages starting with `/name`: platforms whose menu sends something else (Discord's
184    /// slash commands) turn it into such a message. Platforms without a menu ignore this.
185    async fn set_commands(&self, _commands: &[CommandInfo]) -> Result<(), ChannelError> {
186        Ok(())
187    }
188
189    /// The webhook endpoint, for platforms that push events over HTTP.
190    fn webhook(&self) -> Option<Arc<dyn WebhookHandler>> {
191        None
192    }
193}