1use sqlx::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 channel: Option<String>,
23 recipient: Option<String>,
24 body: String,
25 reply_to: Option<i64>,
26 metadata: serde_json::Value,
27 attachments: serde_json::Value,
28 created_at: chrono::DateTime<chrono::Utc>,
29}
30
31impl From<MessageRow> for MessageInfo {
32 fn from(r: MessageRow) -> Self {
33 MessageInfo {
34 id: r.id,
35 from: r.sender,
36 channel: r.channel,
37 to: r.recipient,
38 body: r.body,
39 reply_to: r.reply_to,
40 metadata: r.metadata,
41 attachments: serde_json::from_value(r.attachments).unwrap_or_default(),
42 created_at: ts(r.created_at),
43 }
44 }
45}
46
47const MESSAGE_SELECT: &str = r#"
48 SELECT m.id,
49 s.name AS sender,
50 ch.name AS channel,
51 r.name AS recipient,
52 m.body,
53 m.reply_to,
54 m.metadata,
55 COALESCE(
56 (SELECT json_agg(json_build_object(
57 'id', a.id, 'filename', a.filename,
58 'content_type', a.content_type, 'size_bytes', a.size_bytes)
59 ORDER BY a.id)
60 FROM attachments a WHERE a.message_id = m.id),
61 '[]'::json
62 ) AS attachments,
63 m.created_at
64 FROM messages m
65 JOIN agents s ON s.id = m.sender_agent_id
66 LEFT JOIN channels ch ON ch.id = m.channel_id
67 LEFT JOIN agents r ON r.id = m.recipient_agent_id
68"#;
69
70pub async fn create_channel(
73 pool: &PgPool,
74 auth: &AuthCtx,
75 name: &str,
76 topic: Option<String>,
77) -> BusResult<ChannelInfo> {
78 let name = normalize_channel(name);
79 if name.is_empty() {
80 return Err(BusError::invalid("channel name cannot be empty"));
81 }
82 if name.len() > 64 {
83 return Err(BusError::invalid(
84 "channel name is limited to 64 characters",
85 ));
86 }
87 let topic = match topic.as_deref() {
88 Some(t) => Some(super::check_text("channel topic", t, MAX_TOPIC_BYTES)?),
89 None => None,
90 };
91
92 let row: (Uuid, String, Option<String>, chrono::DateTime<chrono::Utc>) = sqlx::query_as(
93 r#"
94 INSERT INTO channels (team_id, name, topic, created_by)
95 VALUES ($1, $2, $3, $4)
96 ON CONFLICT (team_id, name) DO UPDATE
97 SET topic = COALESCE(EXCLUDED.topic, channels.topic)
98 RETURNING id, name, topic, created_at
99 "#,
100 )
101 .bind(auth.team_id)
102 .bind(&name)
103 .bind(topic)
104 .bind(auth.agent_id)
105 .fetch_one(pool)
106 .await?;
107
108 Ok(ChannelInfo {
109 name: row.1,
110 topic: row.2,
111 message_count: 0,
112 created_at: ts(row.3),
113 })
114}
115
116pub async fn list_channels(pool: &PgPool, auth: &AuthCtx) -> BusResult<ChannelList> {
117 let rows: Vec<(String, Option<String>, i64, chrono::DateTime<chrono::Utc>)> = sqlx::query_as(
118 r#"
119 SELECT c.name,
120 c.topic,
121 (SELECT count(*) FROM messages m WHERE m.channel_id = c.id) AS message_count,
122 c.created_at
123 FROM channels c
124 WHERE c.team_id = $1
125 ORDER BY c.name
126 "#,
127 )
128 .bind(auth.team_id)
129 .fetch_all(pool)
130 .await?;
131
132 Ok(ChannelList {
133 channels: rows
134 .into_iter()
135 .map(|(name, topic, message_count, created_at)| ChannelInfo {
136 name,
137 topic,
138 message_count,
139 created_at: ts(created_at),
140 })
141 .collect(),
142 })
143}
144
145pub struct PostInput {
148 pub channel: Option<String>,
149 pub to: Option<String>,
150 pub body: String,
151 pub reply_to: Option<i64>,
152 pub metadata: Option<serde_json::Value>,
153 pub attachments: Vec<super::attachments::NewAttachment>,
156}
157
158pub async fn post_message(
159 pool: &PgPool,
160 auth: &AuthCtx,
161 input: PostInput,
162) -> BusResult<PostMessageResult> {
163 let body = input.body.trim().to_owned();
164 if body.is_empty() {
165 return Err(BusError::invalid("message body cannot be empty"));
166 }
167 if body.len() > MAX_BODY_BYTES {
168 return Err(BusError::invalid(format!(
169 "message body is {} bytes; the limit is {MAX_BODY_BYTES}. \
170 Attach the file instead of pasting it, or split the message.",
171 body.len()
172 )));
173 }
174
175 let (channel_id, recipient_id, delivered_to) = match (&input.channel, &input.to) {
176 (Some(_), Some(_)) => {
177 return Err(BusError::invalid(
178 "set either `channel` or `to`, not both: a message is either broadcast or direct",
179 ));
180 }
181 (None, None) => {
182 return Err(BusError::invalid(
183 "set `channel` to broadcast, or `to` to send a direct message",
184 ));
185 }
186 (Some(channel), None) => {
187 let id = channel_id_by_name(pool, auth.team_id, channel).await?;
188 let names: Vec<(String,)> = sqlx::query_as(
190 "SELECT name FROM agents WHERE team_id = $1 AND disabled_at IS NULL ORDER BY name",
191 )
192 .bind(auth.team_id)
193 .fetch_all(pool)
194 .await?;
195 (
196 Some(id),
197 None,
198 names.into_iter().map(|r| r.0).collect::<Vec<_>>(),
199 )
200 }
201 (None, Some(to)) => {
202 let id = agent_id_by_name(pool, auth.team_id, to).await?;
203 (None, Some(id), vec![to.trim().to_owned()])
204 }
205 };
206
207 if let Some(reply_to) = input.reply_to {
209 let exists: Option<(i64,)> =
210 sqlx::query_as("SELECT id FROM messages WHERE id = $1 AND team_id = $2")
211 .bind(reply_to)
212 .bind(auth.team_id)
213 .fetch_optional(pool)
214 .await?;
215 if exists.is_none() {
216 return Err(BusError::not_found(format!("message {reply_to}")));
217 }
218 }
219
220 super::check_metadata("message", input.metadata.as_ref())?;
221 let metadata = input
222 .metadata
223 .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
224
225 let mut tx = pool.begin().await?;
228
229 let (id,): (i64,) = sqlx::query_as(
230 r#"
231 INSERT INTO messages
232 (team_id, channel_id, recipient_agent_id, sender_agent_id, body, reply_to, metadata)
233 VALUES ($1, $2, $3, $4, $5, $6, $7)
234 RETURNING id
235 "#,
236 )
237 .bind(auth.team_id)
238 .bind(channel_id)
239 .bind(recipient_id)
240 .bind(auth.agent_id)
241 .bind(&body)
242 .bind(input.reply_to)
243 .bind(&metadata)
244 .fetch_one(&mut *tx)
245 .await?;
246
247 for att in &input.attachments {
248 super::attachments::insert_for_message(&mut tx, auth.team_id, id, auth.agent_id, att)
249 .await?;
250 }
251
252 let row: MessageRow = sqlx::query_as(&format!("{MESSAGE_SELECT} WHERE m.id = $1"))
253 .bind(id)
254 .fetch_one(&mut *tx)
255 .await?;
256
257 tx.commit().await?;
258
259 Ok(PostMessageResult {
260 message: row.into(),
261 delivered_to,
262 })
263}
264
265pub async fn find_answer(
270 pool: &PgPool,
271 auth: &AuthCtx,
272 target_id: Uuid,
273 question_id: i64,
274) -> BusResult<Option<MessageInfo>> {
275 let row: Option<MessageRow> = sqlx::query_as(&format!(
276 r#"{MESSAGE_SELECT}
277 WHERE m.sender_agent_id = $1
278 AND m.recipient_agent_id = $2
279 AND m.id > $3
280 ORDER BY (m.reply_to = $3) DESC NULLS LAST, m.id
281 LIMIT 1"#
282 ))
283 .bind(target_id)
284 .bind(auth.agent_id)
285 .bind(question_id)
286 .fetch_optional(pool)
287 .await?;
288 Ok(row.map(Into::into))
289}
290
291pub async fn verify_question(
294 pool: &PgPool,
295 auth: &AuthCtx,
296 target_id: Uuid,
297 question_id: i64,
298) -> BusResult<()> {
299 let exists: Option<(i64,)> = sqlx::query_as(
300 "SELECT id FROM messages
301 WHERE id = $1 AND sender_agent_id = $2 AND recipient_agent_id = $3",
302 )
303 .bind(question_id)
304 .bind(auth.agent_id)
305 .bind(target_id)
306 .fetch_optional(pool)
307 .await?;
308 if exists.is_none() {
309 return Err(BusError::invalid(format!(
310 "message {question_id} is not a question you sent to this agent; \
311 pass the question_message_id returned by ask_agent"
312 )));
313 }
314 Ok(())
315}
316
317enum Scope {
321 All,
322 Inbox,
323 Channel { id: Uuid, name: String },
324}
325
326impl Scope {
327 fn cursor_key(&self) -> String {
328 match self {
329 Scope::All => "all".into(),
330 Scope::Inbox => "inbox".into(),
331 Scope::Channel { id, .. } => format!("channel:{id}"),
332 }
333 }
334 fn label(&self) -> String {
335 match self {
336 Scope::All => "all".into(),
337 Scope::Inbox => "inbox".into(),
338 Scope::Channel { name, .. } => format!("#{name}"),
339 }
340 }
341}
342
343async fn resolve_scope(pool: &PgPool, auth: &AuthCtx, raw: &str) -> BusResult<Scope> {
344 match raw.trim().to_lowercase().as_str() {
345 "" | "all" => Ok(Scope::All),
346 "inbox" | "dm" | "direct" => Ok(Scope::Inbox),
347 other => {
348 let name = normalize_channel(other);
349 let id = channel_id_by_name(pool, auth.team_id, &name).await?;
350 Ok(Scope::Channel { id, name })
351 }
352 }
353}
354
355pub struct ReadInput {
356 pub scope: String,
357 pub only_new: bool,
358 pub limit: i64,
359}
360
361pub async fn read_messages(
362 pool: &PgPool,
363 auth: &AuthCtx,
364 input: ReadInput,
365) -> BusResult<MessageList> {
366 let scope = resolve_scope(pool, auth, &input.scope).await?;
367 let limit = input.limit.clamp(1, MAX_LIMIT);
368 let cursor_key = scope.cursor_key();
369
370 let since: i64 = if input.only_new {
371 sqlx::query_as::<_, (i64,)>(
372 "SELECT last_message_id FROM read_cursors WHERE agent_id = $1 AND scope = $2",
373 )
374 .bind(auth.agent_id)
375 .bind(&cursor_key)
376 .fetch_optional(pool)
377 .await?
378 .map(|r| r.0)
379 .unwrap_or(0)
380 } else {
381 0
382 };
383
384 let rows: Vec<MessageRow> = match &scope {
388 Scope::All => {
389 sqlx::query_as(&format!(
390 r#"{MESSAGE_SELECT}
391 WHERE m.team_id = $1
392 AND m.id > $2
393 AND (m.channel_id IS NOT NULL
394 OR m.recipient_agent_id = $3
395 OR m.sender_agent_id = $3)
396 ORDER BY m.id DESC
397 LIMIT $4"#
398 ))
399 .bind(auth.team_id)
400 .bind(since)
401 .bind(auth.agent_id)
402 .bind(limit)
403 .fetch_all(pool)
404 .await?
405 }
406 Scope::Inbox => {
407 sqlx::query_as(&format!(
408 r#"{MESSAGE_SELECT}
409 WHERE m.recipient_agent_id = $1 AND m.id > $2
410 ORDER BY m.id DESC
411 LIMIT $3"#
412 ))
413 .bind(auth.agent_id)
414 .bind(since)
415 .bind(limit)
416 .fetch_all(pool)
417 .await?
418 }
419 Scope::Channel { id, .. } => {
420 sqlx::query_as(&format!(
421 r#"{MESSAGE_SELECT}
422 WHERE m.channel_id = $1 AND m.id > $2
423 ORDER BY m.id DESC
424 LIMIT $3"#
425 ))
426 .bind(*id)
427 .bind(since)
428 .bind(limit)
429 .fetch_all(pool)
430 .await?
431 }
432 };
433
434 let truncated = rows.len() as i64 == limit;
435 let mut messages: Vec<MessageInfo> = rows.into_iter().map(Into::into).collect();
436 messages.reverse();
437
438 let new_cursor = messages.iter().map(|m| m.id).max().unwrap_or(since);
439 if input.only_new && new_cursor > since {
440 sqlx::query(
441 r#"
442 INSERT INTO read_cursors (agent_id, scope, last_message_id)
443 VALUES ($1, $2, $3)
444 ON CONFLICT (agent_id, scope) DO UPDATE
445 SET last_message_id = GREATEST(read_cursors.last_message_id, EXCLUDED.last_message_id),
446 updated_at = now()
447 "#,
448 )
449 .bind(auth.agent_id)
450 .bind(&cursor_key)
451 .bind(new_cursor)
452 .execute(pool)
453 .await?;
454 }
455
456 Ok(MessageList {
457 messages,
458 scope: scope.label(),
459 cursor: new_cursor,
460 truncated,
461 })
462}
463
464pub async fn search_messages(
465 pool: &PgPool,
466 auth: &AuthCtx,
467 query: &str,
468 limit: i64,
469) -> BusResult<MessageList> {
470 let query = query.trim();
471 if query.is_empty() {
472 return Err(BusError::invalid("search query cannot be empty"));
473 }
474 let limit = limit.clamp(1, MAX_LIMIT);
475
476 let rows: Vec<MessageRow> = sqlx::query_as(&format!(
477 r#"{MESSAGE_SELECT}
478 WHERE m.team_id = $1
479 AND (m.channel_id IS NOT NULL
480 OR m.recipient_agent_id = $2
481 OR m.sender_agent_id = $2)
482 AND to_tsvector('simple', m.body) @@ plainto_tsquery('simple', $3)
483 ORDER BY m.id DESC
484 LIMIT $4"#
485 ))
486 .bind(auth.team_id)
487 .bind(auth.agent_id)
488 .bind(query)
489 .bind(limit)
490 .fetch_all(pool)
491 .await?;
492
493 let truncated = rows.len() as i64 == limit;
494 let messages: Vec<MessageInfo> = rows.into_iter().map(Into::into).collect();
495 let cursor = messages.iter().map(|m| m.id).max().unwrap_or(0);
496
497 Ok(MessageList {
498 messages,
499 scope: format!("search:{query}"),
500 cursor,
501 truncated,
502 })
503}