//! Who is allowed to answer a group message. //! //! A room used to wake every member for every message, so a group of four paid //! for four model calls (and four desktop claims) to get one useful answer. The //! audience is now decided once, when the message arrives: an `@name` is law, //! and without one a single short model call picks the members whose role fits //! the ask. When nothing fits, the host answers — a message is never left with //! nobody to read it, because a silently dropped message is worse than one //! wasted reply. use std::time::Duration; use lazyboy_contracts::ModelProvider; use lazyboy_harness::{ CredentialChain, DynModel, ResolveModelRequest, connect_model, resolve_backend, }; use rig_core::completion::message::{AssistantContent, Message, UserContent}; use crate::db::Actor; use crate::state::AppState; /// The picker is a nicety, not a gate: past this the host takes over, so a slow /// helper never holds up a chat message. const ROUTER_TIMEOUT: Duration = Duration::from_millis(1200); /// How many members one unprefixed message may wake. const ROUTER_CAP: usize = 3; /// Recent lines and role text handed to the picker. const ROUTER_HISTORY: i64 = 6; const FIELD_CHARS: usize = 100; const LINE_CHARS: usize = 200; /// `@所有人` and its spellings: everybody, and no model call at all. The web /// composer offers the escape hatch in whatever language the screen is in, so /// every spelling it can insert has to be understood here too. const ALL_KEYWORDS: [&str; 5] = ["所有人", "全部", "all", "everyone", "everybody"]; const PICKER_SYSTEM: &str = "You decide which members of a group chat should answer the newest message. Reply with names from the roster only, separated by 、, at most three of them. Choose a member when the message falls inside what that member does. Reply NONE when no roster member fits, and add nothing else. Never invent a name, never explain your choice, and never answer the message yourself."; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Member { pub id: String, pub name: String, /// What this member says it does (title + description). Picker input only; /// empty is fine. pub role: String, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Mentions { /// `@所有人` appeared. pub all: bool, /// Member ids in the order they were named. pub ids: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Reason { All, Mention, Routed, Host, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Audience { pub targets: Vec, pub reason: Reason, } /// A room reduced to what routing needs: who leads it and who is in it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Room { pub id: String, pub host_id: String, pub members: Vec, } /// Names written as `@名字`, longest name first so a short member cannot steal a /// longer member's mention. pub fn parse_mentions(text: &str, members: &[Member]) -> Mentions { let mut mentions = Mentions::default(); let chars: Vec = text.chars().collect(); for (position, ch) in chars.iter().enumerate() { // `mail@example.com` is not a mention: `@` has to open a word. if *ch != '@' || (position > 0 && chars[position - 1].is_alphanumeric()) { continue; } let tail: String = chars[position + 1..].iter().collect(); if ALL_KEYWORDS.iter().any(|word| name_hit(&tail, word)) { mentions.all = true; continue; } if let Some(id) = find_names(&tail, members, true).into_iter().next() && !mentions.ids.contains(&id) { mentions.ids.push(id); } } mentions } /// Names of roster members that appear in `text`, in order of appearance. /// `anchored` accepts a name only at the very start of `text`, which is what a /// mention needs after the `@`. fn find_names(text: &str, members: &[Member], anchored: bool) -> Vec { let mut ranked: Vec<&Member> = members .iter() .filter(|member| !member.name.trim().is_empty()) .collect(); ranked.sort_by_key(|member| std::cmp::Reverse(member.name.chars().count())); let chars: Vec = text.chars().collect(); let mut ids: Vec = Vec::new(); let mut position = 0usize; while position < chars.len() { let tail: String = chars[position..].iter().collect(); if let Some(member) = ranked.iter().find(|member| name_hit(&tail, &member.name)) && !ids.contains(&member.id) { ids.push(member.id.clone()); } if anchored { break; } position += 1; } ids } /// Does `tail` open with `name`? ASCII names need a word boundary after them so /// `@Ali` cannot pick "Alice"; CJK names run together, so the longest match is /// already the boundary. fn name_hit(tail: &str, name: &str) -> bool { let lowered = tail.to_lowercase(); let needle = name.trim().to_lowercase(); if needle.is_empty() || !lowered.starts_with(&needle) { return false; } if !needle .chars() .next_back() .is_some_and(|last| last.is_ascii_alphanumeric()) { return true; } match lowered.chars().nth(needle.chars().count()) { None => true, Some(next) => !(next.is_ascii_alphanumeric() || next == '-' || next == '_'), } } /// The picker's answer reduced to roster ids. It may write "阿明、小美", a JSON /// array, or prose naming people; anything outside the roster is dropped. pub fn parse_router_reply(raw: &str, members: &[Member]) -> Vec { let mut picked = find_names(raw, members, false); picked.truncate(ROUTER_CAP); picked } /// Who should run for one message: a name is law, `@所有人` is everybody, and /// only an unaddressed message consults the picker. pub fn resolve_audience( members: &[Member], mentions: &Mentions, host_id: &str, busy: &[String], routed: &[String], ) -> Audience { let roster: Vec<&String> = members.iter().map(|member| &member.id).collect(); if mentions.all { return Audience { targets: roster.into_iter().cloned().collect(), reason: Reason::All, }; } let named: Vec = mentions .ids .iter() .filter(|id| roster.contains(id)) .cloned() .collect(); if !named.is_empty() { return Audience { targets: named, reason: Reason::Mention, }; } let chosen: Vec = routed .iter() .filter(|id| roster.contains(id) && !busy.contains(id)) .cloned() .collect(); if !chosen.is_empty() { return Audience { targets: chosen, reason: Reason::Routed, }; } // Never zero: the host picks up what nobody was chosen for. Audience { targets: vec![host_id.to_string()], reason: Reason::Host, } } /// The room a thread belongs to, or `None` for a one-to-one conversation. pub(crate) async fn room_for_thread( state: &AppState, thread_id: &str, ) -> Result, String> { // The column is nullable and the row may be missing: both read as "not a // room", so the outer `Option` (no row) and the inner one (NULL) are // flattened together. Decoding the column straight into `String` made every // one-to-one message fail with an "unexpected null". let room_id: Option> = sqlx::query_scalar("SELECT room_id FROM threads WHERE id=$1") .bind(thread_id) .fetch_optional(state.pool()) .await .map_err(|error| error.to_string())?; let Some(room_id) = room_id.flatten() else { return Ok(None); }; let rows: Vec<(String, String, String, String)> = sqlx::query_as( "SELECT b.id, b.name, b.title, b.description FROM room_members m JOIN bots b ON b.id=m.bot_id WHERE m.room_id=$1 ORDER BY m.created_at, b.name", ) .bind(&room_id) .fetch_all(state.pool()) .await .map_err(|error| error.to_string())?; if rows.is_empty() { return Ok(None); } let members: Vec = rows .into_iter() .map(|(id, name, title, description)| Member { role: format!("{title} {description}").trim().to_string(), id, name, }) .collect(); let host: Option> = sqlx::query_scalar("SELECT host_bot_id FROM rooms WHERE id=$1") .bind(&room_id) .fetch_optional(state.pool()) .await .map_err(|error| error.to_string())?; // A deleted host, or a room written before this column existed, falls back // to the first member — who is the one that used to answer anyway. let host_id = host .flatten() .filter(|host| members.iter().any(|member| member.id == *host)) .unwrap_or_else(|| members[0].id.clone()); Ok(Some(Room { id: room_id, host_id, members, })) } /// Members of `room` that already have a run of their own in flight. pub(crate) async fn busy_members(state: &AppState, room: &Room) -> Result, String> { let roster: Vec = room .members .iter() .map(|member| member.id.clone()) .collect(); let busy: Vec = sqlx::query_scalar( "SELECT DISTINCT bot_id FROM runs WHERE bot_id = ANY($1) AND status IN ('queued','leased','running','waiting_input','waiting_takeover')", ) .bind(&roster) .fetch_all(state.pool()) .await .map_err(|error| error.to_string())?; Ok(busy) } /// Decide the audience for a message that has not been written yet. The model /// call happens outside the message transaction, so a slow picker never holds a /// row lock, and every failure path lands on the host. pub(crate) async fn audience_for( state: &AppState, actor: &Actor, room: &Room, thread_id: &str, text: &str, ) -> Audience { let mentions = parse_mentions(text, &room.members); let decided = resolve_audience(&room.members, &mentions, &room.host_id, &[], &[]); if decided.reason != Reason::Host { log(room, thread_id, &decided); return decided; } let busy = busy_members(state, room).await.unwrap_or_default(); // Two members need no referee: the host answers, and naming the other one // is free. let routed = if room.members.len() > 2 { pick(state, actor, room, thread_id, text).await } else { Vec::new() }; let decided = resolve_audience(&room.members, &mentions, &room.host_id, &busy, &routed); log(room, thread_id, &decided); decided } fn log(room: &Room, thread_id: &str, audience: &Audience) { tracing::info!( room = room.id, thread = thread_id, reason = reason_label(audience.reason), targets = ?audience.targets, "room routing" ); } pub fn reason_label(reason: Reason) -> &'static str { match reason { Reason::All => "all", Reason::Mention => "mention", Reason::Routed => "routed", Reason::Host => "host", } } /// One short model call: which names fit this message? async fn pick( state: &AppState, actor: &Actor, room: &Room, thread_id: &str, text: &str, ) -> Vec { let model = match router_model(state, actor, thread_id).await { Ok(model) => model, Err(error) => { tracing::warn!("room router unavailable: {error}"); return Vec::new(); } }; let prompt = match prompt_for(state, room, thread_id, text).await { Ok(prompt) => prompt, Err(error) => { tracing::warn!("room router prompt: {error}"); return Vec::new(); } }; let attempt = tokio::time::timeout( ROUTER_TIMEOUT, crate::runs::complete_once( &model, Message::User { content: vec![UserContent::text(prompt)], }, PICKER_SYSTEM, &[], &[], ), ) .await; let raw = match attempt { Ok(Ok(parts)) => parts .into_iter() .filter_map(|part| match part { AssistantContent::Text(text) => Some(text.text), _ => None, }) .collect::>() .join(" "), Ok(Err(error)) => { tracing::warn!("room router call: {error}"); return Vec::new(); } Err(_) => { tracing::warn!("room router timed out; the host answers"); return Vec::new(); } }; parse_router_reply(&raw, &room.members) } async fn prompt_for( state: &AppState, room: &Room, thread_id: &str, text: &str, ) -> Result { let recent: Vec<(String, String)> = sqlx::query_as( "SELECT COALESCE(b.name, '你') AS speaker, m.body FROM messages m LEFT JOIN bots b ON b.id=m.speaker_bot_id WHERE m.thread_id=$1 ORDER BY m.seq DESC LIMIT $2", ) .bind(thread_id) .bind(ROUTER_HISTORY) .fetch_all(state.pool()) .await .map_err(|error| error.to_string())?; let mut prompt = String::from("Roster (name — what they do):\n"); for member in &room.members { prompt.push_str(&format!( "- {} — {}\n", clip(&member.name, FIELD_CHARS), if member.role.is_empty() { "—".to_string() } else { clip(&member.role, FIELD_CHARS) } )); } if !recent.is_empty() { prompt.push_str("\nRecent lines (newest last):\n"); for (speaker, body) in recent.iter().rev() { prompt.push_str(&format!( "{}: {}\n", clip(speaker, 40), clip(body, LINE_CHARS) )); } } prompt.push_str("\nNewest message:\n"); prompt.push_str(text.trim()); prompt.push_str("\n\nNames that should answer:"); Ok(prompt) } /// The picker runs on the workspace model unless `LAZYBOY_ROUTER_MODEL` names a /// cheaper one; leaving it unset is a valid and common state. async fn router_model( state: &AppState, actor: &Actor, thread_id: &str, ) -> Result { let space = state .db .get_space(actor) .await .map_err(|error| error.to_string())? .ok_or_else(|| "workspace not found".to_string())?; let provider = space .default_model_provider .parse::() .map_err(|error| error.to_string())?; let override_id = std::env::var("LAZYBOY_ROUTER_MODEL") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); let (base_url, api_key) = crate::workspace::credentials_for_provider(&state.db, actor, &space, provider.as_str()) .await .map_err(|error| error.to_string())?; let backend = resolve_backend(ResolveModelRequest { provider, model_id: override_id.or(Some(space.default_model_id)), base_url, credentials: CredentialChain { bot: None, space: api_key, }, }) .map_err(|error| error.to_string())?; connect_model(&backend, thread_id).map_err(|error| error.to_string()) } /// The single hand-off a bot may pass on: a member it names that is neither /// itself nor already working. `@所有人` from a bot is ignored on purpose — a /// bot must never broadcast the room. pub(crate) async fn handoff_target( state: &AppState, run_id: &str, from_bot_id: &str, body: &str, ) -> Result, String> { let producing: Option<(String, Option, String)> = sqlx::query_as( "SELECT r.thread_id, t.room_id, r.\"trigger\" FROM runs r JOIN threads t ON t.id=r.thread_id WHERE r.id=$1", ) .bind(run_id) .fetch_optional(state.pool()) .await .map_err(|error| error.to_string())?; let Some((thread_id, Some(_room_id), trigger)) = producing else { return Ok(None); }; // One hop, hard stop: a run that exists because of a hand-off cannot start // another one, or two chatty bots would keep the room (and the bill) alive. if trigger == "handoff" { return Ok(None); } let Some(room) = room_for_thread(state, &thread_id).await? else { return Ok(None); }; let mentions = parse_mentions(body, &room.members); if mentions.all || mentions.ids.is_empty() { return Ok(None); } let busy = busy_members(state, &room).await.unwrap_or_default(); Ok(mentions .ids .into_iter() .find(|id| id != from_bot_id && !busy.contains(id))) } /// Trim to whole characters so a clipped role or line never splits in half. fn clip(text: &str, chars: usize) -> String { text.chars().take(chars).collect() } #[cfg(test)] mod tests { use super::*; fn member(id: &str, name: &str) -> Member { Member { id: id.to_string(), name: name.to_string(), role: String::new(), } } fn roster() -> Vec { vec![member("a", "阿明"), member("b", "小美"), member("c", "Dev")] } fn ids(values: &[&str]) -> Vec { values.iter().map(|value| value.to_string()).collect() } /// Ids in the order they were found. Anything outside the roster is quoted, /// so a failure points at the stranger rather than looking like a member. fn found(picked: &[String], room: &[Member]) -> Vec { picked .iter() .map(|id| { if room.iter().any(|member| member.id == *id) { id.clone() } else { format!("«{id}»") } }) .collect() } #[test] fn only_a_named_member_is_consulted() { let room = roster(); let mentions = parse_mentions("@小美 幫我看這份報表", &room); assert!(!mentions.all); assert_eq!(found(&mentions.ids, &room), ["b"]); } #[test] fn the_longest_name_wins_when_members_overlap() { let room = vec![member("a", "阿明"), member("b", "阿明哥")]; assert_eq!( found(&parse_mentions("@阿明哥 來一下", &room).ids, &room), ["b"] ); assert_eq!( found(&parse_mentions("@阿明 來一下", &room).ids, &room), ["a"] ); } #[test] fn ascii_names_need_a_word_boundary_after_them() { let room = roster(); assert_eq!( found(&parse_mentions("@dev 幫我看", &room).ids, &room), ["c"] ); assert_eq!( found(&parse_mentions("@Dev, help", &room).ids, &room), ["c"] ); assert!(parse_mentions("@Developer 幫我看", &room).ids.is_empty()); } #[test] fn punctuation_does_not_eat_a_mention() { let room = roster(); assert_eq!( found(&parse_mentions("@小美,@阿明 一起看", &room).ids, &room), ["b", "a"] ); assert_eq!(found(&parse_mentions("(@小美)", &room).ids, &room), ["b"]); } #[test] fn an_email_address_is_not_a_mention() { let room = roster(); let mentions = parse_mentions("寄到 mail@小美 或 report@dev.example 就好", &room); assert!(mentions.ids.is_empty() && !mentions.all); } #[test] fn naming_everyone_is_the_escape_hatch() { let room = roster(); for text in [ "@所有人 結論定了", "@全部 來看", "@all look", "@everyone look", "@everybody, your turn", ] { assert!(parse_mentions(text, &room).all, "{text}"); } // `@allison` is somebody's name, not `all`. assert!(!parse_mentions("@allison 來了", &room).all); } #[test] fn unknown_names_are_dropped_and_repeats_count_once() { let room = roster(); let mentions = parse_mentions("@阿明 和 @路人,@阿明 再說一次", &room); assert_eq!(found(&mentions.ids, &room), ["a"]); } #[test] fn the_picker_may_answer_in_names_json_or_prose() { let room = roster(); assert_eq!( found(&parse_router_reply("小美、阿明", &room), &room), ["b", "a"] ); assert_eq!( found(&parse_router_reply("[\"小美\", \"Dev\"]", &room), &room), ["b", "c"] ); assert_eq!( found(&parse_router_reply("我覺得小美可以回答", &room), &room), ["b"] ); } #[test] fn the_picker_cannot_invent_members_or_a_crowd() { let room = roster(); assert!(parse_router_reply("NONE", &room).is_empty()); assert!(parse_router_reply("請由 Grace 處理", &room).is_empty()); assert!(parse_router_reply("Development work", &room).is_empty()); let crowd = vec![ member("a", "一"), member("b", "二"), member("c", "三"), member("d", "四"), ]; assert_eq!( parse_router_reply("一、二、三、四", &crowd).len(), ROUTER_CAP ); } #[test] fn a_named_member_answers_even_while_the_picker_skips_busy_ones() { let room = roster(); let busy = ids(&["b"]); let mentioned = resolve_audience( &room, &Mentions { all: false, ids: ids(&["b"]), }, "a", &busy, &[], ); assert_eq!(mentioned.targets, ids(&["b"])); assert_eq!(mentioned.reason, Reason::Mention); let routed = resolve_audience(&room, &Mentions::default(), "a", &busy, &ids(&["b", "c"])); assert_eq!(routed.targets, ids(&["c"])); assert_eq!(routed.reason, Reason::Routed); } #[test] fn everybody_or_nobody_is_settled_without_the_picker() { let room = roster(); let all = resolve_audience( &room, &Mentions { all: true, ids: Vec::new(), }, "a", &[], &[], ); assert_eq!(all.targets, ids(&["a", "b", "c"])); assert_eq!(all.reason, Reason::All); // Nothing fitted, the picker named strangers, or it named only busy // members: the host picks it up rather than the message going unread. let stranded: Vec> = vec![vec![], ids(&["ghost"]), ids(&["b"])]; for routed in stranded { let host = resolve_audience(&room, &Mentions::default(), "a", &ids(&["b"]), &routed); assert_eq!(host.targets, ids(&["a"])); assert_eq!(host.reason, Reason::Host); } } #[test] fn an_id_that_left_the_room_is_not_an_audience() { let room = roster(); let audience = resolve_audience( &room, &Mentions { all: false, ids: ids(&["ghost"]), }, "a", &[], &ids(&["c"]), ); assert_eq!(audience.targets, ids(&["c"])); assert_eq!(audience.reason, Reason::Routed); } #[test] fn clipping_counts_characters_not_bytes() { assert_eq!(clip("群組聊天", 3), "群組聊"); } } /// The same decisions with the real tables behind them: how many runs one chat /// message is allowed to start, and where a hand-off stops. #[cfg(test)] mod fan_out { use super::*; use serde_json::json; fn app(pool: sqlx::PgPool) -> AppState { AppState { db: crate::db::Db { pool }, sandbox: std::sync::Arc::new(lazyboy_sandbox::FakeSandbox::new()), data_dir: String::new(), auth: crate::auth::AuthConfig::from_env(), memory: crate::memory::MemoryService::from_env(), mcp: crate::mcp::McpHub::new(), calls: crate::state::CallRegistry::default(), wakes: crate::state::WakeBus::default(), } } /// `members` join one room led by `host`, and share the thread `t`. A /// `group` of one is a direct message, which has no audience to choose. async fn seed(pool: &sqlx::PgPool, members: &[(&str, &str)], host: Option<&str>, group: bool) { sqlx::query("INSERT INTO users (id,name) VALUES ('u','test')") .execute(pool) .await .unwrap(); sqlx::query("INSERT INTO spaces (id,user_id,name) VALUES ('s','u','test')") .execute(pool) .await .unwrap(); for (id, name) in members { sqlx::query("INSERT INTO bots (id,space_id,user_id,name) VALUES ($1,'s','u',$2)") .bind(id) .bind(name) .execute(pool) .await .unwrap(); } if group { sqlx::query( "INSERT INTO rooms (id,space_id,user_id,name,host_bot_id) VALUES ('r','s','u','產品群',$1)", ) .bind(host) .execute(pool) .await .unwrap(); for (id, _) in members { sqlx::query("INSERT INTO room_members (room_id,bot_id) VALUES ('r',$1)") .bind(id) .execute(pool) .await .unwrap(); } } sqlx::query( "INSERT INTO threads (id,space_id,user_id,bot_id,room_id) VALUES ('t','s','u',$1,$2)", ) .bind(members[0].0) .bind(group.then_some("r")) .execute(pool) .await .unwrap(); } async fn start_run(pool: &sqlx::PgPool, id: &str, bot: &str, trigger: &str) { sqlx::query( "INSERT INTO runs (id,space_id,user_id,bot_id,thread_id,status,trigger,prompt) VALUES ($1,'s','u',$2,'t','running',$3,'開始')", ) .bind(id) .bind(bot) .bind(trigger) .execute(pool) .await .unwrap(); } async fn runs_for(pool: &sqlx::PgPool, bot: &str) -> i64 { sqlx::query_scalar("SELECT count(*) FROM runs WHERE bot_id=$1") .bind(bot) .fetch_one(pool) .await .unwrap() } /// The audience written next to the newest message of `role`. Empty means /// nothing was recorded, which is what a direct message must do. async fn audience_of(pool: &sqlx::PgPool, role: &str) -> Vec { let stored: Option>> = sqlx::query_scalar("SELECT reply_bot_ids FROM messages WHERE role=$1 LIMIT 1") .bind(role) .fetch_optional(pool) .await .unwrap(); stored.flatten().unwrap_or_default() } const TRIO: [(&str, &str); 3] = [("a", "阿明"), ("b", "小美"), ("c", "阿強")]; #[sqlx::test(migrations = "../../migrations")] async fn only_the_member_named_in_the_message_is_woken(pool: sqlx::PgPool) { seed(&pool, &TRIO, Some("a"), true).await; let actor = Actor { user_id: "u".into(), space_id: "s".into(), }; crate::runs::send( &app(pool.clone()), &actor, "a", "t", "@小美 幫我看這份", None, &[], &[], ) .await .unwrap(); assert_eq!(runs_for(&pool, "b").await, 1); assert_eq!(runs_for(&pool, "a").await, 0); assert_eq!(runs_for(&pool, "c").await, 0); assert_eq!(audience_of(&pool, "user").await, ["b"]); } #[sqlx::test(migrations = "../../migrations")] async fn a_two_member_room_answers_without_calling_the_picker(pool: sqlx::PgPool) { seed(&pool, &[("a", "阿明"), ("b", "小美")], Some("a"), true).await; let actor = Actor { user_id: "u".into(), space_id: "s".into(), }; crate::runs::send( &app(pool.clone()), &actor, "a", "t", "誰幫我看這份", None, &[], &[], ) .await .unwrap(); assert_eq!(runs_for(&pool, "a").await, 1); assert_eq!(runs_for(&pool, "b").await, 0); assert_eq!(audience_of(&pool, "user").await, ["a"]); } #[sqlx::test(migrations = "../../migrations")] async fn a_mention_is_queued_behind_the_work_already_running(pool: sqlx::PgPool) { seed(&pool, &TRIO, Some("a"), true).await; start_run(&pool, "busy", "b", "message").await; let actor = Actor { user_id: "u".into(), space_id: "s".into(), }; crate::runs::send( &app(pool.clone()), &actor, "a", "t", "@小美 再看一次", None, &[], &[], ) .await .unwrap(); assert_eq!(runs_for(&pool, "b").await, 2); } #[sqlx::test(migrations = "../../migrations")] async fn a_bot_hands_one_item_over_and_stops_there(pool: sqlx::PgPool) { seed(&pool, &TRIO, Some("a"), true).await; start_run(&pool, "r1", "a", "message").await; let app = app(pool.clone()); crate::runs::append_bot_message_with( &app, "t", "r1", "a", "這塊我不熟,@小美 交給妳。", json!([]), ) .await .unwrap(); let passed: Vec<(String, String)> = sqlx::query_as("SELECT bot_id, \"trigger\" FROM runs WHERE id<>'r1'") .fetch_all(&pool) .await .unwrap(); assert_eq!(passed, vec![("b".to_string(), "handoff".to_string())]); assert_eq!(audience_of(&pool, "assistant").await, ["b"]); // The hand-off's own reply starts nothing: one hop is the whole budget, // or two chatty bots would keep the room and the bill alive. let handed_run: String = sqlx::query_scalar("SELECT id FROM runs WHERE id<>'r1'") .fetch_one(&pool) .await .unwrap(); crate::runs::append_bot_message_with( &app, "t", &handed_run, "b", "@阿強 換妳。", json!([]), ) .await .unwrap(); let total: i64 = sqlx::query_scalar("SELECT count(*) FROM runs") .fetch_one(&pool) .await .unwrap(); assert_eq!(total, 2); } #[sqlx::test(migrations = "../../migrations")] async fn a_busy_member_is_not_handed_more_work(pool: sqlx::PgPool) { seed(&pool, &TRIO, Some("a"), true).await; start_run(&pool, "busy", "b", "message").await; start_run(&pool, "r1", "a", "message").await; crate::runs::append_bot_message_with( &app(pool.clone()), "t", "r1", "a", "@小美 或 @阿強,誰有空?", json!([]), ) .await .unwrap(); let passed: Vec = sqlx::query_scalar("SELECT bot_id FROM runs WHERE \"trigger\"='handoff'") .fetch_all(&pool) .await .unwrap(); assert_eq!(passed, vec!["c".to_string()]); } #[sqlx::test(migrations = "../../migrations")] async fn a_bot_naming_everyone_hands_over_nothing(pool: sqlx::PgPool) { seed(&pool, &TRIO, Some("a"), true).await; start_run(&pool, "r1", "a", "message").await; crate::runs::append_bot_message_with( &app(pool.clone()), "t", "r1", "a", "@所有人 一起看這份。", json!([]), ) .await .unwrap(); let total: i64 = sqlx::query_scalar("SELECT count(*) FROM runs") .fetch_one(&pool) .await .unwrap(); assert_eq!(total, 1); } #[sqlx::test(migrations = "../../migrations")] async fn a_direct_message_never_broadcasts(pool: sqlx::PgPool) { seed(&pool, &[("a", "阿明"), ("b", "小美")], None, false).await; start_run(&pool, "r1", "a", "message").await; crate::runs::append_bot_message_with( &app(pool.clone()), "t", "r1", "a", "@小美 @所有人 一起看。", json!([]), ) .await .unwrap(); let total: i64 = sqlx::query_scalar("SELECT count(*) FROM runs") .fetch_one(&pool) .await .unwrap(); assert_eq!(total, 1); assert!(audience_of(&pool, "assistant").await.is_empty()); } /// A one-to-one thread has no room to look up; the send must simply reach /// its bot. This once failed before the message was even written, because a /// NULL `room_id` was decoded as if it could not be NULL. #[sqlx::test(migrations = "../../migrations")] async fn a_direct_message_reaches_its_bot_and_records_no_audience(pool: sqlx::PgPool) { seed(&pool, &[("a", "阿明")], None, false).await; let actor = Actor { user_id: "u".into(), space_id: "s".into(), }; crate::runs::send( &app(pool.clone()), &actor, "a", "t", "幫我看這份", None, &[], &[], ) .await .unwrap(); assert_eq!(runs_for(&pool, "a").await, 1); assert!(audience_of(&pool, "user").await.is_empty()); } /// A room whose host was deleted (or that predates the column) still has a /// host: the first member, as before. #[sqlx::test(migrations = "../../migrations")] async fn a_room_without_a_recorded_host_falls_back_to_its_first_member(pool: sqlx::PgPool) { seed(&pool, &[("a", "阿明"), ("b", "小美")], None, true).await; let room = room_for_thread(&app(pool.clone()), "t") .await .unwrap() .expect("the thread belongs to a room"); assert_eq!(room.host_id, "a"); } }