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    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
57// ---------------------------------------------------------------- channels --
58
59pub 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
128// ---------------------------------------------------------------- posting --
129
130pub 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            // Everyone in the team can read a channel, so report the roster.
167            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    // A reply must point at a message in the same team.
186    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
231// ----------------------------------------------------------------- asking --
232
233/// The answer to a question DM: their explicit reply if there is one, else
234/// their first direct message to the asker after the question was sent.
235pub 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
257/// A resumed ask must point at a question the caller actually sent to the
258/// target; anything else means a mixed-up id.
259pub 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
283// ---------------------------------------------------------------- reading --
284
285/// Normalised read scope plus the cursor key used to remember the read position.
286enum 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    // Ordered ascending so the agent reads the conversation in chronological
351    // order; when not filtering by cursor we take the newest `limit` and then
352    // flip them back, so "the last N messages" is what you get.
353    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}