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