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
85/// A chat platform adapter (design ยง3.2).
86///
87/// A channel receives events (through [`Channel::run`] or a [`WebhookHandler`]) and sends replies.
88/// It knows nothing about agents or knowledge.
89#[async_trait]
90pub trait Channel: Send + Sync {
91 /// The configured instance, for example `zalo:shop`.
92 fn id(&self) -> &ChannelId;
93
94 /// What the platform supports.
95 fn caps(&self) -> &ChannelCaps;
96
97 /// Receives events until `shutdown` is cancelled, delivering them to `sink`.
98 ///
99 /// Polling and socket platforms loop here and reconnect on their own. Webhook platforms may
100 /// only do start-up work (such as registering the webhook) and then wait for `shutdown`.
101 /// Returning an error stops this channel only.
102 async fn run(
103 &self,
104 sink: Arc<dyn InboundSink>,
105 shutdown: CancellationToken,
106 ) -> Result<(), ChannelError>;
107
108 /// Sends one message.
109 async fn send(
110 &self,
111 to: &ReplyTarget,
112 message: &RenderedMessage,
113 ) -> Result<SentRef, ChannelError>;
114
115 /// Replaces the text of a sent message. Only called when [`ChannelCaps::edit`] is true.
116 async fn edit(&self, _sent: &SentRef, _message: &RenderedMessage) -> Result<(), ChannelError> {
117 Err(ChannelError::Unsupported("edit"))
118 }
119
120 /// Shows a typing indicator once. The runtime repeats it while an agent works.
121 async fn typing(&self, _to: &ReplyTarget) -> Result<(), ChannelError> {
122 Ok(())
123 }
124
125 /// Shows a prompt (a message with buttons) in a conversation. Answers come back through
126 /// [`InboundSink::answer`]. Platforms without buttons return [`ChannelError::Unsupported`].
127 async fn prompt(&self, _to: &ReplyTarget, _prompt: &Prompt) -> Result<SentRef, ChannelError> {
128 Err(ChannelError::Unsupported("prompt"))
129 }
130
131 /// Replaces a prompt's text and removes its buttons (answered, cancelled or expired). With
132 /// `keep_buttons`, only the text changes and the prompt can be answered again.
133 async fn close_prompt(
134 &self,
135 _sent: &SentRef,
136 _message: &RenderedMessage,
137 _keep_buttons: bool,
138 ) -> Result<(), ChannelError> {
139 Err(ChannelError::Unsupported("prompt"))
140 }
141
142 /// The direct chat with `user`, opened if needed, for messages the bot starts (notifications)
143 /// to someone who has not written to it yet. Platforms whose bots cannot start a direct chat,
144 /// or need an extra permission for it, return [`ChannelError::Unsupported`]; the runtime then
145 /// only uses direct chats it has already seen.
146 async fn open_direct(&self, _user: &UserId) -> Result<ConversationKey, ChannelError> {
147 Err(ChannelError::Unsupported("open_direct"))
148 }
149
150 /// Lists the bot's commands in the platform's command menu. Called once, before
151 /// [`Channel::run`], and only when the bot has commands of its own. Commands always arrive as
152 /// text messages starting with `/name`: platforms whose menu sends something else (Discord's
153 /// slash commands) turn it into such a message. Platforms without a menu ignore this.
154 async fn set_commands(&self, _commands: &[CommandInfo]) -> Result<(), ChannelError> {
155 Ok(())
156 }
157
158 /// The webhook endpoint, for platforms that push events over HTTP.
159 fn webhook(&self) -> Option<Arc<dyn WebhookHandler>> {
160 None
161 }
162}