diff --git a/CHANGELOG.md b/CHANGELOG.md index 0220b8e..ead6c00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Internal** — Service layer (`crates/void-cli/src/service/`) extracting read/write business logic shared by CLI commands and the upcoming MCP server. - **Reddit** — New connector that polls watched subreddits and surfaces posts matching your keywords and minimum score (one channel conversation per subreddit). Read-only mode uses application-only OAuth (`client_id` + `client_secret`); enabling commenting during `void setup` runs a browser OAuth flow, stores a `refresh_token`, syncs matching posts as comment threads, and lets you reply via `void reply` / `void send --via reddit`. Tune filters at runtime with `void reddit subreddits|keywords|min-score|config`. - **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. diff --git a/crates/void-cli/src/commands/archive.rs b/crates/void-cli/src/commands/archive.rs index 5e97736..42db5b2 100644 --- a/crates/void-cli/src/commands/archive.rs +++ b/crates/void-cli/src/commands/archive.rs @@ -1,13 +1,7 @@ -use std::collections::HashMap; -use std::sync::Arc; - use clap::Args; -use tracing::{debug, info, warn}; - -use void_core::connector::Connector; +use tracing::debug; -use super::connector_factory; -use crate::output; +use crate::service::writes::{self, ArchiveParams}; #[derive(Debug, Args)] pub struct ArchiveArgs { @@ -25,156 +19,22 @@ pub struct ArchiveArgs { } pub async fn run(args: &ArchiveArgs) -> anyhow::Result<()> { - if args.before.is_some() { - return run_bulk_before(args).await; - } - - if args.message_ids.is_empty() { - anyhow::bail!("at least one message ID is required (or use --before DATE)"); - } - - run_by_ids(args).await -} - -async fn run_bulk_before(args: &ArchiveArgs) -> anyhow::Result<()> { - if !args.message_ids.is_empty() { + if args.before.is_some() && !args.message_ids.is_empty() { anyhow::bail!("--before cannot be combined with positional message IDs"); } - let date_str = args - .before - .as_deref() - .ok_or_else(|| anyhow::anyhow!("internal error: --before flag without date"))?; - let before_ts = parse_date_to_ts(date_str) - .ok_or_else(|| anyhow::anyhow!("invalid date \"{date_str}\", expected YYYY-MM-DD"))?; - - let connector_filter = output::resolve_connector_filter(args.connector.as_deref())?; - - debug!(before = date_str, connector = ?connector_filter, "bulk archive before date"); - let _cfg = crate::context::config(); - let db = crate::context::open_db()?; - - let messages = db.bulk_archive_before(before_ts, connector_filter.as_deref())?; - for msg in &messages { - cleanup_cached_files(msg); - } - - info!( - count = messages.len(), - before = date_str, - "bulk archive complete" - ); - let output = serde_json::json!({ "data": { "archived_count": messages.len() }, "error": null }); - println!("{}", serde_json::to_string_pretty(&output)?); - Ok(()) -} - -async fn run_by_ids(args: &ArchiveArgs) -> anyhow::Result<()> { - debug!(count = args.message_ids.len(), "archive by IDs"); + debug!(count = args.message_ids.len(), before = ?args.before, "archive"); let cfg = crate::context::config(); let db = crate::context::open_db()?; + let store_path = crate::context::store_path(); - let mut connectors: HashMap> = HashMap::new(); - let mut results = Vec::new(); - - for message_id in &args.message_ids { - let msg = match super::resolve::resolve_message(&db, message_id) { - Ok(m) => m, - Err(_) => { - warn!(message_id, "message not found, skipping"); - results.push(serde_json::json!({ - "message_id": message_id, - "is_archived": false, - "error": "message not found", - })); - continue; - } - }; - - let conv = match db.get_conversation(&msg.conversation_id)? { - Some(c) => c, - None => { - warn!(message_id, conversation_id = %msg.conversation_id, "conversation not found, skipping"); - results.push(serde_json::json!({ - "message_id": message_id, - "is_archived": false, - "error": "conversation not found", - })); - continue; - } - }; - - let connector_key = format!("{}:{}", msg.connector, msg.connection_id); - if !connectors.contains_key(&connector_key) { - if let Some(connection) = cfg - .find_connection(&msg.connection_id) - .or_else(|| cfg.find_connection_by_connector(&msg.connector)) - { - match connector_factory::build_connector(connection, &crate::context::store_path()) - { - Ok(c) => { - connectors.insert(connector_key.clone(), c); - } - Err(e) => { - warn!(connector_key, error = %e, "failed to build connector"); - } - } - } - } - - let remote_synced = if let Some(conn) = connectors.get(&connector_key) { - match conn.archive(&msg.external_id, &conv.external_id).await { - Ok(()) => true, - Err(e) => { - warn!(message_id, error = %e, "remote archive failed, local only"); - false - } - } - } else { - false - }; - - db.mark_message_archived(message_id)?; - cleanup_cached_files(&msg); - info!(message_id, remote_synced, "archived"); - - results.push(serde_json::json!({ - "message_id": message_id, - "is_archived": true, - "remote_synced": remote_synced, - })); - } + let params = ArchiveParams { + message_ids: &args.message_ids, + before: args.before.as_deref(), + connector: args.connector.as_deref(), + }; - let output = serde_json::json!({ "data": results, "error": null }); - println!("{}", serde_json::to_string_pretty(&output)?); + let value = writes::archive(&db, cfg, &store_path, params).await?; + println!("{}", crate::service::render(&value)?); Ok(()) } - -fn parse_date_to_ts(date: &str) -> Option { - chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") - .ok() - .and_then(|d| d.and_hms_opt(0, 0, 0)) - .map(|dt| dt.and_utc().timestamp()) -} - -/// Delete locally cached files referenced in `metadata.files[].local_path`. -fn cleanup_cached_files(msg: &void_core::models::Message) { - let files = match msg - .metadata - .as_ref() - .and_then(|m| m.get("files")) - .and_then(|f| f.as_array()) - { - Some(f) => f, - None => return, - }; - for file in files { - if let Some(path) = file.get("local_path").and_then(|v| v.as_str()) { - match std::fs::remove_file(path) { - Ok(()) => debug!(path, "deleted cached file"), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => warn!(path, error = %e, "failed to delete cached file"), - } - } - } -} diff --git a/crates/void-cli/src/commands/calendar/list.rs b/crates/void-cli/src/commands/calendar/list.rs index 189df78..77e6ae0 100644 --- a/crates/void-cli/src/commands/calendar/list.rs +++ b/crates/void-cli/src/commands/calendar/list.rs @@ -1,73 +1,24 @@ -use chrono::{Datelike, Local}; - use super::args::CalendarArgs; -use super::parsing::{parse_date_to_ts, parse_day_spec}; -use crate::output::{resolve_connector_filter, OutputFormatter}; +use crate::service; +use crate::service::reads::{self, CalendarQuery}; pub(super) fn run_list(args: &CalendarArgs) -> anyhow::Result<()> { - let connector = resolve_connector_filter(args.connector.as_deref())?; - let _cfg = crate::context::config(); let db = crate::context::open_db()?; - let formatter = OutputFormatter::new(); - - let (from, to) = if let Some(day) = &args.day { - let date = parse_day_spec(day)?; - let start = date - .and_hms_opt(0, 0, 0) - .and_then(|dt| dt.and_local_timezone(Local).single()) - .map(|dt| dt.timestamp()); - let end = (date + chrono::Duration::days(1)) - .and_hms_opt(0, 0, 0) - .and_then(|dt| dt.and_local_timezone(Local).single()) - .map(|dt| dt.timestamp()); - (start, end) - } else { - let today = Local::now().date_naive(); - let from = args.from.as_deref().and_then(parse_date_to_ts).or_else(|| { - today - .and_hms_opt(0, 0, 0) - .and_then(|dt| dt.and_local_timezone(Local).single()) - .map(|dt| dt.timestamp()) - }); - - let to = args.to.as_deref().and_then(parse_date_to_ts).or_else(|| { - (today + chrono::Duration::days(1)) - .and_hms_opt(0, 0, 0) - .and_then(|dt| dt.and_local_timezone(Local).single()) - .map(|dt| dt.timestamp()) - }); - (from, to) + let query = CalendarQuery { + day: args.day.as_deref(), + from: args.from.as_deref(), + to: args.to.as_deref(), + connection: args.connection.as_deref(), + connector: args.connector.as_deref(), }; - - let events = db.list_events( - from, - to, - args.connection.as_deref(), - connector.as_deref(), - 200, - )?; - formatter.print_events(&events) + let value = reads::calendar_list(&db, &query)?; + println!("{}", service::render(&value)?); + Ok(()) } pub(super) fn run_week() -> anyhow::Result<()> { - let _cfg = crate::context::config(); let db = crate::context::open_db()?; - let formatter = OutputFormatter::new(); - - let today = Local::now().date_naive(); - let weekday = today.weekday().num_days_from_monday(); - let monday = today - chrono::Duration::days(weekday as i64); - let sunday = monday + chrono::Duration::days(7); - - let from = monday - .and_hms_opt(0, 0, 0) - .and_then(|dt| dt.and_local_timezone(Local).single()) - .map(|dt| dt.timestamp()); - let to = sunday - .and_hms_opt(0, 0, 0) - .and_then(|dt| dt.and_local_timezone(Local).single()) - .map(|dt| dt.timestamp()); - - let events = db.list_events(from, to, None, None, 200)?; - formatter.print_events(&events) + let value = reads::calendar_week(&db)?; + println!("{}", service::render(&value)?); + Ok(()) } diff --git a/crates/void-cli/src/commands/calendar/mod.rs b/crates/void-cli/src/commands/calendar/mod.rs index 7c4ecf1..5e778c4 100644 --- a/crates/void-cli/src/commands/calendar/mod.rs +++ b/crates/void-cli/src/commands/calendar/mod.rs @@ -3,7 +3,7 @@ mod api; mod args; mod list; -mod parsing; +pub(crate) mod parsing; pub use args::{CalendarArgs, CalendarCommand}; diff --git a/crates/void-cli/src/commands/calendar/parsing.rs b/crates/void-cli/src/commands/calendar/parsing.rs index 9c2ccc0..3151953 100644 --- a/crates/void-cli/src/commands/calendar/parsing.rs +++ b/crates/void-cli/src/commands/calendar/parsing.rs @@ -54,7 +54,7 @@ pub(super) fn normalize_datetime(s: &str) -> anyhow::Result { ) } -pub(super) fn parse_day_spec(spec: &str) -> anyhow::Result { +pub(crate) fn parse_day_spec(spec: &str) -> anyhow::Result { let today = Local::now().date_naive(); match spec.to_lowercase().as_str() { "today" => Ok(today), @@ -68,7 +68,7 @@ pub(super) fn parse_day_spec(spec: &str) -> anyhow::Result { } } -pub(super) fn parse_date_to_ts(date: &str) -> Option { +pub(crate) fn parse_date_to_ts(date: &str) -> Option { chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") .ok() .and_then(|d| d.and_hms_opt(0, 0, 0)) diff --git a/crates/void-cli/src/commands/channels.rs b/crates/void-cli/src/commands/channels.rs index 61e60ad..7e53dde 100644 --- a/crates/void-cli/src/commands/channels.rs +++ b/crates/void-cli/src/commands/channels.rs @@ -1,8 +1,8 @@ use clap::Args; use tracing::debug; -use super::pagination::{build_meta, parse_page}; -use crate::output::{resolve_connector_filter, OutputFormatter, CONNECTOR_FILTER_HELP}; +use crate::service; +use crate::service::reads::{self, ChannelsQuery}; #[derive(Debug, Args)] pub struct ChannelsArgs { @@ -12,7 +12,7 @@ pub struct ChannelsArgs { /// Filter by connection (partial match on connection_id) #[arg(long)] pub connection: Option, - #[arg(long, help = CONNECTOR_FILTER_HELP)] + #[arg(long, help = crate::output::CONNECTOR_FILTER_HELP)] pub connector: Option, /// Maximum number of results to return #[arg(short = 'n', long, default_value = "100")] @@ -27,20 +27,16 @@ pub struct ChannelsArgs { pub fn run(args: &ChannelsArgs) -> anyhow::Result<()> { debug!(search = ?args.search, connection = ?args.connection, connector = ?args.connector, size = args.size, page = args.page, "channels"); - let connector = resolve_connector_filter(args.connector.as_deref())?; - let _cfg = crate::context::config(); let db = crate::context::open_db()?; - let formatter = OutputFormatter::new(); - let offset = parse_page(args.size, args.page)?; - - let (channels, total_elements) = db.list_channels_paginated( - args.connection.as_deref(), - connector.as_deref(), - args.search.as_deref(), - args.size, - offset, - args.include_muted, - )?; - let meta = build_meta(args.page, args.size, total_elements); - formatter.print_paginated(&channels, meta) + let query = ChannelsQuery { + search: args.search.as_deref(), + connection: args.connection.as_deref(), + connector: args.connector.as_deref(), + size: args.size, + page: args.page, + include_muted: args.include_muted, + }; + let value = reads::channels(&db, &query)?; + println!("{}", service::render(&value)?); + Ok(()) } diff --git a/crates/void-cli/src/commands/contacts.rs b/crates/void-cli/src/commands/contacts.rs index 3bd375c..e532ef9 100644 --- a/crates/void-cli/src/commands/contacts.rs +++ b/crates/void-cli/src/commands/contacts.rs @@ -1,8 +1,8 @@ use clap::Args; use tracing::debug; -use super::pagination::{build_meta, parse_page}; -use crate::output::{resolve_connector_filter, OutputFormatter, CONNECTOR_FILTER_HELP}; +use crate::service; +use crate::service::reads::{self, ContactsQuery}; #[derive(Debug, Args)] pub struct ContactsArgs { @@ -12,7 +12,7 @@ pub struct ContactsArgs { /// Filter by connection (partial match on connection_id) #[arg(long)] pub connection: Option, - #[arg(long, help = CONNECTOR_FILTER_HELP)] + #[arg(long, help = crate::output::CONNECTOR_FILTER_HELP)] pub connector: Option, /// Maximum number of results to return #[arg(short = 'n', long, default_value = "100")] @@ -24,19 +24,15 @@ pub struct ContactsArgs { pub fn run(args: &ContactsArgs) -> anyhow::Result<()> { debug!(search = ?args.search, connection = ?args.connection, connector = ?args.connector, size = args.size, page = args.page, "contacts"); - let connector = resolve_connector_filter(args.connector.as_deref())?; - let _cfg = crate::context::config(); let db = crate::context::open_db()?; - let formatter = OutputFormatter::new(); - let offset = parse_page(args.size, args.page)?; - - let (contacts, total_elements) = db.list_contacts_paginated( - args.connection.as_deref(), - connector.as_deref(), - args.search.as_deref(), - args.size, - offset, - )?; - let meta = build_meta(args.page, args.size, total_elements); - formatter.print_paginated(&contacts, meta) + let query = ContactsQuery { + search: args.search.as_deref(), + connection: args.connection.as_deref(), + connector: args.connector.as_deref(), + size: args.size, + page: args.page, + }; + let value = reads::contacts(&db, &query)?; + println!("{}", service::render(&value)?); + Ok(()) } diff --git a/crates/void-cli/src/commands/forward.rs b/crates/void-cli/src/commands/forward.rs index cbb5d91..772e9b1 100644 --- a/crates/void-cli/src/commands/forward.rs +++ b/crates/void-cli/src/commands/forward.rs @@ -1,7 +1,7 @@ use clap::Args; -use tracing::{debug, info}; +use tracing::info; -use crate::commands::connector_factory; +use crate::service::writes::{self, ForwardParams}; #[derive(Debug, Args)] pub struct ForwardArgs { @@ -18,40 +18,16 @@ pub struct ForwardArgs { pub async fn run(args: &ForwardArgs) -> anyhow::Result<()> { info!(message_id = %args.message_id, to = %args.to, "forward"); let cfg = crate::context::void_config(); - let db = crate::context::open_db()?; - - let msg = super::resolve::resolve_message(&db, &args.message_id)?; - - let conv = db - .get_conversation(&msg.conversation_id)? - .ok_or_else(|| anyhow::anyhow!("Conversation not found: {}", msg.conversation_id))?; - - debug!(connector = %msg.connector, connection_id = %msg.connection_id, "resolved message"); - - let connection = cfg - .find_connection(&msg.connection_id) - .or_else(|| cfg.find_connection_by_connector(&msg.connector)) - .ok_or_else(|| { - anyhow::anyhow!( - "No {} connection found in config for message {}", - msg.connector, - msg.id - ) - })?; - let store_path = crate::context::store_path(); - let conn = connector_factory::build_connector(connection, &store_path)?; - let fwd_id = conn - .forward( - &msg.external_id, - &conv.external_id, - &args.to, - args.comment.as_deref(), - ) - .await?; + let params = ForwardParams { + message_id: &args.message_id, + to: &args.to, + comment: args.comment.as_deref(), + }; + let fwd_id = writes::forward(&db, cfg, &store_path, params).await?; eprintln!("Message forwarded (id: {fwd_id})"); Ok(()) } diff --git a/crates/void-cli/src/commands/inbox.rs b/crates/void-cli/src/commands/inbox.rs index 932645a..27873c4 100644 --- a/crates/void-cli/src/commands/inbox.rs +++ b/crates/void-cli/src/commands/inbox.rs @@ -1,15 +1,15 @@ use clap::Args; use tracing::debug; -use super::pagination::{build_meta, parse_page}; -use crate::output::{resolve_connector_filter, OutputFormatter, CONNECTOR_FILTER_HELP}; +use crate::service; +use crate::service::reads::{self, InboxQuery}; #[derive(Debug, Args)] pub struct InboxArgs { /// Filter by connection (partial match on connection_id) #[arg(long)] pub connection: Option, - #[arg(long, help = CONNECTOR_FILTER_HELP)] + #[arg(long, help = crate::output::CONNECTOR_FILTER_HELP)] pub connector: Option, /// Maximum number of results to return #[arg(short = 'n', long, default_value = "50")] @@ -27,45 +27,32 @@ pub struct InboxArgs { pub fn run(args: &InboxArgs, enrich_context: bool) -> anyhow::Result<()> { debug!(connection = ?args.connection, connector = ?args.connector, size = args.size, page = args.page, all = args.all, "inbox"); - let connector = resolve_connector_filter(args.connector.as_deref())?; - let _cfg = crate::context::config(); let db = crate::context::open_db()?; - let formatter = OutputFormatter::new(); - let offset = parse_page(args.size, args.page)?; - - let include_muted = args.include_muted || args.all; - let (mut messages, total_elements) = db.recent_messages_paginated( - args.connection.as_deref(), - connector.as_deref(), - args.size, - offset, - args.all, - include_muted, - enrich_context, - )?; - messages.reverse(); - if enrich_context { - db.enrich_with_context(&mut messages)?; - } - let meta = build_meta(args.page, args.size, total_elements); - formatter.print_paginated(&messages, meta) + let query = InboxQuery { + connection: args.connection.as_deref(), + connector: args.connector.as_deref(), + size: args.size, + page: args.page, + all: args.all, + include_muted: args.include_muted, + }; + let value = reads::inbox(&db, &query, enrich_context)?; + println!("{}", service::render(&value)?); + Ok(()) } pub fn run_conversations(args: &InboxArgs) -> anyhow::Result<()> { debug!(connection = ?args.connection, connector = ?args.connector, size = args.size, page = args.page, "inbox conversations"); - let connector = resolve_connector_filter(args.connector.as_deref())?; - let _cfg = crate::context::config(); let db = crate::context::open_db()?; - let formatter = OutputFormatter::new(); - let offset = parse_page(args.size, args.page)?; - - let (conversations, total_elements) = db.list_conversations_paginated( - args.connection.as_deref(), - connector.as_deref(), - args.size, - offset, - args.include_muted, - )?; - let meta = build_meta(args.page, args.size, total_elements); - formatter.print_paginated(&conversations, meta) + let query = InboxQuery { + connection: args.connection.as_deref(), + connector: args.connector.as_deref(), + size: args.size, + page: args.page, + all: args.all, + include_muted: args.include_muted, + }; + let value = reads::conversations(&db, &query)?; + println!("{}", service::render(&value)?); + Ok(()) } diff --git a/crates/void-cli/src/commands/messages.rs b/crates/void-cli/src/commands/messages.rs index 7ce6676..06a627c 100644 --- a/crates/void-cli/src/commands/messages.rs +++ b/crates/void-cli/src/commands/messages.rs @@ -1,9 +1,8 @@ use clap::Args; use tracing::debug; -use super::pagination::{build_meta, parse_page}; -use super::resolve::{resolve_messages_target, MessagesTarget}; -use crate::output::OutputFormatter; +use crate::service; +use crate::service::reads::{self, MessagesQuery}; #[derive(Debug, Args)] pub struct MessagesArgs { @@ -25,77 +24,15 @@ pub struct MessagesArgs { pub fn run(args: &MessagesArgs, enrich_context: bool) -> anyhow::Result<()> { debug!(target = %args.target, size = args.size, page = args.page, "messages"); - let _cfg = crate::context::config(); let db = crate::context::open_db()?; - let formatter = OutputFormatter::new(); - - match resolve_messages_target(&db, &args.target)? { - MessagesTarget::Link { - message_id, - conversation_id: _, - } => { - let msg = db - .get_message(&message_id)? - .ok_or_else(|| anyhow::anyhow!("Message vanished after lookup: {message_id}"))?; - let mut messages = vec![msg]; - if enrich_context { - db.enrich_with_context(&mut messages)?; - } - formatter.print_messages(&messages) - } - MessagesTarget::UnresolvedSlackLink { - channel_id, - message_ts, - workspace, - } => { - anyhow::bail!( - "Slack message not found locally for link (workspace: {workspace}, channel: {channel_id}, ts: {message_ts}). \ - The channel may not be synced yet, or the specific message hasn't been fetched — try `void sync` first." - ) - } - MessagesTarget::ConversationId(conv_id) => { - let since = args.since.as_deref().and_then(parse_date_to_ts); - let until = args.until.as_deref().and_then(parse_date_to_ts); - let offset = parse_page(args.size, args.page)?; - - let (mut messages, total_elements) = db.list_messages_paginated( - &conv_id, - args.size, - offset, - since, - until, - enrich_context, - )?; - if enrich_context { - db.enrich_with_context(&mut messages)?; - } - let meta = build_meta(args.page, args.size, total_elements); - formatter.print_paginated(&messages, meta) - } - MessagesTarget::Connector { connector } => { - let offset = parse_page(args.size, args.page)?; - let (mut messages, total_elements) = db.recent_messages_paginated( - None, - Some(&connector), - args.size, - offset, - true, - true, - enrich_context, - )?; - messages.reverse(); - if enrich_context { - db.enrich_with_context(&mut messages)?; - } - let meta = build_meta(args.page, args.size, total_elements); - formatter.print_paginated(&messages, meta) - } - } -} - -fn parse_date_to_ts(date: &str) -> Option { - chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") - .ok() - .and_then(|d| d.and_hms_opt(0, 0, 0)) - .map(|dt| dt.and_utc().timestamp()) + let query = MessagesQuery { + target: &args.target, + since: args.since.as_deref(), + until: args.until.as_deref(), + size: args.size, + page: args.page, + }; + let value = reads::messages(&db, &query, enrich_context)?; + println!("{}", service::render(&value)?); + Ok(()) } diff --git a/crates/void-cli/src/commands/mute/mod.rs b/crates/void-cli/src/commands/mute/mod.rs index bf5d3c2..d679cac 100644 --- a/crates/void-cli/src/commands/mute/mod.rs +++ b/crates/void-cli/src/commands/mute/mod.rs @@ -1,14 +1,12 @@ -use std::collections::HashSet; - use clap::Args; -use tracing::debug; use void_core::config::VoidConfig; use crate::output::{resolve_connector_filter, CONNECTOR_FILTER_HELP}; +use crate::service::writes::{self, MuteParams}; mod list; mod migrate; -mod resolve; +pub(crate) mod resolve; #[cfg(test)] mod tests; @@ -59,66 +57,14 @@ pub fn run(args: &MuteArgs) -> anyhow::Result<()> { ); } - let mute = !args.unmute; - let action = if mute { "muted" } else { "unmuted" }; - let mut results = Vec::new(); - let mut affected_connections = HashSet::new(); - let mut config_changed = false; - - for target in &args.targets { - debug!(target, mute, "processing mute target"); - - let matches = resolve::resolve_targets( - &db, - target, - args.connection.as_deref(), - connector.as_deref(), - )?; - - if matches.is_empty() { - eprintln!("no conversation matching \"{target}\" found"); - results.push(serde_json::json!({ - "target": target, - "error": "not found", - })); - continue; - } - - for conv in matches { - let changed = if mute { - cfg.add_ignore_conversation(&conv.connection_id, conv.external_id.clone()) - } else { - cfg.remove_ignore_conversation( - &conv.connection_id, - &conv.external_id, - conv.name.as_deref(), - ) - }; - config_changed |= changed; - affected_connections.insert(conv.connection_id.clone()); - - let name = conv.name.as_deref().unwrap_or(&conv.id); - eprintln!("{action}: {name} [{}] ({})", conv.connector, conv.id); - results.push(serde_json::json!({ - "id": conv.id, - "name": name, - "connector": conv.connector, - "connection_id": conv.connection_id, - "external_id": conv.external_id, - "is_muted": mute, - })); - } - } - - if config_changed { - cfg.save(&config_path)?; - for connection_id in &affected_connections { - if let Some(conn) = cfg.connections.iter().find(|c| c.id == *connection_id) { - db.sync_ignore_conversations(&conn.id, &conn.ignore_conversations)?; - } - } - } + let params = MuteParams { + targets: &args.targets, + unmute: args.unmute, + connection: args.connection.as_deref(), + connector: args.connector.as_deref(), + }; - println!("{}", serde_json::json!({ "data": results, "error": null })); + let value = writes::mute(&db, &mut cfg, &config_path, params)?; + println!("{value}"); Ok(()) } diff --git a/crates/void-cli/src/commands/mute/resolve.rs b/crates/void-cli/src/commands/mute/resolve.rs index 62a4540..a277f9d 100644 --- a/crates/void-cli/src/commands/mute/resolve.rs +++ b/crates/void-cli/src/commands/mute/resolve.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; use void_core::db::Database; use void_core::models::Conversation; -pub(super) fn resolve_targets( +pub(crate) fn resolve_targets( db: &Database, target: &str, connection_filter: Option<&str>, diff --git a/crates/void-cli/src/commands/reply.rs b/crates/void-cli/src/commands/reply.rs index 6fd44c7..ff65b62 100644 --- a/crates/void-cli/src/commands/reply.rs +++ b/crates/void-cli/src/commands/reply.rs @@ -1,12 +1,7 @@ use clap::Args; -use tracing::{debug, info}; +use tracing::info; -use void_core::models::MessageContent; -use void_core::sync::is_daemon_running; - -use crate::commands::connector_factory; -use crate::connectors; -use crate::output::parse_connector_type; +use crate::service::writes::{self, ReplyParams}; #[derive(Debug, Args)] pub struct ReplyArgs { @@ -29,119 +24,30 @@ pub struct ReplyArgs { pub async fn run(args: &ReplyArgs) -> anyhow::Result<()> { info!(message_id = %args.message_id, "reply"); let cfg = crate::context::void_config(); - let db = crate::context::open_db()?; - - let msg = super::resolve::resolve_message(&db, &args.message_id)?; - - let conv = db - .get_conversation(&msg.conversation_id)? - .ok_or_else(|| anyhow::anyhow!("Conversation not found: {}", msg.conversation_id))?; - - debug!("message and conversation found"); - - let connection = cfg - .find_connection_by_connector(&msg.connector) - .ok_or_else(|| { - anyhow::anyhow!( - "No {} connection found in config.toml for message {}", - msg.connector, - msg.id - ) - })?; - - let plugin = connectors::by_id(connection.connector_type.as_str()) - .ok_or_else(|| anyhow::anyhow!("Unknown connector type: {}", connection.connector_type))?; - - if let Some(ref at_str) = args.at { - if !plugin.supports_scheduling { - anyhow::bail!("Scheduled sending (--at) is only supported for Slack."); - } - return run_slack_scheduled_reply( - connection, - &conv.external_id, - &msg.external_id, - &args.message, - at_str, - ) - .await; - } - - let connector_type = parse_connector_type(&connection.connector_type.to_string()) - .ok_or_else(|| anyhow::anyhow!("Unknown connector type: {}", connection.connector_type))?; - let store_path = crate::context::store_path(); - let reply_id = connectors::build_reply_id(connector_type, &conv.external_id, &msg.external_id); - - let content = if let Some(ref path) = args.file { - MessageContent::File { - path: path.into(), - caption: Some(args.message.clone()), - mime_type: None, - subject: None, - } - } else { - MessageContent::from_text(args.message.clone()) - }; - let sent_id = if plugin.uses_daemon_rpc && is_daemon_running(&store_path) { - void_whatsapp::rpc::reply_message( - &store_path, - &connection.id, - &reply_id, - content, - args.in_thread, - ) - .await? - } else { - let conn = connector_factory::build_connector(connection, &store_path)?; - conn.reply(&reply_id, content, args.in_thread).await? + let params = ReplyParams { + message_id: &args.message_id, + message: &args.message, + file: args.file.as_deref(), + in_thread: args.in_thread, + at: args.at.as_deref(), }; - eprintln!("Reply sent (id: {sent_id})"); - Ok(()) -} + let sent_id = writes::reply(&db, cfg, &store_path, params).await?; -async fn run_slack_scheduled_reply( - connection: &void_core::config::ConnectionConfig, - channel_id: &str, - thread_ts: &str, - message: &str, - at_str: &str, -) -> anyhow::Result<()> { - use super::slack::parse_schedule_time; - - let post_at = parse_schedule_time(at_str)?; - let now = chrono::Utc::now().timestamp(); - if post_at <= now { - anyhow::bail!("Scheduled time must be in the future."); + if args.at.is_some() { + let at_str = args.at.as_deref().unwrap_or(""); + let post_at = crate::commands::slack::parse_schedule_time(at_str)?; + let dt = chrono::DateTime::from_timestamp(post_at, 0) + .map(|utc| utc.with_timezone(&chrono::Local)) + .map(|local| local.format("%Y-%m-%d %H:%M %Z").to_string()) + .unwrap_or_else(|| post_at.to_string()); + eprintln!("Reply scheduled for {dt} (id: {sent_id})"); + } else { + eprintln!("Reply sent (id: {sent_id})"); } - - let user_token = void_core::config::settings_string(&connection.settings, "user_token") - .ok_or_else(|| anyhow::anyhow!("missing user_token"))?; - let app_token = void_core::config::settings_string(&connection.settings, "app_token") - .ok_or_else(|| anyhow::anyhow!("missing app_token"))?; - - let connector = void_slack::connector::SlackConnector::new( - &connection.id, - &user_token, - &app_token, - None, - None, - std::env::temp_dir().as_path(), - None, - )?; - - let scheduled_id = connector - .schedule_message(channel_id, message, post_at, Some(thread_ts)) - .await?; - - let dt = chrono::DateTime::from_timestamp(post_at, 0) - .map(|utc| utc.with_timezone(&chrono::Local)) - .map(|local| local.format("%Y-%m-%d %H:%M %Z").to_string()) - .unwrap_or_else(|| post_at.to_string()); - - eprintln!("Reply scheduled for {dt} (id: {scheduled_id})"); Ok(()) } diff --git a/crates/void-cli/src/commands/search.rs b/crates/void-cli/src/commands/search.rs index 71ff51d..3662be5 100644 --- a/crates/void-cli/src/commands/search.rs +++ b/crates/void-cli/src/commands/search.rs @@ -1,8 +1,8 @@ use clap::Args; use tracing::debug; -use super::pagination::{build_meta, parse_page}; -use crate::output::{resolve_connector_filter, OutputFormatter, CONNECTOR_FILTER_HELP}; +use crate::service; +use crate::service::reads::{self, SearchQuery}; #[derive(Debug, Args)] pub struct SearchArgs { @@ -11,7 +11,7 @@ pub struct SearchArgs { /// Filter by connection (partial match on connection_id) #[arg(long)] pub connection: Option, - #[arg(long, help = CONNECTOR_FILTER_HELP)] + #[arg(long, help = crate::output::CONNECTOR_FILTER_HELP)] pub connector: Option, /// Maximum number of results to return #[arg(short = 'n', long, default_value = "50")] @@ -26,26 +26,16 @@ pub struct SearchArgs { pub fn run(args: &SearchArgs, enrich_context: bool) -> anyhow::Result<()> { debug!(query = %args.query, connection = ?args.connection, connector = ?args.connector, size = args.size, page = args.page, "search"); - let connector = resolve_connector_filter(args.connector.as_deref())?; - let _cfg = crate::context::config(); let db = crate::context::open_db()?; - let formatter = OutputFormatter::new(); - let offset = parse_page(args.size, args.page)?; - - // Search must not dedupe by context_id: older thread matches would disappear - // once a newer reply exists (e.g. searching "trop bien" after you replied). - let (mut messages, total_elements) = db.search_messages_paginated( - &args.query, - args.connection.as_deref(), - connector.as_deref(), - args.size, - offset, - args.include_muted, - false, - )?; - if enrich_context { - db.enrich_with_context(&mut messages)?; - } - let meta = build_meta(args.page, args.size, total_elements); - formatter.print_paginated(&messages, meta) + let query = SearchQuery { + query: &args.query, + connection: args.connection.as_deref(), + connector: args.connector.as_deref(), + size: args.size, + page: args.page, + include_muted: args.include_muted, + }; + let value = reads::search(&db, &query, enrich_context)?; + println!("{}", service::render(&value)?); + Ok(()) } diff --git a/crates/void-cli/src/commands/send.rs b/crates/void-cli/src/commands/send.rs index 9e13c2a..18e3280 100644 --- a/crates/void-cli/src/commands/send.rs +++ b/crates/void-cli/src/commands/send.rs @@ -1,13 +1,7 @@ use clap::Args; -use tracing::{debug, info}; +use tracing::info; -use void_core::config::VoidConfig; -use void_core::models::MessageContent; -use void_core::sync::is_daemon_running; - -use crate::commands::connector_factory; -use crate::connectors; -use crate::output::parse_connector_type; +use crate::service::writes::{self, SendParams}; #[derive(Debug, Args)] pub struct SendArgs { @@ -51,166 +45,44 @@ pub async fn run(args: &SendArgs) -> anyhow::Result<()> { conversation = ?args.recipient.conversation, "send" ); - let connector_type = parse_connector_type(&args.via) - .ok_or_else(|| anyhow::anyhow!("Unknown connector type: {}", args.via))?; - let cfg = crate::context::void_config(); - - let target_type = connector_type.to_string(); - let connection = cfg - .connections - .iter() - .find(|a| { - let type_matches = a.connector_type.to_string() == target_type; - let name_matches = args.connection.as_ref().is_none_or(|n| a.id == *n); - type_matches && name_matches - }) - .ok_or_else(|| anyhow::anyhow!("No {target_type} connection found in config.toml"))?; - - let plugin = connectors::by_id(connection.connector_type.as_str()) - .ok_or_else(|| anyhow::anyhow!("Unknown connector type: {}", connection.connector_type))?; - - if let Some(ref at_str) = args.at { - if !plugin.supports_scheduling { - anyhow::bail!("Scheduled sending (--at) is only supported for Slack."); - } - let to = args - .recipient - .to - .as_deref() - .ok_or_else(|| anyhow::anyhow!("--to is required for scheduled Slack sends"))?; - return run_slack_scheduled_send(connection, cfg, to, &args.message, at_str).await; - } - + let db = crate::context::open_db()?; let store_path = crate::context::store_path(); - let to = resolve_target( - args.recipient.to.as_deref(), - args.recipient.conversation.as_deref(), - &target_type, - cfg, - )?; - let content = if let Some(ref path) = args.file { - MessageContent::File { - path: path.into(), - caption: Some(args.message.clone()), - mime_type: None, - subject: args.subject.clone(), - } - } else { - MessageContent::Text { - body: args.message.clone(), - subject: args.subject.clone(), - } - }; - - let msg_id = if plugin.uses_daemon_rpc && is_daemon_running(&store_path) { - void_whatsapp::rpc::send_message(&store_path, &connection.id, &to, content).await? - } else { - let conn = connector_factory::build_connector(connection, &store_path)?; - conn.send_message(&to, content).await? + let params = SendParams { + to: args.recipient.to.as_deref(), + conversation: args.recipient.conversation.as_deref(), + via: &args.via, + connection: args.connection.as_deref(), + message: &args.message, + subject: args.subject.as_deref(), + file: args.file.as_deref(), + at: args.at.as_deref(), }; - eprintln!("Message sent (id: {msg_id})"); - Ok(()) -} -async fn run_slack_scheduled_send( - connection: &void_core::config::ConnectionConfig, - _cfg: &VoidConfig, - channel: &str, - message: &str, - at_str: &str, -) -> anyhow::Result<()> { - use super::slack::parse_schedule_time; - - let post_at = parse_schedule_time(at_str)?; - let now = chrono::Utc::now().timestamp(); - if post_at <= now { - anyhow::bail!("Scheduled time must be in the future."); - } + let msg_id = writes::send(&db, cfg, &store_path, params).await?; - let user_token = void_core::config::settings_string(&connection.settings, "user_token") - .ok_or_else(|| anyhow::anyhow!("missing user_token"))?; - let app_token = void_core::config::settings_string(&connection.settings, "app_token") - .ok_or_else(|| anyhow::anyhow!("missing app_token"))?; - - let connector = void_slack::connector::SlackConnector::new( - &connection.id, - &user_token, - &app_token, - None, - None, - std::env::temp_dir().as_path(), - None, - )?; - - let scheduled_id = connector - .schedule_message(channel, message, post_at, None) - .await?; - - let dt = chrono::DateTime::from_timestamp(post_at, 0) - .map(|utc| utc.with_timezone(&chrono::Local)) - .map(|local| local.format("%Y-%m-%d %H:%M %Z").to_string()) - .unwrap_or_else(|| post_at.to_string()); - - eprintln!("Message scheduled for {dt} (id: {scheduled_id})"); - Ok(()) -} - -/// Resolve `#channel-name` to a channel ID using the local database, or map a -/// void conversation id to its connector external id when `--conversation` is used. -fn resolve_target( - to: Option<&str>, - conversation: Option<&str>, - connector_type: &str, - _cfg: &VoidConfig, -) -> anyhow::Result { - if let Some(conv_id) = conversation { - let db = crate::context::open_db()?; - let conv = db - .get_conversation(conv_id)? - .ok_or_else(|| anyhow::anyhow!("Conversation not found: {conv_id}"))?; - if conv.connector != connector_type { - anyhow::bail!( - "Conversation {conv_id} belongs to connector {}, not {connector_type}", - conv.connector - ); - } - debug!( - conversation_id = conv_id, - external_id = %conv.external_id, - kind = %conv.kind, - "resolved conversation to external id" - ); - return Ok(conv.external_id); - } - - let to = to.ok_or_else(|| anyhow::anyhow!("Either --to or --conversation is required"))?; - - if !to.starts_with('#') { - return Ok(to.to_string()); - } - let name = &to[1..]; - let db = crate::context::open_db()?; - if let Some(conv) = db.find_conversation_by_name(name, connector_type)? { - debug!(name, external_id = %conv.external_id, "resolved channel name to ID from DB"); - Ok(conv.external_id) + if args.at.is_some() { + let at_str = args.at.as_deref().unwrap_or(""); + let post_at = crate::commands::slack::parse_schedule_time(at_str)?; + let dt = chrono::DateTime::from_timestamp(post_at, 0) + .map(|utc| utc.with_timezone(&chrono::Local)) + .map(|local| local.format("%Y-%m-%d %H:%M %Z").to_string()) + .unwrap_or_else(|| post_at.to_string()); + eprintln!("Message scheduled for {dt} (id: {msg_id})"); } else { - debug!( - name, - "channel not in local DB, passing through for API resolution" - ); - Ok(to.to_string()) + eprintln!("Message sent (id: {msg_id})"); } + Ok(()) } #[cfg(test)] mod tests { - use super::resolve_target; - use void_core::config::VoidConfig; use void_core::db::Database; use void_core::models::{Conversation, ConversationKind}; + use crate::service::writes::resolve_send_target; + fn test_db() -> Database { Database::open(std::path::Path::new(":memory:")).expect("in-memory db") } @@ -235,7 +107,6 @@ mod tests { fn resolve_target_conversation_returns_external_id() { let db = test_db(); seed_self_chat(&db); - // resolve_target opens its own db via context in production; test the logic inline let conv = db .get_conversation("wa_whatsapp_94004066660357@lid") .unwrap() @@ -246,8 +117,8 @@ mod tests { #[test] fn resolve_target_passthrough_non_channel() { - let cfg = VoidConfig::default(); - let target = resolve_target(Some("33651090627"), None, "whatsapp", &cfg).unwrap(); + let db = test_db(); + let target = resolve_send_target(&db, Some("33651090627"), None, "whatsapp").unwrap(); assert_eq!(target, "33651090627"); } } diff --git a/crates/void-cli/src/commands/slack/saved.rs b/crates/void-cli/src/commands/slack/saved.rs index 4962414..7de1425 100644 --- a/crates/void-cli/src/commands/slack/saved.rs +++ b/crates/void-cli/src/commands/slack/saved.rs @@ -1,8 +1,8 @@ use clap::Args; use tracing::debug; -use super::super::pagination::{build_meta, parse_page}; -use crate::output::OutputFormatter; +use crate::service; +use crate::service::reads::{self, SlackSavedQuery}; #[derive(Debug, Args)] pub struct SavedArgs { @@ -24,14 +24,13 @@ pub fn run(args: &SavedArgs) -> anyhow::Result<()> { 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) + let query = SlackSavedQuery { + connection: args.connection.as_deref(), + size: args.size, + page: args.page, + }; + let value = reads::slack_saved(&db, &query)?; + println!("{}", service::render(&value)?); + Ok(()) } diff --git a/crates/void-cli/src/main.rs b/crates/void-cli/src/main.rs index fa93dd5..fb87ec0 100644 --- a/crates/void-cli/src/main.rs +++ b/crates/void-cli/src/main.rs @@ -3,6 +3,7 @@ mod commands; pub mod connectors; pub mod context; pub mod output; +mod service; pub(crate) use cli::Command; diff --git a/crates/void-cli/src/output.rs b/crates/void-cli/src/output.rs index 78b9a98..fb25947 100644 --- a/crates/void-cli/src/output.rs +++ b/crates/void-cli/src/output.rs @@ -66,11 +66,11 @@ impl OutputFormatter { } } -fn json_wrap(data: T) -> serde_json::Value { +pub(crate) fn json_wrap(data: T) -> serde_json::Value { serde_json::json!({ "data": data, "error": null }) } -fn json_wrap_paginated( +pub(crate) fn json_wrap_paginated( data: T, pagination: PaginationMeta, ) -> serde_json::Value { diff --git a/crates/void-cli/src/service/mod.rs b/crates/void-cli/src/service/mod.rs new file mode 100644 index 0000000..f61d2ba --- /dev/null +++ b/crates/void-cli/src/service/mod.rs @@ -0,0 +1,11 @@ +//! Shared business logic for CLI commands and the upcoming MCP server. + +pub mod reads; +pub mod writes; + +use serde_json::Value; + +/// Render a JSON envelope the same way CLI commands print to stdout. +pub fn render(value: &Value) -> anyhow::Result { + Ok(serde_json::to_string_pretty(value)?) +} diff --git a/crates/void-cli/src/service/reads.rs b/crates/void-cli/src/service/reads.rs new file mode 100644 index 0000000..343aecd --- /dev/null +++ b/crates/void-cli/src/service/reads.rs @@ -0,0 +1,365 @@ +//! Read-path service functions returning CLI-identical JSON envelopes. + +use chrono::{Datelike, Local}; +use serde_json::Value; +use void_core::db::Database; + +use crate::commands::calendar::parsing::{parse_date_to_ts, parse_day_spec}; +use crate::commands::pagination::{build_meta, parse_page}; +use crate::commands::resolve::{resolve_messages_target, MessagesTarget}; +use crate::output::{json_wrap, json_wrap_paginated, resolve_connector_filter}; + +pub struct InboxQuery<'a> { + pub connection: Option<&'a str>, + pub connector: Option<&'a str>, + pub size: i64, + pub page: i64, + pub all: bool, + pub include_muted: bool, +} + +pub struct SearchQuery<'a> { + pub query: &'a str, + pub connection: Option<&'a str>, + pub connector: Option<&'a str>, + pub size: i64, + pub page: i64, + pub include_muted: bool, +} + +pub struct ContactsQuery<'a> { + pub search: Option<&'a str>, + pub connection: Option<&'a str>, + pub connector: Option<&'a str>, + pub size: i64, + pub page: i64, +} + +pub struct ChannelsQuery<'a> { + pub search: Option<&'a str>, + pub connection: Option<&'a str>, + pub connector: Option<&'a str>, + pub size: i64, + pub page: i64, + pub include_muted: bool, +} + +pub struct MessagesQuery<'a> { + pub target: &'a str, + pub since: Option<&'a str>, + pub until: Option<&'a str>, + pub size: i64, + pub page: i64, +} + +pub struct SlackSavedQuery<'a> { + pub connection: Option<&'a str>, + pub size: i64, + pub page: i64, +} + +pub struct CalendarQuery<'a> { + pub day: Option<&'a str>, + pub from: Option<&'a str>, + pub to: Option<&'a str>, + pub connection: Option<&'a str>, + pub connector: Option<&'a str>, +} + +pub fn inbox(db: &Database, query: &InboxQuery<'_>, enrich_context: bool) -> anyhow::Result { + let connector = resolve_connector_filter(query.connector)?; + let offset = parse_page(query.size, query.page)?; + + let include_muted = query.include_muted || query.all; + let (mut messages, total_elements) = db.recent_messages_paginated( + query.connection, + connector.as_deref(), + query.size, + offset, + query.all, + include_muted, + enrich_context, + )?; + messages.reverse(); + if enrich_context { + db.enrich_with_context(&mut messages)?; + } + let meta = build_meta(query.page, query.size, total_elements); + Ok(json_wrap_paginated(&messages, meta)) +} + +pub fn conversations(db: &Database, query: &InboxQuery<'_>) -> anyhow::Result { + let connector = resolve_connector_filter(query.connector)?; + let offset = parse_page(query.size, query.page)?; + + let (conversations, total_elements) = db.list_conversations_paginated( + query.connection, + connector.as_deref(), + query.size, + offset, + query.include_muted, + )?; + let meta = build_meta(query.page, query.size, total_elements); + Ok(json_wrap_paginated(&conversations, meta)) +} + +pub fn messages( + db: &Database, + query: &MessagesQuery<'_>, + enrich_context: bool, +) -> anyhow::Result { + match resolve_messages_target(db, query.target)? { + MessagesTarget::Link { + message_id, + conversation_id: _, + } => { + let msg = db + .get_message(&message_id)? + .ok_or_else(|| anyhow::anyhow!("Message vanished after lookup: {message_id}"))?; + let mut messages = vec![msg]; + if enrich_context { + db.enrich_with_context(&mut messages)?; + } + Ok(json_wrap(&messages)) + } + MessagesTarget::UnresolvedSlackLink { + channel_id, + message_ts, + workspace, + } => anyhow::bail!( + "Slack message not found locally for link (workspace: {workspace}, channel: {channel_id}, ts: {message_ts}). \ + The channel may not be synced yet, or the specific message hasn't been fetched — try `void sync` first." + ), + MessagesTarget::ConversationId(conv_id) => { + let since = query.since.and_then(parse_date_to_ts); + let until = query.until.and_then(parse_date_to_ts); + let offset = parse_page(query.size, query.page)?; + + let (mut messages, total_elements) = db.list_messages_paginated( + &conv_id, + query.size, + offset, + since, + until, + enrich_context, + )?; + if enrich_context { + db.enrich_with_context(&mut messages)?; + } + let meta = build_meta(query.page, query.size, total_elements); + Ok(json_wrap_paginated(&messages, meta)) + } + MessagesTarget::Connector { connector } => { + let offset = parse_page(query.size, query.page)?; + let (mut messages, total_elements) = db.recent_messages_paginated( + None, + Some(&connector), + query.size, + offset, + true, + true, + enrich_context, + )?; + messages.reverse(); + if enrich_context { + db.enrich_with_context(&mut messages)?; + } + let meta = build_meta(query.page, query.size, total_elements); + Ok(json_wrap_paginated(&messages, meta)) + } + } +} + +pub fn search( + db: &Database, + query: &SearchQuery<'_>, + enrich_context: bool, +) -> anyhow::Result { + let connector = resolve_connector_filter(query.connector)?; + let offset = parse_page(query.size, query.page)?; + + let (mut messages, total_elements) = db.search_messages_paginated( + query.query, + query.connection, + connector.as_deref(), + query.size, + offset, + query.include_muted, + false, + )?; + if enrich_context { + db.enrich_with_context(&mut messages)?; + } + let meta = build_meta(query.page, query.size, total_elements); + Ok(json_wrap_paginated(&messages, meta)) +} + +pub fn contacts(db: &Database, query: &ContactsQuery<'_>) -> anyhow::Result { + let connector = resolve_connector_filter(query.connector)?; + let offset = parse_page(query.size, query.page)?; + + let (contacts, total_elements) = db.list_contacts_paginated( + query.connection, + connector.as_deref(), + query.search, + query.size, + offset, + )?; + let meta = build_meta(query.page, query.size, total_elements); + Ok(json_wrap_paginated(&contacts, meta)) +} + +pub fn channels(db: &Database, query: &ChannelsQuery<'_>) -> anyhow::Result { + let connector = resolve_connector_filter(query.connector)?; + let offset = parse_page(query.size, query.page)?; + + let (channels, total_elements) = db.list_channels_paginated( + query.connection, + connector.as_deref(), + query.search, + query.size, + offset, + query.include_muted, + )?; + let meta = build_meta(query.page, query.size, total_elements); + Ok(json_wrap_paginated(&channels, meta)) +} + +pub fn slack_saved(db: &Database, query: &SlackSavedQuery<'_>) -> anyhow::Result { + let offset = parse_page(query.size, query.page)?; + + let (mut messages, total_elements) = + db.list_saved_messages(query.connection, Some("slack"), query.size, offset)?; + messages.reverse(); + let meta = build_meta(query.page, query.size, total_elements); + Ok(json_wrap_paginated(&messages, meta)) +} + +pub fn calendar_list(db: &Database, query: &CalendarQuery<'_>) -> anyhow::Result { + let connector = resolve_connector_filter(query.connector)?; + + let (from, to) = if let Some(day) = query.day { + let date = parse_day_spec(day)?; + let start = date + .and_hms_opt(0, 0, 0) + .and_then(|dt| dt.and_local_timezone(Local).single()) + .map(|dt| dt.timestamp()); + let end = (date + chrono::Duration::days(1)) + .and_hms_opt(0, 0, 0) + .and_then(|dt| dt.and_local_timezone(Local).single()) + .map(|dt| dt.timestamp()); + (start, end) + } else { + let today = Local::now().date_naive(); + let from = query.from.and_then(parse_date_to_ts).or_else(|| { + today + .and_hms_opt(0, 0, 0) + .and_then(|dt| dt.and_local_timezone(Local).single()) + .map(|dt| dt.timestamp()) + }); + + let to = query.to.and_then(parse_date_to_ts).or_else(|| { + (today + chrono::Duration::days(1)) + .and_hms_opt(0, 0, 0) + .and_then(|dt| dt.and_local_timezone(Local).single()) + .map(|dt| dt.timestamp()) + }); + (from, to) + }; + + let events = db.list_events(from, to, query.connection, connector.as_deref(), 200)?; + Ok(json_wrap(&events)) +} + +pub fn calendar_week(db: &Database) -> anyhow::Result { + let today = Local::now().date_naive(); + let weekday = today.weekday().num_days_from_monday(); + let monday = today - chrono::Duration::days(weekday as i64); + let sunday = monday + chrono::Duration::days(7); + + let from = monday + .and_hms_opt(0, 0, 0) + .and_then(|dt| dt.and_local_timezone(Local).single()) + .map(|dt| dt.timestamp()); + let to = sunday + .and_hms_opt(0, 0, 0) + .and_then(|dt| dt.and_local_timezone(Local).single()) + .map(|dt| dt.timestamp()); + + let events = db.list_events(from, to, None, None, 200)?; + Ok(json_wrap(&events)) +} + +#[cfg(test)] +mod tests { + use super::*; + use void_core::models::ConversationKind; + use void_core::test_fixtures::{make_conversation_named, make_message_with_sender}; + + fn test_db() -> Database { + Database::open(std::path::Path::new(":memory:")).expect("in-memory db") + } + + fn seed_basic(db: &Database) { + let channel = + make_conversation_named("c-chan", "C-CHAN-EXT", "general", ConversationKind::Channel); + db.upsert_conversation(&channel).expect("upsert channel"); + let mut msg = make_message_with_sender( + "m1", + "c-chan", + "alice@example.com", + "hello saved", + 1_700_000_100, + ); + msg.is_saved = true; + db.upsert_message(&msg).expect("upsert saved message"); + let mut unsaved = make_message_with_sender( + "m2", + "c-chan", + "bob@example.com", + "not saved", + 1_700_000_200, + ); + unsaved.is_saved = false; + db.upsert_message(&unsaved).expect("upsert unsaved message"); + } + + #[test] + fn inbox_envelope_has_data_and_pagination() { + let db = test_db(); + seed_basic(&db); + let query = InboxQuery { + connection: None, + connector: None, + size: 50, + page: 1, + all: true, + include_muted: false, + }; + let value = inbox(&db, &query, false).unwrap(); + assert!(value.get("data").is_some()); + assert!(value.get("pagination").is_some()); + assert!(value.get("error").unwrap().is_null()); + } + + #[test] + fn slack_saved_filters_to_saved_messages_only() { + let db = test_db(); + seed_basic(&db); + let query = SlackSavedQuery { + connection: None, + size: 50, + page: 1, + }; + let value = slack_saved(&db, &query).unwrap(); + let data = value.get("data").unwrap().as_array().unwrap(); + assert_eq!(data.len(), 1); + assert_eq!(data[0].get("id").unwrap(), "m1"); + } + + #[test] + fn parse_day_spec_today_via_calendar_parsing() { + let today = Local::now().date_naive(); + assert_eq!(parse_day_spec("today").unwrap(), today); + } +} diff --git a/crates/void-cli/src/service/writes.rs b/crates/void-cli/src/service/writes.rs new file mode 100644 index 0000000..4211382 --- /dev/null +++ b/crates/void-cli/src/service/writes.rs @@ -0,0 +1,582 @@ +//! Write-path service functions returning data values for CLI/MCP formatting. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use serde_json::{json, Value}; +use void_core::config::VoidConfig; +use void_core::connector::Connector; +use void_core::db::Database; +use void_core::models::MessageContent; +use void_core::sync::is_daemon_running; + +use crate::commands::connector_factory; +use crate::commands::resolve::resolve_message; +use crate::connectors; +use crate::output::{parse_connector_type, resolve_connector_filter}; + +pub struct SendParams<'a> { + pub to: Option<&'a str>, + pub conversation: Option<&'a str>, + pub via: &'a str, + pub connection: Option<&'a str>, + pub message: &'a str, + pub subject: Option<&'a str>, + pub file: Option<&'a str>, + pub at: Option<&'a str>, +} + +pub struct ReplyParams<'a> { + pub message_id: &'a str, + pub message: &'a str, + pub file: Option<&'a str>, + pub in_thread: bool, + pub at: Option<&'a str>, +} + +pub struct ForwardParams<'a> { + pub message_id: &'a str, + pub to: &'a str, + pub comment: Option<&'a str>, +} + +pub struct ArchiveParams<'a> { + pub message_ids: &'a [String], + pub before: Option<&'a str>, + pub connector: Option<&'a str>, +} + +pub struct MuteParams<'a> { + pub targets: &'a [String], + pub unmute: bool, + pub connection: Option<&'a str>, + pub connector: Option<&'a str>, +} + +pub async fn send( + db: &Database, + cfg: &VoidConfig, + store_path: &Path, + params: SendParams<'_>, +) -> anyhow::Result { + let connector_type = parse_connector_type(params.via) + .ok_or_else(|| anyhow::anyhow!("Unknown connector type: {}", params.via))?; + + let target_type = connector_type.to_string(); + let connection = cfg + .connections + .iter() + .find(|a| { + let type_matches = a.connector_type.to_string() == target_type; + let name_matches = params.connection.is_none_or(|n| a.id == n); + type_matches && name_matches + }) + .ok_or_else(|| anyhow::anyhow!("No {target_type} connection found in config.toml"))?; + + let plugin = connectors::by_id(connection.connector_type.as_str()) + .ok_or_else(|| anyhow::anyhow!("Unknown connector type: {}", connection.connector_type))?; + + if let Some(at_str) = params.at { + if !plugin.supports_scheduling { + anyhow::bail!("Scheduled sending (--at) is only supported for Slack."); + } + let to = params + .to + .ok_or_else(|| anyhow::anyhow!("--to is required for scheduled Slack sends"))?; + return run_slack_scheduled_send(connection, to, params.message, at_str).await; + } + + let to = resolve_send_target(db, params.to, params.conversation, &target_type)?; + + let content = if let Some(path) = params.file { + MessageContent::File { + path: path.into(), + caption: Some(params.message.to_string()), + mime_type: None, + subject: params.subject.map(str::to_string), + } + } else { + MessageContent::Text { + body: params.message.to_string(), + subject: params.subject.map(str::to_string), + } + }; + + let msg_id = if plugin.uses_daemon_rpc && is_daemon_running(store_path) { + void_whatsapp::rpc::send_message(store_path, &connection.id, &to, content).await? + } else { + let conn = connector_factory::build_connector(connection, store_path)?; + conn.send_message(&to, content).await? + }; + Ok(msg_id) +} + +pub async fn reply( + db: &Database, + cfg: &VoidConfig, + store_path: &Path, + params: ReplyParams<'_>, +) -> anyhow::Result { + let msg = resolve_message(db, params.message_id)?; + + let conv = db + .get_conversation(&msg.conversation_id)? + .ok_or_else(|| anyhow::anyhow!("Conversation not found: {}", msg.conversation_id))?; + + let connection = cfg + .find_connection_by_connector(&msg.connector) + .ok_or_else(|| { + anyhow::anyhow!( + "No {} connection found in config.toml for message {}", + msg.connector, + msg.id + ) + })?; + + let plugin = connectors::by_id(connection.connector_type.as_str()) + .ok_or_else(|| anyhow::anyhow!("Unknown connector type: {}", connection.connector_type))?; + + if let Some(at_str) = params.at { + if !plugin.supports_scheduling { + anyhow::bail!("Scheduled sending (--at) is only supported for Slack."); + } + return run_slack_scheduled_reply( + connection, + &conv.external_id, + &msg.external_id, + params.message, + at_str, + ) + .await; + } + + let connector_type = parse_connector_type(&connection.connector_type.to_string()) + .ok_or_else(|| anyhow::anyhow!("Unknown connector type: {}", connection.connector_type))?; + + let reply_id = connectors::build_reply_id(connector_type, &conv.external_id, &msg.external_id); + + let content = if let Some(path) = params.file { + MessageContent::File { + path: path.into(), + caption: Some(params.message.to_string()), + mime_type: None, + subject: None, + } + } else { + MessageContent::from_text(params.message.to_string()) + }; + + let sent_id = if plugin.uses_daemon_rpc && is_daemon_running(store_path) { + void_whatsapp::rpc::reply_message( + store_path, + &connection.id, + &reply_id, + content, + params.in_thread, + ) + .await? + } else { + let conn = connector_factory::build_connector(connection, store_path)?; + conn.reply(&reply_id, content, params.in_thread).await? + }; + Ok(sent_id) +} + +pub async fn forward( + db: &Database, + cfg: &VoidConfig, + store_path: &Path, + params: ForwardParams<'_>, +) -> anyhow::Result { + let msg = resolve_message(db, params.message_id)?; + + let conv = db + .get_conversation(&msg.conversation_id)? + .ok_or_else(|| anyhow::anyhow!("Conversation not found: {}", msg.conversation_id))?; + + let connection = cfg + .find_connection(&msg.connection_id) + .or_else(|| cfg.find_connection_by_connector(&msg.connector)) + .ok_or_else(|| { + anyhow::anyhow!( + "No {} connection found in config for message {}", + msg.connector, + msg.id + ) + })?; + + let conn = connector_factory::build_connector(connection, store_path)?; + + let fwd_id = conn + .forward( + &msg.external_id, + &conv.external_id, + params.to, + params.comment, + ) + .await?; + Ok(fwd_id) +} + +pub async fn archive( + db: &Database, + cfg: &VoidConfig, + store_path: &Path, + params: ArchiveParams<'_>, +) -> anyhow::Result { + if let Some(before) = params.before { + return archive_bulk_before(db, before, params.connector).await; + } + + if params.message_ids.is_empty() { + anyhow::bail!("at least one message ID is required (or use --before DATE)"); + } + + archive_by_ids(db, cfg, store_path, params.message_ids).await +} + +pub fn mute( + db: &Database, + cfg: &mut VoidConfig, + config_path: &Path, + params: MuteParams<'_>, +) -> anyhow::Result { + use crate::commands::mute::resolve; + + let connector = resolve_connector_filter(params.connector)?; + let mute = !params.unmute; + let action = if mute { "muted" } else { "unmuted" }; + let mut results = Vec::new(); + let mut affected_connections = HashSet::new(); + let mut config_changed = false; + + for target in params.targets { + let matches = + resolve::resolve_targets(db, target, params.connection, connector.as_deref())?; + + if matches.is_empty() { + eprintln!("no conversation matching \"{target}\" found"); + results.push(json!({ + "target": target, + "error": "not found", + })); + continue; + } + + for conv in matches { + let changed = if mute { + cfg.add_ignore_conversation(&conv.connection_id, conv.external_id.clone()) + } else { + cfg.remove_ignore_conversation( + &conv.connection_id, + &conv.external_id, + conv.name.as_deref(), + ) + }; + config_changed |= changed; + affected_connections.insert(conv.connection_id.clone()); + + let name = conv.name.as_deref().unwrap_or(&conv.id); + eprintln!("{action}: {name} [{}] ({})", conv.connector, conv.id); + results.push(json!({ + "id": conv.id, + "name": name, + "connector": conv.connector, + "connection_id": conv.connection_id, + "external_id": conv.external_id, + "is_muted": mute, + })); + } + } + + if config_changed { + cfg.save(config_path)?; + for connection_id in &affected_connections { + if let Some(conn) = cfg.connections.iter().find(|c| c.id == *connection_id) { + db.sync_ignore_conversations(&conn.id, &conn.ignore_conversations)?; + } + } + } + + Ok(json!({ "data": results, "error": null })) +} + +/// Resolve `#channel-name` to a channel ID using the local database, or map a +/// void conversation id to its connector external id when `--conversation` is used. +pub fn resolve_send_target( + db: &Database, + to: Option<&str>, + conversation: Option<&str>, + connector_type: &str, +) -> anyhow::Result { + if let Some(conv_id) = conversation { + let conv = db + .get_conversation(conv_id)? + .ok_or_else(|| anyhow::anyhow!("Conversation not found: {conv_id}"))?; + if conv.connector != connector_type { + anyhow::bail!( + "Conversation {conv_id} belongs to connector {}, not {connector_type}", + conv.connector + ); + } + return Ok(conv.external_id); + } + + let to = to.ok_or_else(|| anyhow::anyhow!("Either --to or --conversation is required"))?; + + if !to.starts_with('#') { + return Ok(to.to_string()); + } + let name = &to[1..]; + if let Some(conv) = db.find_conversation_by_name(name, connector_type)? { + Ok(conv.external_id) + } else { + Ok(to.to_string()) + } +} + +async fn archive_bulk_before( + db: &Database, + date_str: &str, + connector: Option<&str>, +) -> anyhow::Result { + let before_ts = parse_date_to_ts(date_str) + .ok_or_else(|| anyhow::anyhow!("invalid date \"{date_str}\", expected YYYY-MM-DD"))?; + + let connector_filter = resolve_connector_filter(connector)?; + + let messages = db.bulk_archive_before(before_ts, connector_filter.as_deref())?; + for msg in &messages { + cleanup_cached_files(msg); + } + + Ok(json!({ "data": { "archived_count": messages.len() }, "error": null })) +} + +async fn archive_by_ids( + db: &Database, + cfg: &VoidConfig, + store_path: &Path, + message_ids: &[String], +) -> anyhow::Result { + let mut connectors: HashMap> = HashMap::new(); + let mut results = Vec::new(); + + for message_id in message_ids { + let msg = match resolve_message(db, message_id) { + Ok(m) => m, + Err(_) => { + results.push(json!({ + "message_id": message_id, + "is_archived": false, + "error": "message not found", + })); + continue; + } + }; + + let conv = match db.get_conversation(&msg.conversation_id)? { + Some(c) => c, + None => { + results.push(json!({ + "message_id": message_id, + "is_archived": false, + "error": "conversation not found", + })); + continue; + } + }; + + let connector_key = format!("{}:{}", msg.connector, msg.connection_id); + if !connectors.contains_key(&connector_key) { + if let Some(connection) = cfg + .find_connection(&msg.connection_id) + .or_else(|| cfg.find_connection_by_connector(&msg.connector)) + { + if let Ok(c) = connector_factory::build_connector(connection, store_path) { + connectors.insert(connector_key.clone(), c); + } + } + } + + let remote_synced = if let Some(conn) = connectors.get(&connector_key) { + conn.archive(&msg.external_id, &conv.external_id) + .await + .is_ok() + } else { + false + }; + + db.mark_message_archived(message_id)?; + cleanup_cached_files(&msg); + + results.push(json!({ + "message_id": message_id, + "is_archived": true, + "remote_synced": remote_synced, + })); + } + + Ok(json!({ "data": results, "error": null })) +} + +async fn run_slack_scheduled_send( + connection: &void_core::config::ConnectionConfig, + channel: &str, + message: &str, + at_str: &str, +) -> anyhow::Result { + use crate::commands::slack::parse_schedule_time; + + let post_at = parse_schedule_time(at_str)?; + let now = chrono::Utc::now().timestamp(); + if post_at <= now { + anyhow::bail!("Scheduled time must be in the future."); + } + + let user_token = void_core::config::settings_string(&connection.settings, "user_token") + .ok_or_else(|| anyhow::anyhow!("missing user_token"))?; + let app_token = void_core::config::settings_string(&connection.settings, "app_token") + .ok_or_else(|| anyhow::anyhow!("missing app_token"))?; + + let connector = void_slack::connector::SlackConnector::new( + &connection.id, + &user_token, + &app_token, + None, + None, + std::env::temp_dir().as_path(), + None, + )?; + + connector + .schedule_message(channel, message, post_at, None) + .await +} + +async fn run_slack_scheduled_reply( + connection: &void_core::config::ConnectionConfig, + channel_id: &str, + thread_ts: &str, + message: &str, + at_str: &str, +) -> anyhow::Result { + use crate::commands::slack::parse_schedule_time; + + let post_at = parse_schedule_time(at_str)?; + let now = chrono::Utc::now().timestamp(); + if post_at <= now { + anyhow::bail!("Scheduled time must be in the future."); + } + + let user_token = void_core::config::settings_string(&connection.settings, "user_token") + .ok_or_else(|| anyhow::anyhow!("missing user_token"))?; + let app_token = void_core::config::settings_string(&connection.settings, "app_token") + .ok_or_else(|| anyhow::anyhow!("missing app_token"))?; + + let connector = void_slack::connector::SlackConnector::new( + &connection.id, + &user_token, + &app_token, + None, + None, + std::env::temp_dir().as_path(), + None, + )?; + + connector + .schedule_message(channel_id, message, post_at, Some(thread_ts)) + .await +} + +fn parse_date_to_ts(date: &str) -> Option { + chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") + .ok() + .and_then(|d| d.and_hms_opt(0, 0, 0)) + .map(|dt| dt.and_utc().timestamp()) +} + +fn cleanup_cached_files(msg: &void_core::models::Message) { + let files = match msg + .metadata + .as_ref() + .and_then(|m| m.get("files")) + .and_then(|f| f.as_array()) + { + Some(f) => f, + None => return, + }; + for file in files { + if let Some(path) = file.get("local_path").and_then(|v| v.as_str()) { + let _ = std::fs::remove_file(path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use void_core::config::VoidConfig; + use void_core::models::{Conversation, ConversationKind}; + + fn test_db() -> Database { + Database::open(std::path::Path::new(":memory:")).expect("in-memory db") + } + + fn seed_self_chat(db: &Database) { + db.upsert_conversation(&Conversation { + id: "wa_whatsapp_94004066660357@lid".into(), + connection_id: "whatsapp".into(), + connector: "whatsapp".into(), + external_id: "94004066660357@lid".into(), + name: Some("Message yourself".into()), + kind: ConversationKind::SelfChat, + last_message_at: None, + unread_count: 0, + is_muted: false, + metadata: None, + }) + .expect("seed conversation"); + } + + #[test] + fn resolve_send_target_conversation_returns_external_id() { + let db = test_db(); + seed_self_chat(&db); + let target = resolve_send_target( + &db, + None, + Some("wa_whatsapp_94004066660357@lid"), + "whatsapp", + ) + .unwrap(); + assert_eq!(target, "94004066660357@lid"); + } + + #[test] + fn resolve_send_target_passthrough_non_channel() { + let db = test_db(); + let target = resolve_send_target(&db, Some("33651090627"), None, "whatsapp").unwrap(); + assert_eq!(target, "33651090627"); + } + + #[test] + fn archive_requires_ids_or_before() { + let db = test_db(); + let cfg = VoidConfig::default(); + let rt = tokio::runtime::Runtime::new().unwrap(); + let err = rt + .block_on(archive( + &db, + &cfg, + std::path::Path::new("/tmp"), + ArchiveParams { + message_ids: &[], + before: None, + connector: None, + }, + )) + .unwrap_err(); + assert!(err.to_string().contains("message ID is required")); + } +}