diff --git a/CHANGELOG.md b/CHANGELOG.md index 019b223..d7a87ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Circleback** — new read-only connector for [Circleback](https://circleback.ai) meetings. Each meeting becomes a conversation carrying its notes, its action items and (optionally) every transcript turn, so meeting content is searchable alongside messages. Configure with `api_key`, `backfill_days` (default 365) and `include_transcript` (default true); `void setup` has a wizard for it. - **Remote** — `void remote status` reports `local_version` and `remote_version` so version skew between the client and the server binary is visible at a glance. ### Fixed diff --git a/Cargo.lock b/Cargo.lock index d772798..5c8e66c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4723,6 +4723,24 @@ dependencies = [ "wiremock", ] +[[package]] +name = "void-circleback" +version = "0.11.1" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "reqwest", + "serde", + "serde_json", + "tempfile", + "tokio", + "tokio-util", + "tracing", + "void-core", + "wiremock", +] + [[package]] name = "void-cli" version = "0.11.1" @@ -4752,6 +4770,7 @@ dependencies = [ "urlencoding", "uuid", "void-calendar", + "void-circleback", "void-core", "void-github", "void-gmail", diff --git a/Cargo.toml b/Cargo.toml index 7ee1b3c..6ee3960 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "crates/void-linkedin", "crates/void-reddit", "crates/void-github", + "crates/void-circleback", ] [workspace.package] @@ -90,3 +91,4 @@ void-googlenews = { path = "crates/void-googlenews" } void-linkedin = { path = "crates/void-linkedin" } void-reddit = { path = "crates/void-reddit" } void-github = { path = "crates/void-github" } +void-circleback = { path = "crates/void-circleback" } diff --git a/README.md b/README.md index 0e7f0c8..d757881 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](LICENSE) [![Rust](https://img.shields.io/badge/rust-1.95%2B-orange.svg)](Cargo.toml) -**One inbox for everything.** `void` unifies WhatsApp, Telegram, Slack, Gmail, Google Calendar, LinkedIn, GitHub, Hacker News, Google News, and Reddit into a single local-first command-line tool — one inbox, one search index, one set of commands. +**One inbox for everything.** `void` unifies WhatsApp, Telegram, Slack, Gmail, Google Calendar, LinkedIn, GitHub, Circleback, Hacker News, Google News, and Reddit into a single local-first command-line tool — one inbox, one search index, one set of commands. It is built for terminals, shell scripts, and AI agents: @@ -133,6 +133,16 @@ void reddit config void reply --message "Thanks!" ``` +### Circleback + +Meetings recorded by [Circleback](https://circleback.ai) sync read-only into the same inbox: one conversation per meeting, holding the notes, the action items, and every transcript turn attributed to its speaker. Past meetings become searchable next to your messages. + +```bash +void inbox --connector circleback +void search "pricing objection" --connector circleback +void messages +``` + ### Google News Keyword-watched articles from the public Google News RSS feed land in your inbox — one search per keyword, filtered by recency: @@ -174,7 +184,7 @@ A background daemon keeps a local SQLite database in sync with every connected s |-------|------| | `void-core` | Config, database, models, hooks, `Connector` trait, sync engine | | `void-cli` | The `void` binary: clap commands, output formatting | -| `void-slack`, `void-gmail`, `void-calendar`, `void-whatsapp`, `void-telegram`, `void-hackernews`, `void-googlenews`, `void-linkedin`, `void-github`, `void-reddit` | One crate per connector | +| `void-slack`, `void-gmail`, `void-calendar`, `void-whatsapp`, `void-telegram`, `void-hackernews`, `void-googlenews`, `void-linkedin`, `void-github`, `void-reddit`, `void-circleback` | One crate per connector | All data stays on your machine in `~/.local/share/void` — no external database, no Docker, no cloud. Layout details: [Configuration](docs/configuration.md#data-storage-layout). diff --git a/crates/void-circleback/Cargo.toml b/crates/void-circleback/Cargo.toml new file mode 100644 index 0000000..a6a87ed --- /dev/null +++ b/crates/void-circleback/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "void-circleback" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +description = "Circleback (meeting notes & transcripts) adapter for Void CLI" + +[dependencies] +void-core = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +anyhow = { workspace = true } +async-trait = { workspace = true } +tokio-util = { workspace = true } +reqwest = { workspace = true } +chrono = { workspace = true } + +[dev-dependencies] +wiremock = { workspace = true } +tokio = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/void-circleback/src/api.rs b/crates/void-circleback/src/api.rs new file mode 100644 index 0000000..08bed82 --- /dev/null +++ b/crates/void-circleback/src/api.rs @@ -0,0 +1,426 @@ +//! Minimal Circleback REST client (). +//! +//! Endpoints used: +//! - `GET /meetings?cursor=…` — newest first, 20 per page, RFC 8288 `Link: <…>; rel="next"` +//! - `GET /meeting/{id}` — one meeting (notes, attendees, action items) +//! - `GET /meeting/{id}/transcript` — `[{speaker, text, timestamp}]` +//! +//! Rate limits are per account (free plan: 3 req/s, 20 req/min); the client +//! paces every call and honours `Retry-After` on `429`. + +use std::time::Duration; + +use reqwest::{Client, Response, StatusCode}; +use serde::Deserialize; + +pub const DEFAULT_BASE_URL: &str = "https://circleback.ai/api"; + +/// Minimum spacing between two requests. +const REQUEST_PACE: Duration = Duration::from_millis(350); +/// How many times a `429` is retried before giving up. +const MAX_RATE_LIMIT_RETRIES: u32 = 3; +/// Fallback wait when `429` comes without a usable `Retry-After`. +const DEFAULT_RETRY_AFTER: Duration = Duration::from_secs(20); + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Meeting { + pub id: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub url: Option, + pub created_at: String, + #[serde(default)] + pub updated_at: Option, + /// Seconds. + #[serde(default)] + pub duration: Option, + /// Markdown notes, `null` until Circleback has processed the recording. + #[serde(default)] + pub notes: Option, + #[serde(default)] + pub private_notes: Option, + #[serde(default)] + pub ical_uid: Option, + #[serde(default)] + pub recording_url: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub attendees: Vec, + #[serde(default)] + pub action_items: Vec, + #[serde(default)] + pub calendar_event: Option, +} + +impl Meeting { + /// Notes or a duration mean Circleback finished processing the recording. + pub fn is_processed(&self) -> bool { + self.notes.as_deref().is_some_and(|n| !n.trim().is_empty()) || self.duration.is_some() + } + + /// `updatedAt` when present, else `createdAt`: changes whenever notes or + /// action items are (re)generated. + pub fn version(&self) -> &str { + self.updated_at.as_deref().unwrap_or(&self.created_at) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Attendee { + #[serde(default)] + pub profile_id: Option, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub email: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub company_name: Option, + #[serde(default)] + pub is_calendar_invitee: Option, + #[serde(default)] + pub is_calendar_event_organizer: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionItem { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub description: Option, + /// `PENDING`, `COMPLETED`, … + #[serde(default)] + pub status: Option, + #[serde(default)] + pub completed_at: Option, + #[serde(default)] + pub assignee: Option, +} + +impl ActionItem { + pub fn is_done(&self) -> bool { + self.completed_at.is_some() + || self.status.as_deref().is_some_and(|s| { + s.eq_ignore_ascii_case("completed") || s.eq_ignore_ascii_case("done") + }) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Assignee { + #[serde(default)] + pub profile_id: Option, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub email: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TranscriptTurn { + #[serde(default)] + pub speaker: Option, + #[serde(default)] + pub text: String, + /// Seconds since the start of the recording. + #[serde(default)] + pub timestamp: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct MeetingsPage { + pub meetings: Vec, + /// Opaque cursor for the next page, `None` on the last page. + pub next_cursor: Option, +} + +pub struct CirclebackClient { + http: Client, + base_url: String, + api_key: String, +} + +impl CirclebackClient { + pub fn new(api_key: impl Into) -> Self { + Self::with_base_url(api_key, DEFAULT_BASE_URL) + } + + /// Override the API base URL (tests point it at a mock server). + pub fn with_base_url(api_key: impl Into, base_url: impl Into) -> Self { + Self { + http: Client::new(), + base_url: base_url.into().trim_end_matches('/').to_string(), + api_key: api_key.into(), + } + } + + async fn get(&self, path: &str) -> anyhow::Result { + let url = format!("{}/{}", self.base_url, path.trim_start_matches('/')); + let mut attempt = 0; + loop { + tokio::time::sleep(REQUEST_PACE).await; + let resp = self + .http + .get(&url) + .bearer_auth(&self.api_key) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .await?; + match resp.status() { + StatusCode::TOO_MANY_REQUESTS if attempt < MAX_RATE_LIMIT_RETRIES => { + let wait = resp + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse::().ok()) + .map(Duration::from_secs) + .unwrap_or(DEFAULT_RETRY_AFTER); + tracing::warn!(?wait, attempt, "circleback rate limited, waiting"); + tokio::time::sleep(wait).await; + attempt += 1; + } + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + anyhow::bail!("Circleback rejected the API key ({})", resp.status()) + } + _ => return Ok(resp), + } + } + } + + /// One page of meetings, newest first. Pass the previous page's + /// `next_cursor` to continue, `None` for the first page. + pub async fn list_meetings(&self, cursor: Option<&str>) -> anyhow::Result { + let path = match cursor { + Some(c) => format!("meetings?cursor={}", urlencode(c)), + None => "meetings".to_string(), + }; + let resp = self.get(&path).await?; + if !resp.status().is_success() { + anyhow::bail!("GET /meetings failed: {}", resp.status()); + } + let next_cursor = resp + .headers() + .get(reqwest::header::LINK) + .and_then(|v| v.to_str().ok()) + .and_then(next_cursor_from_link); + let meetings: Vec = resp.json().await?; + Ok(MeetingsPage { + meetings, + next_cursor, + }) + } + + /// A single meeting, `None` when it does not exist (or is not visible). + pub async fn get_meeting(&self, id: &str) -> anyhow::Result> { + let resp = self.get(&format!("meeting/{}", urlencode(id))).await?; + if resp.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + if !resp.status().is_success() { + anyhow::bail!("GET /meeting/{id} failed: {}", resp.status()); + } + Ok(Some(resp.json().await?)) + } + + /// Speaker turns of a meeting; empty when no transcript exists. + pub async fn transcript(&self, id: &str) -> anyhow::Result> { + let resp = self + .get(&format!("meeting/{}/transcript", urlencode(id))) + .await?; + if resp.status() == StatusCode::NOT_FOUND { + return Ok(Vec::new()); + } + if !resp.status().is_success() { + anyhow::bail!("GET /meeting/{id}/transcript failed: {}", resp.status()); + } + Ok(resp.json().await?) + } +} + +/// Extract the `cursor` query parameter of the `rel="next"` link. +/// +/// `; rel="next"` → `eyJwYWdlIjoxfQ` +pub(crate) fn next_cursor_from_link(link: &str) -> Option { + for part in link.split(',') { + let part = part.trim(); + if !part.contains("rel=\"next\"") && !part.contains("rel=next") { + continue; + } + let url = part + .split(';') + .next()? + .trim() + .trim_start_matches('<') + .trim_end_matches('>'); + let query = url.split_once('?').map(|(_, q)| q).unwrap_or(""); + for kv in query.split('&') { + if let Some(v) = kv.strip_prefix("cursor=") { + if !v.is_empty() { + return Some(v.to_string()); + } + } + } + } + None +} + +fn urlencode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{header, method, path, query_param, query_param_is_missing}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn meeting_json(id: &str, notes: Option<&str>) -> serde_json::Value { + serde_json::json!({ + "id": id, + "name": "Daily Model", + "url": "https://meet.google.com/abc-defg-hij", + "createdAt": "2026-09-10T14:01:02.106Z", + "updatedAt": "2026-09-10T15:02:00.000Z", + "duration": notes.map(|_| 2904.04), + "notes": notes, + "icalUid": "abc@google.com", + "recordingUrl": null, + "tags": [], + "attendees": [{"profileId": 1, "name": "Ada", "email": "ada@example.com", "title": "CEO", "companyName": "Acme", "isCalendarInvitee": true, "isCalendarEventOrganizer": false}], + "actionItems": [{"id": 7, "title": "Ship it", "description": "Soon", "status": "PENDING", "completedAt": null, "assignee": {"profileId": 2, "name": "Bob", "email": "bob@example.com"}}], + "calendarEvent": {"id": 1, "platform": "GoogleCalendar"}, + "privateNotes": "", + "insights": {} + }) + } + + #[test] + fn parses_next_cursor_from_link_header() { + let link = "; rel=\"next\""; + assert_eq!( + next_cursor_from_link(link).as_deref(), + Some("eyJwYWdlIjoxfQ") + ); + assert_eq!( + next_cursor_from_link("; rel=\"prev\""), + None + ); + assert_eq!(next_cursor_from_link(""), None); + } + + #[test] + fn deserializes_meeting_and_flags_processing_state() { + let m: Meeting = serde_json::from_value(meeting_json("m1", Some("#### Overview"))).unwrap(); + assert!(m.is_processed()); + assert_eq!(m.version(), "2026-09-10T15:02:00.000Z"); + assert_eq!(m.attendees[0].name.as_deref(), Some("Ada")); + assert_eq!( + m.action_items[0].assignee.as_ref().unwrap().name.as_deref(), + Some("Bob") + ); + assert!(!m.action_items[0].is_done()); + + let pending: Meeting = serde_json::from_value(meeting_json("m2", None)).unwrap(); + assert!(!pending.is_processed()); + } + + #[tokio::test] + async fn list_meetings_follows_link_cursor() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/meetings")) + .and(query_param_is_missing("cursor")) + .and(header("authorization", "Bearer cb_test")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(vec![meeting_json("m1", Some("notes"))]) + .insert_header("link", "; rel=\"next\""), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/meetings")) + .and(query_param("cursor", "page2")) + .respond_with(ResponseTemplate::new(200).set_body_json(vec![meeting_json("m2", None)])) + .mount(&server) + .await; + + let client = CirclebackClient::with_base_url("cb_test", server.uri()); + let first = client.list_meetings(None).await.unwrap(); + assert_eq!(first.meetings.len(), 1); + assert_eq!(first.next_cursor.as_deref(), Some("page2")); + let second = client + .list_meetings(first.next_cursor.as_deref()) + .await + .unwrap(); + assert_eq!(second.meetings[0].id, "m2"); + assert!(second.next_cursor.is_none()); + } + + #[tokio::test] + async fn transcript_returns_turns_and_empty_on_404() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/meeting/m1/transcript")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + {"speaker": "Ada", "text": "Hello", "timestamp": 2.48}, + {"speaker": "Bob", "text": "Hi", "timestamp": 5.0} + ]))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/meeting/m2/transcript")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let client = CirclebackClient::with_base_url("cb_test", server.uri()); + let turns = client.transcript("m1").await.unwrap(); + assert_eq!(turns.len(), 2); + assert_eq!(turns[1].speaker.as_deref(), Some("Bob")); + assert!(client.transcript("m2").await.unwrap().is_empty()); + } + + #[tokio::test] + async fn rejected_key_is_an_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/meetings")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + let client = CirclebackClient::with_base_url("bad", server.uri()); + let err = client.list_meetings(None).await.unwrap_err(); + assert!(err.to_string().contains("401")); + } + + #[tokio::test] + async fn get_meeting_returns_none_on_404() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/meeting/nope")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + let client = CirclebackClient::with_base_url("cb_test", server.uri()); + assert!(client.get_meeting("nope").await.unwrap().is_none()); + } +} diff --git a/crates/void-circleback/src/connector/mod.rs b/crates/void-circleback/src/connector/mod.rs new file mode 100644 index 0000000..d284aae --- /dev/null +++ b/crates/void-circleback/src/connector/mod.rs @@ -0,0 +1,108 @@ +mod sync; + +use std::sync::Arc; + +use async_trait::async_trait; +use tokio_util::sync::CancellationToken; +use void_core::connector::Connector; +use void_core::db::Database; +use void_core::models::{ConnectorType, HealthStatus, MessageContent}; + +use crate::api::CirclebackClient; +use crate::CONNECTOR_ID; + +pub use sync::{ + build_action_items_message, build_conversation, build_notes_message, build_transcript_messages, +}; + +pub struct CirclebackConnector { + config_id: String, + api_key: String, + backfill_days: u32, + include_transcript: bool, + poll_interval_secs: u64, +} + +impl CirclebackConnector { + pub fn new( + connection_id: &str, + api_key: impl Into, + backfill_days: u32, + include_transcript: bool, + poll_interval_secs: u64, + ) -> Self { + Self { + config_id: connection_id.to_string(), + api_key: api_key.into(), + backfill_days, + include_transcript, + poll_interval_secs, + } + } + + fn client(&self) -> CirclebackClient { + CirclebackClient::new(self.api_key.clone()) + } +} + +#[async_trait] +impl Connector for CirclebackConnector { + fn connector_type(&self) -> ConnectorType { + ConnectorType::from_static(CONNECTOR_ID) + } + + fn connection_id(&self) -> &str { + &self.config_id + } + + async fn authenticate(&mut self) -> anyhow::Result<()> { + self.client().list_meetings(None).await.map(|_| ()) + } + + async fn start_sync(&self, db: Arc, cancel: CancellationToken) -> anyhow::Result<()> { + sync::run_sync( + &db, + &self.config_id, + self.client(), + self.backfill_days, + self.include_transcript, + self.poll_interval_secs, + cancel, + ) + .await + } + + async fn health_check(&self) -> anyhow::Result { + let (ok, message) = match self.client().list_meetings(None).await { + Ok(page) => ( + true, + format!( + "API key valid ({} meetings on the first page)", + page.meetings.len() + ), + ), + Err(e) => (false, format!("Circleback API unreachable: {e}")), + }; + Ok(HealthStatus { + connection_id: self.config_id.clone(), + connector_type: ConnectorType::from_static(CONNECTOR_ID), + ok, + message, + last_sync: None, + message_count: None, + }) + } + + async fn send_message(&self, _to: &str, _content: MessageContent) -> anyhow::Result { + anyhow::bail!("Circleback is a read-only connector") + } + + async fn reply( + &self, + _message_id: &str, + _content: MessageContent, + _in_thread: bool, + ) -> anyhow::Result { + anyhow::bail!("Circleback is a read-only connector") + } +} diff --git a/crates/void-circleback/src/connector/sync.rs b/crates/void-circleback/src/connector/sync.rs new file mode 100644 index 0000000..7c0ebfd --- /dev/null +++ b/crates/void-circleback/src/connector/sync.rs @@ -0,0 +1,590 @@ +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; +use void_core::db::Database; +use void_core::models::{Conversation, ConversationKind, Message}; +use void_core::progress::BackfillProgress; + +use crate::api::{CirclebackClient, Meeting, TranscriptTurn}; +use crate::CONNECTOR_ID; + +/// Wall-clock threshold to detect hibernation gaps (same rationale as Gmail/Slack). +const IDLE_THRESHOLD: Duration = Duration::from_secs(3 * 60); +/// Meetings created before this many days ago are not re-checked on regular +/// polls: Circleback publishes notes within minutes, and action-item edits on +/// old meetings are not worth a full history walk every poll. +const RECHECK_WINDOW_DAYS: i64 = 14; + +const STATE_LAST_POLL: &str = "last_poll_at"; + +pub(super) async fn run_sync( + db: &Arc, + connection_id: &str, + client: CirclebackClient, + backfill_days: u32, + include_transcript: bool, + poll_interval_secs: u64, + cancel: CancellationToken, +) -> anyhow::Result<()> { + info!( + connection_id, + backfill_days, "running initial Circleback sync" + ); + if let Err(e) = sync_once( + &client, + db, + connection_id, + backfill_days, + include_transcript, + &cancel, + true, + ) + .await + { + error!(connection_id, error = %e, "initial Circleback sync failed"); + } + + let mut interval = tokio::time::interval(Duration::from_secs(poll_interval_secs.max(60))); + // First tick fires immediately; skip it since we just did the initial sync. + interval.tick().await; + let mut last_poll = SystemTime::now(); + + loop { + tokio::select! { + _ = cancel.cancelled() => { + info!(connection_id, "Circleback sync cancelled"); + break; + } + _ = interval.tick() => { + let elapsed = last_poll.elapsed().unwrap_or_default(); + let catching_up = elapsed > IDLE_THRESHOLD + Duration::from_secs(poll_interval_secs); + if catching_up { + warn!(connection_id, idle_secs = elapsed.as_secs(), "Circleback sync was idle, catching up"); + void_core::status!( + "[circleback:{connection_id}] sync idle for {}s, catching up", + elapsed.as_secs(), + ); + } else { + info!(connection_id, "polling Circleback"); + } + if let Err(e) = sync_once( + &client, + db, + connection_id, + backfill_days, + include_transcript, + &cancel, + catching_up, + ) + .await + { + error!(connection_id, error = %e, "Circleback poll error"); + } + last_poll = SystemTime::now(); + } + } + } + Ok(()) +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct SyncStats { + pub seen: u64, + pub imported: u64, + pub transcript_turns: u64, +} + +/// Walk `/meetings` newest-first until the backfill / re-check horizon and +/// import every meeting whose `updatedAt` differs from what we stored. +pub(super) async fn sync_once( + client: &CirclebackClient, + db: &Arc, + connection_id: &str, + backfill_days: u32, + include_transcript: bool, + cancel: &CancellationToken, + show_progress: bool, +) -> anyhow::Result { + let now = chrono::Utc::now().timestamp(); + let backfill_cutoff = (backfill_days > 0).then(|| now - i64::from(backfill_days) * 86_400); + let last_poll: Option = db + .get_sync_state(connection_id, STATE_LAST_POLL)? + .and_then(|v| v.parse().ok()); + // After the first run only look back a bounded window for late notes. + let horizon = match (backfill_cutoff, last_poll) { + (Some(cut), Some(last)) => Some(cut.max(last - RECHECK_WINDOW_DAYS * 86_400)), + (Some(cut), None) => Some(cut), + (None, Some(last)) => Some(last - RECHECK_WINDOW_DAYS * 86_400), + (None, None) => None, + }; + + let mut progress = show_progress.then(|| { + BackfillProgress::new(&format!("circleback:{connection_id}"), "meetings") + .with_secondary("imported") + }); + + let mut stats = SyncStats::default(); + let mut cursor: Option = None; + 'pages: loop { + if cancel.is_cancelled() { + break; + } + let page = client.list_meetings(cursor.as_deref()).await?; + if let Some(ref mut p) = progress { + p.inc_page(); + } + for meeting in &page.meetings { + if cancel.is_cancelled() { + break 'pages; + } + let created = parse_ts(&meeting.created_at).unwrap_or(now); + if horizon.is_some_and(|h| created < h) { + break 'pages; + } + stats.seen += 1; + if let Some(ref mut p) = progress { + p.inc(1); + } + match import_meeting(client, db, connection_id, meeting, include_transcript).await { + Ok(Some(turns)) => { + stats.imported += 1; + stats.transcript_turns += turns; + if let Some(ref mut p) = progress { + p.inc_secondary(1); + } + } + Ok(None) => {} + Err(e) => { + warn!(connection_id, meeting = %meeting.id, error = %e, "failed to import meeting") + } + } + } + match page.next_cursor { + Some(next) if !page.meetings.is_empty() => cursor = Some(next), + _ => break, + } + } + + if let Some(p) = progress { + p.finish(); + } + db.set_sync_state(connection_id, STATE_LAST_POLL, &now.to_string())?; + info!( + connection_id, + seen = stats.seen, + imported = stats.imported, + turns = stats.transcript_turns, + "Circleback sync done" + ); + Ok(stats) +} + +/// Import one meeting. Returns `Some(transcript turns written)` when the +/// meeting was (re)written, `None` when it was up to date or not processed yet. +async fn import_meeting( + client: &CirclebackClient, + db: &Arc, + connection_id: &str, + meeting: &Meeting, + include_transcript: bool, +) -> anyhow::Result> { + if !meeting.is_processed() { + // Recording still being processed: come back on a later poll. + return Ok(None); + } + let version_key = format!("meeting:{}", meeting.id); + if db.get_sync_state(connection_id, &version_key)?.as_deref() == Some(meeting.version()) { + return Ok(None); + } + + let conv = build_conversation(meeting, connection_id); + db.upsert_conversation(&conv)?; + db.upsert_message(&build_notes_message(meeting, connection_id, &conv.id))?; + if let Some(actions) = build_action_items_message(meeting, connection_id, &conv.id) { + db.upsert_message(&actions)?; + } + + let mut turns_written = 0u64; + let transcript_key = format!("transcript:{}", meeting.id); + if include_transcript && db.get_sync_state(connection_id, &transcript_key)?.is_none() { + let turns = client.transcript(&meeting.id).await?; + for msg in build_transcript_messages(meeting, &turns, connection_id, &conv.id) { + db.upsert_message(&msg)?; + turns_written += 1; + } + db.set_sync_state(connection_id, &transcript_key, &turns.len().to_string())?; + } + + db.set_sync_state(connection_id, &version_key, meeting.version())?; + Ok(Some(turns_written)) +} + +fn parse_ts(iso: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(iso) + .ok() + .map(|d| d.timestamp()) +} + +fn meeting_start(meeting: &Meeting) -> i64 { + parse_ts(&meeting.created_at).unwrap_or_else(|| chrono::Utc::now().timestamp()) +} + +fn meeting_end(meeting: &Meeting) -> i64 { + meeting_start(meeting) + meeting.duration.unwrap_or(0.0).round() as i64 +} + +fn meeting_title(meeting: &Meeting) -> String { + meeting + .name + .as_deref() + .map(str::trim) + .filter(|n| !n.is_empty()) + .unwrap_or("Untitled meeting") + .to_string() +} + +fn attendee_names(meeting: &Meeting) -> Vec { + meeting + .attendees + .iter() + .filter_map(|a| { + a.name + .as_deref() + .map(str::trim) + .filter(|n| !n.is_empty()) + .map(String::from) + .or_else(|| a.email.clone()) + }) + .collect() +} + +fn attendees_json(meeting: &Meeting) -> serde_json::Value { + serde_json::Value::Array( + meeting + .attendees + .iter() + .map(|a| { + serde_json::json!({ + "name": a.name, + "email": a.email, + "title": a.title, + "company": a.company_name, + "organizer": a.is_calendar_event_organizer.unwrap_or(false), + }) + }) + .collect(), + ) +} + +fn base_message(meeting: &Meeting, connection_id: &str, conv_id: &str, suffix: &str) -> Message { + let now = chrono::Utc::now().timestamp(); + Message { + id: format!("{connection_id}-{}-{suffix}", meeting.id), + conversation_id: conv_id.to_string(), + connection_id: connection_id.to_string(), + connector: CONNECTOR_ID.to_string(), + external_id: format!("{CONNECTOR_ID}_{connection_id}_{}_{suffix}", meeting.id), + sender: "circleback".to_string(), + sender_name: Some("Circleback".to_string()), + sender_avatar_url: None, + body: None, + timestamp: meeting_end(meeting), + synced_at: Some(now), + is_archived: false, + is_saved: false, + reply_to_id: None, + media_type: None, + metadata: None, + // Every message of a meeting shares one context: the inbox shows one + // row per meeting, `void messages` shows notes + transcript together. + context_id: Some(meeting.id.clone()), + context: None, + } +} + +pub fn build_conversation(meeting: &Meeting, connection_id: &str) -> Conversation { + Conversation { + id: format!("{connection_id}-{}", meeting.id), + connection_id: connection_id.to_string(), + connector: CONNECTOR_ID.to_string(), + external_id: meeting.id.clone(), + name: Some(meeting_title(meeting)), + kind: ConversationKind::Group, + last_message_at: Some(meeting_end(meeting)), + unread_count: 0, + is_muted: false, + metadata: Some(serde_json::json!({ + "meeting_id": meeting.id, + "url": meeting.url, + "started_at": meeting.created_at, + "duration_secs": meeting.duration, + "attendees": attendees_json(meeting), + "ical_uid": meeting.ical_uid, + "recording_url": meeting.recording_url, + "tags": meeting.tags, + "calendar_event": meeting.calendar_event, + })), + } +} + +/// The AI notes: one message per meeting, timestamped at the end of the +/// meeting so it is the newest item of the context (the inbox representative). +pub fn build_notes_message(meeting: &Meeting, connection_id: &str, conv_id: &str) -> Message { + let title = meeting_title(meeting); + let minutes = meeting.duration.map(|d| (d / 60.0).round() as i64); + let mut header = vec![title.clone()]; + let mut facts = Vec::new(); + if let Some(m) = minutes { + facts.push(format!("{m} min")); + } + let names = attendee_names(meeting); + if !names.is_empty() { + facts.push(names.join(", ")); + } + if !facts.is_empty() { + header.push(facts.join(" · ")); + } + if let Some(url) = meeting.url.as_deref().filter(|u| !u.is_empty()) { + header.push(url.to_string()); + } + let notes = meeting + .notes + .as_deref() + .map(str::trim) + .filter(|n| !n.is_empty()) + .unwrap_or("(no notes)"); + let body = format!("{}\n\n{notes}", header.join("\n")); + + let mut msg = base_message(meeting, connection_id, conv_id, "notes"); + msg.timestamp = meeting_end(meeting) + 2; + msg.body = Some(body); + msg.metadata = Some(serde_json::json!({ + "kind": "notes", + "meeting_id": meeting.id, + "title": title, + "url": meeting.url, + "started_at": meeting.created_at, + "duration_secs": meeting.duration, + "attendees": attendees_json(meeting), + "action_items": meeting.action_items.len(), + "recording_url": meeting.recording_url, + })); + msg +} + +/// Action items as a checklist; `None` when the meeting has none. +pub fn build_action_items_message( + meeting: &Meeting, + connection_id: &str, + conv_id: &str, +) -> Option { + if meeting.action_items.is_empty() { + return None; + } + let mut lines = vec![format!("Action items ({})", meeting.action_items.len())]; + let mut items = Vec::new(); + for item in &meeting.action_items { + let title = item + .title + .as_deref() + .map(str::trim) + .filter(|t| !t.is_empty()) + .unwrap_or("(untitled)"); + let who = item + .assignee + .as_ref() + .and_then(|a| a.name.clone().or_else(|| a.email.clone())); + let mut line = format!("- [{}] {title}", if item.is_done() { "x" } else { " " }); + if let Some(who) = &who { + line.push_str(&format!(" — {who}")); + } + lines.push(line); + items.push(serde_json::json!({ + "id": item.id, + "title": title, + "description": item.description, + "status": item.status, + "done": item.is_done(), + "assignee": who, + "assignee_email": item.assignee.as_ref().and_then(|a| a.email.clone()), + })); + } + let mut msg = base_message(meeting, connection_id, conv_id, "actions"); + msg.timestamp = meeting_end(meeting) + 1; + msg.body = Some(lines.join("\n")); + msg.metadata = Some(serde_json::json!({ + "kind": "action_items", + "meeting_id": meeting.id, + "items": items, + })); + Some(msg) +} + +/// One message per speaker turn, timestamped from the meeting start. +pub fn build_transcript_messages( + meeting: &Meeting, + turns: &[TranscriptTurn], + connection_id: &str, + conv_id: &str, +) -> Vec { + let start = meeting_start(meeting); + turns + .iter() + .enumerate() + .filter(|(_, t)| !t.text.trim().is_empty()) + .map(|(i, t)| { + let offset = t.timestamp.unwrap_or(0.0).max(0.0); + let speaker = t + .speaker + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("Unknown speaker"); + let mut msg = base_message(meeting, connection_id, conv_id, &format!("t{i}")); + msg.sender = speaker.to_string(); + msg.sender_name = Some(speaker.to_string()); + msg.body = Some(t.text.trim().to_string()); + msg.timestamp = start + offset.floor() as i64; + msg.metadata = Some(serde_json::json!({ + "kind": "transcript", + "meeting_id": meeting.id, + "turn": i, + "offset_secs": offset, + })); + msg + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::{ActionItem, Assignee, Attendee}; + + fn meeting(notes: Option<&str>, duration: Option) -> Meeting { + Meeting { + id: "m1".into(), + name: Some("Daily Model".into()), + url: Some("https://meet.google.com/abc".into()), + created_at: "2026-09-10T14:00:00Z".into(), + updated_at: Some("2026-09-10T15:00:00Z".into()), + duration, + notes: notes.map(String::from), + private_notes: None, + ical_uid: None, + recording_url: None, + tags: vec![], + attendees: vec![ + Attendee { + profile_id: Some(1), + name: Some("Ada".into()), + email: Some("ada@example.com".into()), + title: None, + company_name: None, + is_calendar_invitee: Some(true), + is_calendar_event_organizer: Some(true), + }, + Attendee { + profile_id: None, + name: None, + email: Some("bob@example.com".into()), + title: None, + company_name: None, + is_calendar_invitee: None, + is_calendar_event_organizer: None, + }, + ], + action_items: vec![ActionItem { + id: Some(7), + title: Some("Ship it".into()), + description: None, + status: Some("PENDING".into()), + completed_at: None, + assignee: Some(Assignee { + profile_id: None, + name: Some("Bob".into()), + email: None, + }), + }], + calendar_event: None, + } + } + + #[test] + fn conversation_is_one_group_per_meeting() { + let conv = build_conversation(&meeting(Some("n"), Some(600.0)), "cb"); + assert_eq!(conv.id, "cb-m1"); + assert_eq!(conv.external_id, "m1"); + assert_eq!(conv.name.as_deref(), Some("Daily Model")); + assert_eq!(conv.kind, ConversationKind::Group); + // ends 10 minutes after createdAt + assert_eq!(conv.last_message_at, Some(1_789_048_800 + 600)); + } + + #[test] + fn notes_message_carries_header_and_notes() { + let msg = build_notes_message( + &meeting(Some("#### Overview\n* thing"), Some(600.0)), + "cb", + "cb-m1", + ); + let body = msg.body.unwrap(); + assert!(body.starts_with("Daily Model\n10 min · Ada, bob@example.com\nhttps://meet.google.com/abc\n\n#### Overview")); + assert_eq!(msg.external_id, "circleback_cb_m1_notes"); + assert_eq!(msg.context_id.as_deref(), Some("m1")); + assert_eq!(msg.timestamp, 1_789_048_800 + 600 + 2); + assert_eq!(msg.metadata.unwrap()["action_items"], 1); + } + + #[test] + fn action_items_render_as_checklist() { + let msg = + build_action_items_message(&meeting(Some("n"), Some(60.0)), "cb", "cb-m1").unwrap(); + assert_eq!( + msg.body.as_deref(), + Some("Action items (1)\n- [ ] Ship it — Bob") + ); + assert_eq!(msg.timestamp, 1_789_048_800 + 60 + 1); + let mut m = meeting(Some("n"), Some(60.0)); + m.action_items.clear(); + assert!(build_action_items_message(&m, "cb", "cb-m1").is_none()); + } + + #[test] + fn transcript_turns_become_speaker_messages() { + let turns = vec![ + TranscriptTurn { + speaker: Some("Ada".into()), + text: "Hello".into(), + timestamp: Some(2.4), + }, + TranscriptTurn { + speaker: None, + text: " ".into(), + timestamp: Some(3.0), + }, + TranscriptTurn { + speaker: None, + text: "Hi".into(), + timestamp: None, + }, + ]; + let msgs = + build_transcript_messages(&meeting(Some("n"), Some(60.0)), &turns, "cb", "cb-m1"); + assert_eq!(msgs.len(), 2, "blank turns are dropped"); + assert_eq!(msgs[0].sender, "Ada"); + assert_eq!(msgs[0].timestamp, 1_789_048_800 + 2); + assert_eq!(msgs[0].external_id, "circleback_cb_m1_t0"); + assert_eq!(msgs[1].sender, "Unknown speaker"); + assert_eq!(msgs[1].external_id, "circleback_cb_m1_t2"); + assert!(msgs.iter().all(|m| m.context_id.as_deref() == Some("m1"))); + } + + #[test] + fn unprocessed_meeting_is_detected() { + assert!(!meeting(None, None).is_processed()); + assert!(meeting(None, Some(1.0)).is_processed()); + } +} diff --git a/crates/void-circleback/src/lib.rs b/crates/void-circleback/src/lib.rs new file mode 100644 index 0000000..7f43935 --- /dev/null +++ b/crates/void-circleback/src/lib.rs @@ -0,0 +1,11 @@ +//! Circleback connector: meeting notes, action items and transcripts. +//! +//! Read-only. Each meeting becomes a conversation; its AI notes, action items +//! and transcript turns become messages sharing one context group, so the +//! inbox shows a single row per meeting while `void messages` and `void search` +//! reach every spoken turn. + +pub mod api; +pub mod connector; + +pub const CONNECTOR_ID: &str = "circleback"; diff --git a/crates/void-cli/Cargo.toml b/crates/void-cli/Cargo.toml index cb746e3..2e2cf45 100644 --- a/crates/void-cli/Cargo.toml +++ b/crates/void-cli/Cargo.toml @@ -22,6 +22,7 @@ void-googlenews = { workspace = true } void-linkedin = { workspace = true } void-reddit = { workspace = true } void-github = { workspace = true } +void-circleback = { workspace = true } clap = { workspace = true } tokio = { workspace = true } serde = { workspace = true } diff --git a/crates/void-cli/src/commands/setup/circleback.rs b/crates/void-cli/src/commands/setup/circleback.rs new file mode 100644 index 0000000..7f9548a --- /dev/null +++ b/crates/void-cli/src/commands/setup/circleback.rs @@ -0,0 +1,85 @@ +use void_core::config::{ + empty_settings, settings_set_string, settings_set_u32, ConnectionConfig, VoidConfig, +}; +use void_core::models::ConnectorType; + +use super::auth::{pick_connector_action, ConnectorAction}; +use super::prompt::{confirm_default_yes, prompt, prompt_default}; +use crate::connectors::circleback::DEFAULT_BACKFILL_DAYS; + +pub(crate) async fn setup_circleback(cfg: &mut VoidConfig, add_only: bool) -> anyhow::Result<()> { + eprintln!("🎙️ CIRCLEBACK"); + eprintln!(); + eprintln!("Syncs your Circleback meetings (read-only):"); + eprintln!(" • AI notes and action items of every meeting"); + eprintln!(" • The transcript, one message per speaker turn"); + eprintln!(); + eprintln!( + "Create an API key at https://circleback.ai → Settings → API (keys start with `cb_`)." + ); + + let cb_type = ConnectorType::from_static(void_circleback::CONNECTOR_ID); + if !add_only { + let existing: Vec = cfg + .connections + .iter() + .enumerate() + .filter(|(_, a)| a.connector_type == cb_type) + .map(|(i, _)| i) + .collect(); + + let action = pick_connector_action("Circleback", &existing, cfg); + match action { + ConnectorAction::Skip => return Ok(()), + ConnectorAction::Keep => return Ok(()), + ConnectorAction::Replace(idx) => { + cfg.connections.remove(idx); + } + ConnectorAction::Add => {} + } + } + + eprintln!(); + let api_key = prompt("Circleback API key: "); + let api_key = api_key.trim().to_string(); + if api_key.is_empty() { + anyhow::bail!("Circleback API key is required"); + } + + let client = void_circleback::api::CirclebackClient::new(api_key.clone()); + let page = client.list_meetings(None).await?; + eprintln!( + " ✓ Key valid ({} meetings on the first page)", + page.meetings.len() + ); + + eprintln!(); + eprintln!("How far back to import on the first sync (0 = everything)."); + let backfill_days: u32 = prompt_default("Backfill days", &DEFAULT_BACKFILL_DAYS.to_string()) + .trim() + .parse() + .unwrap_or(DEFAULT_BACKFILL_DAYS); + let include_transcript = + confirm_default_yes("Import full transcripts (one message per speaker turn)?"); + + let connection_id = prompt_default("\nAccount name", "circleback"); + + let mut settings = empty_settings(); + settings_set_string(&mut settings, "api_key", &api_key); + settings_set_u32(&mut settings, "backfill_days", backfill_days); + settings.insert( + "include_transcript".to_string(), + toml::Value::Boolean(include_transcript), + ); + + let connection = ConnectionConfig { + id: connection_id, + connector_type: cb_type, + ignore_conversations: vec![], + settings, + }; + + cfg.connections.push(connection); + eprintln!(" ✓ Circleback configured."); + Ok(()) +} diff --git a/crates/void-cli/src/commands/setup/mod.rs b/crates/void-cli/src/commands/setup/mod.rs index 478e861..09f6843 100644 --- a/crates/void-cli/src/commands/setup/mod.rs +++ b/crates/void-cli/src/commands/setup/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod auth; pub(crate) mod calendar; +pub(crate) mod circleback; mod config_ui; pub(crate) mod connection_menu; pub(crate) mod github; diff --git a/crates/void-cli/src/commands/sync/args.rs b/crates/void-cli/src/commands/sync/args.rs index 0eb9f94..d8d5fd0 100644 --- a/crates/void-cli/src/commands/sync/args.rs +++ b/crates/void-cli/src/commands/sync/args.rs @@ -2,7 +2,7 @@ use clap::Args; #[derive(Clone, Debug, Args)] pub struct SyncArgs { - /// Sync only specific connectors (comma-separated: whatsapp,telegram,slack,gmail,calendar,hackernews,googlenews,reddit) + /// Sync only specific connectors (comma-separated: whatsapp,telegram,slack,gmail,calendar,hackernews,googlenews,reddit,circleback) #[arg(long)] pub connectors: Option, /// Detach and run as a background daemon @@ -14,7 +14,7 @@ pub struct SyncArgs { /// Clear the database before syncing (fresh start) #[arg(long)] pub clear: bool, - /// Clear data for a specific connector before syncing (e.g. whatsapp, telegram, slack, gmail, calendar, hackernews, googlenews, reddit) + /// Clear data for a specific connector before syncing (e.g. whatsapp, telegram, slack, gmail, calendar, hackernews, googlenews, reddit, circleback) #[arg(long)] pub clear_connector: Option, /// Stop the running sync daemon diff --git a/crates/void-cli/src/connectors/circleback.rs b/crates/void-cli/src/connectors/circleback.rs new file mode 100644 index 0000000..7727918 --- /dev/null +++ b/crates/void-cli/src/connectors/circleback.rs @@ -0,0 +1,151 @@ +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; + +use void_core::config::{ + redact_token, settings_str, settings_string, settings_u32, ConnectionConfig, SyncConfig, +}; +use void_core::connector::Connector; + +use super::{ConnectorPlugin, ReplyIdStyle, SetupCtx}; + +const DEFAULT_POLL_INTERVAL_SECS: u64 = 900; +pub(crate) const DEFAULT_BACKFILL_DAYS: u32 = 365; + +inventory::submit! { + ConnectorPlugin { + id: void_circleback::CONNECTOR_ID, + aliases: &["circleback", "cb"], + menu_label: "Circleback", + badge: "CB", + default_poll_interval_secs: Some(DEFAULT_POLL_INTERVAL_SECS), + reply_id_style: ReplyIdStyle::MsgOnly, + supports_scheduling: false, + uses_daemon_rpc: false, + prompt_token_reauth: false, + session_files, + build, + setup, + parse_settings, + show_config, + } +} + +fn session_files(_store: &Path, _connection_id: &str) -> Vec { + vec![] +} + +pub(crate) fn include_transcript(table: &toml::Table) -> bool { + table + .get("include_transcript") + .and_then(|v| v.as_bool()) + .unwrap_or(true) +} + +fn build( + connection: &ConnectionConfig, + _store_path: &Path, + sync: &SyncConfig, +) -> anyhow::Result> { + let api_key = settings_string(&connection.settings, "api_key").ok_or_else(|| { + anyhow::anyhow!( + "missing api_key for Circleback connection '{}'", + connection.id + ) + })?; + let backfill_days = + settings_u32(&connection.settings, "backfill_days").unwrap_or(DEFAULT_BACKFILL_DAYS); + let poll_secs = + sync.poll_interval_secs(void_circleback::CONNECTOR_ID, DEFAULT_POLL_INTERVAL_SECS); + Ok(Arc::new( + void_circleback::connector::CirclebackConnector::new( + &connection.id, + api_key, + backfill_days, + include_transcript(&connection.settings), + poll_secs, + ), + )) +} + +fn setup(ctx: SetupCtx<'_>) -> Pin> + '_>> { + Box::pin(crate::commands::setup::circleback::setup_circleback( + ctx.cfg, + ctx.add_only, + )) +} + +fn parse_settings(table: &toml::Table) -> anyhow::Result<()> { + match settings_str(table, "api_key") { + None => anyhow::bail!("missing api_key"), + Some(k) if k.trim().is_empty() => anyhow::bail!("api_key is empty"), + Some(_) => {} + } + if let Some(v) = table.get("include_transcript") { + if !v.is_bool() { + anyhow::bail!("include_transcript must be true or false"); + } + } + if let Some(v) = table.get("backfill_days") { + if !v.is_integer() || v.as_integer().is_some_and(|n| n < 0) { + anyhow::bail!("backfill_days must be a non-negative integer"); + } + } + Ok(()) +} + +fn show_config(table: &toml::Table, out: &mut dyn std::fmt::Write) -> std::fmt::Result { + if let Some(key) = settings_str(table, "api_key") { + writeln!(out, " api_key: {}", redact_token(key))?; + } + writeln!( + out, + " backfill_days: {}", + settings_u32(table, "backfill_days").unwrap_or(DEFAULT_BACKFILL_DAYS) + )?; + writeln!(out, " include_transcript: {}", include_transcript(table))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn table(toml_src: &str) -> toml::Table { + toml::from_str(toml_src).unwrap() + } + + #[test] + fn parse_settings_requires_api_key() { + assert!(parse_settings(&table("")).is_err()); + assert!(parse_settings(&table("api_key = \"\"")).is_err()); + assert!(parse_settings(&table("api_key = \"cb_x\"")).is_ok()); + } + + #[test] + fn parse_settings_validates_optional_fields() { + assert!( + parse_settings(&table("api_key = \"cb_x\"\ninclude_transcript = \"yes\"")).is_err() + ); + assert!(parse_settings(&table("api_key = \"cb_x\"\nbackfill_days = -1")).is_err()); + assert!(parse_settings(&table( + "api_key = \"cb_x\"\nbackfill_days = 30\ninclude_transcript = false" + )) + .is_ok()); + } + + #[test] + fn include_transcript_defaults_to_true() { + assert!(include_transcript(&table("api_key = \"cb_x\""))); + assert!(!include_transcript(&table("include_transcript = false"))); + } + + #[test] + fn show_config_redacts_the_key() { + let mut out = String::new(); + show_config(&table("api_key = \"cb_supersecretvalue\""), &mut out).unwrap(); + assert!(!out.contains("supersecretvalue")); + assert!(out.contains("backfill_days: 365")); + assert!(out.contains("include_transcript: true")); + } +} diff --git a/crates/void-cli/src/connectors/mod.rs b/crates/void-cli/src/connectors/mod.rs index 7e2f769..b93ca0d 100644 --- a/crates/void-cli/src/connectors/mod.rs +++ b/crates/void-cli/src/connectors/mod.rs @@ -1,6 +1,7 @@ //! Compile-time connector plugin registry (`inventory`). mod calendar; +pub(crate) mod circleback; mod github; mod gmail; mod googlenews; diff --git a/docs/commands.md b/docs/commands.md index 302cc9a..26740d2 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -35,7 +35,7 @@ Most read commands accept: | Flag | Description | |------|-------------| -| `--connector ` | Filter by connector: `slack`, `gmail`, `whatsapp`, `telegram`, `calendar`, `linkedin` (alias: `li`), `hackernews` (alias: `hn`), `googlenews` (alias: `gn`), `reddit` (alias: `rd`) | +| `--connector ` | Filter by connector: `slack`, `gmail`, `whatsapp`, `telegram`, `calendar`, `linkedin` (alias: `li`), `hackernews` (alias: `hn`), `googlenews` (alias: `gn`), `reddit` (alias: `rd`), `circleback` (alias: `cb`) | | `--connection ` | Filter by connection ID (when you have several accounts of one type) | | `-n`, `--size ` | Limit number of results (default: 50) | | `--page ` | Page through results | diff --git a/docs/configuration.md b/docs/configuration.md index c107b5b..54d1852 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,6 +34,7 @@ reddit_poll_interval_secs = 3600 linkedin_poll_interval_secs = 1800 linkedin_backfill_days = 15 github_poll_interval_secs = 120 +circleback_poll_interval_secs = 900 [[connections]] id = "whatsapp" @@ -113,6 +114,7 @@ Polling intervals for connectors that poll (push-based connectors — WhatsApp, | `linkedin_poll_interval_secs` | 1800 | | `linkedin_backfill_days` | 15 | | `github_poll_interval_secs` | 120 | +| `circleback_poll_interval_secs` | 900 | ## `[[connections]]` @@ -121,7 +123,7 @@ Each connection is one account on one service. Every connection has: | Field | Required | Description | |-------|----------|-------------| | `id` | yes | Unique name you choose — used by `--connection ` | -| `type` | yes | One of `whatsapp`, `telegram`, `slack`, `gmail`, `calendar`, `hackernews`, `googlenews`, `linkedin`, `reddit`, `github` | +| `type` | yes | One of `whatsapp`, `telegram`, `slack`, `gmail`, `calendar`, `hackernews`, `googlenews`, `linkedin`, `reddit`, `github`, `circleback` | | `ignore_conversations` | no | List of conversations to auto-mute (see below) | Per-type fields: @@ -138,6 +140,7 @@ Per-type fields: | `reddit` | `client_id`, `client_secret` | `refresh_token` (optional, enables commenting), `subreddits` (default: `[]`), `keywords` (default: `[]`), `min_score` (default: 0) | | `linkedin` | `api_key`, `dsn`, `account_id` (Unipile) | — | | `github` | `token`, `username` | — | +| `circleback` | `api_key` | `backfill_days` (default: 365), `include_transcript` (default: `true`) | You can declare multiple connections of the same type (two Slack workspaces, several Gmail accounts, …) — give each a distinct `id`. diff --git a/docs/connectors.md b/docs/connectors.md index 0b622ae..3c32cd7 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -192,6 +192,45 @@ Each repository appears as its own conversation. Mute noisy repos with `void mut ignore_conversations = ["facebook/react", "kubernetes"] ``` +## Circleback + +[Circleback](https://circleback.ai) records and summarizes meetings. The connector pulls them +read-only: every meeting becomes a conversation holding its notes, its action items and, when +enabled, the full transcript — so past meetings are searchable next to your messages. + +1. Open Circleback → Settings → API and create an API key +2. Run `void setup`, select Circleback, and paste the key + +```toml +[[connections]] +id = "circleback" +type = "circleback" +api_key = "cb_..." +backfill_days = 365 +include_transcript = true +``` + +| Setting | Default | Meaning | +|---------|---------|---------| +| `api_key` | — | required; the key from Circleback → Settings → API | +| `backfill_days` | 365 | how far back the first sync reaches | +| `include_transcript` | `true` | import each speaker turn as a message; set to `false` to keep only notes and action items | + +Each meeting yields one conversation named after the meeting, with: + +- a **notes** message: title, duration, attendees, meeting URL, then Circleback's summary +- an **action items** message: a checklist with the assignee of each item +- one message per **transcript** turn, attributed to the speaker, ordered by timestamp + +Meetings still being processed by Circleback are skipped and picked up on a later poll. A meeting +already imported is re-imported only when Circleback changes it, and its transcript is fetched once. +The connector is read-only: `void send` and `void reply` refuse a Circleback conversation. + +```bash +void inbox --connector circleback +void search "pricing" --connector circleback +``` + ## Multiple accounts Add as many connections as you want, including several of the same type. Target a specific one anywhere with `--connection `: