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 super::check_metadata("message", input.metadata.as_ref())?;
391 let metadata = input
392 .metadata
393 .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
394
395 let mut tx = pool.begin().await?;
398
399 let (id,): (i64,) = sqlx::query_as(
400 r#"
401 INSERT INTO messages
402 (team_id, channel_id, recipient_agent_id, recipient_session,
403 sender_agent_id, sender_session, body, reply_to, metadata, announce)
404 VALUES ($1, $2, $3, $8, $4, $9, $5, $6, $7, $10)
405 RETURNING id
406 "#,
407 )
408 .bind(auth.team_id)
409 .bind(channel_id)
410 .bind(recipient_id)
411 .bind(auth.agent_id)
412 .bind(&body)
413 .bind(input.reply_to)
414 .bind(&metadata)
415 .bind(recipient_session.as_deref())
416 .bind(super::session_label(auth))
419 .bind(input.announce)
420 .fetch_one(&mut *tx)
421 .await?;
422
423 for att in &input.attachments {
424 super::attachments::insert_for_message(&mut tx, auth.team_id, id, auth.agent_id, att)
425 .await?;
426 }
427
428 let row: MessageRow =
429 sqlx::query_as(AssertSqlSafe(format!("{MESSAGE_SELECT} WHERE m.id = $1")))
430 .bind(id)
431 .fetch_one(&mut *tx)
432 .await?;
433
434 tx.commit().await?;
435
436 Ok(PostMessageResult {
437 message: row.into(),
438 delivered_to,
439 })
440}
441
442pub async fn find_answer(
447 pool: &PgPool,
448 auth: &AuthCtx,
449 target_id: Uuid,
450 target_session: Option<&str>,
451 question_id: i64,
452) -> BusResult<Option<MessageInfo>> {
453 let row: Option<MessageRow> = sqlx::query_as(AssertSqlSafe(format!(
461 r#"{MESSAGE_SELECT}
462 WHERE m.sender_agent_id = $1
463 AND m.recipient_agent_id = $2
464 AND m.id > $3
465 AND (m.recipient_session IS NULL OR m.recipient_session = $4)
466 AND ($5::text IS NULL OR COALESCE(m.sender_session, '') = $5)
467 ORDER BY (m.reply_to = $3) DESC NULLS LAST, m.id
468 LIMIT 1"#
469 )))
470 .bind(target_id)
471 .bind(auth.agent_id)
472 .bind(question_id)
473 .bind(&auth.session)
474 .bind(target_session)
475 .fetch_optional(pool)
476 .await?;
477 Ok(row.map(Into::into))
478}
479
480pub async fn verify_question(
483 pool: &PgPool,
484 auth: &AuthCtx,
485 target_id: Uuid,
486 target_session: Option<&str>,
487 question_id: i64,
488) -> BusResult<()> {
489 let exists: Option<(i64,)> = sqlx::query_as(
495 "SELECT id FROM messages
496 WHERE id = $1 AND sender_agent_id = $2 AND recipient_agent_id = $3
497 AND COALESCE(sender_session, '') = $4
498 AND recipient_session IS NOT DISTINCT FROM $5",
499 )
500 .bind(question_id)
501 .bind(auth.agent_id)
502 .bind(target_id)
503 .bind(&auth.session)
504 .bind(target_session)
505 .fetch_optional(pool)
506 .await?;
507 if exists.is_none() {
508 return Err(BusError::invalid(format!(
509 "message {question_id} is not a question this session sent to that exact \
510 address; pass the question_message_id returned by ask_agent in this \
511 session, with the same `to`"
512 )));
513 }
514 Ok(())
515}
516
517enum Scope {
521 All,
522 Inbox,
523 Channel { id: Uuid, name: String },
524}
525
526impl Scope {
527 fn cursor_key_for(&self, auth: &AuthCtx, all_sessions: bool) -> String {
530 let base = match self {
531 Scope::All => "all".to_owned(),
532 Scope::Inbox => "inbox".to_owned(),
533 Scope::Channel { id, .. } => format!("channel:{id}"),
534 };
535 cursor_scope_for(&base, &auth.session, all_sessions)
536 }
537 fn label(&self) -> String {
538 match self {
539 Scope::All => "all".into(),
540 Scope::Inbox => "inbox".into(),
541 Scope::Channel { name, .. } => format!("#{name}"),
542 }
543 }
544}
545
546async fn resolve_scope(pool: &PgPool, auth: &AuthCtx, raw: &str) -> BusResult<Scope> {
547 match raw.trim().to_lowercase().as_str() {
548 "" | "all" => Ok(Scope::All),
549 "inbox" | "dm" | "direct" => Ok(Scope::Inbox),
550 other => {
551 let name = normalize_channel(other);
552 let id = channel_id_by_name(pool, auth.team_id, &name).await?;
553 Ok(Scope::Channel { id, name })
554 }
555 }
556}
557
558pub struct ReadInput {
559 pub scope: String,
560 pub only_new: bool,
561 pub limit: i64,
562 pub all_sessions: bool,
566}
567
568pub async fn read_messages(
569 pool: &PgPool,
570 auth: &AuthCtx,
571 input: ReadInput,
572) -> BusResult<MessageList> {
573 let scope = resolve_scope(pool, auth, &input.scope).await?;
574 let limit = input.limit.clamp(1, MAX_LIMIT);
575 let cursor_key = scope.cursor_key_for(auth, input.all_sessions);
578 let session_filter = input.all_sessions;
581
582 let since: i64 = if input.only_new {
583 sqlx::query_as::<_, (i64,)>(
584 "SELECT last_message_id FROM read_cursors WHERE agent_id = $1 AND scope = $2",
585 )
586 .bind(auth.agent_id)
587 .bind(&cursor_key)
588 .fetch_optional(pool)
589 .await?
590 .map(|r| r.0)
591 .unwrap_or(0)
592 } else {
593 0
594 };
595
596 let rows: Vec<MessageRow> = match &scope {
600 Scope::All => {
601 sqlx::query_as(AssertSqlSafe(format!(
602 r#"{MESSAGE_SELECT}
603 WHERE m.team_id = $1
604 AND m.id > $2
605 AND (m.channel_id IS NOT NULL
606 OR (m.recipient_agent_id = $3
607 AND ($5::bool
608 OR m.recipient_session IS NULL
609 OR m.recipient_session = $6))
610 OR m.sender_agent_id = $3)
611 ORDER BY m.id DESC
612 LIMIT $4"#
613 )))
614 .bind(auth.team_id)
615 .bind(since)
616 .bind(auth.agent_id)
617 .bind(limit)
618 .bind(session_filter)
619 .bind(&auth.session)
620 .fetch_all(pool)
621 .await?
622 }
623 Scope::Inbox => {
624 sqlx::query_as(AssertSqlSafe(format!(
625 r#"{MESSAGE_SELECT}
626 WHERE m.recipient_agent_id = $1
627 AND m.id > $2
628 AND ($4::bool
629 OR m.recipient_session IS NULL
630 OR m.recipient_session = $5)
631 ORDER BY m.id DESC
632 LIMIT $3"#
633 )))
634 .bind(auth.agent_id)
635 .bind(since)
636 .bind(limit)
637 .bind(session_filter)
638 .bind(&auth.session)
639 .fetch_all(pool)
640 .await?
641 }
642 Scope::Channel { id, .. } => {
643 sqlx::query_as(AssertSqlSafe(format!(
644 r#"{MESSAGE_SELECT}
645 WHERE m.channel_id = $1 AND m.id > $2
646 ORDER BY m.id DESC
647 LIMIT $3"#
648 )))
649 .bind(*id)
650 .bind(since)
651 .bind(limit)
652 .fetch_all(pool)
653 .await?
654 }
655 };
656
657 let truncated = rows.len() as i64 == limit;
658 let mut messages: Vec<MessageInfo> = rows.into_iter().map(Into::into).collect();
659 messages.reverse();
660
661 let new_cursor = messages.iter().map(|m| m.id).max().unwrap_or(since);
662 if input.only_new && new_cursor > since {
663 sqlx::query(
664 r#"
665 INSERT INTO read_cursors (agent_id, scope, last_message_id)
666 VALUES ($1, $2, $3)
667 ON CONFLICT (agent_id, scope) DO UPDATE
668 SET last_message_id = GREATEST(read_cursors.last_message_id, EXCLUDED.last_message_id),
669 updated_at = now()
670 "#,
671 )
672 .bind(auth.agent_id)
673 .bind(&cursor_key)
674 .bind(new_cursor)
675 .execute(pool)
676 .await?;
677 }
678
679 Ok(MessageList {
680 messages,
681 scope: scope.label(),
682 cursor: new_cursor,
683 truncated,
684 })
685}
686
687pub async fn search_messages(
688 pool: &PgPool,
689 auth: &AuthCtx,
690 query: &str,
691 limit: i64,
692) -> BusResult<MessageList> {
693 let query = query.trim();
694 if query.is_empty() {
695 return Err(BusError::invalid("search query cannot be empty"));
696 }
697 let limit = limit.clamp(1, MAX_LIMIT);
698
699 let rows: Vec<MessageRow> = sqlx::query_as(AssertSqlSafe(format!(
700 r#"{MESSAGE_SELECT}
701 WHERE m.team_id = $1
702 AND (m.channel_id IS NOT NULL
703 OR m.recipient_agent_id = $2
704 OR m.sender_agent_id = $2)
705 AND to_tsvector('simple', m.body) @@ plainto_tsquery('simple', $3)
706 ORDER BY m.id DESC
707 LIMIT $4"#
708 )))
709 .bind(auth.team_id)
710 .bind(auth.agent_id)
711 .bind(query)
712 .bind(limit)
713 .fetch_all(pool)
714 .await?;
715
716 let truncated = rows.len() as i64 == limit;
717 let messages: Vec<MessageInfo> = rows.into_iter().map(Into::into).collect();
718 let cursor = messages.iter().map(|m| m.id).max().unwrap_or(0);
719
720 Ok(MessageList {
721 messages,
722 scope: format!("search:{query}"),
723 cursor,
724 truncated,
725 })
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731
732 #[test]
733 fn an_address_validates_its_session_like_the_header_does() {
734 assert!(parse_address(&format!("dani/{}", "x".repeat(1000))).is_err());
737 assert!(parse_address("dani/${BUS_SESSION}").is_err());
738 assert!(parse_address("dani/api\u{7f}").is_err());
739 assert!(parse_address("dani/").is_err());
740 assert!(parse_address("/api").is_err());
741 }
742
743 #[test]
744 fn an_address_normalises_its_session_like_the_header_does() {
745 assert_eq!(
746 parse_address("dani/ Market-Data ").unwrap(),
747 ("dani".to_owned(), Some("market-data".to_owned()))
748 );
749 assert_eq!(parse_address("dani").unwrap(), ("dani".to_owned(), None));
750 }
751
752 #[test]
753 fn cursor_keys_cannot_collide_between_a_session_and_a_view() {
754 let per_session_of_odd_label = cursor_scope_for("inbox", "api+all-sessions", false);
759 let all_sessions_of_api = cursor_scope_for("inbox", "api", true);
760 assert_ne!(per_session_of_odd_label, all_sessions_of_api);
761
762 let keys = [
764 cursor_scope_for("inbox", "", false),
765 cursor_scope_for("inbox", "", true),
766 cursor_scope_for("inbox", "api", false),
767 cursor_scope_for("inbox", "api", true),
768 per_session_of_odd_label,
769 cursor_scope_for("inbox", "api+all-sessions", true),
770 ];
771 let unique: std::collections::HashSet<&String> = keys.iter().collect();
772 assert_eq!(unique.len(), keys.len(), "{keys:?}");
773 }
774
775 #[test]
776 fn the_shared_session_keeps_the_cursor_it_already_had() {
777 assert_eq!(cursor_scope_for("inbox", "", false), "inbox");
780 assert_eq!(cursor_scope_for("all", "", false), "all");
781 }
782}