| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | | use crate::event::{Auth, Event, NewEvent}; |
| 10 | | use crate::keys::KeyPair; |
| 11 | | use crate::ledger::Ledger; |
| 12 | | use crate::principals::{signed_event, Principal}; |
| 13 | | use anyhow::{bail, Context, Result}; |
| 14 | | use serde::Serialize; |
| 15 | | use serde_json::{json, Value as Json}; |
| 16 | | use sqlx::SqliteConnection; |
| 17 | |
|
| 18 | |
|
| 19 | | pub const MAX_BODY: usize = 64 * 1024; |
| 20 | |
|
| 21 | |
|
| 22 | | pub const DUPLICATE_MS: u64 = 60_000; |
| 23 | |
|
| 24 | | pub const DEFAULT_CHANNELS: &[(&str, &str, &str)] = &[ |
| 25 | | ("general", "shared", "Everyone: agents and the human"), |
| 26 | | ("humans", "shared", "Where agents ask the human"), |
| 27 | | ("system", "system", "Guard rails, schedules and hooks; read-only for agents"), |
| 28 | | ]; |
| 29 | |
|
| 30 | | pub const KINDS: &[&str] = &["chat", "request", "answer", "handoff", "status", "decision", "system"]; |
| 31 | | pub const REACTIONS: &[&str] = &["ack", "done", "eyes", "no"]; |
| 32 | |
|
| 33 | |
|
| 34 | | pub struct Author<'a> { |
| 35 | | pub principal: Principal, |
| 36 | | pub signer: Signer<'a>, |
| 37 | | } |
| 38 | |
|
| 39 | |
|
| 40 | |
|
| 41 | | pub enum Signer<'a> { |
| 42 | | Cli(&'a KeyPair), |
| 43 | | Request(Auth), |
| 44 | | System, |
| 45 | | } |
| 46 | |
|
| 47 | | impl Author<'_> { |
| 48 | 40× | fn event(&self, kind: &str, payload: Json) -> NewEvent { |
| 49 | 40× | let actor = self.principal.actor(); |
| 50 | 40× | match &self.signer { |
| 51 | 19× | Signer::Cli(pair) => signed_event(pair, &actor, kind, payload), |
| 52 | 21× | Signer::Request(auth) => NewEvent::system(kind, payload).by(actor, auth.clone()), |
| 53 | 0× | Signer::System => NewEvent::system(kind, payload), |
| 54 | | } |
| 55 | 40× | } |
| 56 | | } |
| 57 | |
|
| 58 | | #[derive(Clone, Debug, Serialize, sqlx::FromRow)] |
| 59 | | pub struct Channel { |
| 60 | | pub id: String, |
| 61 | | pub name: String, |
| 62 | | pub kind: String, |
| 63 | | pub project: Option<String>, |
| 64 | | pub purpose: Option<String>, |
| 65 | | pub members: String, |
| 66 | | } |
| 67 | |
|
| 68 | | impl Channel { |
| 69 | 4× | pub fn members(&self) -> Vec<String> { |
| 70 | 4× | serde_json::from_str(&self.members).unwrap_or_default() |
| 71 | 4× | } |
| 72 | | |
| 73 | 49× | pub fn display(&self) -> String { |
| 74 | 49× | match self.name.strip_prefix("dm:") { |
| 75 | 5× | Some(pair) => format!("@{pair}"), |
| 76 | 44× | None => format!("#{}", self.name), |
| 77 | | } |
| 78 | 49× | } |
| 79 | | } |
| 80 | |
|
| 81 | | #[derive(Clone, Debug, Serialize, sqlx::FromRow)] |
| 82 | | pub struct Message { |
| 83 | | pub id: String, |
| 84 | | pub channel: String, |
| 85 | | pub reply_to: Option<String>, |
| 86 | | pub author: String, |
| 87 | | pub kind: String, |
| 88 | | pub body: String, |
| 89 | | pub payload: Option<String>, |
| 90 | | pub mentions: String, |
| 91 | | pub refs: String, |
| 92 | | pub at_ms: i64, |
| 93 | | pub seq: i64, |
| 94 | | pub replies: i64, |
| 95 | | pub redacted: Option<String>, |
| 96 | | } |
| 97 | |
|
| 98 | |
|
| 99 | | #[derive(Clone, Debug, Default)] |
| 100 | | pub struct Post { |
| 101 | | pub channel: Option<String>, |
| 102 | | pub body: String, |
| 103 | | pub reply_to: Option<String>, |
| 104 | | pub kind: Option<String>, |
| 105 | | pub payload: Option<Json>, |
| 106 | | pub mentions: Vec<String>, |
| 107 | | pub refs: Vec<String>, |
| 108 | | } |
| 109 | |
|
| 110 | |
|
| 111 | | #[derive(Clone, Debug, Serialize)] |
| 112 | | pub enum Posted { |
| 113 | | Message { id: String, channel: String }, |
| 114 | | Duplicate { id: String }, |
| 115 | | } |
| 116 | |
|
| 117 | |
|
| 118 | 21× | pub fn default_channel_events() -> Vec<NewEvent> { |
| 119 | 63× | DEFAULT_CHANNELS.iter().map(|(name, kind, purpose)| channel_event(name, kind, None, Some(purpose), &[])).collect() |
| 120 | 21× | } |
| 121 | |
|
| 122 | |
|
| 123 | 25× | pub fn project_channel_event(project: &str) -> NewEvent { |
| 124 | 25× | channel_event(project, "project", Some(project), Some(&format!("The {project} project")), &[]) |
| 125 | 25× | } |
| 126 | |
|
| 127 | 90× | fn channel_event(name: &str, kind: &str, project: Option<&str>, purpose: Option<&str>, members: &[String]) -> NewEvent { |
| 128 | 90× | let id = crate::ids::ulid(); |
| 129 | 90× | NewEvent::system( |
| 130 | 90× | "channel.created", |
| 131 | 90× | json!({ "id": id, "name": name, "kind": kind, "project": project, "purpose": purpose, "members": members }), |
| 132 | | ) |
| 133 | 90× | .subject(id) |
| 134 | 90× | } |
| 135 | |
|
| 136 | |
|
| 137 | 5× | pub fn direct_name(a: &str, b: &str) -> String { |
| 138 | 10× | let short = |x: &str| x.strip_prefix("agent:").unwrap_or(x).to_string(); |
| 139 | 5× | let mut v = [short(a), short(b)]; |
| 140 | 5× | v.sort(); |
| 141 | 5× | format!("dm:{}+{}", v[0], v[1]) |
| 142 | 5× | } |
| 143 | |
|
| 144 | |
|
| 145 | 51× | pub async fn channel(ledger: &Ledger, name: &str) -> Result<Option<Channel>> { |
| 146 | 51× | let n = name.strip_prefix('#').unwrap_or(name); |
| 147 | 51× | let n = match n.strip_prefix('@') { |
| 148 | 0× | Some(pair) => format!("dm:{pair}"), |
| 149 | 51× | None => n.to_string(), |
| 150 | | }; |
| 151 | 51× | Ok(sqlx::query_as("SELECT id, name, kind, project, purpose, members FROM channels WHERE name = ? OR id = ?") |
| 152 | 51× | .bind(&n) |
| 153 | 51× | .bind(&n) |
| 154 | 51× | .fetch_optional(ledger.pool()) |
| 155 | 51× | .await?) |
| 156 | 51× | } |
| 157 | |
|
| 158 | 1× | pub async fn channels(ledger: &Ledger) -> Result<Vec<Channel>> { |
| 159 | 1× | Ok(sqlx::query_as("SELECT id, name, kind, project, purpose, members FROM channels ORDER BY kind = 'direct', name") |
| 160 | 1× | .fetch_all(ledger.pool()) |
| 161 | 1× | .await?) |
| 162 | 1× | } |
| 163 | |
|
| 164 | |
|
| 165 | 13× | pub fn can_read(who: &Principal, ch: &Channel) -> bool { |
| 166 | 13× | ch.kind != "direct" || matches!(who, Principal::Owner { .. }) || ch.members().contains(&who.actor()) |
| 167 | 13× | } |
| 168 | |
|
| 169 | |
|
| 170 | 38× | pub fn can_post(who: &Principal, ch: &Channel) -> Result<(), String> { |
| 171 | 38× | match (who, ch.kind.as_str()) { |
| 172 | 0× | (Principal::Instance { .. }, _) => Ok(()), |
| 173 | 38× | (_, "system") => Err("#system is Spectra's: guard rails, schedules and hooks post there".into()), |
| 174 | 6× | (Principal::Owner { .. }, _) => Ok(()), |
| 175 | 29× | (_, "direct") if !ch.members().contains(&who.actor()) => Err(format!("{} is a direct channel you are not in", ch.display())), |
| 176 | 26× | (Principal::Agent { project, scopes, .. }, "project") => { |
| 177 | 8× | if ch.project.as_deref() == Some(project) || scopes.iter().any(|s| s == "projects:all") { |
| 178 | 7× | Ok(()) |
| 179 | | } else { |
| 180 | 1× | Err(format!("{} belongs to another project; post in #{project}, #general, or ask for projects:all", ch.display())) |
| 181 | | } |
| 182 | | } |
| 183 | 20× | _ => Ok(()), |
| 184 | | } |
| 185 | 38× | } |
| 186 | |
|
| 187 | |
|
| 188 | 39× | pub async fn post(ledger: &Ledger, who: &Author<'_>, mut p: Post, now_ms: u64) -> Result<Posted> { |
| 189 | 39× | if p.body.trim().is_empty() { |
| 190 | 1× | bail!("a message needs a body"); |
| 191 | 38× | } |
| 192 | 38× | if p.body.len() > MAX_BODY { |
| 193 | 1× | bail!("a message's body is at most 64 KiB; this is {} KiB", p.body.len() / 1024); |
| 194 | 37× | } |
| 195 | 37× | if p.payload.as_ref().is_some_and(|v| v.to_string().len() > MAX_BODY) { |
| 196 | 0× | bail!("a message's payload is at most 64 KiB"); |
| 197 | 37× | } |
| 198 | 37× | let kind = p.kind.take().unwrap_or_else(|| "chat".into()); |
| 199 | 37× | if !KINDS.contains(&kind.as_str()) { |
| 200 | 1× | bail!("a message's kind is one of {}", KINDS.join(", ")); |
| 201 | 36× | } |
| 202 | 36× | let ch = match (&p.channel, &p.reply_to, &who.principal) { |
| 203 | 26× | (Some(c), _, _) => channel(ledger, c).await?.with_context(|| format!("no channel {c}"))?, |
| 204 | 4× | (None, Some(r), _) => { |
| 205 | 4× | let root = message(ledger, r).await?.with_context(|| format!("no message {r}"))?; |
| 206 | 4× | channel(ledger, &root.channel).await?.context("the thread's channel")? |
| 207 | | } |
| 208 | 6× | (None, None, Principal::Agent { project, .. }) => { |
| 209 | 6× | channel(ledger, project).await?.with_context(|| format!("no channel for project {project}"))? |
| 210 | | } |
| 211 | 0× | (None, None, _) => channel(ledger, "general").await?.context("no #general (spectra init makes it)")?, |
| 212 | | }; |
| 213 | 36× | can_post(&who.principal, &ch).map_err(anyhow::Error::msg)?; |
| 214 | 31× | let reply_to = match &p.reply_to { |
| 215 | 4× | Some(r) => { |
| 216 | 4× | let m = message(ledger, r).await?.with_context(|| format!("no message {r}"))?; |
| 217 | 4× | if m.channel != ch.id { |
| 218 | 0× | bail!("{r} is in another channel"); |
| 219 | 4× | } |
| 220 | | |
| 221 | 4× | Some(m.reply_to.unwrap_or(m.id)) |
| 222 | | } |
| 223 | 27× | None => None, |
| 224 | | }; |
| 225 | 31× | let author = who.principal.actor(); |
| 226 | 31× | let dup: Option<String> = sqlx::query_scalar("SELECT id FROM messages WHERE channel = ? AND author = ? AND body = ? AND at_ms >= ? AND redacted IS NULL ORDER BY seq DESC LIMIT 1") |
| 227 | 31× | .bind(&ch.id) |
| 228 | 31× | .bind(&author) |
| 229 | 31× | .bind(&p.body) |
| 230 | 31× | .bind(now_ms.saturating_sub(DUPLICATE_MS) as i64) |
| 231 | 31× | .fetch_optional(ledger.pool()) |
| 232 | 31× | .await?; |
| 233 | 31× | if let Some(id) = dup { |
| 234 | 1× | return Ok(Posted::Duplicate { id }); |
| 235 | 30× | } |
| 236 | 30× | let mut mentions = resolve_mentions(ledger, &p.body, &ch).await?; |
| 237 | 30× | for m in p.mentions.drain(..) { |
| 238 | 0× | if let Some(a) = resolve_name(ledger, &m).await? { |
| 239 | 0× | mentions.push(a); |
| 240 | 0× | } |
| 241 | | } |
| 242 | 30× | mentions.retain(|m| m != &author); |
| 243 | 30× | mentions.sort(); |
| 244 | 30× | mentions.dedup(); |
| 245 | 30× | let mut refs = parse_refs(&p.body); |
| 246 | 30× | refs.append(&mut p.refs); |
| 247 | 30× | refs.sort(); |
| 248 | 30× | refs.dedup(); |
| 249 | 30× | let id = crate::ids::ulid(); |
| 250 | 30× | let payload = json!({ |
| 251 | 30× | "id": id, "channel": ch.id, "reply_to": reply_to, "kind": kind, "body": p.body, |
| 252 | 30× | "payload": p.payload, "mentions": mentions, "refs": refs, |
| 253 | | }); |
| 254 | 30× | let mut e = who.event("message.posted", payload).subject(id.clone()); |
| 255 | 30× | e.project = ch.project.clone(); |
| 256 | 30× | ledger.append_one(e).await?; |
| 257 | 30× | Ok(Posted::Message { id, channel: ch.display() }) |
| 258 | 39× | } |
| 259 | |
|
| 260 | |
|
| 261 | 2× | pub async fn ensure_direct(ledger: &Ledger, who: &Principal, other: &str) -> Result<Channel> { |
| 262 | 2× | let other = resolve_name(ledger, other).await?.with_context(|| format!("nobody is called {other}"))?; |
| 263 | 2× | let name = direct_name(&who.actor(), &other); |
| 264 | 2× | if let Some(c) = channel(ledger, &name).await? { |
| 265 | 0× | return Ok(c); |
| 266 | 2× | } |
| 267 | 2× | let mut members = vec![who.actor(), other]; |
| 268 | 2× | members.sort(); |
| 269 | 2× | ledger.append_one(channel_event(&name, "direct", None, None, &members)).await?; |
| 270 | 2× | channel(ledger, &name).await?.context("the direct channel") |
| 271 | 2× | } |
| 272 | |
|
| 273 | 22× | pub async fn message(ledger: &Ledger, id: &str) -> Result<Option<Message>> { |
| 274 | 22× | Ok(sqlx::query_as(&format!("{SELECT_MESSAGE} WHERE id = ? OR (length(?) >= 6 AND id LIKE ? || '%') ORDER BY seq LIMIT 1")) |
| 275 | 22× | .bind(id) |
| 276 | 22× | .bind(id) |
| 277 | 22× | .bind(id) |
| 278 | 22× | .fetch_optional(ledger.pool()) |
| 279 | 22× | .await?) |
| 280 | 22× | } |
| 281 | |
|
| 282 | | const SELECT_MESSAGE: &str = |
| 283 | | "SELECT id, channel, reply_to, author, kind, body, payload, mentions, refs, at_ms, seq, replies, redacted FROM messages"; |
| 284 | |
|
| 285 | |
|
| 286 | |
|
| 287 | 9× | pub async fn read( |
| 288 | 9× | ledger: &Ledger, |
| 289 | 9× | who: &Principal, |
| 290 | 9× | channel_name: Option<&str>, |
| 291 | 9× | thread: Option<&str>, |
| 292 | 9× | since: Option<&str>, |
| 293 | 9× | limit: u32, |
| 294 | 9× | ) -> Result<(Channel, Vec<Message>)> { |
| 295 | 9× | let limit = limit.clamp(1, 500) as i64; |
| 296 | 9× | let since_seq = match since { |
| 297 | 9× | None => 0, |
| 298 | 0× | Some(s) => match s.parse::<i64>() { |
| 299 | 0× | Ok(ms) if ms > 1_000_000_000_000 => { |
| 300 | 0× | sqlx::query_scalar("SELECT COALESCE(MIN(seq), 1 << 62) - 1 FROM messages WHERE at_ms > ?") |
| 301 | 0× | .bind(ms) |
| 302 | 0× | .fetch_one(ledger.pool()) |
| 303 | 0× | .await? |
| 304 | | } |
| 305 | 0× | _ => message(ledger, s).await?.map(|m| m.seq).with_context(|| format!("no message {s}"))?, |
| 306 | | }, |
| 307 | | }; |
| 308 | 9× | let (ch, rows) = match thread { |
| 309 | 3× | Some(t) => { |
| 310 | 3× | let root = message(ledger, t).await?.with_context(|| format!("no message {t}"))?; |
| 311 | 3× | let root_id = root.reply_to.clone().unwrap_or(root.id.clone()); |
| 312 | 3× | let ch = channel(ledger, &root.channel).await?.context("the thread's channel")?; |
| 313 | 3× | let rows: Vec<Message> = |
| 314 | 3× | sqlx::query_as(&format!("{SELECT_MESSAGE} WHERE (id = ? OR reply_to = ?) AND seq > ? ORDER BY seq LIMIT ?")) |
| 315 | 3× | .bind(&root_id) |
| 316 | 3× | .bind(&root_id) |
| 317 | 3× | .bind(since_seq) |
| 318 | 3× | .bind(limit) |
| 319 | 3× | .fetch_all(ledger.pool()) |
| 320 | 3× | .await?; |
| 321 | 3× | (ch, rows) |
| 322 | | } |
| 323 | | None => { |
| 324 | 6× | let name = match (channel_name, who) { |
| 325 | 5× | (Some(c), _) => c.to_string(), |
| 326 | 1× | (None, Principal::Agent { project, .. }) => project.clone(), |
| 327 | 0× | (None, _) => "general".into(), |
| 328 | | }; |
| 329 | 6× | let ch = channel(ledger, &name).await?.with_context(|| format!("no channel {name}"))?; |
| 330 | | |
| 331 | 6× | let mut rows: Vec<Message> = |
| 332 | 6× | sqlx::query_as(&format!("{SELECT_MESSAGE} WHERE channel = ? AND reply_to IS NULL AND seq > ? ORDER BY seq DESC LIMIT ?")) |
| 333 | 6× | .bind(&ch.id) |
| 334 | 6× | .bind(since_seq) |
| 335 | 6× | .bind(limit) |
| 336 | 6× | .fetch_all(ledger.pool()) |
| 337 | 6× | .await?; |
| 338 | 6× | rows.reverse(); |
| 339 | 6× | (ch, rows) |
| 340 | | } |
| 341 | | }; |
| 342 | 9× | if !can_read(who, &ch) { |
| 343 | 1× | bail!("{} is a direct channel you are not in", ch.display()); |
| 344 | 8× | } |
| 345 | 8× | Ok((ch, rows.into_iter().map(shown).collect())) |
| 346 | 9× | } |
| 347 | |
|
| 348 | |
|
| 349 | 14× | fn shown(mut m: Message) -> Message { |
| 350 | 14× | if let Some(why) = &m.redacted { |
| 351 | 1× | m.body = format!("[redacted: {why}]"); |
| 352 | 1× | m.payload = None; |
| 353 | 13× | } |
| 354 | 14× | m |
| 355 | 14× | } |
| 356 | |
|
| 357 | |
|
| 358 | | #[derive(Clone, Debug, Serialize)] |
| 359 | | pub struct Item { |
| 360 | | pub id: String, |
| 361 | | pub kind: String, |
| 362 | | pub at_ms: i64, |
| 363 | | pub from: String, |
| 364 | | pub channel: String, |
| 365 | | pub summary: String, |
| 366 | | pub action_hint: String, |
| 367 | | } |
| 368 | |
|
| 369 | |
|
| 370 | |
|
| 371 | 16× | pub async fn inbox(ledger: &Ledger, actor: &str, deliver: Option<&Author<'_>>) -> Result<Vec<Item>> { |
| 372 | 16× | let rows: Vec<InboxRow> = sqlx::query_as( |
| 373 | 16× | "SELECT i.item AS id, i.kind, m.at_ms, m.author AS from_, c.name AS channel, m.body, m.redacted FROM inbox i JOIN messages m ON m.id = i.item JOIN channels c ON c.id = m.channel \ |
| 374 | 16× | WHERE i.principal = ? AND i.read = 0 ORDER BY i.seq", |
| 375 | 16× | ) |
| 376 | 16× | .bind(actor) |
| 377 | 16× | .fetch_all(ledger.pool()) |
| 378 | 16× | .await?; |
| 379 | 16× | let items: Vec<Item> = rows |
| 380 | 16× | .into_iter() |
| 381 | 16× | .map(|InboxRow { id, kind, at_ms, from_: from, channel: ch, body, redacted }| { |
| 382 | 9× | let channel = match ch.strip_prefix("dm:") { |
| 383 | 1× | Some(p) => format!("@{p}"), |
| 384 | 8× | None => format!("#{ch}"), |
| 385 | | }; |
| 386 | 9× | let summary = if redacted.is_some() { "[redacted]".into() } else { summarise(&body, 500) }; |
| 387 | 9× | Item { |
| 388 | 9× | action_hint: format!("read(thread: \"{id}\") for context, then say(reply_to: \"{id}\", …)"), |
| 389 | 9× | id, |
| 390 | 9× | kind, |
| 391 | 9× | at_ms, |
| 392 | 9× | from, |
| 393 | 9× | channel, |
| 394 | 9× | summary, |
| 395 | 9× | } |
| 396 | 9× | }) |
| 397 | 16× | .collect(); |
| 398 | 16× | if let (Some(who), Some(last)) = (deliver, items.last()) { |
| 399 | 3× | let undelivered: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM inbox WHERE principal = ? AND delivered = 0 AND read = 0") |
| 400 | 3× | .bind(actor) |
| 401 | 3× | .fetch_one(ledger.pool()) |
| 402 | 3× | .await?; |
| 403 | 3× | if undelivered > 0 { |
| 404 | 3× | ledger.append_one(who.event("inbox.delivered", json!({ "principal": actor, "upto": last.id }))).await?; |
| 405 | 0× | } |
| 406 | 13× | } |
| 407 | 16× | Ok(items) |
| 408 | 16× | } |
| 409 | |
|
| 410 | |
|
| 411 | 77× | pub async fn unread(ledger: &Ledger, actor: &str) -> Result<i64> { |
| 412 | 77× | Ok(sqlx::query_scalar("SELECT COUNT(*) FROM inbox WHERE principal = ? AND read = 0").bind(actor).fetch_one(ledger.pool()).await?) |
| 413 | 77× | } |
| 414 | |
|
| 415 | |
|
| 416 | 3× | pub async fn ack(ledger: &Ledger, who: &Author<'_>, ids: &[String], reaction: Option<&str>) -> Result<usize> { |
| 417 | 3× | if let Some(r) = reaction { |
| 418 | 2× | if !REACTIONS.contains(&r) { |
| 419 | 0× | bail!("a reaction is one of {}", REACTIONS.join(", ")); |
| 420 | 2× | } |
| 421 | 1× | } |
| 422 | 3× | let actor = who.principal.actor(); |
| 423 | 3× | let mut events = Vec::new(); |
| 424 | 3× | let mut found = Vec::new(); |
| 425 | 7× | for id in ids { |
| 426 | 7× | let m = message(ledger, id).await?.with_context(|| format!("no message {id}"))?; |
| 427 | 7× | found.push(m.id.clone()); |
| 428 | 7× | if let Some(r) = reaction { |
| 429 | 2× | events.push(who.event("message.reaction", json!({ "id": m.id, "reaction": r })).subject(m.id.clone())); |
| 430 | 5× | } |
| 431 | | } |
| 432 | 3× | events.push(who.event("inbox.read", json!({ "principal": actor, "ids": found }))); |
| 433 | 3× | let n = found.len(); |
| 434 | 3× | ledger.append(events).await?; |
| 435 | 3× | Ok(n) |
| 436 | 3× | } |
| 437 | |
|
| 438 | |
|
| 439 | 4× | pub async fn redact(ledger: &Ledger, who: &Author<'_>, id: &str, reason: &str) -> Result<()> { |
| 440 | 4× | let m = message(ledger, id).await?.with_context(|| format!("no message {id}"))?; |
| 441 | 4× | if m.author != who.principal.actor() && !matches!(who.principal, Principal::Owner { .. }) { |
| 442 | 2× | bail!("only its author or the owner may redact a message"); |
| 443 | 2× | } |
| 444 | 2× | if reason.trim().is_empty() { |
| 445 | 0× | bail!("say why it is redacted"); |
| 446 | 2× | } |
| 447 | 2× | ledger.append_one(who.event("message.redacted", json!({ "id": m.id, "reason": reason })).subject(m.id)).await?; |
| 448 | 2× | Ok(()) |
| 449 | 4× | } |
| 450 | |
|
| 451 | | #[derive(sqlx::FromRow)] |
| 452 | | struct InboxRow { |
| 453 | | id: String, |
| 454 | | kind: String, |
| 455 | | at_ms: i64, |
| 456 | | from_: String, |
| 457 | | channel: String, |
| 458 | | body: String, |
| 459 | | redacted: Option<String>, |
| 460 | | } |
| 461 | |
|
| 462 | | #[derive(sqlx::FromRow)] |
| 463 | | struct HitRow { |
| 464 | | id: String, |
| 465 | | name: String, |
| 466 | | author: String, |
| 467 | | at_ms: i64, |
| 468 | | snippet: String, |
| 469 | | reply_to: Option<String>, |
| 470 | | kind: String, |
| 471 | | members: String, |
| 472 | | } |
| 473 | |
|
| 474 | |
|
| 475 | | #[derive(Clone, Debug, Serialize)] |
| 476 | | pub struct Hit { |
| 477 | | pub id: String, |
| 478 | | pub channel: String, |
| 479 | | pub author: String, |
| 480 | | pub at_ms: i64, |
| 481 | | pub snippet: String, |
| 482 | | pub thread: Option<String>, |
| 483 | | } |
| 484 | |
|
| 485 | |
|
| 486 | 10× | pub async fn search( |
| 487 | 10× | ledger: &Ledger, |
| 488 | 10× | who: &Principal, |
| 489 | 10× | query: &str, |
| 490 | 10× | channel_name: Option<&str>, |
| 491 | 10× | author: Option<&str>, |
| 492 | 10× | since_ms: Option<i64>, |
| 493 | 10× | limit: u32, |
| 494 | 10× | ) -> Result<Vec<Hit>> { |
| 495 | 10× | if query.trim().is_empty() { |
| 496 | 0× | bail!("search needs a query"); |
| 497 | 10× | } |
| 498 | 10× | let ch = match channel_name { |
| 499 | 2× | Some(c) => Some(channel(ledger, c).await?.with_context(|| format!("no channel {c}"))?.id), |
| 500 | 8× | None => None, |
| 501 | | }; |
| 502 | 10× | let author = match author { |
| 503 | 1× | Some(a) => Some(resolve_name(ledger, a).await?.unwrap_or_else(|| a.to_string())), |
| 504 | 9× | None => None, |
| 505 | | }; |
| 506 | 10× | let rows: Vec<HitRow> = sqlx::query_as( |
| 507 | 10× | "SELECT m.id, c.name, m.author, m.at_ms, snippet(messages_fts, 3, '[', ']', '…', 16) AS snippet, m.reply_to, c.kind, c.members \ |
| 508 | 10× | FROM messages_fts f JOIN messages m ON m.id = f.id JOIN channels c ON c.id = m.channel \ |
| 509 | 10× | WHERE messages_fts MATCH ? AND m.redacted IS NULL AND (? IS NULL OR m.channel = ?) AND (? IS NULL OR m.author = ?) AND (? IS NULL OR m.at_ms >= ?) \ |
| 510 | 10× | ORDER BY m.seq DESC LIMIT ?", |
| 511 | 10× | ) |
| 512 | 10× | .bind(query) |
| 513 | 10× | .bind(&ch) |
| 514 | 10× | .bind(&ch) |
| 515 | 10× | .bind(&author) |
| 516 | 10× | .bind(&author) |
| 517 | 10× | .bind(since_ms) |
| 518 | 10× | .bind(since_ms) |
| 519 | 10× | .bind(limit.clamp(1, 100) as i64) |
| 520 | 10× | .fetch_all(ledger.pool()) |
| 521 | 10× | .await |
| 522 | 10× | .map_err(|e| match e { |
| 523 | 1× | sqlx::Error::Database(d) if d.message().contains("fts5") || d.message().contains("syntax") => { |
| 524 | 1× | anyhow::anyhow!("the query does not parse: words, \"phrases\", prefix*, AND, OR, NOT ({})", d.message()) |
| 525 | | } |
| 526 | 0× | e => e.into(), |
| 527 | 1× | })?; |
| 528 | 9× | Ok(rows |
| 529 | 9× | .into_iter() |
| 530 | 11× | .filter(|r| { |
| 531 | 11× | r.kind != "direct" |
| 532 | 2× | || matches!(who, Principal::Owner { .. }) |
| 533 | 1× | || serde_json::from_str::<Vec<String>>(&r.members).unwrap_or_default().contains(&who.actor()) |
| 534 | 11× | }) |
| 535 | 9× | .map(|HitRow { id, name, author, at_ms, snippet, reply_to, .. }| Hit { |
| 536 | 10× | channel: match name.strip_prefix("dm:") { |
| 537 | 1× | Some(p) => format!("@{p}"), |
| 538 | 9× | None => format!("#{name}"), |
| 539 | | }, |
| 540 | 10× | thread: reply_to.or(Some(id.clone())), |
| 541 | 10× | id, |
| 542 | 10× | author, |
| 543 | 10× | at_ms, |
| 544 | 10× | snippet, |
| 545 | 10× | }) |
| 546 | 9× | .collect()) |
| 547 | 10× | } |
| 548 | |
|
| 549 | |
|
| 550 | |
|
| 551 | 30× | async fn resolve_mentions(ledger: &Ledger, body: &str, ch: &Channel) -> Result<Vec<String>> { |
| 552 | 30× | let mut out = Vec::new(); |
| 553 | 732× | for word in body.split(|c: char| c.is_whitespace() || ",.;:!?()[]".contains(c)) { |
| 554 | 140× | let Some(name) = word.strip_prefix('@') else { continue }; |
| 555 | 13× | if name == "here" { |
| 556 | 0× | let online: Vec<String> = |
| 557 | 0× | sqlx::query_scalar("SELECT 'agent:' || agent FROM presence WHERE state IN ('online', 'busy', 'idle')") |
| 558 | 0× | .fetch_all(ledger.pool()) |
| 559 | 0× | .await?; |
| 560 | 0× | let members = ch.members(); |
| 561 | 0× | out.extend(online.into_iter().filter(|a| members.is_empty() || members.contains(a))); |
| 562 | 13× | } else if let Some(a) = resolve_name(ledger, name).await? { |
| 563 | 13× | out.push(a); |
| 564 | 13× | } |
| 565 | | } |
| 566 | 30× | Ok(out) |
| 567 | 30× | } |
| 568 | |
|
| 569 | |
|
| 570 | |
|
| 571 | 16× | pub async fn resolve_name(ledger: &Ledger, name: &str) -> Result<Option<String>> { |
| 572 | 16× | let n = name.trim_start_matches('@').to_lowercase(); |
| 573 | 16× | if n == "human" || n == "owner" { |
| 574 | 1× | return Ok(Some("human".into())); |
| 575 | 15× | } |
| 576 | 15× | let n = n.strip_prefix("agent:").unwrap_or(&n).to_string(); |
| 577 | 15× | let agent: Option<String> = sqlx::query_scalar("SELECT name FROM agents WHERE name = ?").bind(&n).fetch_optional(ledger.pool()).await?; |
| 578 | 15× | if let Some(a) = agent { |
| 579 | 7× | return Ok(Some(format!("agent:{a}"))); |
| 580 | 8× | } |
| 581 | 8× | let owner: Option<String> = |
| 582 | 8× | sqlx::query_scalar("SELECT name FROM identities WHERE role = 'owner' AND current = 1").fetch_optional(ledger.pool()).await?; |
| 583 | 8× | let first = owner.and_then(|o| o.split_whitespace().next().map(str::to_lowercase)); |
| 584 | 8× | Ok((first.as_deref() == Some(n.as_str())).then(|| "human".into())) |
| 585 | 16× | } |
| 586 | |
|
| 587 | |
|
| 588 | |
|
| 589 | 54× | pub async fn display_name(ledger: &Ledger, actor: &str) -> String { |
| 590 | 54× | if actor == "human" { |
| 591 | 14× | let name: Option<String> = sqlx::query_scalar("SELECT name FROM identities WHERE role = 'owner' AND current = 1") |
| 592 | 14× | .fetch_optional(ledger.pool()) |
| 593 | 14× | .await |
| 594 | 14× | .ok() |
| 595 | 14× | .flatten(); |
| 596 | 14× | return name.unwrap_or_else(|| "the owner".into()); |
| 597 | 40× | } |
| 598 | 40× | if let Some(a) = actor.strip_prefix("agent:") { |
| 599 | 12× | let owner: Option<String> = |
| 600 | 12× | sqlx::query_scalar("SELECT owner_name FROM agents WHERE name = ?").bind(a).fetch_optional(ledger.pool()).await.ok().flatten(); |
| 601 | 12× | return match owner { |
| 602 | 12× | Some(o) => format!("{a} · {o}'s agent"), |
| 603 | 0× | None => a.to_string(), |
| 604 | | }; |
| 605 | 28× | } |
| 606 | 28× | "Spectra".into() |
| 607 | 54× | } |
| 608 | |
|
| 609 | |
|
| 610 | 31× | pub fn parse_refs(body: &str) -> Vec<String> { |
| 611 | 795× | body.split(|c: char| c.is_whitespace() || "()[]<>,;\"'".contains(c)) |
| 612 | 141× | .map(|w| w.trim_end_matches(['.', ':', '!', '?'])) |
| 613 | 141× | .filter(|w| { |
| 614 | 141× | w.starts_with("task:") |
| 615 | 140× | || w.starts_with("decision:") |
| 616 | 140× | || w.starts_with("oxy:change:") |
| 617 | 138× | || w.starts_with("https://") |
| 618 | 137× | || w.starts_with("http://") |
| 619 | 141× | }) |
| 620 | 31× | .filter(|w| w.len() > 6) |
| 621 | 31× | .map(String::from) |
| 622 | 31× | .collect() |
| 623 | 31× | } |
| 624 | |
|
| 625 | 11× | fn summarise(body: &str, max: usize) -> String { |
| 626 | 11× | let one: String = body.split_whitespace().collect::<Vec<_>>().join(" "); |
| 627 | 11× | if one.chars().count() <= max { |
| 628 | 10× | one |
| 629 | | } else { |
| 630 | 1× | format!("{}…", one.chars().take(max - 1).collect::<String>()) |
| 631 | | } |
| 632 | 11× | } |
| 633 | |
|
| 634 | |
|
| 635 | 42500× | pub async fn project(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> { |
| 636 | 42500× | let p = e.json(); |
| 637 | 42500× | let s = |k: &str| p.get(k).and_then(Json::as_str).map(String::from); |
| 638 | 42500× | let list = |k: &str| p.get(k).cloned().filter(Json::is_array).unwrap_or(json!([])); |
| 639 | 42500× | match e.kind.as_str() { |
| 640 | 42500× | "channel.created" if s("id").is_some() && s("name").is_some() => { |
| 641 | 124× | sqlx::query("INSERT OR IGNORE INTO channels (id, name, kind, project, purpose, members, seq) VALUES (?, ?, ?, ?, ?, ?, ?)") |
| 642 | 124× | .bind(s("id")) |
| 643 | 124× | .bind(s("name")) |
| 644 | 124× | .bind(s("kind").unwrap_or_else(|| "shared".into())) |
| 645 | 124× | .bind(s("project")) |
| 646 | 124× | .bind(s("purpose")) |
| 647 | 124× | .bind(list("members").to_string()) |
| 648 | 124× | .bind(e.seq as i64) |
| 649 | 124× | .execute(&mut *conn) |
| 650 | 124× | .await?; |
| 651 | | } |
| 652 | 42376× | "message.posted" if s("id").is_some() && s("channel").is_some() && s("body").is_some() => { |
| 653 | 44× | let id = s("id").unwrap_or_default(); |
| 654 | 44× | let ch = s("channel").unwrap_or_default(); |
| 655 | 44× | let body = s("body").unwrap_or_default(); |
| 656 | 44× | let inserted = sqlx::query( |
| 657 | 44× | "INSERT OR IGNORE INTO messages (id, channel, reply_to, author, kind, body, payload, mentions, refs, at_ms, seq) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", |
| 658 | | ) |
| 659 | 44× | .bind(&id) |
| 660 | 44× | .bind(&ch) |
| 661 | 44× | .bind(s("reply_to")) |
| 662 | 44× | .bind(&e.actor) |
| 663 | 44× | .bind(s("kind").unwrap_or_else(|| "chat".into())) |
| 664 | 44× | .bind(&body) |
| 665 | 44× | .bind(p.get("payload").filter(|v| !v.is_null()).map(|v| v.to_string())) |
| 666 | 44× | .bind(list("mentions").to_string()) |
| 667 | 44× | .bind(list("refs").to_string()) |
| 668 | 44× | .bind(e.at_ms as i64) |
| 669 | 44× | .bind(e.seq as i64) |
| 670 | 44× | .execute(&mut *conn) |
| 671 | 44× | .await? |
| 672 | 44× | .rows_affected(); |
| 673 | 44× | if inserted == 0 { |
| 674 | 0× | return Ok(()); |
| 675 | 44× | } |
| 676 | 44× | if let Some(root) = s("reply_to") { |
| 677 | 6× | sqlx::query("UPDATE messages SET replies = replies + 1 WHERE id = ?").bind(root).execute(&mut *conn).await?; |
| 678 | 38× | } |
| 679 | 44× | sqlx::query("INSERT INTO messages_fts (id, channel, author, body) VALUES (?, ?, ?, ?)") |
| 680 | 44× | .bind(&id) |
| 681 | 44× | .bind(&ch) |
| 682 | 44× | .bind(&e.actor) |
| 683 | 44× | .bind(&body) |
| 684 | 44× | .execute(&mut *conn) |
| 685 | 44× | .await?; |
| 686 | 44× | let mut targets: Vec<(String, &str)> = |
| 687 | 44× | list("mentions").as_array().into_iter().flatten().filter_map(|m| m.as_str().map(|a| (a.to_string(), "mention"))).collect(); |
| 688 | 44× | let members: Option<(String, String)> = |
| 689 | 44× | sqlx::query_as("SELECT kind, members FROM channels WHERE id = ?").bind(&ch).fetch_optional(&mut *conn).await?; |
| 690 | 44× | if let Some((kind, members)) = members { |
| 691 | 44× | if kind == "direct" { |
| 692 | 8× | for m in serde_json::from_str::<Vec<String>>(&members).unwrap_or_default() { |
| 693 | 8× | targets.push((m, "dm")); |
| 694 | 8× | } |
| 695 | 40× | } |
| 696 | 0× | } |
| 697 | 44× | for (who, kind) in targets { |
| 698 | 23× | if who == e.actor { |
| 699 | 4× | continue; |
| 700 | 19× | } |
| 701 | 19× | sqlx::query("INSERT OR IGNORE INTO inbox (principal, item, kind, seq) VALUES (?, ?, ?, ?)") |
| 702 | 19× | .bind(who) |
| 703 | 19× | .bind(&id) |
| 704 | 19× | .bind(kind) |
| 705 | 19× | .bind(e.seq as i64) |
| 706 | 19× | .execute(&mut *conn) |
| 707 | 19× | .await?; |
| 708 | | } |
| 709 | | } |
| 710 | 39168× | "message.redacted" if s("id").is_some() => { |
| 711 | 3× | sqlx::query("UPDATE messages SET redacted = ? WHERE id = ?") |
| 712 | 3× | .bind(s("reason").unwrap_or_else(|| "redacted".into())) |
| 713 | 3× | .bind(s("id")) |
| 714 | 3× | .execute(&mut *conn) |
| 715 | 3× | .await?; |
| 716 | 3× | sqlx::query("DELETE FROM messages_fts WHERE id = ?").bind(s("id")).execute(&mut *conn).await?; |
| 717 | | } |
| 718 | 39165× | "message.reaction" if s("id").is_some() && s("reaction").is_some() => { |
| 719 | 3× | sqlx::query("INSERT OR IGNORE INTO reactions (message, actor, reaction) VALUES (?, ?, ?)") |
| 720 | 3× | .bind(s("id")) |
| 721 | 3× | .bind(&e.actor) |
| 722 | 3× | .bind(s("reaction")) |
| 723 | 3× | .execute(&mut *conn) |
| 724 | 3× | .await?; |
| 725 | | } |
| 726 | 39162× | "inbox.delivered" if s("principal").is_some() => { |
| 727 | 4× | let upto: Option<i64> = sqlx::query_scalar("SELECT seq FROM inbox WHERE principal = ? AND item = ?") |
| 728 | 4× | .bind(s("principal")) |
| 729 | 4× | .bind(s("upto")) |
| 730 | 4× | .fetch_optional(&mut *conn) |
| 731 | 4× | .await?; |
| 732 | 4× | if let Some(seq) = upto { |
| 733 | 4× | sqlx::query("UPDATE inbox SET delivered = 1 WHERE principal = ? AND seq <= ?") |
| 734 | 4× | .bind(s("principal")) |
| 735 | 4× | .bind(seq) |
| 736 | 4× | .execute(&mut *conn) |
| 737 | 4× | .await?; |
| 738 | 0× | } |
| 739 | | } |
| 740 | 39158× | "inbox.read" if s("principal").is_some() => { |
| 741 | 8× | for id in list("ids").as_array().into_iter().flatten().filter_map(Json::as_str) { |
| 742 | 8× | sqlx::query("UPDATE inbox SET read = 1, delivered = 1 WHERE principal = ? AND item = ?") |
| 743 | 8× | .bind(s("principal")) |
| 744 | 8× | .bind(id) |
| 745 | 8× | .execute(&mut *conn) |
| 746 | 8× | .await?; |
| 747 | | } |
| 748 | | } |
| 749 | 42318× | _ => {} |
| 750 | | } |
| 751 | 42500× | Ok(()) |
| 752 | 42500× | } |
| 753 | |
|
| 754 | | #[cfg(test)] |
| 755 | | mod tests { |
| 756 | | use super::*; |
| 757 | |
|
| 758 | | #[test] |
| 759 | 1× | fn refs_and_direct_names() { |
| 760 | 1× | assert_eq!( |
| 761 | 1× | parse_refs("see task:01ABC and oxy:change:abcdef12, https://oxygenhub.io/x."), |
| 762 | | ["task:01ABC", "oxy:change:abcdef12", "https://oxygenhub.io/x"] |
| 763 | | ); |
| 764 | 1× | assert_eq!(direct_name("agent:zed", "human"), "dm:human+zed"); |
| 765 | 1× | assert_eq!(direct_name("human", "agent:zed"), direct_name("agent:zed", "human")); |
| 766 | 1× | assert_eq!(summarise("a\n b", 10), "a b"); |
| 767 | 1× | assert_eq!(summarise("abcdefghijkl", 5), "abcd…"); |
| 768 | 1× | } |
| 769 | | } |