diff --git a/app/src/store/notificationSlice.ts b/app/src/store/notificationSlice.ts index 6886e5912..b2525c47f 100644 --- a/app/src/store/notificationSlice.ts +++ b/app/src/store/notificationSlice.ts @@ -13,6 +13,12 @@ export type NotificationCategory = | 'reminders' | 'important'; +export interface NotificationAction { + actionId: string; + label: string; + payload?: unknown; +} + export interface NotificationItem { id: string; category: NotificationCategory; @@ -23,6 +29,7 @@ export interface NotificationItem { accountId?: string; provider?: string; deepLink?: string; + actions?: NotificationAction[]; } export interface NotificationPreferences { diff --git a/src/openhuman/agent_meetings/mod.rs b/src/openhuman/agent_meetings/mod.rs index 09538370e..8719d6a7a 100644 --- a/src/openhuman/agent_meetings/mod.rs +++ b/src/openhuman/agent_meetings/mod.rs @@ -8,12 +8,14 @@ //! //! ## Module layout //! -//! - [`types`] — request/response types +//! - [`types`] — request/response types + meeting session model //! - [`ops`] — RPC handlers that emit Socket.IO events //! - [`schemas`] — controller schema + registered handler wrappers +//! - [`store`] — SQLite persistence for meeting sessions pub mod ops; pub mod schemas; +pub mod store; pub mod types; pub use schemas::{ diff --git a/src/openhuman/agent_meetings/store.rs b/src/openhuman/agent_meetings/store.rs new file mode 100644 index 000000000..62d836f92 --- /dev/null +++ b/src/openhuman/agent_meetings/store.rs @@ -0,0 +1,366 @@ +//! SQLite persistence for `MeetingSession` records. + +use anyhow::{Context, Result}; +use rusqlite::{params, Connection}; + +use crate::openhuman::config::Config; + +use super::types::{AutoJoinSource, MeetingSession, MeetingSessionStatus}; + +const SCHEMA: &str = " +CREATE TABLE IF NOT EXISTS meeting_sessions ( + id TEXT PRIMARY KEY, + meet_url TEXT NOT NULL, + title TEXT, + calendar_event_id TEXT, + status TEXT NOT NULL DEFAULT 'pending', + source TEXT NOT NULL DEFAULT 'manual', + thread_id TEXT, + transcript_received INTEGER NOT NULL DEFAULT 0, + summary_generated INTEGER NOT NULL DEFAULT 0, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_meeting_sessions_status + ON meeting_sessions(status); +CREATE INDEX IF NOT EXISTS idx_meeting_sessions_meet_url + ON meeting_sessions(meet_url); +"; + +fn with_connection(config: &Config, f: impl FnOnce(&Connection) -> Result) -> Result { + let db_path = config.workspace_dir.join("meetings").join("meetings.db"); + + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "[meetings::store] failed to create dir {}", + parent.display() + ) + })?; + } + + let conn = Connection::open(&db_path).with_context(|| { + format!( + "[meetings::store] failed to open DB at {}", + db_path.display() + ) + })?; + + conn.execute_batch(SCHEMA) + .context("[meetings::store] schema migration failed")?; + + f(&conn) +} + +fn status_to_str(s: MeetingSessionStatus) -> &'static str { + match s { + MeetingSessionStatus::Pending => "pending", + MeetingSessionStatus::Joined => "joined", + MeetingSessionStatus::Active => "active", + MeetingSessionStatus::Ended => "ended", + } +} + +fn str_to_status(s: &str) -> MeetingSessionStatus { + match s { + "joined" => MeetingSessionStatus::Joined, + "active" => MeetingSessionStatus::Active, + "ended" => MeetingSessionStatus::Ended, + _ => MeetingSessionStatus::Pending, + } +} + +fn source_to_str(s: AutoJoinSource) -> &'static str { + match s { + AutoJoinSource::Calendar => "calendar", + AutoJoinSource::Manual => "manual", + AutoJoinSource::Api => "api", + } +} + +fn str_to_source(s: &str) -> AutoJoinSource { + match s { + "calendar" => AutoJoinSource::Calendar, + "api" => AutoJoinSource::Api, + _ => AutoJoinSource::Manual, + } +} + +fn row_to_session(row: &rusqlite::Row) -> rusqlite::Result { + Ok(MeetingSession { + id: row.get(0)?, + meet_url: row.get(1)?, + title: row.get(2)?, + calendar_event_id: row.get(3)?, + status: str_to_status(row.get::<_, String>(4)?.as_str()), + source: str_to_source(row.get::<_, String>(5)?.as_str()), + thread_id: row.get(6)?, + transcript_received: row.get::<_, i64>(7)? != 0, + summary_generated: row.get::<_, i64>(8)? != 0, + created_at_ms: row.get::<_, i64>(9)? as u64, + updated_at_ms: row.get::<_, i64>(10)? as u64, + }) +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Insert a new meeting session into the store. Fails if the `id` already exists. +pub fn create_session(config: &Config, session: &MeetingSession) -> Result<()> { + with_connection(config, |conn| { + conn.execute( + "INSERT INTO meeting_sessions + (id, meet_url, title, calendar_event_id, status, source, + thread_id, transcript_received, summary_generated, + created_at_ms, updated_at_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + session.id, + session.meet_url, + session.title, + session.calendar_event_id, + status_to_str(session.status), + source_to_str(session.source), + session.thread_id, + session.transcript_received as i64, + session.summary_generated as i64, + session.created_at_ms as i64, + session.updated_at_ms as i64, + ], + )?; + Ok(()) + }) +} + +/// Retrieve a session by its unique ID. Returns `None` if not found. +pub fn get_session(config: &Config, id: &str) -> Result> { + with_connection(config, |conn| { + let mut stmt = conn.prepare( + "SELECT id, meet_url, title, calendar_event_id, status, source, + thread_id, transcript_received, summary_generated, + created_at_ms, updated_at_ms + FROM meeting_sessions WHERE id = ?1", + )?; + let mut rows = stmt.query_map(params![id], row_to_session)?; + match rows.next() { + Some(Ok(s)) => Ok(Some(s)), + Some(Err(e)) => Err(e.into()), + None => Ok(None), + } + }) +} + +/// Find the most recent session for a given meet URL. Returns `None` if none exist. +pub fn get_session_by_meet_url(config: &Config, url: &str) -> Result> { + with_connection(config, |conn| { + let mut stmt = conn.prepare( + "SELECT id, meet_url, title, calendar_event_id, status, source, + thread_id, transcript_received, summary_generated, + created_at_ms, updated_at_ms + FROM meeting_sessions WHERE meet_url = ?1 + ORDER BY created_at_ms DESC LIMIT 1", + )?; + let mut rows = stmt.query_map(params![url], row_to_session)?; + match rows.next() { + Some(Ok(s)) => Ok(Some(s)), + Some(Err(e)) => Err(e.into()), + None => Ok(None), + } + }) +} + +/// Transition a session to a new status and update `updated_at_ms`. +pub fn update_session_status( + config: &Config, + id: &str, + status: MeetingSessionStatus, + now_ms: u64, +) -> Result<()> { + with_connection(config, |conn| { + conn.execute( + "UPDATE meeting_sessions SET status = ?1, updated_at_ms = ?2 WHERE id = ?3", + params![status_to_str(status), now_ms as i64, id], + )?; + Ok(()) + }) +} + +/// Associate a conversation thread with an existing session. +pub fn set_session_thread_id( + config: &Config, + id: &str, + thread_id: &str, + now_ms: u64, +) -> Result<()> { + with_connection(config, |conn| { + conn.execute( + "UPDATE meeting_sessions SET thread_id = ?1, updated_at_ms = ?2 WHERE id = ?3", + params![thread_id, now_ms as i64, id], + )?; + Ok(()) + }) +} + +/// Return all sessions with status `pending`, `joined`, or `active` (most recent first). +pub fn list_active_sessions(config: &Config) -> Result> { + with_connection(config, |conn| { + let mut stmt = conn.prepare( + "SELECT id, meet_url, title, calendar_event_id, status, source, + thread_id, transcript_received, summary_generated, + created_at_ms, updated_at_ms + FROM meeting_sessions WHERE status IN ('pending', 'joined', 'active') + ORDER BY created_at_ms DESC", + )?; + let rows = stmt.query_map([], row_to_session)?; + rows.collect::, _>>() + .map_err(Into::into) + }) +} + +/// Flag that a transcript was received for this session. +pub fn mark_transcript_received(config: &Config, id: &str, now_ms: u64) -> Result<()> { + with_connection(config, |conn| { + conn.execute( + "UPDATE meeting_sessions SET transcript_received = 1, updated_at_ms = ?1 WHERE id = ?2", + params![now_ms as i64, id], + )?; + Ok(()) + }) +} + +/// Flag that a summary was generated for this session. +pub fn mark_summary_generated(config: &Config, id: &str, now_ms: u64) -> Result<()> { + with_connection(config, |conn| { + conn.execute( + "UPDATE meeting_sessions SET summary_generated = 1, updated_at_ms = ?1 WHERE id = ?2", + params![now_ms as i64, id], + )?; + Ok(()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::config::Config; + use tempfile::TempDir; + + fn test_config() -> (Config, TempDir) { + let dir = TempDir::new().unwrap(); + let mut config = Config::default(); + config.workspace_dir = dir.path().to_path_buf(); + (config, dir) + } + + #[test] + fn crud_cycle() { + let (config, _dir) = test_config(); + + let session = MeetingSession { + id: "meet-001".into(), + meet_url: "https://meet.google.com/abc-defg-hij".into(), + title: Some("Daily standup".into()), + calendar_event_id: Some("cal-xyz".into()), + status: MeetingSessionStatus::Pending, + source: AutoJoinSource::Calendar, + thread_id: None, + transcript_received: false, + summary_generated: false, + created_at_ms: 1000, + updated_at_ms: 1000, + }; + + create_session(&config, &session).unwrap(); + + let fetched = get_session(&config, "meet-001").unwrap().unwrap(); + assert_eq!(fetched.meet_url, session.meet_url); + assert_eq!(fetched.status, MeetingSessionStatus::Pending); + assert_eq!(fetched.source, AutoJoinSource::Calendar); + assert!(!fetched.transcript_received); + + update_session_status(&config, "meet-001", MeetingSessionStatus::Joined, 2000).unwrap(); + let fetched = get_session(&config, "meet-001").unwrap().unwrap(); + assert_eq!(fetched.status, MeetingSessionStatus::Joined); + assert_eq!(fetched.updated_at_ms, 2000); + + set_session_thread_id(&config, "meet-001", "thread-42", 3000).unwrap(); + let fetched = get_session(&config, "meet-001").unwrap().unwrap(); + assert_eq!(fetched.thread_id.as_deref(), Some("thread-42")); + + mark_transcript_received(&config, "meet-001", 4000).unwrap(); + let fetched = get_session(&config, "meet-001").unwrap().unwrap(); + assert!(fetched.transcript_received); + + mark_summary_generated(&config, "meet-001", 5000).unwrap(); + let fetched = get_session(&config, "meet-001").unwrap().unwrap(); + assert!(fetched.summary_generated); + } + + #[test] + fn get_by_meet_url() { + let (config, _dir) = test_config(); + + let session = MeetingSession { + id: "meet-002".into(), + meet_url: "https://meet.google.com/xyz".into(), + title: None, + calendar_event_id: None, + status: MeetingSessionStatus::Active, + source: AutoJoinSource::Manual, + thread_id: None, + transcript_received: false, + summary_generated: false, + created_at_ms: 100, + updated_at_ms: 100, + }; + create_session(&config, &session).unwrap(); + + let found = get_session_by_meet_url(&config, "https://meet.google.com/xyz") + .unwrap() + .unwrap(); + assert_eq!(found.id, "meet-002"); + + let none = get_session_by_meet_url(&config, "https://meet.google.com/nope").unwrap(); + assert!(none.is_none()); + } + + #[test] + fn list_active_excludes_ended() { + let (config, _dir) = test_config(); + + for (id, status) in [ + ("m1", MeetingSessionStatus::Active), + ("m2", MeetingSessionStatus::Ended), + ("m3", MeetingSessionStatus::Pending), + ] { + let s = MeetingSession { + id: id.into(), + meet_url: format!("https://meet.google.com/{id}"), + title: None, + calendar_event_id: None, + status, + source: AutoJoinSource::Api, + thread_id: None, + transcript_received: false, + summary_generated: false, + created_at_ms: 1, + updated_at_ms: 1, + }; + create_session(&config, &s).unwrap(); + } + + let active = list_active_sessions(&config).unwrap(); + assert_eq!(active.len(), 2); + assert!(active + .iter() + .all(|s| s.status != MeetingSessionStatus::Ended)); + } + + #[test] + fn missing_session_returns_none() { + let (config, _dir) = test_config(); + let result = get_session(&config, "nonexistent").unwrap(); + assert!(result.is_none()); + } +} diff --git a/src/openhuman/agent_meetings/types.rs b/src/openhuman/agent_meetings/types.rs index a06f3634c..ce1b93f36 100644 --- a/src/openhuman/agent_meetings/types.rs +++ b/src/openhuman/agent_meetings/types.rs @@ -2,6 +2,85 @@ use serde::{Deserialize, Serialize}; +// --------------------------------------------------------------------------- +// Meeting session types (PR-0: #3506) +// --------------------------------------------------------------------------- + +/// Opaque meeting identifier correlating calendar event → join → transcript → thread. +pub type MeetingId = String; + +/// How the meeting join was triggered. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AutoJoinSource { + Calendar, + Manual, + Api, +} + +/// Lifecycle state of a meeting session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MeetingSessionStatus { + /// Scheduled / awaiting join. + Pending, + /// Bot has joined the call. + Joined, + /// Call is in progress with active transcription. + Active, + /// Call has ended. + Ended, +} + +/// A single tracked meeting session. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MeetingSession { + pub id: MeetingId, + pub meet_url: String, + pub title: Option, + pub calendar_event_id: Option, + pub status: MeetingSessionStatus, + pub source: AutoJoinSource, + pub thread_id: Option, + pub transcript_received: bool, + pub summary_generated: bool, + pub created_at_ms: u64, + pub updated_at_ms: u64, +} + +/// Kind of action item extracted from a meeting. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActionItemKind { + /// Can be executed via a connected tool (requires approval). + Executable, + /// Informational only — no connected tool available. + Advisory, +} + +/// A single action item extracted from a meeting transcript. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActionItem { + pub description: String, + pub kind: ActionItemKind, + pub tool_name: Option, + pub assignee: Option, +} + +/// Structured post-call summary. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MeetingSummary { + pub meeting_id: MeetingId, + pub headline: String, + pub key_points: Vec, + pub action_items: Vec, + pub generated_at_ms: u64, +} + +// --------------------------------------------------------------------------- +// Existing backend RPC types +// --------------------------------------------------------------------------- + /// Optional Rive animation color overrides. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct RiveColors { @@ -26,21 +105,23 @@ pub struct BackendMeetJoinRequest { #[serde(default)] pub system_prompt: Option, /// Selects which Rive mascot appears in the meeting (e.g. "yellow", "blue"). - /// Defaults to the backend's configured default mascot when omitted. #[serde(default)] pub mascot_id: Option, /// Optional Rive mascot color palette overrides. #[serde(default)] pub rive_colors: Option, /// Only respond to this participant's messages (empty/absent = respond to everyone). - /// Case-insensitive substring match against the speaker name in the transcript. #[serde(default)] pub respond_to_participant: Option, /// Wake phrase the participant must say before the bot responds. - /// When set, captions without this phrase are silently dropped. - /// The phrase is stripped from the text before it reaches the LLM. #[serde(default)] pub wake_phrase: Option, + /// Correlation ID linking this join to a `MeetingSession`. + #[serde(default)] + pub correlation_id: Option, + /// Join in listen-only mode (mic muted, no bot replies). + #[serde(default)] + pub listen_only: Option, } /// Outputs from `openhuman.agent_meetings_join`. @@ -63,3 +144,61 @@ pub struct BackendMeetLeaveRequest { pub struct BackendMeetHarnessResponseRequest { pub result: String, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn meeting_session_serde_round_trip() { + let session = MeetingSession { + id: "meet-123".into(), + meet_url: "https://meet.google.com/abc-defg-hij".into(), + title: Some("Standup".into()), + calendar_event_id: Some("cal-456".into()), + status: MeetingSessionStatus::Active, + source: AutoJoinSource::Calendar, + thread_id: Some("thread-789".into()), + transcript_received: true, + summary_generated: false, + created_at_ms: 1000, + updated_at_ms: 2000, + }; + let json = serde_json::to_string(&session).unwrap(); + let back: MeetingSession = serde_json::from_str(&json).unwrap(); + assert_eq!(back.id, "meet-123"); + assert_eq!(back.status, MeetingSessionStatus::Active); + assert_eq!(back.source, AutoJoinSource::Calendar); + assert!(back.transcript_received); + } + + #[test] + fn action_item_kinds_serialize() { + let exec = ActionItemKind::Executable; + let adv = ActionItemKind::Advisory; + assert_eq!(serde_json::to_string(&exec).unwrap(), "\"executable\""); + assert_eq!(serde_json::to_string(&adv).unwrap(), "\"advisory\""); + } + + #[test] + fn join_request_with_correlation_fields() { + let json = serde_json::json!({ + "meet_url": "https://meet.google.com/x", + "correlation_id": "meet-123", + "listen_only": true + }); + let req: BackendMeetJoinRequest = serde_json::from_value(json).unwrap(); + assert_eq!(req.correlation_id.as_deref(), Some("meet-123")); + assert_eq!(req.listen_only, Some(true)); + } + + #[test] + fn join_request_backward_compat_no_new_fields() { + let json = serde_json::json!({ + "meet_url": "https://meet.google.com/x" + }); + let req: BackendMeetJoinRequest = serde_json::from_value(json).unwrap(); + assert!(req.correlation_id.is_none()); + assert!(req.listen_only.is_none()); + } +} diff --git a/src/openhuman/config/mod.rs b/src/openhuman/config/mod.rs index 3ec7ac135..f5eceb998 100644 --- a/src/openhuman/config/mod.rs +++ b/src/openhuman/config/mod.rs @@ -30,27 +30,27 @@ pub use schema::{ pub use schema::{ apply_runtime_proxy_to_builder, build_runtime_proxy_client, build_runtime_proxy_client_with_timeouts, output_language_directive, runtime_proxy_config, - set_runtime_proxy_config, AgentConfig, AuditConfig, AutocompleteConfig, AutonomyConfig, - BrowserComputerUseConfig, BrowserConfig, CapabilityProviderConfig, - CapabilityProviderTrustState, ChannelsConfig, ComposioConfig, Config, ContextConfig, - CostConfig, CronConfig, CurlConfig, DashboardConfig, DelegateAgentConfig, DiagramViewerConfig, - DictationActivationMode, DictationConfig, DiscordConfig, DockerRuntimeConfig, - EmbeddingRouteConfig, GitbooksConfig, HeartbeatConfig, HttpRequestConfig, IMessageConfig, - IntegrationToggle, IntegrationsConfig, LarkConfig, LearningConfig, LlmBackend, LocalAiConfig, - MatrixConfig, McpAuthConfig, McpClientConfig, McpClientIdentityConfig, McpServerConfig, - MeetConfig, MemoryConfig, MemoryTreeConfig, ModelRouteConfig, MultimodalConfig, - MultimodalFileConfig, ObservabilityConfig, OrchestratorModelConfig, PolymarketClobCredentials, - PolymarketConfig, ProxyConfig, ProxyScope, ReflectionSource, ReliabilityConfig, - ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig, - SchedulerGateConfig, SchedulerGateMode, ScreenIntelligenceConfig, SearchConfig, SearchEngine, - SearchEngineCredentials, SearxngConfig, SecretsConfig, SecurityConfig, SlackConfig, - StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode, TeamModelConfig, - TelegramConfig, UpdateConfig, UpdateRestartStrategy, VoiceActivationMode, VoiceServerConfig, - WebSearchConfig, WebhookConfig, DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MEMORY_SYNC_INTERVAL_SECS, - DEFAULT_MODEL, MEMORY_SYNC_INTERVAL_PRESETS_SECS, MODEL_AGENTIC_V1, MODEL_CHAT_V1, - MODEL_CODING_V1, MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1, MODEL_SUMMARIZATION_V1, - SEARCH_ENGINE_BRAVE, SEARCH_ENGINE_DISABLED, SEARCH_ENGINE_MANAGED, SEARCH_ENGINE_PARALLEL, - SEARCH_ENGINE_QUERIT, + set_runtime_proxy_config, AgentConfig, AuditConfig, AutoJoinPolicy, AutoSummarizePolicy, + AutocompleteConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig, + CapabilityProviderConfig, CapabilityProviderTrustState, ChannelsConfig, ComposioConfig, Config, + ContextConfig, CostConfig, CronConfig, CurlConfig, DashboardConfig, DelegateAgentConfig, + DiagramViewerConfig, DictationActivationMode, DictationConfig, DiscordConfig, + DockerRuntimeConfig, EmbeddingRouteConfig, GitbooksConfig, HeartbeatConfig, HttpRequestConfig, + IMessageConfig, IntegrationToggle, IntegrationsConfig, LarkConfig, LearningConfig, LlmBackend, + LocalAiConfig, MatrixConfig, McpAuthConfig, McpClientConfig, McpClientIdentityConfig, + McpServerConfig, MeetConfig, MemoryConfig, MemoryTreeConfig, ModelRouteConfig, + MultimodalConfig, MultimodalFileConfig, ObservabilityConfig, OrchestratorModelConfig, + PolymarketClobCredentials, PolymarketConfig, ProxyConfig, ProxyScope, ReflectionSource, + ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, + SchedulerConfig, SchedulerGateConfig, SchedulerGateMode, ScreenIntelligenceConfig, + SearchConfig, SearchEngine, SearchEngineCredentials, SearxngConfig, SecretsConfig, + SecurityConfig, SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection, + StreamMode, TeamModelConfig, TelegramConfig, UpdateConfig, UpdateRestartStrategy, + VoiceActivationMode, VoiceServerConfig, WebSearchConfig, WebhookConfig, + DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MEMORY_SYNC_INTERVAL_SECS, DEFAULT_MODEL, + MEMORY_SYNC_INTERVAL_PRESETS_SECS, MODEL_AGENTIC_V1, MODEL_CHAT_V1, MODEL_CODING_V1, + MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1, MODEL_SUMMARIZATION_V1, SEARCH_ENGINE_BRAVE, + SEARCH_ENGINE_DISABLED, SEARCH_ENGINE_MANAGED, SEARCH_ENGINE_PARALLEL, SEARCH_ENGINE_QUERIT, }; pub use schemas::{ all_controller_schemas as all_config_controller_schemas, diff --git a/src/openhuman/config/schema/meet.rs b/src/openhuman/config/schema/meet.rs index 2fd859dcf..a6b4af65f 100644 --- a/src/openhuman/config/schema/meet.rs +++ b/src/openhuman/config/schema/meet.rs @@ -1,34 +1,75 @@ //! Google Meet integration settings. //! -//! Currently exposes a single privacy-relevant flag: -//! `auto_orchestrator_handoff` — when `true`, ending a Google Meet call -//! inside the OpenHuman webview hands the captured transcript to the -//! orchestrator agent, which may **proactively** execute tools (e.g. post -//! summaries to Slack, draft messages, schedule follow-ups). Default -//! `false` so the user must opt in before any external action fires. +//! Exposes privacy-relevant gates (`auto_orchestrator_handoff`, +//! `ingest_backend_transcripts`) and Meeting Assistant policies +//! (`auto_join_policy`, `auto_summarize_policy`, `listen_only_default`). //! -//! See issue tinyhumansai/openhuman#1299. +//! See epic tinyhumansai/openhuman#3505. use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +/// Controls whether the bot auto-joins meetings from the calendar. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AutoJoinPolicy { + /// Prompt the user before every join (default). + AskEachTime, + /// Always join without prompting. + Always, + /// Never auto-join. + Never, +} + +impl Default for AutoJoinPolicy { + fn default() -> Self { + Self::AskEachTime + } +} + +/// Controls whether post-call summaries are generated automatically. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AutoSummarizePolicy { + /// Ask the user after the call ends (default). + Ask, + /// Always generate a summary. + Always, + /// Never generate. + Never, +} + +impl Default for AutoSummarizePolicy { + fn default() -> Self { + Self::Ask + } +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(default)] pub struct MeetConfig { /// When `true`, the orchestrator agent receives the transcript of every /// completed Google Meet call as a fresh chat thread and is invited to - /// take proactive actions on it (drafting messages, scheduling - /// follow-ups, etc.). When `false` (the default), transcripts still - /// land in memory but no auto-orchestrator handoff fires. + /// take proactive actions on it. #[serde(default = "default_auto_orchestrator_handoff")] pub auto_orchestrator_handoff: bool, - /// When `true`, backend-bot (Recall.ai) meeting transcripts are ingested - /// into the memory tree after the call ends. Defaults to `false` so users - /// must explicitly opt in before meeting content is written to durable - /// memory — privacy-conservative default. + /// When `true`, backend-bot meeting transcripts are ingested into the + /// memory tree after the call ends. #[serde(default = "default_ingest_backend_transcripts")] pub ingest_backend_transcripts: bool, + + /// Whether the bot should auto-join calendar meetings with Meet links. + #[serde(default)] + pub auto_join_policy: AutoJoinPolicy, + + /// Whether to auto-generate a summary after a call ends. + #[serde(default)] + pub auto_summarize_policy: AutoSummarizePolicy, + + /// When `true`, the bot joins in listen-only mode (mic muted). + #[serde(default = "default_listen_only")] + pub listen_only_default: bool, } fn default_auto_orchestrator_handoff() -> bool { @@ -39,11 +80,18 @@ fn default_ingest_backend_transcripts() -> bool { false } +fn default_listen_only() -> bool { + true +} + impl Default for MeetConfig { fn default() -> Self { Self { auto_orchestrator_handoff: false, ingest_backend_transcripts: false, + auto_join_policy: AutoJoinPolicy::default(), + auto_summarize_policy: AutoSummarizePolicy::default(), + listen_only_default: true, } } } @@ -56,67 +104,71 @@ mod tests { #[test] fn default_disables_handoff() { let cfg = MeetConfig::default(); - assert!( - !cfg.auto_orchestrator_handoff, - "auto_orchestrator_handoff must default to false (privacy-conservative)" - ); + assert!(!cfg.auto_orchestrator_handoff); } #[test] fn default_disables_ingest_backend_transcripts() { let cfg = MeetConfig::default(); - assert!( - !cfg.ingest_backend_transcripts, - "ingest_backend_transcripts must default to false (opt-in)" - ); + assert!(!cfg.ingest_backend_transcripts); } #[test] - fn default_helper_returns_false() { - assert!(!default_auto_orchestrator_handoff()); - assert!(!default_ingest_backend_transcripts()); + fn default_auto_join_is_ask_each_time() { + let cfg = MeetConfig::default(); + assert_eq!(cfg.auto_join_policy, AutoJoinPolicy::AskEachTime); } #[test] - fn deserialize_missing_optional_fields_uses_defaults() { + fn default_auto_summarize_is_ask() { + let cfg = MeetConfig::default(); + assert_eq!(cfg.auto_summarize_policy, AutoSummarizePolicy::Ask); + } + + #[test] + fn default_listen_only_is_true() { + let cfg = MeetConfig::default(); + assert!(cfg.listen_only_default); + } + + #[test] + fn deserialize_missing_fields_uses_defaults() { let cfg: MeetConfig = serde_json::from_value(json!({})).unwrap(); - assert!( - !cfg.auto_orchestrator_handoff, - "missing field must deserialize to false" - ); - assert!( - !cfg.ingest_backend_transcripts, - "missing field must deserialize to false" - ); + assert!(!cfg.auto_orchestrator_handoff); + assert!(!cfg.ingest_backend_transcripts); + assert_eq!(cfg.auto_join_policy, AutoJoinPolicy::AskEachTime); + assert_eq!(cfg.auto_summarize_policy, AutoSummarizePolicy::Ask); + assert!(cfg.listen_only_default); } #[test] - fn deserialize_respects_explicit_handoff_flag() { + fn deserialize_explicit_policies() { let cfg: MeetConfig = serde_json::from_value(json!({ - "auto_orchestrator_handoff": true + "auto_join_policy": "always", + "auto_summarize_policy": "never", + "listen_only_default": false })) .unwrap(); - assert!(cfg.auto_orchestrator_handoff); + assert_eq!(cfg.auto_join_policy, AutoJoinPolicy::Always); + assert_eq!(cfg.auto_summarize_policy, AutoSummarizePolicy::Never); + assert!(!cfg.listen_only_default); } #[test] - fn deserialize_respects_ingest_backend_transcripts_flag() { - let cfg: MeetConfig = serde_json::from_value(json!({ - "ingest_backend_transcripts": true - })) - .unwrap(); - assert!(cfg.ingest_backend_transcripts); - } - - #[test] - fn round_trip_preserves_handoff_flag() { + fn round_trip_preserves_all_fields() { let original = MeetConfig { auto_orchestrator_handoff: true, ingest_backend_transcripts: true, + auto_join_policy: AutoJoinPolicy::Never, + auto_summarize_policy: AutoSummarizePolicy::Always, + listen_only_default: false, }; let s = serde_json::to_string(&original).unwrap(); let back: MeetConfig = serde_json::from_str(&s).unwrap(); assert!(back.auto_orchestrator_handoff); assert!(back.ingest_backend_transcripts); + assert_eq!(back.auto_join_policy, AutoJoinPolicy::Never); + assert_eq!(back.auto_summarize_policy, AutoSummarizePolicy::Always); + assert!(!back.listen_only_default); } } diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index c82175e4b..452321a83 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -65,7 +65,7 @@ pub use heartbeat_cron::{CronConfig, HeartbeatConfig, SubconsciousMode}; pub use identity_cost::{CostConfig, ModelPricing}; pub use learning::{LearningConfig, ReflectionSource}; pub use local_ai::{LocalAiConfig, LocalAiUsage}; -pub use meet::MeetConfig; +pub use meet::{AutoJoinPolicy, AutoSummarizePolicy, MeetConfig}; pub use node::NodeConfig; pub use observability::ObservabilityConfig; pub use proxy::{ diff --git a/src/openhuman/notifications/bus.rs b/src/openhuman/notifications/bus.rs index 289c8582f..908448f99 100644 --- a/src/openhuman/notifications/bus.rs +++ b/src/openhuman/notifications/bus.rs @@ -79,6 +79,7 @@ pub fn event_to_notification(event: &DomainEvent) -> Option Option Option Option Option None, diff --git a/src/openhuman/notifications/bus_tests.rs b/src/openhuman/notifications/bus_tests.rs index 8ce1ca8eb..1a8c19452 100644 --- a/src/openhuman/notifications/bus_tests.rs +++ b/src/openhuman/notifications/bus_tests.rs @@ -206,6 +206,7 @@ fn publish_and_subscribe_deliver_event() { body: "Test body".into(), deep_link: None, timestamp_ms: 0, + actions: None, }; let sent = publish_core_notification(evt.clone()); @@ -241,6 +242,7 @@ fn publish_with_no_subscribers_does_not_panic() { body: "nobody is listening".into(), deep_link: None, timestamp_ms: 42, + actions: None, }); // count is 0 when no subscribers, but the call itself must not panic. let _ = count; diff --git a/src/openhuman/notifications/types.rs b/src/openhuman/notifications/types.rs index 7af264933..8062597bb 100644 --- a/src/openhuman/notifications/types.rs +++ b/src/openhuman/notifications/types.rs @@ -39,6 +39,23 @@ pub struct CoreNotificationEvent { pub deep_link: Option, /// Wall-clock milliseconds since the unix epoch at publish time. pub timestamp_ms: u64, + /// Optional action buttons displayed alongside the notification. + /// Backward-compatible: old events without this field deserialize to `None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actions: Option>, +} + +/// A single action button attached to a notification. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CoreNotificationAction { + /// Machine-readable identifier for this action (e.g. `"approve"`, `"dismiss"`). + pub action_id: String, + /// Human-readable button label. + pub label: String, + /// Opaque payload forwarded back when the user clicks the button. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payload: Option, } // --------------------------------------------------------------------------- @@ -162,3 +179,60 @@ pub struct NotificationSettingsUpsertRequest { pub importance_threshold: f32, pub route_to_orchestrator: bool, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn core_notification_backward_compat_no_actions() { + let json = json!({ + "id": "test-1", + "category": "system", + "title": "Hello", + "body": "World", + "timestamp_ms": 123456 + }); + let event: CoreNotificationEvent = serde_json::from_value(json).unwrap(); + assert!(event.actions.is_none()); + assert!(event.deep_link.is_none()); + } + + #[test] + fn core_notification_with_actions() { + let json = json!({ + "id": "test-2", + "category": "meetings", + "title": "Join call?", + "body": "Standup in 5 min", + "timestamp_ms": 999, + "actions": [ + {"actionId": "yes", "label": "Yes"}, + {"actionId": "no", "label": "No", "payload": {"meeting_id": "m1"}} + ] + }); + let event: CoreNotificationEvent = serde_json::from_value(json).unwrap(); + let actions = event.actions.unwrap(); + assert_eq!(actions.len(), 2); + assert_eq!(actions[0].action_id, "yes"); + assert!(actions[0].payload.is_none()); + assert_eq!(actions[1].action_id, "no"); + assert!(actions[1].payload.is_some()); + } + + #[test] + fn core_notification_serialize_skips_empty_actions() { + let event = CoreNotificationEvent { + id: "x".into(), + category: CoreNotificationCategory::System, + title: "t".into(), + body: "b".into(), + deep_link: None, + timestamp_ms: 1, + actions: None, + }; + let s = serde_json::to_string(&event).unwrap(); + assert!(!s.contains("actions")); + } +} diff --git a/src/openhuman/subconscious/heartbeat/planner/mod.rs b/src/openhuman/subconscious/heartbeat/planner/mod.rs index d97e4353a..302d51e34 100644 --- a/src/openhuman/subconscious/heartbeat/planner/mod.rs +++ b/src/openhuman/subconscious/heartbeat/planner/mod.rs @@ -152,6 +152,7 @@ pub async fn evaluate_and_dispatch(config: &Config, now: DateTime) -> Plann body: plan.body, deep_link: event.deep_link.clone(), timestamp_ms: now.timestamp_millis().max(0) as u64, + actions: None, }); if config.heartbeat.external_delivery_enabled && plan.allow_external {