1use sqlx::{AssertSqlSafe, PgPool};
2use uuid::Uuid;
3
4use super::{agent_id_by_name, channel_id_by_name, normalize_channel};
5use crate::{
6 auth::AuthCtx,
7 error::{BusError, BusResult},
8 model::{ChannelInfo, ChannelList, MessageInfo, MessageList, PostMessageResult, ts},
9};
10
11const MAX_LIMIT: i64 = 200;
12const MAX_BODY_BYTES: usize = 1024 * 1024;
14const MAX_TOPIC_BYTES: usize = 256;
16
17#[derive(sqlx::FromRow)]
19struct MessageRow {
20 id: i64,
21 sender: String,
22 sender_session: Option<String>,
23 announce: bool,
24 channel: Option<String>,
25 recipient: Option<String>,
26 recipient_session: Option<String>,
27 body: String,
28 reply_to: Option<i64>,
29 metadata: serde_json::Value,
30 attachments: serde_json::Value,
31 created_at: chrono::DateTime<chrono::Utc>,
32}
33
34impl From<MessageRow> for MessageInfo {
35 fn from(r: MessageRow) -> Self {
36 MessageInfo {
37 id: r.id,
38 from: r.sender,
39 from_session: r.sender_session.filter(|s| !s.is_empty()),
40 announce: r.announce,
41 channel: r.channel,
42 to: r.recipient,
43 to_session: r.recipient_session.filter(|s| !s.is_empty()),
44 body: r.body,
45 reply_to: r.reply_to,
46 metadata: r.metadata,
47 attachments: serde_json::from_value(r.attachments).unwrap_or_default(),
48 created_at: ts(r.created_at),
49 }
50 }
51}
52
53const MESSAGE_SELECT: &str = r#"
54 SELECT m.id,
55 s.name AS sender,
56 m.sender_session,
57 m.announce,
58 ch.name AS channel,
59 r.name AS recipient,
60 m.recipient_session,
61 m.body,
62 m.reply_to,
63 m.metadata,
64 COALESCE(
65 (SELECT json_agg(json_build_object(
66 'id', a.id, 'filename', a.filename,
67 'content_type', a.content_type, 'size_bytes', a.size_bytes)
68 ORDER BY a.id)
69 FROM attachments a WHERE a.message_id = m.id),
70 '[]'::json
71 ) AS attachments,
72 m.created_at
73 FROM messages m
74 JOIN agents s ON s.id = m.sender_agent_id
75 LEFT JOIN channels ch ON ch.id = m.channel_id
76 LEFT JOIN agents r ON r.id = m.recipient_agent_id
77"#;
78
79pub async fn create_channel(
82 pool: &PgPool,
83 auth: &AuthCtx,
84 name: &str,
85 topic: Option<String>,
86) -> BusResult<ChannelInfo> {
87 let name = normalize_channel(name);
88 if name.is_empty() {
89 return Err(BusError::invalid("channel name cannot be empty"));
90 }
91 if name.len() > 64 {
92 return Err(BusError::invalid(
93 "channel name is limited to 64 characters",
94 ));
95 }
96 let topic = match topic.as_deref() {
97 Some(t) => Some(super::check_text("channel topic", t, MAX_TOPIC_BYTES)?),
98 None => None,
99 };
100
101 let row: (Uuid, String, Option<String>, chrono::DateTime<chrono::Utc>) = sqlx::query_as(
102 r#"
103 INSERT INTO channels (team_id, name, topic, created_by)
104 VALUES ($1, $2, $3, $4)
105 ON CONFLICT (team_id, name) DO UPDATE
106 SET topic = COALESCE(EXCLUDED.topic, channels.topic)
107 RETURNING id, name, topic, created_at
108 "#,
109 )
110 .bind(auth.team_id)
111 .bind(&name)
112 .bind(topic)
113 .bind(auth.agent_id)
114 .fetch_one(pool)
115 .await?;
116
117 Ok(ChannelInfo {
118 name: row.1,
119 topic: row.2,
120 message_count: 0,
121 created_at: ts(row.3),
122 })
123}
124
125pub async fn list_channels(pool: &PgPool, auth: &AuthCtx) -> BusResult<ChannelList> {
126 let rows: Vec<(String, Option<String>, i64, chrono::DateTime<chrono::Utc>)> = sqlx::query_as(
127 r#"
128 SELECT c.name,
129 c.topic,
130 (SELECT count(*) FROM messages m WHERE m.channel_id = c.id) AS message_count,
131 c.created_at
132 FROM channels c
133 WHERE c.team_id = $1
134 ORDER BY c.name
135 "#,
136 )
137 .bind(auth.team_id)
138 .fetch_all(pool)
139 .await?;
140
141 Ok(ChannelList {
142 channels: rows
143 .into_iter()
144 .map(|(name, topic, message_count, created_at)| ChannelInfo {
145 name,
146 topic,
147 message_count,
148 created_at: ts(created_at),
149 })
150 .collect(),
151 })
152}
153
154pub fn parse_address(raw: &str) -> BusResult<(String, Option<String>)> {
167 let raw = raw.trim();
168 match raw.split_once('/') {
169 None => Ok((raw.to_owned(), None)),
170 Some((agent, session)) => {
171 let agent = agent.trim();
172 if agent.is_empty() {
173 return Err(BusError::invalid(
174 "an address is 'agent' or 'agent/session'; the agent part is missing",
175 ));
176 }
177 let session = crate::auth::normalize_session(session)
182 .map_err(|why| BusError::invalid(format!("the session in '{raw}' {why}")))?;
183 if session.is_empty() {
184 return Err(BusError::invalid(format!(
185 "'{raw}' has an empty session; write '{agent}' to reach every \
186 session of theirs, or '{agent}/<session>' for one of them"
187 )));
188 }
189 Ok((agent.to_owned(), Some(session)))
190 }
191 }
192}
193
194pub async fn default_channel(pool: &PgPool, auth: &AuthCtx) -> BusResult<Option<(Uuid, String)>> {
203 if auth.session.is_empty() {
204 return Ok(None);
205 }
206 let name = normalize_channel(&auth.session);
207 let row: Option<(Uuid,)> =
208 sqlx::query_as("SELECT id FROM channels WHERE team_id = $1 AND name = $2")
209 .bind(auth.team_id)
210 .bind(&name)
211 .fetch_optional(pool)
212 .await?;
213 Ok(row.map(|r| (r.0, name)))
214}
215
216pub fn cursor_scope(base: &str, session: &str) -> String {
231 cursor_scope_for(base, session, false)
232}
233
234pub fn cursor_scope_for(base: &str, session: &str, all_sessions: bool) -> String {
235 match (session.is_empty(), all_sessions) {
236 (true, false) => base.to_owned(),
237 (_, false) => format!("{base}/s/{session}"),
238 (_, true) => format!("{base}/a/{session}"),
239 }
240}
241
242pub const DM_FOR_SESSION: &str =
247 "m.recipient_agent_id = $1 AND (m.recipient_session IS NULL OR m.recipient_session = $2)";
248
249pub struct PostInput {
250 pub channel: Option<String>,
251 pub to: Option<String>,
252 pub announce: bool,
255 pub body: String,
256 pub reply_to: Option<i64>,
257 pub metadata: Option<serde_json::Value>,
258 pub attachments: Vec<super::attachments::NewAttachment>,
261}
262
263pub async fn post_message(
264 pool: &PgPool,
265 auth: &AuthCtx,
266 input: PostInput,
267) -> BusResult<PostMessageResult> {
268 let body = input.body.trim().to_owned();
269 if body.is_empty() {
270 return Err(BusError::invalid("message body cannot be empty"));
271 }
272 if body.len() > MAX_BODY_BYTES {
273 return Err(BusError::invalid(format!(
274 "message body is {} bytes; the limit is {MAX_BODY_BYTES}. \
275 Attach the file instead of pasting it, or split the message.",
276 body.len()
277 )));
278 }
279
280 if input.announce && input.to.is_some() && input.channel.is_none() {
287 return Err(BusError::invalid(
288 "`announce` applies to channel messages. A direct message already reaches \
289 its recipient whatever they are focused on, so drop the flag, or post to \
290 a channel if the whole team needs to see this.",
291 ));
292 }
293
294 let (channel_id, recipient_id, recipient_session, delivered_to) = match (
295 &input.channel,
296 &input.to,
297 ) {
298 (Some(_), Some(_)) => {
299 return Err(BusError::invalid(
300 "set either `channel` or `to`, not both: a message is either broadcast or direct",
301 ));
302 }
303 (None, None) => match default_channel(pool, auth).await? {
304 Some((id, _name)) => {
307 let names: Vec<(String,)> = sqlx::query_as(
308 "SELECT name FROM agents WHERE team_id = $1 AND disabled_at IS NULL ORDER BY name",
309 )
310 .bind(auth.team_id)
311 .fetch_all(pool)
312 .await?;
313 (
314 Some(id),
315 None,
316 None,
317 names.into_iter().map(|r| r.0).collect::<Vec<_>>(),
318 )
319 }
320 None if auth.session.is_empty() => {
321 return Err(BusError::invalid(
322 "set `channel` to broadcast, or `to` to send a direct message",
323 ));
324 }
325 None => {
326 return Err(BusError::invalid(format!(
327 "set `channel` to broadcast, or `to` to send a direct message. \
328 This session is '{}', and there is no channel of that name to \
329 fall back on — create_channel '{}' to make it the default here.",
330 auth.session, auth.session
331 )));
332 }
333 },
334 (Some(channel), None) => {
335 let id = channel_id_by_name(pool, auth.team_id, channel).await?;
336 let names: Vec<(String,)> = sqlx::query_as(
338 "SELECT name FROM agents WHERE team_id = $1 AND disabled_at IS NULL ORDER BY name",
339 )
340 .bind(auth.team_id)
341 .fetch_all(pool)
342 .await?;
343 (
344 Some(id),
345 None,
346 None,
347 names.into_iter().map(|r| r.0).collect::<Vec<_>>(),
348 )
349 }
350 (None, Some(to)) => {
351 let (agent, session) = parse_address(to)?;
352 let id = agent_id_by_name(pool, auth.team_id, &agent).await?;
353 if id == auth.agent_id && session.as_deref() == Some(auth.session.as_str()) {
361 return Err(BusError::invalid(
362 "that address is this session — a message to yourself here would \
363 never be read. Use set_note to leave something durable, post to \
364 a channel, or address another of your sessions as 'you/<session>'.",
365 ));
366 }
367 let label = match &session {
370 Some(s) => format!("{agent}/{s}"),
371 None => agent.clone(),
372 };
373 (None, Some(id), session, vec![label])
374 }
375 };
376
377 if let Some(reply_to) = input.reply_to {
379 let exists: Option<(i64,)> =
380 sqlx::query_as("SELECT id FROM messages WHERE id = $1 AND team_id = $2")
381 .bind(reply_to)
382 .bind(auth.team_id)
383 .fetch_optional(pool)
384 .await?;
385 if exists.is_none() {
386 return Err(BusError::not_found(format!("message {reply_to}")));
387 }
388 }
389
390 let metadata_in = super::normalize_metadata(input.metadata);
391 super::check_metadata("message", metadata_in.as_ref())?;
392 let metadata = metadata_in.unwrap_or_else(|| serde_json::Value::Object(Default::default()));
393
394 let mut tx = pool.begin().await?;
397
398 let (id,): (i64,) = sqlx::query_as(
399 r#"
400 INSERT INTO messages
401 (team_id, channel_id, recipient_agent_id, recipient_session,
402 sender_agent_id, sender_session, body, reply_to, metadata, announce)
403 VALUES ($1, $2, $3, $8, $4, $9, $5, $6, $7, $10)
404 RETURNING id
405 "#,
406 )
407 .bind(auth.team_id)
408 .bind(channel_id)
409 .bind(recipient_id)
410 .bind(auth.agent_id)
411 .bind(&body)
412 .bind(input.reply_to)
413 .bind(&metadata)
414 .bind(recipient_session.as_deref())
415 .bind(super::session_label(auth))
418 .bind(input.announce)
419 .fetch_one(&mut *tx)
420 .await?;
421
422 for att in &input.attachments {
423 super::attachments::insert_for_message(&mut tx, auth.team_id, id, auth.agent_id, att)
424 .await?;
425 }
426
427 let row: MessageRow =
428 sqlx::query_as(AssertSqlSafe(format!("{MESSAGE_SELECT} WHERE m.id = $1")))
429 .bind(id)
430 .fetch_one(&mut *tx)
431 .await?;
432
433 tx.commit().await?;
434
435 Ok(PostMessageResult {
436 message: row.into(),
437 delivered_to,
438 })
439}
440
441pub async fn find_answer(
446 pool: &PgPool,
447 auth: &AuthCtx,
448 target_id: Uuid,
449 target_session: Option<&str>,
450 question_id: i64,
451) -> BusResult<Option<MessageInfo>> {
452 let row: Option<MessageRow> = sqlx::query_as(AssertSqlSafe(format!(
460 r#"{MESSAGE_SELECT}
461 WHERE m.sender_agent_id = $1
462 AND m.recipient_agent_id = $2
463 AND m.id > $3
464 AND (m.recipient_session IS NULL OR m.recipient_session = $4)
465 AND ($5::text IS NULL OR COALESCE(m.sender_session, '') = $5)
466 ORDER BY (m.reply_to = $3) DESC NULLS LAST, m.id
467 LIMIT 1"#
468 )))
469 .bind(target_id)
470 .bind(auth.agent_id)
471 .bind(question_id)
472 .bind(&auth.session)
473 .bind(target_session)
474 .fetch_optional(pool)
475 .await?;
476 Ok(row.map(Into::into))
477}
478
479pub async fn verify_question(
482 pool: &PgPool,
483 auth: &AuthCtx,
484 target_id: Uuid,
485 target_session: Option<&str>,
486 question_id: i64,
487) -> BusResult<()> {
488 let exists: Option<(i64,)> = sqlx::query_as(
494 "SELECT id FROM messages
495 WHERE id = $1 AND sender_agent_id = $2 AND recipient_agent_id = $3
496 AND COALESCE(sender_session, '') = $4
497 AND recipient_session IS NOT DISTINCT FROM $5",
498 )
499 .bind(question_id)
500 .bind(auth.agent_id)
501 .bind(target_id)
502 .bind(&auth.session)
503 .bind(target_session)
504 .fetch_optional(pool)
505 .await?;
506 if exists.is_none() {
507 return Err(BusError::invalid(format!(
508 "message {question_id} is not a question this session sent to that exact \
509 address; pass the question_message_id returned by ask_agent in this \
510 session, with the same `to`"
511 )));
512 }
513 Ok(())
514}
515
516enum Scope {
520 All,
521 Inbox,
522 Channel { id: Uuid, name: String },
523}
524
525impl Scope {
526 fn cursor_key_for(&self, auth: &AuthCtx, all_sessions: bool) -> String {
529 let base = match self {
530 Scope::All => "all".to_owned(),
531 Scope::Inbox => "inbox".to_owned(),
532 Scope::Channel { id, .. } => format!("channel:{id}"),
533 };
534 cursor_scope_for(&base, &auth.session, all_sessions)
535 }
536 fn label(&self) -> String {
537 match self {
538 Scope::All => "all".into(),
539 Scope::Inbox => "inbox".into(),
540 Scope::Channel { name, .. } => format!("#{name}"),
541 }
542 }
543}
544
545async fn resolve_scope(pool: &PgPool, auth: &AuthCtx, raw: &str) -> BusResult<Scope> {
546 match raw.trim().to_lowercase().as_str() {
547 "" | "all" => Ok(Scope::All),
548 "inbox" | "dm" | "direct" => Ok(Scope::Inbox),
549 other => {
550 let name = normalize_channel(other);
551 let id = channel_id_by_name(pool, auth.team_id, &name).await?;
552 Ok(Scope::Channel { id, name })
553 }
554 }
555}
556
557pub struct ReadInput {
558 pub scope: String,
559 pub only_new: bool,
560 pub limit: i64,
561 pub all_sessions: bool,
565}
566
567pub async fn read_messages(
568 pool: &PgPool,
569 auth: &AuthCtx,
570 input: ReadInput,
571) -> BusResult<MessageList> {
572 let scope = resolve_scope(pool, auth, &input.scope).await?;
573 let limit = input.limit.clamp(1, MAX_LIMIT);
574 let cursor_key = scope.cursor_key_for(auth, input.all_sessions);
577 let session_filter = input.all_sessions;
580
581 let since: i64 = if input.only_new {
582 sqlx::query_as::<_, (i64,)>(
583 "SELECT last_message_id FROM read_cursors WHERE agent_id = $1 AND scope = $2",
584 )
585 .bind(auth.agent_id)
586 .bind(&cursor_key)
587 .fetch_optional(pool)
588 .await?
589 .map(|r| r.0)
590 .unwrap_or(0)
591 } else {
592 0
593 };
594
595 let rows: Vec<MessageRow> = match &scope {
599 Scope::All => {
600 sqlx::query_as(AssertSqlSafe(format!(
601 r#"{MESSAGE_SELECT}
602 WHERE m.team_id = $1
603 AND m.id > $2
604 AND (m.channel_id IS NOT NULL
605 OR (m.recipient_agent_id = $3
606 AND ($5::bool
607 OR m.recipient_session IS NULL
608 OR m.recipient_session = $6))
609 OR m.sender_agent_id = $3)
610 ORDER BY m.id DESC
611 LIMIT $4"#
612 )))
613 .bind(auth.team_id)
614 .bind(since)
615 .bind(auth.agent_id)
616 .bind(limit)
617 .bind(session_filter)
618 .bind(&auth.session)
619 .fetch_all(pool)
620 .await?
621 }
622 Scope::Inbox => {
623 sqlx::query_as(AssertSqlSafe(format!(
624 r#"{MESSAGE_SELECT}
625 WHERE m.recipient_agent_id = $1
626 AND m.id > $2
627 AND ($4::bool
628 OR m.recipient_session IS NULL
629 OR m.recipient_session = $5)
630 ORDER BY m.id DESC
631 LIMIT $3"#
632 )))
633 .bind(auth.agent_id)
634 .bind(since)
635 .bind(limit)
636 .bind(session_filter)
637 .bind(&auth.session)
638 .fetch_all(pool)
639 .await?
640 }
641 Scope::Channel { id, .. } => {
642 sqlx::query_as(AssertSqlSafe(format!(
643 r#"{MESSAGE_SELECT}
644 WHERE m.channel_id = $1 AND m.id > $2
645 ORDER BY m.id DESC
646 LIMIT $3"#
647 )))
648 .bind(*id)
649 .bind(since)
650 .bind(limit)
651 .fetch_all(pool)
652 .await?
653 }
654 };
655
656 let truncated = rows.len() as i64 == limit;
657 let mut messages: Vec<MessageInfo> = rows.into_iter().map(Into::into).collect();
658 messages.reverse();
659
660 let new_cursor = messages.iter().map(|m| m.id).max().unwrap_or(since);
661 if input.only_new && new_cursor > since {
662 sqlx::query(
663 r#"
664 INSERT INTO read_cursors (agent_id, scope, last_message_id)
665 VALUES ($1, $2, $3)
666 ON CONFLICT (agent_id, scope) DO UPDATE
667 SET last_message_id = GREATEST(read_cursors.last_message_id, EXCLUDED.last_message_id),
668 updated_at = now()
669 "#,
670 )
671 .bind(auth.agent_id)
672 .bind(&cursor_key)
673 .bind(new_cursor)
674 .execute(pool)
675 .await?;
676 }
677
678 Ok(MessageList {
679 messages,
680 scope: scope.label(),
681 cursor: new_cursor,
682 truncated,
683 })
684}
685
686pub async fn search_messages(
687 pool: &PgPool,
688 auth: &AuthCtx,
689 query: &str,
690 limit: i64,
691) -> BusResult<MessageList> {
692 let query = query.trim();
693 if query.is_empty() {
694 return Err(BusError::invalid("search query cannot be empty"));
695 }
696 let limit = limit.clamp(1, MAX_LIMIT);
697
698 let rows: Vec<MessageRow> = sqlx::query_as(AssertSqlSafe(format!(
699 r#"{MESSAGE_SELECT}
700 WHERE m.team_id = $1
701 AND (m.channel_id IS NOT NULL
702 OR m.recipient_agent_id = $2
703 OR m.sender_agent_id = $2)
704 AND to_tsvector('simple', m.body) @@ plainto_tsquery('simple', $3)
705 ORDER BY m.id DESC
706 LIMIT $4"#
707 )))
708 .bind(auth.team_id)
709 .bind(auth.agent_id)
710 .bind(query)
711 .bind(limit)
712 .fetch_all(pool)
713 .await?;
714
715 let truncated = rows.len() as i64 == limit;
716 let messages: Vec<MessageInfo> = rows.into_iter().map(Into::into).collect();
717 let cursor = messages.iter().map(|m| m.id).max().unwrap_or(0);
718
719 Ok(MessageList {
720 messages,
721 scope: format!("search:{query}"),
722 cursor,
723 truncated,
724 })
725}
726
727#[cfg(test)]
728mod tests {
729 use super::*;
730
731 #[test]
732 fn an_address_validates_its_session_like_the_header_does() {
733 assert!(parse_address(&format!("dani/{}", "x".repeat(1000))).is_err());
736 assert!(parse_address("dani/${BUS_SESSION}").is_err());
737 assert!(parse_address("dani/api\u{7f}").is_err());
738 assert!(parse_address("dani/").is_err());
739 assert!(parse_address("/api").is_err());
740 }
741
742 #[test]
743 fn an_address_normalises_its_session_like_the_header_does() {
744 assert_eq!(
745 parse_address("dani/ Market-Data ").unwrap(),
746 ("dani".to_owned(), Some("market-data".to_owned()))
747 );
748 assert_eq!(parse_address("dani").unwrap(), ("dani".to_owned(), None));
749 }
750
751 #[test]
752 fn cursor_keys_cannot_collide_between_a_session_and_a_view() {
753 let per_session_of_odd_label = cursor_scope_for("inbox", "api+all-sessions", false);
758 let all_sessions_of_api = cursor_scope_for("inbox", "api", true);
759 assert_ne!(per_session_of_odd_label, all_sessions_of_api);
760
761 let keys = [
763 cursor_scope_for("inbox", "", false),
764 cursor_scope_for("inbox", "", true),
765 cursor_scope_for("inbox", "api", false),
766 cursor_scope_for("inbox", "api", true),
767 per_session_of_odd_label,
768 cursor_scope_for("inbox", "api+all-sessions", true),
769 ];
770 let unique: std::collections::HashSet<&String> = keys.iter().collect();
771 assert_eq!(unique.len(), keys.len(), "{keys:?}");
772 }
773
774 #[test]
775 fn the_shared_session_keeps_the_cursor_it_already_had() {
776 assert_eq!(cursor_scope_for("inbox", "", false), "inbox");
779 assert_eq!(cursor_scope_for("all", "", false), "all");
780 }
781}