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