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;
12/// Bodies carry logs, diffs and generated reports, not just chat.
13const MAX_BODY_BYTES: usize = 1024 * 1024;
14/// A topic is one line describing what belongs in the channel.
15const MAX_TOPIC_BYTES: usize = 256;
16
17/// A joined message row as it comes back from Postgres.
18#[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
70// ---------------------------------------------------------------- channels --
71
72pub 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
145// ---------------------------------------------------------------- posting --
146
147pub 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    /// Already decoded and size-checked; inserted in the same transaction as
154    /// the message so readers never see the message without its files.
155    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            // Everyone in the team can read a channel, so report the roster.
189            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    // A reply must point at a message in the same team.
208    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    // Message + attachments commit together, so the NOTIFY that wakes
226    // teammates only fires once everything is readable.
227    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
265// ----------------------------------------------------------------- asking --
266
267/// The answer to a question DM: their explicit reply if there is one, else
268/// their first direct message to the asker after the question was sent.
269pub 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
291/// A resumed ask must point at a question the caller actually sent to the
292/// target; anything else means a mixed-up id.
293pub 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
317// ---------------------------------------------------------------- reading --
318
319/// Normalised read scope plus the cursor key used to remember the read position.
320enum 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    // Ordered ascending so the agent reads the conversation in chronological
385    // order; when not filtering by cursor we take the newest `limit` and then
386    // flip them back, so "the last N messages" is what you get.
387    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}