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