Skip to main content

ai_crew_sync/store/
messaging.rs

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/// A joined message row as it comes back from Postgres.
15#[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
67// ---------------------------------------------------------------- channels --
68
69pub 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
138// ---------------------------------------------------------------- posting --
139
140pub 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    /// Already decoded and size-checked; inserted in the same transaction as
147    /// the message so readers never see the message without its files.
148    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            // Everyone in the team can read a channel, so report the roster.
180            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    // A reply must point at a message in the same team.
199    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    // Message + attachments commit together, so the NOTIFY that wakes
216    // teammates only fires once everything is readable.
217    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
255// ----------------------------------------------------------------- asking --
256
257/// The answer to a question DM: their explicit reply if there is one, else
258/// their first direct message to the asker after the question was sent.
259pub 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
281/// A resumed ask must point at a question the caller actually sent to the
282/// target; anything else means a mixed-up id.
283pub 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
307// ---------------------------------------------------------------- reading --
308
309/// Normalised read scope plus the cursor key used to remember the read position.
310enum 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    // Ordered ascending so the agent reads the conversation in chronological
375    // order; when not filtering by cursor we take the newest `limit` and then
376    // flip them back, so "the last N messages" is what you get.
377    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}