fix: codex review — scope ack to user, deduplicate inbox entries

- ack_inbox now requires ?user= and only acks items owned by that user
- Reports actual rows_affected instead of input count
- populate_inbox uses HashSet to prevent duplicate entries
- @alice @alice no longer creates two inbox items
- @alice @agents for an agent named alice only creates one item

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 22:51:48 +02:00
parent 8dffd07190
commit 5c59b598c6
2 changed files with 28 additions and 18 deletions

View File

@@ -90,7 +90,7 @@ impl ColonyClient {
pub async fn ack_inbox(&self, ids: &[i64]) -> serde_json::Value { pub async fn ack_inbox(&self, ids: &[i64]) -> serde_json::Value {
let body = AckRequest { ids: ids.to_vec() }; let body = AckRequest { ids: ids.to_vec() };
let res = self.http let res = self.http
.post(self.url("/api/inbox/ack")) .post(self.url(&format!("/api/inbox/ack?{}", self.config.user_query())))
.json(&body) .json(&body)
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); }); .send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
if !res.status().is_success() { self.handle_error(res).await; } if !res.status().is_success() { self.handle_error(res).await; }

View File

@@ -392,31 +392,37 @@ pub async fn get_inbox(
pub async fn ack_inbox( pub async fn ack_inbox(
State(state): State<AppState>, State(state): State<AppState>,
Query(user_param): Query<UserParam>,
Json(body): Json<AckRequest>, Json(body): Json<AckRequest>,
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
let user_id = resolve_user(&state.db, &user_param).await?;
let mut acked = 0i64;
for id in &body.ids { for id in &body.ids {
sqlx::query("UPDATE inbox SET acked_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND acked_at IS NULL") let result = sqlx::query("UPDATE inbox SET acked_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND user_id = ? AND acked_at IS NULL")
.bind(id) .bind(id)
.bind(&user_id)
.execute(&state.db) .execute(&state.db)
.await?; .await?;
acked += result.rows_affected() as i64;
} }
Ok(Json(serde_json::json!({"acked": body.ids.len()}))) Ok(Json(serde_json::json!({"acked": acked})))
} }
/// Populate inbox entries when a message is posted /// Populate inbox entries when a message is posted
async fn populate_inbox(db: &SqlitePool, message_id: &str, channel_id: &str, content: &str, sender_id: &str) { async fn populate_inbox(db: &SqlitePool, message_id: &str, channel_id: &str, content: &str, sender_id: &str) {
let mentions = crate::db::parse_mentions(content); let mentions = crate::db::parse_mentions(content);
let mut notified: std::collections::HashSet<String> = std::collections::HashSet::new();
for mention in &mentions { for mention in &mentions {
// Resolve mentioned user // Resolve mentioned user
if let Ok(Some(user_id)) = sqlx::query_scalar::<_, String>("SELECT id FROM users WHERE username = ?") if let Ok(Some(uid)) = sqlx::query_scalar::<_, String>("SELECT id FROM users WHERE username = ?")
.bind(mention) .bind(mention)
.fetch_optional(db) .fetch_optional(db)
.await .await
{ {
if user_id != sender_id { if uid != sender_id && notified.insert(uid.clone()) {
let _ = sqlx::query("INSERT INTO inbox (user_id, message_id, channel_id, trigger) VALUES (?, ?, ?, 'mention')") let _ = sqlx::query("INSERT INTO inbox (user_id, message_id, channel_id, trigger) VALUES (?, ?, ?, 'mention')")
.bind(&user_id) .bind(&uid)
.bind(message_id) .bind(message_id)
.bind(channel_id) .bind(channel_id)
.execute(db) .execute(db)
@@ -432,6 +438,7 @@ async fn populate_inbox(db: &SqlitePool, message_id: &str, channel_id: &str, con
.await .await
.unwrap_or_default(); .unwrap_or_default();
for agent_id in agents { for agent_id in agents {
if notified.insert(agent_id.clone()) {
let _ = sqlx::query("INSERT INTO inbox (user_id, message_id, channel_id, trigger) VALUES (?, ?, ?, 'broadcast')") let _ = sqlx::query("INSERT INTO inbox (user_id, message_id, channel_id, trigger) VALUES (?, ?, ?, 'broadcast')")
.bind(&agent_id) .bind(&agent_id)
.bind(message_id) .bind(message_id)
@@ -440,6 +447,7 @@ async fn populate_inbox(db: &SqlitePool, message_id: &str, channel_id: &str, con
.await; .await;
} }
} }
}
// Handle @apes broadcast // Handle @apes broadcast
if mention == "apes" { if mention == "apes" {
@@ -449,6 +457,7 @@ async fn populate_inbox(db: &SqlitePool, message_id: &str, channel_id: &str, con
.await .await
.unwrap_or_default(); .unwrap_or_default();
for ape_id in apes { for ape_id in apes {
if notified.insert(ape_id.clone()) {
let _ = sqlx::query("INSERT INTO inbox (user_id, message_id, channel_id, trigger) VALUES (?, ?, ?, 'broadcast')") let _ = sqlx::query("INSERT INTO inbox (user_id, message_id, channel_id, trigger) VALUES (?, ?, ?, 'broadcast')")
.bind(&ape_id) .bind(&ape_id)
.bind(message_id) .bind(message_id)
@@ -458,6 +467,7 @@ async fn populate_inbox(db: &SqlitePool, message_id: &str, channel_id: &str, con
} }
} }
} }
}
} }
// ── Inbox row type ── // ── Inbox row type ──