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 mm;
|
||||||
pub mod net;
|
pub mod net;
|
||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
|
pub mod sessions;
|
||||||
pub mod ws;
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
//! Which posts become turns, in which session, and how messages wait for a running turn (M4a spec,
|
||||||
|
//! section 7, including its table of examples). Do not edit.
|
||||||
|
|
||||||
|
use gatewayd::mm::Post;
|
||||||
|
use gatewayd::sessions::{
|
||||||
|
BUSY, Batch, Ignored, M4B_COMMAND, Message, Pushed, Queues, Route, Router, Thread,
|
||||||
|
UNKNOWN_COMMAND, named,
|
||||||
|
};
|
||||||
|
use proto::SessionId;
|
||||||
|
|
||||||
|
const BOT: &str = "b0000000000000000000000000";
|
||||||
|
const KYLE: &str = "k0000000000000000000000000";
|
||||||
|
const EVE: &str = "e0000000000000000000000000";
|
||||||
|
const DM: &str = "d0000000000000000000000000";
|
||||||
|
const SHARED: &str = "c0000000000000000000000000";
|
||||||
|
const OTHER: &str = "o0000000000000000000000000";
|
||||||
|
const ROOT: &str = "r0000000000000000000000000";
|
||||||
|
const POST: &str = "p0000000000000000000000000";
|
||||||
|
|
||||||
|
fn router() -> Router {
|
||||||
|
Router::new(
|
||||||
|
BOT,
|
||||||
|
"boxmaker-straylight",
|
||||||
|
&[KYLE.to_string()],
|
||||||
|
&[SHARED.to_string()],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post(channel: &str, root: &str, message: &str) -> Post {
|
||||||
|
Post {
|
||||||
|
id: POST.to_string(),
|
||||||
|
user_id: KYLE.to_string(),
|
||||||
|
channel_id: channel.to_string(),
|
||||||
|
root_id: root.to_string(),
|
||||||
|
message: message.to_string(),
|
||||||
|
create_at: 5,
|
||||||
|
delete_at: 0,
|
||||||
|
kind: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn known(root: &str) -> bool {
|
||||||
|
root == ROOT
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unknown(_: &str) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queued(route: Route) -> Message {
|
||||||
|
match route {
|
||||||
|
Route::Queue(m) => m,
|
||||||
|
other => panic!("not queued: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_examples_in_the_spec() {
|
||||||
|
let r = router();
|
||||||
|
let cases = [
|
||||||
|
("", "@boxmaker-straylight summarise the audit log", true),
|
||||||
|
(ROOT, "and the older files?", true),
|
||||||
|
(ROOT, "@hermes what do you think?", false),
|
||||||
|
("", "@boxmaker-straylight @hermes compare notes", true),
|
||||||
|
("", "@boxmaker-straylightx hello", false),
|
||||||
|
("", "@channel standup in five", false),
|
||||||
|
];
|
||||||
|
for (root, message, yes) in cases {
|
||||||
|
let got = r.route(&post(SHARED, root, message), "O", &known);
|
||||||
|
assert_eq!(matches!(got, Route::Queue(_)), yes, "{message}: {got:?}");
|
||||||
|
if !yes {
|
||||||
|
assert_eq!(got, Route::Ignore(Ignored::NotForUs), "{message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn naming() {
|
||||||
|
assert_eq!(named("@Boxmaker-Straylight, look"), ["boxmaker-straylight"]);
|
||||||
|
assert_eq!(named("ask @boxmaker-straylight."), ["boxmaker-straylight"]);
|
||||||
|
assert_eq!(named("@a.b_c-d... and @e"), ["a.b_c-d", "e"]);
|
||||||
|
assert_eq!(named("@ alone, @@x, trailing @"), ["x"]);
|
||||||
|
assert_eq!(named("ünïcødé @ʙob @bob"), ["bob"]);
|
||||||
|
assert!(named("no names here").is_empty());
|
||||||
|
let r = router();
|
||||||
|
for message in [
|
||||||
|
"hi @BOXMAKER-STRAYLIGHT",
|
||||||
|
"@boxmaker-straylight.",
|
||||||
|
"(@boxmaker-straylight)",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
r.route(&post(SHARED, "", message), "P", &unknown),
|
||||||
|
Route::Queue(_)
|
||||||
|
),
|
||||||
|
"{message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replies_in_our_thread_that_name_everyone_are_still_ours() {
|
||||||
|
let r = router();
|
||||||
|
for message in ["@here any news?", "@all done", "thanks @channel"] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
r.route(&post(SHARED, ROOT, message), "O", &known),
|
||||||
|
Route::Queue(_)
|
||||||
|
),
|
||||||
|
"{message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let got = r.route(
|
||||||
|
&post(
|
||||||
|
SHARED,
|
||||||
|
"q0000000000000000000000000",
|
||||||
|
"a reply in someone else's thread",
|
||||||
|
),
|
||||||
|
"O",
|
||||||
|
&known,
|
||||||
|
);
|
||||||
|
assert_eq!(got, Route::Ignore(Ignored::NotForUs));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn who_and_where() {
|
||||||
|
let r = router();
|
||||||
|
let mut own = post(DM, "", "hello");
|
||||||
|
own.user_id = BOT.to_string();
|
||||||
|
assert_eq!(r.route(&own, "D", &unknown), Route::Ignore(Ignored::Own));
|
||||||
|
let mut system = post(DM, "", "joined");
|
||||||
|
system.kind = "system_join_channel".to_string();
|
||||||
|
assert_eq!(
|
||||||
|
r.route(&system, "D", &unknown),
|
||||||
|
Route::Ignore(Ignored::System)
|
||||||
|
);
|
||||||
|
let mut stranger = post(DM, "", "@boxmaker-straylight hello");
|
||||||
|
stranger.user_id = EVE.to_string();
|
||||||
|
assert_eq!(
|
||||||
|
r.route(&stranger, "D", &unknown),
|
||||||
|
Route::Ignore(Ignored::NotAllowed)
|
||||||
|
);
|
||||||
|
let mut stranger_cmd = post(DM, "", "!approve 1");
|
||||||
|
stranger_cmd.user_id = EVE.to_string();
|
||||||
|
assert_eq!(
|
||||||
|
r.route(&stranger_cmd, "D", &unknown),
|
||||||
|
Route::Ignore(Ignored::NotAllowed)
|
||||||
|
);
|
||||||
|
let naming = "@boxmaker-straylight hello";
|
||||||
|
assert_eq!(
|
||||||
|
r.route(&post(OTHER, "", naming), "O", &unknown),
|
||||||
|
Route::Ignore(Ignored::NotForUs)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
r.route(&post(SHARED, "", naming), "X", &unknown),
|
||||||
|
Route::Ignore(Ignored::NotForUs)
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
r.route(&post(SHARED, "", naming), "G", &unknown),
|
||||||
|
Route::Queue(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
r.route(&post(DM, "", "no name needed"), "D", &unknown),
|
||||||
|
Route::Queue(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sessions_and_threads() {
|
||||||
|
let r = router();
|
||||||
|
let top = queued(r.route(&post(DM, "", "hello"), "D", &unknown));
|
||||||
|
assert_eq!(top.session.as_str(), format!("mm-{POST}"));
|
||||||
|
assert_eq!(
|
||||||
|
top.thread,
|
||||||
|
Thread {
|
||||||
|
channel: DM.to_string(),
|
||||||
|
root: POST.to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(!top.resume && !top.joins_thread);
|
||||||
|
assert_eq!(top.text, "hello");
|
||||||
|
let reply = queued(r.route(&post(DM, ROOT, "more"), "D", &unknown));
|
||||||
|
assert_eq!(reply.session.as_str(), format!("mm-{ROOT}"));
|
||||||
|
assert_eq!(reply.thread.root, ROOT);
|
||||||
|
assert!(reply.resume);
|
||||||
|
let channel = queued(r.route(&post(SHARED, "", "@boxmaker-straylight hi"), "O", &unknown));
|
||||||
|
assert!(channel.joins_thread && !channel.resume);
|
||||||
|
assert_eq!(channel.text, "@boxmaker-straylight hi");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commands() {
|
||||||
|
let r = router();
|
||||||
|
let thread = Thread {
|
||||||
|
channel: DM.to_string(),
|
||||||
|
root: ROOT.to_string(),
|
||||||
|
};
|
||||||
|
let reply = |text: &str| Route::Reply {
|
||||||
|
thread: thread.clone(),
|
||||||
|
text: text.to_string(),
|
||||||
|
};
|
||||||
|
for (message, answer) in [
|
||||||
|
("!approve 42", M4B_COMMAND),
|
||||||
|
("!deny 42 not now", M4B_COMMAND),
|
||||||
|
("!deny", M4B_COMMAND),
|
||||||
|
("!approved", UNKNOWN_COMMAND),
|
||||||
|
("!help", UNKNOWN_COMMAND),
|
||||||
|
("!", UNKNOWN_COMMAND),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
r.route(&post(DM, ROOT, message), "D", &unknown),
|
||||||
|
reply(answer),
|
||||||
|
"{message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
queued(r.route(&post(DM, ROOT, "!!approve is a word"), "D", &unknown)).text,
|
||||||
|
"!approve is a word"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
queued(r.route(&post(DM, ROOT, "!!"), "D", &unknown)).text,
|
||||||
|
"!"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
queued(r.route(&post(DM, ROOT, " !help"), "D", &unknown)).text,
|
||||||
|
" !help"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message(root: &str, resume: bool, text: &str) -> Message {
|
||||||
|
Message {
|
||||||
|
session: SessionId::new(&format!("mm-{root}")).unwrap(),
|
||||||
|
thread: Thread {
|
||||||
|
channel: DM.to_string(),
|
||||||
|
root: root.to_string(),
|
||||||
|
},
|
||||||
|
resume,
|
||||||
|
text: text.to_string(),
|
||||||
|
joins_thread: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn messages_wait_for_the_running_turn_and_go_together() {
|
||||||
|
let mut q = Queues::new(3);
|
||||||
|
let first = message(ROOT, false, "one");
|
||||||
|
let Pushed::Start(batch) = q.push(first.clone()) else {
|
||||||
|
panic!("not started")
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
batch,
|
||||||
|
Batch {
|
||||||
|
session: first.session.clone(),
|
||||||
|
thread: first.thread.clone(),
|
||||||
|
resume: false,
|
||||||
|
text: "one".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(q.push(message(ROOT, true, "two")), Pushed::Waiting);
|
||||||
|
assert_eq!(q.push(message(ROOT, true, "three\nlines")), Pushed::Waiting);
|
||||||
|
let other = message(POST, false, "elsewhere");
|
||||||
|
assert!(
|
||||||
|
matches!(q.push(other.clone()), Pushed::Start(_)),
|
||||||
|
"another session starts at once"
|
||||||
|
);
|
||||||
|
assert_eq!(q.running(), 2);
|
||||||
|
let mut roots: Vec<String> = q.threads().into_iter().map(|t| t.root).collect();
|
||||||
|
roots.sort();
|
||||||
|
assert_eq!(roots, [POST, ROOT]);
|
||||||
|
let next = q.finish(&first.session).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(next.text.as_str(), next.resume),
|
||||||
|
("two\n\nthree\nlines", true)
|
||||||
|
);
|
||||||
|
assert_eq!(q.finish(&first.session), None);
|
||||||
|
assert_eq!(q.finish(&other.session), None);
|
||||||
|
assert_eq!(q.running(), 0);
|
||||||
|
assert!(
|
||||||
|
matches!(q.push(message(ROOT, true, "later")), Pushed::Start(_)),
|
||||||
|
"idle again"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_queue_drops_the_message() {
|
||||||
|
let mut q = Queues::new(2);
|
||||||
|
let m = message(ROOT, false, "run");
|
||||||
|
assert!(matches!(q.push(m.clone()), Pushed::Start(_)));
|
||||||
|
assert_eq!(q.push(message(ROOT, true, "a")), Pushed::Waiting);
|
||||||
|
assert_eq!(q.push(message(ROOT, true, "b")), Pushed::Waiting);
|
||||||
|
assert_eq!(
|
||||||
|
q.push(message(ROOT, true, "c")),
|
||||||
|
Pushed::Full(m.thread.clone())
|
||||||
|
);
|
||||||
|
assert_eq!(q.finish(&m.session).unwrap().text, "a\n\nb");
|
||||||
|
assert!(!BUSY.is_empty());
|
||||||
|
assert_eq!(q.finish(&SessionId::new("mm-never").unwrap()), None);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
|||||||
|
|
||||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| M4a/11-gatewayd-sessions | 2026-09-23 | done | 2 | fail | Removed the `resume` field from the copied `Pending` struct (written by the skeleton but never read) | Filled `named` (byte scan for `@`, the longest run of ASCII alnum/`.` `-` `_` after it, trailing `.` trimmed, lower-cased, empty runs skipped, then continue past the name) and the `Router` (`route` is a straight line of early returns in spec order: own -> Ignore(Own); kind not empty -> System; user not in allow.users -> NotAllowed; then the channel where "D" is always ours and "O"/"P"/"G" needs an allowed channel id plus `for_us`; the thread root is `root_id` or the post id; `!!...` keeps one `!` and queues, a lone `!` or `!approve`/`!deny` is a command where approve/deny answers M4B_COMMAND and anything else UNKNOWN_COMMAND, a command never reaches loopd; then Queue with session `mm-<root>`, `resume = root_id not empty`, `joins_thread = channel_type != "D"`). `for_us` is true when it names this bot (case-insensitive), otherwise a reply in a known thread that names only channel/here/all. `Queues`: `push` starts a turn when idle (Start with that message alone), waits while running and returns Full(thread) at the limit; `finish` joins the waiting texts with "\n\n" as a resume:true Batch, clears the queue and stays running, removing the session when nothing waits. `Pending.resume` was dead code (the next turn is always a continuation so `finish` hardcodes resume:true) so I removed it rather than allow a lint. First gate failed on clippy `manual_strip`; switched `starts_with("!!")`/`starts_with('!')` plus `&message[2..]`/`&message[1..]` slicing to `strip_prefix`. All 8 sessions tests pass; `make gate` prints `gate: ok` on the second run. | ? |
|
||||||
| M4a/10-gatewayd-mm | 2026-09-23 | done | 1 | pass | none | Copied `tests/mm_json.rs`, `tests/mm_rest.rs` and `tests/support/http_server.rs`, added the `src/mm/mod.rs` and `src/mm/rest.rs` skeletons to `crates/gatewayd/src/mm/` and `pub mod mm;` to `lib.rs` (before `secrets`, alphabetical). Filled `mod.rs`: `Post::check` requires `id`/`user_id`/`channel_id` `valid_id` and `root_id` empty-or-`valid_id`, else `Json` quoting the offending id with `{:?}`; `json` is `serde_json::from_slice` mapped to `Json(e.to_string())`; `parse_event` matches `hello`/`posted`/other — `posted` takes `data.post` as a JSON *string* (an object or missing is `Json`), parses it, `check`s it, and reads `data.channel_type` (else ""), any other name (or an empty-name reply) is `Other(name)`; `typing` is `serde_json::json!` compacted; `since_list` walks `order` only (skipping ids not in `posts`, keeping `create_at > since && delete_at == 0` after `check`, deduping, then sorting by `(create_at, id)`), `full` when `order.len() >= SINCE_LIMIT`. Filled `rest.rs`: `Client::new` stores the three fields; `once` connects within `timeout`, sets the read timeout, sends `Authorization: Bearer <token>` (the only `expose`), `Accept`/`Content-Type` headers and `host_header`, mapping every error to `Net("<method> <path>: <e>")`; `call` loops `once` — 2xx returns the body, 401/403 `Auth`, 429 waits `rate_limit_wait` up to `RETRIES` then `RateLimited`, 5xx retried up to `RETRIES` times sleeping `RETRY_5XX`, else `Status` with the first `BODY_KEPT` lossy-UTF-8 chars via a `status_error` helper; `me`/`create_post`/`posts_since`/`direct_channel` build the four calls, `posts_since` and `me`/`direct_channel` reject non-`valid_id` ids as `Json` before sending. All 7 `mm_json` and 10 `mm_rest` tests pass (the latter ~3 s on two deliberate rate-limit waits); `make gate` prints `gate: ok` first run. | ? |
|
| M4a/10-gatewayd-mm | 2026-09-23 | done | 1 | pass | none | Copied `tests/mm_json.rs`, `tests/mm_rest.rs` and `tests/support/http_server.rs`, added the `src/mm/mod.rs` and `src/mm/rest.rs` skeletons to `crates/gatewayd/src/mm/` and `pub mod mm;` to `lib.rs` (before `secrets`, alphabetical). Filled `mod.rs`: `Post::check` requires `id`/`user_id`/`channel_id` `valid_id` and `root_id` empty-or-`valid_id`, else `Json` quoting the offending id with `{:?}`; `json` is `serde_json::from_slice` mapped to `Json(e.to_string())`; `parse_event` matches `hello`/`posted`/other — `posted` takes `data.post` as a JSON *string* (an object or missing is `Json`), parses it, `check`s it, and reads `data.channel_type` (else ""), any other name (or an empty-name reply) is `Other(name)`; `typing` is `serde_json::json!` compacted; `since_list` walks `order` only (skipping ids not in `posts`, keeping `create_at > since && delete_at == 0` after `check`, deduping, then sorting by `(create_at, id)`), `full` when `order.len() >= SINCE_LIMIT`. Filled `rest.rs`: `Client::new` stores the three fields; `once` connects within `timeout`, sets the read timeout, sends `Authorization: Bearer <token>` (the only `expose`), `Accept`/`Content-Type` headers and `host_header`, mapping every error to `Net("<method> <path>: <e>")`; `call` loops `once` — 2xx returns the body, 401/403 `Auth`, 429 waits `rate_limit_wait` up to `RETRIES` then `RateLimited`, 5xx retried up to `RETRIES` times sleeping `RETRY_5XX`, else `Status` with the first `BODY_KEPT` lossy-UTF-8 chars via a `status_error` helper; `me`/`create_post`/`posts_since`/`direct_channel` build the four calls, `posts_since` and `me`/`direct_channel` reject non-`valid_id` ids as `Json` before sending. All 7 `mm_json` and 10 `mm_rest` tests pass (the latter ~3 s on two deliberate rate-limit waits); `make gate` prints `gate: ok` first run. | ? |
|
||||||
| M4a/09-gatewayd-ws-conn | 2026-09-23 | done | 1 | pass | none | Filled the eight functions in the copied `crates/gatewayd/src/ws/conn.rs` skeleton (the written `poll` was the glue). `open`: `connector.connect(dead_after)` mapped to `Handshake(e.to_string())`, then `handshake` with `host_header(connector.server())`, a Ws with a new `Decoder` and `last_heard`/`last_ping` both `now`. `send`: `read_exact` 4 mask bytes from `random`, then `encode(opcode, payload, mask)` written and flushed. `send_text`: `send(TEXT, text.as_bytes())`. `take_messages`: loop `next_message`, `Text` returns, `Ping` answered with `send(PONG, &payload)`, `Pong` ignored, `Close` replies the code as 2 big-endian bytes (empty when none) via a best-effort `send(CLOSE, ...)` (the peer may be gone) and returns `Closed`. `keep_alive`: `now.duration_since(last_heard) >= dead_after` is `Dead`, else `now.duration_since(last_ping) >= ping_every` pings and stamps `last_ping`. `read_timeout`: the least of next-ping, next-dead and until-left (each `saturating_duration_since`), then `.max(1ms)`. `read_some`: `set_read_timeout`, a 16 KiB buffer, `Ok(0)` -> `Closed`, `Ok(n)` feeds `buf.get(..n).unwrap_or_default()` and stamps `last_heard`, `WouldBlock`/`TimedOut`/`Interrupted` -> `Ok(())`, any other `Err` -> `Io`. `close`: best-effort `send(CLOSE, &1000u16.to_be_bytes())`. `host_header`: host alone when the port is the scheme default (443 for tls, 80 otherwise) else `host:port`. All 10 tests in `tests/ws_conn.rs` pass five runs under a second; `make gate` prints `gate: ok` first run. | ? |
|
| M4a/09-gatewayd-ws-conn | 2026-09-23 | done | 1 | pass | none | Filled the eight functions in the copied `crates/gatewayd/src/ws/conn.rs` skeleton (the written `poll` was the glue). `open`: `connector.connect(dead_after)` mapped to `Handshake(e.to_string())`, then `handshake` with `host_header(connector.server())`, a Ws with a new `Decoder` and `last_heard`/`last_ping` both `now`. `send`: `read_exact` 4 mask bytes from `random`, then `encode(opcode, payload, mask)` written and flushed. `send_text`: `send(TEXT, text.as_bytes())`. `take_messages`: loop `next_message`, `Text` returns, `Ping` answered with `send(PONG, &payload)`, `Pong` ignored, `Close` replies the code as 2 big-endian bytes (empty when none) via a best-effort `send(CLOSE, ...)` (the peer may be gone) and returns `Closed`. `keep_alive`: `now.duration_since(last_heard) >= dead_after` is `Dead`, else `now.duration_since(last_ping) >= ping_every` pings and stamps `last_ping`. `read_timeout`: the least of next-ping, next-dead and until-left (each `saturating_duration_since`), then `.max(1ms)`. `read_some`: `set_read_timeout`, a 16 KiB buffer, `Ok(0)` -> `Closed`, `Ok(n)` feeds `buf.get(..n).unwrap_or_default()` and stamps `last_heard`, `WouldBlock`/`TimedOut`/`Interrupted` -> `Ok(())`, any other `Err` -> `Io`. `close`: best-effort `send(CLOSE, &1000u16.to_be_bytes())`. `host_header`: host alone when the port is the scheme default (443 for tls, 80 otherwise) else `host:port`. All 10 tests in `tests/ws_conn.rs` pass five runs under a second; `make gate` prints `gate: ok` first run. | ? |
|
||||||
| M4a/08-gatewayd-ws-frames | 2026-09-23 | done | 1 | pass | none | Copied `tests/ws_frame.rs` and the `src/ws/frame.rs` skeleton, added `pub mod frame;` (before `handshake`, alphabetical). Filled the seven functions the comments specified verbatim: `check_first_bytes` (reserved bits `b0 & 0x70`, mask `b1 & 0x80`, opcode `matches!(b0 & 0x0F, CONTINUATION | TEXT | CLOSE | PING | PONG)`); `check_control` (not fin, then >125); `length` (match on `short`: 0..=125, 126 reading `buf.get(2..4)` into `u16::from_be_bytes` with `len < 126` refused, 127 reading `buf.get(2..10)` into `u64::from_be_bytes` with top-bit and `<= 0xFFFF` refused, `usize::try_from(len).unwrap_or(usize::MAX)`); `check_data` (TEXT while partial, CONTINUATION while none, `payload_len > MAX_MESSAGE.saturating_sub(so_far)`); `data_frame` (empty Vec for TEXT else `partial.take().unwrap_or_default()`, append, defer when not fin, `String::from_utf8` at fin); `close` (slice-pattern match, `u16::from_be_bytes` code, UTF-8 reason); `encode` (FIN+opcode, three length branches with mask bit, XOR with `mask.iter().cycle()`). All string literals got `.to_string()` for the `Protocol(String)` variant, matching http.rs. `length`'s match needed a defensive `_` arm (`128..=u8::MAX` unreachable since `short = b1 & 0x7F`) so the codec stays exhaustive without a panic. All 7 tests in `tests/ws_frame.rs` pass including the 300-seed property test against the naive decoder; `make gate` prints `gate: ok` first run. | ? |
|
| M4a/08-gatewayd-ws-frames | 2026-09-23 | done | 1 | pass | none | Copied `tests/ws_frame.rs` and the `src/ws/frame.rs` skeleton, added `pub mod frame;` (before `handshake`, alphabetical). Filled the seven functions the comments specified verbatim: `check_first_bytes` (reserved bits `b0 & 0x70`, mask `b1 & 0x80`, opcode `matches!(b0 & 0x0F, CONTINUATION | TEXT | CLOSE | PING | PONG)`); `check_control` (not fin, then >125); `length` (match on `short`: 0..=125, 126 reading `buf.get(2..4)` into `u16::from_be_bytes` with `len < 126` refused, 127 reading `buf.get(2..10)` into `u64::from_be_bytes` with top-bit and `<= 0xFFFF` refused, `usize::try_from(len).unwrap_or(usize::MAX)`); `check_data` (TEXT while partial, CONTINUATION while none, `payload_len > MAX_MESSAGE.saturating_sub(so_far)`); `data_frame` (empty Vec for TEXT else `partial.take().unwrap_or_default()`, append, defer when not fin, `String::from_utf8` at fin); `close` (slice-pattern match, `u16::from_be_bytes` code, UTF-8 reason); `encode` (FIN+opcode, three length branches with mask bit, XOR with `mask.iter().cycle()`). All string literals got `.to_string()` for the `Protocol(String)` variant, matching http.rs. `length`'s match needed a defensive `_` arm (`128..=u8::MAX` unreachable since `short = b1 & 0x7F`) so the codec stays exhaustive without a panic. All 7 tests in `tests/ws_frame.rs` pass including the 300-seed property test against the naive decoder; `make gate` prints `gate: ok` first run. | ? |
|
||||||
|
|||||||
Reference in New Issue
Block a user