feat(slack): sync Saved for Later items via void slack saved#25
Conversation
Add background sync of Slack's "Later" view using `search.messages` with `is:saved`. Items are stored with an `is_saved` flag (DB migration v13) and reconciled on each sync cycle. Missing messages are fetched on demand. The command lives under `void slack saved` (Slack-specific). Includes: API client, sync logic with fetch-on-miss, CLI subcommand, DB reconcile/list/count helpers, setup wizard scope docs, and tests. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
MaximeGaudin
left a comment
There was a problem hiding this comment.
Thanks for this — it's a well-structured, well-tested PR and the DB/upsert/reconcile design is correct (the ON CONFLICT … DO UPDATE rightly leaves is_saved untouched, and external_id == msg.ts keeps reconcile matching sound). Security and supply-chain are clean. I'd like one change before merge, because Slack is a critical channel for us and the saved view needs to be reliable on every sync cycle.
Required: make per-item ingestion in sync_saved resilient (don't let one bad item abort the whole run)
Where: crates/void-slack/src/connector/sync.rs, the matches loop inside sync_saved.
Problem: the per-item fetch propagates with ?:
} else if self
.ingest_saved_match(db, &m.channel.id, &m.ts, &user_cache)
.await? // <-- aborts the entire sync_saved run
{ingest_saved_match calls conversations.info + get_single_message. If a single saved item lives in a channel the user can no longer access (left/archived private channel, deleted message) or a transient network/permission error hits, that ? aborts the whole run before reconcile_saved is reached. The call sites in connector_trait.rs and socket_mode.rs wrap sync_saved as non-fatal (warn!), so the failure is swallowed and the saved view silently stops updating — and it fails the same way every cycle, since the offending item is always returned by search. One un-fetchable saved message permanently breaks void slack saved.
Fix: warn-and-continue per item, mirroring the existing resilient pattern in backfill_avatars (users.info lookups already warn! … skipping and continue). Replace the if/else if/else with:
for m in &resp.messages.matches {
slack_matches += 1;
match db.find_message_by_external_id(&self.connection_id, &m.ts) {
Ok(Some(_)) => {
saved_external_ids.insert(m.ts.clone());
}
Ok(None) => match self
.ingest_saved_match(db, &m.channel.id, &m.ts, &user_cache)
.await
{
Ok(true) => {
ingested += 1;
saved_external_ids.insert(m.ts.clone());
}
Ok(false) => {
warn!(
connection_id = %self.connection_id,
channel_id = %m.channel.id,
ts = %m.ts,
"saved message could not be ingested"
);
}
Err(e) => {
warn!(
connection_id = %self.connection_id,
channel_id = %m.channel.id,
ts = %m.ts,
error = %e,
"failed to fetch saved message; skipping"
);
}
},
Err(e) => {
warn!(
connection_id = %self.connection_id,
ts = %m.ts,
error = %e,
"saved lookup failed; skipping"
);
}
}
}Important nuance — keep the page-level ? fatal
Please do not soften the search call itself:
let resp = self
.api
.search_messages_saved(cursor.as_deref(), page_size)
.await?; // keep this fatalreconcile_saved clears is_saved = 0 for every message of the connection not in saved_external_ids. So the saved set must be a complete, trustworthy enumeration before reconcile runs. If a search.messages page fails we cannot enumerate reliably, and continuing would wrongly un-save items — so aborting there (before reconcile) is the correct, safe behavior. Keep it as-is.
The per-item skip above is safe w.r.t. reconcile precisely because the fetch path only runs for items not yet in the local DB (the Ok(None) branch): an item we fail to fetch was never saved locally, so omitting it from the set can't clear an existing flag. Already-present saved items go through Ok(Some(_)) and never touch the network.
Please add a regression test
In crates/void-slack/src/connector/tests.rs, alongside sync_saved_fetches_missing_message_and_marks_saved, add a test where search returns two saved matches and one channel's conversations.info returns an error/non-200: assert sync_saved still Ok, the healthy item is ingested + marked saved, and list_saved_messages returns exactly that one. That locks in the "one bad item doesn't break the batch" guarantee.
Optional (non-blocking) nit
sync_saved runs on every Socket Mode reconnect (socket_mode.rs) in addition to start. search.messages is rate-limited (tier 2); get_with_retry handles 429s so it's fine, but if reconnects get chatty you may want to debounce saved-sync (e.g. skip if it ran in the last N minutes). Happy to leave for a follow-up.
Everything else looks good to merge once the per-item resilience is in. Thanks again!
Per-item fetch errors in sync_saved no longer block reconcile_saved. Add regression test for mixed accessible/inaccessible batches. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Pushed review fix to Changes:
Tests:
|
The GitHub connector landed on main after the slack saved PR added is_saved to Message; fill the field so CI compiles again. Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Sync Slack's "Saved for Later" (Later view) items into void's local DB during background sync, exposed via a new
void slack savedCLI command.How it works:
search.messageswithis:savedquery using the user's OAuth token (search:readscope)is_savedflag on messages (DB migration v13 adds column + index)conversations.info+get_single_message)void slack saved(not top-level)Includes:
search_messages_saved()with cursor paginationsync_saved()+ingest_saved_match()fetch-on-miss logicreconcile_saved,list_saved_messages,count_saved_messagesvoid slack savedwith--connection,-n,--pagesearch:readscope, reinstall noteThis branch is based directly on upstream
main(cherry-picked from Telvary/void#7) to avoid duplicate commits from prior fork-only history already landed upstream in #20.Test plan
cargo test -p void-slackcargo test -p void-core savedvoid slack savedagainst a connected Slack workspace