gatewayd: sessions, routing posts to sessions, commands and the queue
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -6,4 +6,5 @@ pub mod http;
|
||||
pub mod mm;
|
||||
pub mod net;
|
||||
pub mod secrets;
|
||||
pub mod sessions;
|
||||
pub mod ws;
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
//! Which posts `gatewayd` acts on, which session each belongs to, commands, and the queue of
|
||||
//! messages per session (M4a spec, section 7). Pure: the state file and the network are elsewhere.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use proto::SessionId;
|
||||
|
||||
use crate::mm::Post;
|
||||
|
||||
pub const M4B_COMMAND: &str = "approvals over Mattermost arrive in M4b; use `bxctl approvals`";
|
||||
pub const UNKNOWN_COMMAND: &str = "unknown command; the commands are !approve and !deny";
|
||||
pub const BUSY: &str = "busy: too many messages are waiting in this conversation";
|
||||
|
||||
/// Names that name nobody: every agent in the channel would answer them.
|
||||
const EVERYONE: [&str; 3] = ["channel", "here", "all"];
|
||||
|
||||
/// Why a post was not acted on. Only `NotAllowed` is logged, by user id and post id.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Ignored {
|
||||
Own,
|
||||
System,
|
||||
NotAllowed,
|
||||
NotForUs,
|
||||
}
|
||||
|
||||
/// Where an answer goes: a channel, and the root of the thread in it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Thread {
|
||||
pub channel: String,
|
||||
pub root: String,
|
||||
}
|
||||
|
||||
/// A message for a session.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Message {
|
||||
pub session: SessionId,
|
||||
pub thread: Thread,
|
||||
/// A reply in a thread: the session should exist already.
|
||||
pub resume: bool,
|
||||
pub text: String,
|
||||
/// A thread in a channel or group message that this Boxmaker now takes part in.
|
||||
pub joins_thread: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Route {
|
||||
Ignore(Ignored),
|
||||
/// Answer in the thread without a turn (a command).
|
||||
Reply {
|
||||
thread: Thread,
|
||||
text: String,
|
||||
},
|
||||
Queue(Message),
|
||||
}
|
||||
|
||||
pub struct Router {
|
||||
me_id: String,
|
||||
me_name: String,
|
||||
users: BTreeSet<String>,
|
||||
channels: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// Every `@name` in a message, lower-cased: `a-z`, `0-9`, `.`, `-` and `_` after an `@`, without
|
||||
/// trailing dots.
|
||||
pub fn named(message: &str) -> Vec<String> {
|
||||
// Find each "@". The name after it is the longest run of ASCII letters, digits, ".", "-" and
|
||||
// "_", with trailing "." removed and lower-cased. Skip empty names. Continue after the name.
|
||||
let mut names = Vec::new();
|
||||
let bytes = message.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] != b'@' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i + 1;
|
||||
let mut j = start;
|
||||
while j < bytes.len() {
|
||||
let c = bytes[j];
|
||||
if c.is_ascii_alphanumeric() || c == b'.' || c == b'-' || c == b'_' {
|
||||
j += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if j > start {
|
||||
let trimmed = message[start..j].trim_end_matches('.').to_lowercase();
|
||||
if !trimmed.is_empty() {
|
||||
names.push(trimmed);
|
||||
}
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
impl Router {
|
||||
pub fn new(me_id: &str, me_name: &str, users: &[String], channels: &[String]) -> Router {
|
||||
// Store the ids, the username lower-cased, and the two lists as sets.
|
||||
Router {
|
||||
me_id: me_id.to_string(),
|
||||
me_name: me_name.to_lowercase(),
|
||||
users: users.iter().cloned().collect(),
|
||||
channels: channels.iter().cloned().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is a post in a channel or group message for this Boxmaker? `known` says whether it has a
|
||||
/// session for a thread root.
|
||||
fn for_us(&self, post: &Post, known: &dyn Fn(&str) -> bool) -> bool {
|
||||
// Named this bot -> true. Otherwise true only for a reply (root_id not empty) in a known
|
||||
// thread (`known(root_id)`) that names nobody but channel, here or all.
|
||||
let names = named(&post.message);
|
||||
if names.iter().any(|n| n == &self.me_name) {
|
||||
return true;
|
||||
}
|
||||
if post.root_id.is_empty() || !known(&post.root_id) {
|
||||
return false;
|
||||
}
|
||||
names.iter().all(|n| EVERYONE.contains(&n.as_str()))
|
||||
}
|
||||
|
||||
/// What to do with a new post (a post seen before is dropped by the caller first).
|
||||
pub fn route(&self, post: &Post, channel_type: &str, known: &dyn Fn(&str) -> bool) -> Route {
|
||||
// Section 7 of the spec, in this order: own post -> Ignore(Own); kind not empty ->
|
||||
// Ignore(System); user not allowed -> Ignore(NotAllowed); then the channel: "D" is always
|
||||
// ours; "O", "P" or "G" is ours when its id is allowed and `for_us`; anything else ->
|
||||
// Ignore(NotForUs). The thread root is root_id, or the post id when root_id is empty. Then
|
||||
// commands: "!!..." drops one "!" and goes on as a message; "!" then a first word approve
|
||||
// or deny -> Reply M4B_COMMAND; any other "!" -> Reply UNKNOWN_COMMAND. Then Queue: session
|
||||
// "mm-<root>" (if SessionId::new fails, Ignore(NotForUs)), resume = root_id not empty,
|
||||
// joins_thread = not "D".
|
||||
if post.user_id == self.me_id {
|
||||
return Route::Ignore(Ignored::Own);
|
||||
}
|
||||
if !post.kind.is_empty() {
|
||||
return Route::Ignore(Ignored::System);
|
||||
}
|
||||
if !self.users.contains(&post.user_id) {
|
||||
return Route::Ignore(Ignored::NotAllowed);
|
||||
}
|
||||
let channel_ok = match channel_type {
|
||||
"D" => true,
|
||||
"O" | "P" | "G" => self.channels.contains(&post.channel_id) && self.for_us(post, known),
|
||||
_ => false,
|
||||
};
|
||||
if !channel_ok {
|
||||
return Route::Ignore(Ignored::NotForUs);
|
||||
}
|
||||
|
||||
let root = if post.root_id.is_empty() {
|
||||
post.id.clone()
|
||||
} else {
|
||||
post.root_id.clone()
|
||||
};
|
||||
let session = match SessionId::new(&format!("mm-{root}")) {
|
||||
Ok(session) => session,
|
||||
Err(_) => return Route::Ignore(Ignored::NotForUs),
|
||||
};
|
||||
let thread = Thread {
|
||||
channel: post.channel_id.clone(),
|
||||
root: root.clone(),
|
||||
};
|
||||
|
||||
let message = &post.message;
|
||||
if let Some(rest) = message.strip_prefix("!!") {
|
||||
return Route::Queue(Message {
|
||||
session,
|
||||
thread,
|
||||
resume: !post.root_id.is_empty(),
|
||||
text: format!("!{rest}"),
|
||||
joins_thread: channel_type != "D",
|
||||
});
|
||||
}
|
||||
if let Some(after) = message.strip_prefix('!') {
|
||||
let answer = match after.split_whitespace().next() {
|
||||
Some("approve") | Some("deny") => M4B_COMMAND,
|
||||
_ => UNKNOWN_COMMAND,
|
||||
};
|
||||
return Route::Reply {
|
||||
thread,
|
||||
text: answer.to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
Route::Queue(Message {
|
||||
session,
|
||||
thread,
|
||||
resume: !post.root_id.is_empty(),
|
||||
text: message.clone(),
|
||||
joins_thread: channel_type != "D",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A turn to send: every message that was waiting, joined with a blank line.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Batch {
|
||||
pub session: SessionId,
|
||||
pub thread: Thread,
|
||||
pub resume: bool,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Pushed {
|
||||
/// No turn was running: send this one now.
|
||||
Start(Batch),
|
||||
/// A turn is running; the message waits for the next.
|
||||
Waiting,
|
||||
/// Too many are waiting: the message is dropped, answer `BUSY`.
|
||||
Full(Thread),
|
||||
}
|
||||
|
||||
struct Pending {
|
||||
thread: Thread,
|
||||
waiting: Vec<String>,
|
||||
}
|
||||
|
||||
/// The sessions with a turn running, and the messages waiting for each.
|
||||
pub struct Queues {
|
||||
limit: usize,
|
||||
running: HashMap<SessionId, Pending>,
|
||||
}
|
||||
|
||||
impl Queues {
|
||||
/// `limit` is the most messages that may wait per session.
|
||||
pub fn new(limit: usize) -> Queues {
|
||||
// An empty map.
|
||||
Queues {
|
||||
limit,
|
||||
running: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, message: Message) -> Pushed {
|
||||
// If the session is running: with `limit` messages already waiting -> Full(thread); else
|
||||
// add the text to waiting -> Waiting. Otherwise insert it as running (its later batches
|
||||
// resume: true) and return Start with this message alone.
|
||||
let limit = self.limit;
|
||||
if let Some(pending) = self.running.get_mut(&message.session) {
|
||||
if pending.waiting.len() >= limit {
|
||||
return Pushed::Full(pending.thread.clone());
|
||||
}
|
||||
pending.waiting.push(message.text.clone());
|
||||
return Pushed::Waiting;
|
||||
}
|
||||
let batch = Batch {
|
||||
session: message.session.clone(),
|
||||
thread: message.thread.clone(),
|
||||
resume: message.resume,
|
||||
text: message.text.clone(),
|
||||
};
|
||||
self.running.insert(
|
||||
message.session,
|
||||
Pending {
|
||||
thread: message.thread.clone(),
|
||||
waiting: Vec::new(),
|
||||
},
|
||||
);
|
||||
Pushed::Start(batch)
|
||||
}
|
||||
|
||||
/// A session's turn ended: the next batch, or `None`, and the session is no longer running.
|
||||
pub fn finish(&mut self, session: &SessionId) -> Option<Batch> {
|
||||
// Not running -> None. Nothing waiting -> remove it, None. Otherwise take every waiting
|
||||
// text, joined with "\n\n", as the next Batch (it stays running).
|
||||
let mut pending = self.running.remove(session)?;
|
||||
if pending.waiting.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let batch = Batch {
|
||||
session: session.clone(),
|
||||
thread: pending.thread.clone(),
|
||||
resume: true,
|
||||
text: pending.waiting.join("\n\n"),
|
||||
};
|
||||
pending.waiting.clear();
|
||||
self.running.insert(session.clone(), pending);
|
||||
Some(batch)
|
||||
}
|
||||
|
||||
/// How many sessions have a turn running.
|
||||
pub fn running(&self) -> usize {
|
||||
// How many sessions are running.
|
||||
self.running.len()
|
||||
}
|
||||
|
||||
/// The threads with a turn running, for showing this bot as typing in them.
|
||||
pub fn threads(&self) -> Vec<Thread> {
|
||||
// The thread of every running session.
|
||||
self.running.values().map(|p| p.thread.clone()).collect()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user