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