Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Slack** — Sync "Saved for Later" items from the Later view during background sync; view with `void slack saved`. Setup wizard documents the required `search:read` scope.
- **Google News** — New read-only connector that watches the public Google News RSS feed. Each configured keyword triggers its own search; matching articles land in your inbox, filtered by a recency window. Configure with `void gn keywords`, `void gn when`, `void gn language`, and `void gn country` (or interactively via `void setup`). Language/country default to `fr`/`FR`; add one connection per edition to follow several.

### Removed
Expand Down Expand Up @@ -392,3 +393,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **CLI commands** — `inbox`, `conversations`, `messages`, `search`, `contacts`, `channels`, `calendar`, `send`, `reply`, `archive`, `doctor`, `status`
- **Output formatting** — JSON mode and human-readable tables
- **Skills** — daily routine, calendar, Gmail, Slack, WhatsApp skill files

49 changes: 49 additions & 0 deletions crates/void-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,55 @@ mod tests {
assert!(context::runs_with_local_cache(cmd));
}

#[test]
fn slack_saved_uses_local_cache() {
let cli = parse(&["void", "slack", "saved"]);
let cmd = cli.command.as_ref().expect("command");
assert!(context::runs_with_local_cache(cmd));
}

#[test]
fn parse_slack_saved_minimal() {
let cli = parse(&["void", "slack", "saved"]);
match cli.command {
Some(Command::Slack(ref args)) => match &args.command {
commands::slack::SlackCommand::Saved(saved) => {
assert!(saved.connection.is_none());
assert_eq!(saved.size, 50);
assert_eq!(saved.page, 1);
}
other => panic!("expected Saved, got {other:?}"),
},
other => panic!("expected Slack, got {other:?}"),
}
}

#[test]
fn parse_slack_saved_with_filters() {
let cli = parse(&[
"void",
"slack",
"saved",
"--connection",
"slack-gladia",
"-n",
"10",
"--page",
"2",
]);
match cli.command {
Some(Command::Slack(ref args)) => match &args.command {
commands::slack::SlackCommand::Saved(saved) => {
assert_eq!(saved.connection.as_deref(), Some("slack-gladia"));
assert_eq!(saved.size, 10);
assert_eq!(saved.page, 2);
}
other => panic!("expected Saved, got {other:?}"),
},
other => panic!("expected Slack, got {other:?}"),
}
}

// --- Gmail forward parsing ---

#[test]
Expand Down
1 change: 1 addition & 0 deletions crates/void-cli/src/commands/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ mod tests {
timestamp: 1,
synced_at: None,
is_archived: false,
is_saved: false,
reply_to_id: None,
media_type: None,
metadata: None,
Expand Down
4 changes: 3 additions & 1 deletion crates/void-cli/src/commands/setup/slack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ pub(crate) async fn setup_slack(
eprintln!(" mpim:read — View basic info about group DMs");
eprintln!(" reactions:read — View emoji reactions");
eprintln!(" reactions:write — Add emoji reactions");
eprintln!(" search:read — Search workspace content (Saved for Later)");
eprintln!(" users:read — View people in the workspace");
eprintln!();
if !confirm_default_yes("Done? Continue to next step") {
Expand Down Expand Up @@ -145,7 +146,8 @@ pub(crate) async fn setup_slack(
eprintln!("STEP 5 — Install the App & Collect Tokens");
eprintln!();
eprintln!(" Go to \"Install App\" in the left sidebar and install to your workspace.");
eprintln!(" (If already installed, click \"Reinstall to Workspace\" to apply scope changes.)");
eprintln!(" (If already installed, click \"Reinstall to Workspace\" to apply scope changes,");
eprintln!(" including search:read for Saved for Later sync.)");
eprintln!();
eprintln!(" You need two tokens:");
eprintln!(" • User OAuth Token (xoxp-...) → found under \"OAuth & Permissions\"");
Expand Down
2 changes: 2 additions & 0 deletions crates/void-cli/src/commands/slack/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ pub enum SlackCommand {
Open(OpenArgs),
/// Forward a message to another channel or user
Forward(ForwardArgs),
/// Show messages saved for later (Slack Later view)
Saved(super::saved::SavedArgs),
}

#[derive(Debug, Args)]
Expand Down
2 changes: 2 additions & 0 deletions crates/void-cli/src/commands/slack/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Slack CLI helpers (react, edit, schedule, open DM/group).

mod args;
mod saved;

pub use args::*;

Expand All @@ -17,6 +18,7 @@ pub async fn run(args: &SlackArgs) -> anyhow::Result<()> {
SlackCommand::Schedule(a) => run_schedule(a).await,
SlackCommand::Open(a) => run_open(a).await,
SlackCommand::Forward(a) => run_forward(a).await,
SlackCommand::Saved(a) => saved::run(a),
}
}

Expand Down
37 changes: 37 additions & 0 deletions crates/void-cli/src/commands/slack/saved.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use clap::Args;
use tracing::debug;

use super::super::pagination::{build_meta, parse_page};
use crate::output::OutputFormatter;

#[derive(Debug, Args)]
pub struct SavedArgs {
/// Filter by connection (partial match on connection_id)
#[arg(long)]
pub connection: Option<String>,
/// Maximum number of results to return
#[arg(short = 'n', long, default_value = "50")]
pub size: i64,
/// Page number (1-based)
#[arg(long, default_value = "1")]
pub page: i64,
}

pub fn run(args: &SavedArgs) -> anyhow::Result<()> {
debug!(
connection = ?args.connection,
size = args.size,
page = args.page,
"slack saved"
);
let _cfg = crate::context::config();
let db = crate::context::open_db()?;
let formatter = OutputFormatter::new();
let offset = parse_page(args.size, args.page)?;

let (mut messages, total_elements) =
db.list_saved_messages(args.connection.as_deref(), Some("slack"), args.size, offset)?;
messages.reverse();
let meta = build_meta(args.page, args.size, total_elements);
formatter.print_paginated(&messages, meta)
}
7 changes: 7 additions & 0 deletions crates/void-cli/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ pub(crate) fn runs_with_local_cache(command: &crate::Command) -> bool {
| Command::Remote(_) => true,
Command::Calendar(args) => calendar_reads_local_cache(args),
Command::Hn(args) => hackernews_reads_local_cache(args),
Command::Slack(args) => slack_reads_local_cache(args),
Command::Sync(args) => args.status,
Command::Setup => false,
_ => false,
Expand All @@ -149,6 +150,12 @@ fn calendar_reads_local_cache(args: &crate::commands::calendar::CalendarArgs) ->
matches!(args.command, None | Some(CalendarCommand::Week))
}

fn slack_reads_local_cache(args: &crate::commands::slack::SlackArgs) -> bool {
use crate::commands::slack::SlackCommand;

matches!(args.command, SlackCommand::Saved(_))
}

fn hackernews_reads_local_cache(args: &crate::commands::hackernews::HackerNewsArgs) -> bool {
use crate::commands::hackernews::{HnCommand, KeywordsAction};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ expression: json
"external_id": "ext-m1",
"id": "m1",
"is_archived": false,
"is_saved": false,
"media_type": null,
"metadata": null,
"reply_to_id": null,
Expand All @@ -31,6 +32,7 @@ expression: json
"external_id": "ext-m2",
"id": "m2",
"is_archived": false,
"is_saved": false,
"media_type": null,
"metadata": null,
"reply_to_id": null,
Expand Down
24 changes: 24 additions & 0 deletions crates/void-core/src/db/database_access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,30 @@ impl Database {
messages::reconcile_inbox(&*self.conn()?, connection_id, connector, inbox_external_ids)
}

/// Reconcile `is_saved` for all messages of a connection to match the given saved set.
/// Returns (newly_saved_count, newly_unsaved_count).
pub fn reconcile_saved(
&self,
connection_id: &str,
connector: &str,
saved_external_ids: &std::collections::HashSet<String>,
) -> Result<(usize, usize), DbError> {
messages::reconcile_saved(&*self.conn()?, connection_id, connector, saved_external_ids)
}

pub fn list_saved_messages(
&self,
connection_filter: Option<&str>,
connector_filter: Option<&str>,
limit: i64,
offset: i64,
) -> Result<(Vec<Message>, i64), DbError> {
let conn = self.conn()?;
let rows = messages::list_saved(&conn, connection_filter, connector_filter, limit, offset)?;
let total = messages::count_saved(&conn, connection_filter, connector_filter)?;
Ok((rows, total))
}

pub fn find_message_by_external_id(
&self,
connection_id: &str,
Expand Down
2 changes: 1 addition & 1 deletion crates/void-core/src/db/messages/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ pub fn bulk_archive_before(
connector_filter: Option<&str>,
) -> Result<Vec<Message>, DbError> {
let mut sql = String::from(
"SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id
"SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id, is_saved
FROM messages WHERE is_archived = 0 AND timestamp < ?1",
);
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(before_ts)];
Expand Down
4 changes: 2 additions & 2 deletions crates/void-core/src/db/messages/inbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ pub fn enrich_with_context(conn: &Connection, messages: &mut [Message]) -> Resul
let mut context_map: HashMap<String, Vec<Message>> = HashMap::new();

let mut stmt = conn.prepare(
"SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id
"SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id, is_saved
FROM messages WHERE context_id = ?1 ORDER BY timestamp ASC LIMIT 50",
)?;

Expand Down Expand Up @@ -90,7 +90,7 @@ pub fn messages_pending_file_download(
limit: i64,
) -> Result<Vec<Message>, DbError> {
let mut stmt = conn.prepare(
"SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id
"SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id, is_saved
FROM messages
WHERE connection_id = ?1 AND connector = ?2
AND metadata LIKE '%url_private%'
Expand Down
6 changes: 3 additions & 3 deletions crates/void-core/src/db/messages/lookup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ pub fn find_by_external_id(
external_id: &str,
) -> Result<Option<Message>, DbError> {
conn.query_row(
"SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id
"SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id, is_saved
FROM messages WHERE connection_id = ?1 AND external_id = ?2",
params![connection_id, external_id],
row::row_to_message,
Expand All @@ -32,7 +32,7 @@ pub fn find_by_slack_link(
message_ts: &str,
) -> Result<Option<Message>, DbError> {
conn.query_row(
"SELECT m.id, m.conversation_id, m.connection_id, m.connector, m.external_id, m.sender, m.sender_name, m.sender_avatar_url, m.body, m.timestamp, m.synced_at, m.is_archived, m.reply_to_id, m.media_type, m.metadata, m.context_id
"SELECT m.id, m.conversation_id, m.connection_id, m.connector, m.external_id, m.sender, m.sender_name, m.sender_avatar_url, m.body, m.timestamp, m.synced_at, m.is_archived, m.reply_to_id, m.media_type, m.metadata, m.context_id, m.is_saved
FROM messages m
JOIN conversations c ON m.conversation_id = c.id
WHERE m.connector = 'slack'
Expand Down Expand Up @@ -69,7 +69,7 @@ pub fn last_in_conversation(
conversation_id: &str,
) -> Result<Option<Message>, DbError> {
conn.query_row(
"SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id
"SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id, is_saved
FROM messages WHERE conversation_id = ?1 ORDER BY timestamp DESC LIMIT 1",
params![conversation_id],
row::row_to_message,
Expand Down
2 changes: 2 additions & 0 deletions crates/void-core/src/db/messages/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod archive;
mod inbox;
mod lookup;
mod read;
mod saved;
mod upsert;

/// SQL clause that keeps only the most recent message per `context_id`,
Expand Down Expand Up @@ -34,4 +35,5 @@ pub use lookup::{
pub use read::{
count_for_conversation, count_recent, get, latest_timestamp, list_for_conversation, list_recent,
};
pub use saved::{count_saved, list_saved, reconcile_saved};
pub use upsert::{message_exists, upsert_row};
6 changes: 3 additions & 3 deletions crates/void-core/src/db/messages/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ pub fn list_for_conversation(
) -> Result<Vec<Message>, DbError> {
let suffix_pattern = format!("%-{conversation_id}");
let mut sql = String::from(
"SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id
"SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id, is_saved
FROM messages WHERE (conversation_id = ?1 OR conversation_id LIKE ?2)",
);
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = vec![
Expand Down Expand Up @@ -89,7 +89,7 @@ pub fn count_for_conversation(
pub fn get(conn: &Connection, id: &str) -> Result<Option<Message>, DbError> {
let suffix_pattern = format!("%-{id}");
conn.query_row(
"SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id
"SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id, is_saved
FROM messages WHERE id = ?1 OR id LIKE ?2",
params![id, suffix_pattern],
row::row_to_message,
Expand Down Expand Up @@ -123,7 +123,7 @@ pub fn list_recent(
dedup_context: bool,
) -> Result<Vec<Message>, DbError> {
let mut sql = String::from(
"SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id
"SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id, is_saved
FROM messages WHERE 1=1",
);
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
Expand Down
Loading