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 (project, _) = super::presence::labels_of(pool, auth).await?;
213 let mut candidates: Vec<String> = Vec::new();
214 if let Some(p) = project.filter(|p| !p.is_empty()) {
215 candidates.push(normalize_channel(&p));
216 }
217 let by_session = normalize_channel(&auth.session);
218 if !candidates.contains(&by_session) {
219 candidates.push(by_session);
220 }
221 for name in candidates {
222 let row: Option<(Uuid,)> =
223 sqlx::query_as("SELECT id FROM channels WHERE team_id = $1 AND name = $2")
224 .bind(auth.team_id)
225 .bind(&name)
226 .fetch_optional(pool)
227 .await?;
228 if let Some((id,)) = row {
229 return Ok(Some((id, name)));
230 }
231 }
232 Ok(None)
233}
234
235pub fn cursor_scope(base: &str, session: &str) -> String {
250 cursor_scope_for(base, session, false)
251}
252
253pub fn cursor_scope_for(base: &str, session: &str, all_sessions: bool) -> String {
254 match (session.is_empty(), all_sessions) {
255 (true, false) => base.to_owned(),
256 (_, false) => format!("{base}/s/{session}"),
257 (_, true) => format!("{base}/a/{session}"),
258 }
259}
260
261pub const DM_FOR_SESSION: &str =
266 "m.recipient_agent_id = $1 AND (m.recipient_session IS NULL OR m.recipient_session = $2)";
267
268pub struct PostInput {
269 pub channel: Option<String>,
270 pub to: Option<String>,
271 pub announce: bool,
274 pub body: String,
275 pub reply_to: Option<i64>,
276 pub metadata: Option<serde_json::Value>,
277 pub attachments: Vec<super::attachments::NewAttachment>,
280}
281
282pub async fn post_message(
283 pool: &PgPool,
284 auth: &AuthCtx,
285 input: PostInput,
286) -> BusResult<PostMessageResult> {
287 let body = input.body.trim().to_owned();
288 if body.is_empty() {
289 return Err(BusError::invalid("message body cannot be empty"));
290 }
291 if body.len() > MAX_BODY_BYTES {
292 return Err(BusError::invalid(format!(
293 "message body is {} bytes; the limit is {MAX_BODY_BYTES}. \
294 Attach the file instead of pasting it, or split the message.",
295 body.len()
296 )));
297 }
298
299 if input.announce && input.to.is_some() && input.channel.is_none() {
306 return Err(BusError::invalid(
307 "`announce` applies to channel messages. A direct message already reaches \
308 its recipient whatever they are focused on, so drop the flag, or post to \
309 a channel if the whole team needs to see this.",
310 ));
311 }
312
313 let (channel_id, recipient_id, recipient_session, delivered_to) = match (
314 &input.channel,
315 &input.to,
316 ) {
317 (Some(_), Some(_)) => {
318 return Err(BusError::invalid(
319 "set either `channel` or `to`, not both: a message is either broadcast or direct",
320 ));
321 }
322 (None, None) => match default_channel(pool, auth).await? {
323 Some((id, _name)) => {
326 let names: Vec<(String,)> = sqlx::query_as(
327 "SELECT name FROM agents WHERE team_id = $1 AND disabled_at IS NULL ORDER BY name",
328 )
329 .bind(auth.team_id)
330 .fetch_all(pool)
331 .await?;
332 (
333 Some(id),
334 None,
335 None,
336 names.into_iter().map(|r| r.0).collect::<Vec<_>>(),
337 )
338 }
339 None if auth.session.is_empty() => {
340 return Err(BusError::invalid(
341 "set `channel` to broadcast, or `to` to send a direct message",
342 ));
343 }
344 None => {
345 return Err(BusError::invalid(format!(
346 "set `channel` to broadcast, or `to` to send a direct message. \
347 This session is '{}', and there is no channel of that name to \
348 fall back on — create_channel '{}' to make it the default here.",
349 auth.session, auth.session
350 )));
351 }
352 },
353 (Some(channel), None) => {
354 let id = channel_id_by_name(pool, auth.team_id, channel).await?;
355 let names: Vec<(String,)> = sqlx::query_as(
357 "SELECT name FROM agents WHERE team_id = $1 AND disabled_at IS NULL ORDER BY name",
358 )
359 .bind(auth.team_id)
360 .fetch_all(pool)
361 .await?;
362 (
363 Some(id),
364 None,
365 None,
366 names.into_iter().map(|r| r.0).collect::<Vec<_>>(),
367 )
368 }
369 (None, Some(to)) => {
370 let (agent, session) = parse_address(to)?;
371 let id = agent_id_by_name(pool, auth.team_id, &agent).await?;
372 if id == auth.agent_id && session.as_deref() == Some(auth.session.as_str()) {
380 return Err(BusError::invalid(
381 "that address is this session — a message to yourself here would \
382 never be read. Use set_note to leave something durable, post to \
383 a channel, or address another of your sessions as 'you/<session>'.",
384 ));
385 }
386 let label = match &session {
389 Some(s) => format!("{agent}/{s}"),
390 None => agent.clone(),
391 };
392 (None, Some(id), session, vec![label])
393 }
394 };
395
396 if let Some(reply_to) = input.reply_to {
398 let exists: Option<(i64,)> =
399 sqlx::query_as("SELECT id FROM messages WHERE id = $1 AND team_id = $2")
400 .bind(reply_to)
401 .bind(auth.team_id)
402 .fetch_optional(pool)
403 .await?;
404 if exists.is_none() {
405 return Err(BusError::not_found(format!("message {reply_to}")));
406 }
407 }
408
409 let metadata_in = super::normalize_metadata(input.metadata);
410 super::check_metadata("message", metadata_in.as_ref())?;
411 let metadata = metadata_in.unwrap_or_else(|| serde_json::Value::Object(Default::default()));
412
413 let mut tx = pool.begin().await?;
416 super::sessions::guard(&mut tx, auth).await?;
419
420 let (id,): (i64,) = sqlx::query_as(
421 r#"
422 INSERT INTO messages
423 (team_id, channel_id, recipient_agent_id, recipient_session,
424 sender_agent_id, sender_session, body, reply_to, metadata, announce)
425 VALUES ($1, $2, $3, $8, $4, $9, $5, $6, $7, $10)
426 RETURNING id
427 "#,
428 )
429 .bind(auth.team_id)
430 .bind(channel_id)
431 .bind(recipient_id)
432 .bind(auth.agent_id)
433 .bind(&body)
434 .bind(input.reply_to)
435 .bind(&metadata)
436 .bind(recipient_session.as_deref())
437 .bind(super::session_label(auth))
440 .bind(input.announce)
441 .fetch_one(&mut *tx)
442 .await?;
443
444 for att in &input.attachments {
445 super::attachments::insert_for_message(&mut tx, auth.team_id, id, auth.agent_id, att)
446 .await?;
447 }
448
449 let row: MessageRow =
450 sqlx::query_as(AssertSqlSafe(format!("{MESSAGE_SELECT} WHERE m.id = $1")))
451 .bind(id)
452 .fetch_one(&mut *tx)
453 .await?;
454
455 tx.commit().await?;
456
457 Ok(PostMessageResult {
458 message: row.into(),
459 delivered_to,
460 })
461}
462
463pub async fn find_answer(
468 pool: &PgPool,
469 auth: &AuthCtx,
470 target_id: Uuid,
471 target_session: Option<&str>,
472 question_id: i64,
473) -> BusResult<Option<MessageInfo>> {
474 let row: Option<MessageRow> = sqlx::query_as(AssertSqlSafe(format!(
482 r#"{MESSAGE_SELECT}
483 WHERE m.sender_agent_id = $1
484 AND m.recipient_agent_id = $2
485 AND m.id > $3
486 AND (m.recipient_session IS NULL OR m.recipient_session = $4)
487 AND ($5::text IS NULL OR COALESCE(m.sender_session, '') = $5)
488 ORDER BY (m.reply_to = $3) DESC NULLS LAST, m.id
489 LIMIT 1"#
490 )))
491 .bind(target_id)
492 .bind(auth.agent_id)
493 .bind(question_id)
494 .bind(&auth.session)
495 .bind(target_session)
496 .fetch_optional(pool)
497 .await?;
498 Ok(row.map(Into::into))
499}
500
501pub async fn verify_question(
504 pool: &PgPool,
505 auth: &AuthCtx,
506 target_id: Uuid,
507 target_session: Option<&str>,
508 question_id: i64,
509) -> BusResult<()> {
510 let exists: Option<(i64,)> = sqlx::query_as(
516 "SELECT id FROM messages
517 WHERE id = $1 AND sender_agent_id = $2 AND recipient_agent_id = $3
518 AND COALESCE(sender_session, '') = $4
519 AND recipient_session IS NOT DISTINCT FROM $5",
520 )
521 .bind(question_id)
522 .bind(auth.agent_id)
523 .bind(target_id)
524 .bind(&auth.session)
525 .bind(target_session)
526 .fetch_optional(pool)
527 .await?;
528 if exists.is_none() {
529 return Err(BusError::invalid(format!(
530 "message {question_id} is not a question this session sent to that exact \
531 address; pass the question_message_id returned by ask_agent in this \
532 session, with the same `to`"
533 )));
534 }
535 Ok(())
536}
537
538enum Scope {
542 All,
543 Inbox,
544 Channel { id: Uuid, name: String },
545}
546
547impl Scope {
548 fn cursor_key_for(&self, auth: &AuthCtx, all_sessions: bool) -> String {
551 let base = match self {
552 Scope::All => "all".to_owned(),
553 Scope::Inbox => "inbox".to_owned(),
554 Scope::Channel { id, .. } => format!("channel:{id}"),
555 };
556 cursor_scope_for(&base, &auth.session, all_sessions)
557 }
558 fn label(&self) -> String {
559 match self {
560 Scope::All => "all".into(),
561 Scope::Inbox => "inbox".into(),
562 Scope::Channel { name, .. } => format!("#{name}"),
563 }
564 }
565}
566
567async fn resolve_scope(pool: &PgPool, auth: &AuthCtx, raw: &str) -> BusResult<Scope> {
568 match raw.trim().to_lowercase().as_str() {
569 "" | "all" => Ok(Scope::All),
570 "inbox" | "dm" | "direct" => Ok(Scope::Inbox),
571 other => {
572 let name = normalize_channel(other);
573 let id = channel_id_by_name(pool, auth.team_id, &name).await?;
574 Ok(Scope::Channel { id, name })
575 }
576 }
577}
578
579pub struct ReadInput {
580 pub scope: String,
581 pub only_new: bool,
582 pub limit: i64,
583 pub all_sessions: bool,
587}
588
589pub async fn read_messages(
590 pool: &PgPool,
591 auth: &AuthCtx,
592 input: ReadInput,
593) -> BusResult<MessageList> {
594 let scope = resolve_scope(pool, auth, &input.scope).await?;
595 let limit = input.limit.clamp(1, MAX_LIMIT);
596 let cursor_key = scope.cursor_key_for(auth, input.all_sessions);
599 let session_filter = input.all_sessions;
602
603 let since: i64 = if input.only_new {
604 sqlx::query_as::<_, (i64,)>(
605 "SELECT last_message_id FROM read_cursors WHERE agent_id = $1 AND scope = $2",
606 )
607 .bind(auth.agent_id)
608 .bind(&cursor_key)
609 .fetch_optional(pool)
610 .await?
611 .map(|r| r.0)
612 .unwrap_or(0)
613 } else {
614 0
615 };
616
617 let order = if input.only_new { "ASC" } else { "DESC" };
625 let rows: Vec<MessageRow> = match &scope {
626 Scope::All => {
627 sqlx::query_as(AssertSqlSafe(format!(
628 r#"{MESSAGE_SELECT}
629 WHERE m.team_id = $1
630 AND m.id > $2
631 AND (m.channel_id IS NOT NULL
632 OR (m.recipient_agent_id = $3
633 AND ($5::bool
634 OR m.recipient_session IS NULL
635 OR m.recipient_session = $6))
636 OR m.sender_agent_id = $3)
637 ORDER BY m.id {order}
638 LIMIT $4"#
639 )))
640 .bind(auth.team_id)
641 .bind(since)
642 .bind(auth.agent_id)
643 .bind(limit)
644 .bind(session_filter)
645 .bind(&auth.session)
646 .fetch_all(pool)
647 .await?
648 }
649 Scope::Inbox => {
650 sqlx::query_as(AssertSqlSafe(format!(
651 r#"{MESSAGE_SELECT}
652 WHERE m.recipient_agent_id = $1
653 AND m.id > $2
654 AND ($4::bool
655 OR m.recipient_session IS NULL
656 OR m.recipient_session = $5)
657 ORDER BY m.id {order}
658 LIMIT $3"#
659 )))
660 .bind(auth.agent_id)
661 .bind(since)
662 .bind(limit)
663 .bind(session_filter)
664 .bind(&auth.session)
665 .fetch_all(pool)
666 .await?
667 }
668 Scope::Channel { id, .. } => {
669 sqlx::query_as(AssertSqlSafe(format!(
670 r#"{MESSAGE_SELECT}
671 WHERE m.channel_id = $1 AND m.id > $2
672 ORDER BY m.id {order}
673 LIMIT $3"#
674 )))
675 .bind(*id)
676 .bind(since)
677 .bind(limit)
678 .fetch_all(pool)
679 .await?
680 }
681 };
682
683 let truncated = rows.len() as i64 == limit;
684 let mut messages: Vec<MessageInfo> = rows.into_iter().map(Into::into).collect();
685 if !input.only_new {
686 messages.reverse();
687 }
688
689 let new_cursor = messages.iter().map(|m| m.id).max().unwrap_or(since);
690 if input.only_new && new_cursor > since {
691 let mut tx = pool.begin().await?;
695 super::sessions::guard(&mut tx, auth).await?;
696 sqlx::query(
697 r#"
698 INSERT INTO read_cursors (agent_id, scope, last_message_id)
699 VALUES ($1, $2, $3)
700 ON CONFLICT (agent_id, scope) DO UPDATE
701 SET last_message_id = GREATEST(read_cursors.last_message_id, EXCLUDED.last_message_id),
702 updated_at = now()
703 "#,
704 )
705 .bind(auth.agent_id)
706 .bind(&cursor_key)
707 .bind(new_cursor)
708 .execute(&mut *tx)
709 .await?;
710 tx.commit().await?;
711 }
712
713 Ok(MessageList {
714 messages,
715 scope: scope.label(),
716 cursor: new_cursor,
717 truncated,
718 })
719}
720
721pub async fn search_messages(
722 pool: &PgPool,
723 auth: &AuthCtx,
724 query: &str,
725 limit: i64,
726) -> BusResult<MessageList> {
727 let query = query.trim();
728 if query.is_empty() {
729 return Err(BusError::invalid("search query cannot be empty"));
730 }
731 let limit = limit.clamp(1, MAX_LIMIT);
732
733 let rows: Vec<MessageRow> = sqlx::query_as(AssertSqlSafe(format!(
734 r#"{MESSAGE_SELECT}
735 WHERE m.team_id = $1
736 AND (m.channel_id IS NOT NULL
737 OR m.recipient_agent_id = $2
738 OR m.sender_agent_id = $2)
739 AND to_tsvector('simple', m.body) @@ plainto_tsquery('simple', $3)
740 ORDER BY m.id DESC
741 LIMIT $4"#
742 )))
743 .bind(auth.team_id)
744 .bind(auth.agent_id)
745 .bind(query)
746 .bind(limit)
747 .fetch_all(pool)
748 .await?;
749
750 let truncated = rows.len() as i64 == limit;
751 let messages: Vec<MessageInfo> = rows.into_iter().map(Into::into).collect();
752 let cursor = messages.iter().map(|m| m.id).max().unwrap_or(0);
753
754 Ok(MessageList {
755 messages,
756 scope: format!("search:{query}"),
757 cursor,
758 truncated,
759 })
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765
766 #[test]
767 fn an_address_validates_its_session_like_the_header_does() {
768 assert!(parse_address(&format!("dani/{}", "x".repeat(1000))).is_err());
771 assert!(parse_address("dani/${BUS_SESSION}").is_err());
772 assert!(parse_address("dani/api\u{7f}").is_err());
773 assert!(parse_address("dani/").is_err());
774 assert!(parse_address("/api").is_err());
775 }
776
777 #[test]
778 fn an_address_normalises_its_session_like_the_header_does() {
779 assert_eq!(
780 parse_address("dani/ Market-Data ").unwrap(),
781 ("dani".to_owned(), Some("market-data".to_owned()))
782 );
783 assert_eq!(parse_address("dani").unwrap(), ("dani".to_owned(), None));
784 }
785
786 #[test]
787 fn cursor_keys_cannot_collide_between_a_session_and_a_view() {
788 let per_session_of_odd_label = cursor_scope_for("inbox", "api+all-sessions", false);
793 let all_sessions_of_api = cursor_scope_for("inbox", "api", true);
794 assert_ne!(per_session_of_odd_label, all_sessions_of_api);
795
796 let keys = [
798 cursor_scope_for("inbox", "", false),
799 cursor_scope_for("inbox", "", true),
800 cursor_scope_for("inbox", "api", false),
801 cursor_scope_for("inbox", "api", true),
802 per_session_of_odd_label,
803 cursor_scope_for("inbox", "api+all-sessions", true),
804 ];
805 let unique: std::collections::HashSet<&String> = keys.iter().collect();
806 assert_eq!(unique.len(), keys.len(), "{keys:?}");
807 }
808
809 #[test]
810 fn the_shared_session_keeps_the_cursor_it_already_had() {
811 assert_eq!(cursor_scope_for("inbox", "", false), "inbox");
814 assert_eq!(cursor_scope_for("all", "", false), "all");
815 }
816}