feat(meetings): add config, types, store, and notification actions for Meeting Assistant (#3516)

This commit is contained in:
Sathvik Gilakamsetty
2026-06-08 10:08:19 -07:00
committed by GitHub
parent f891fb1508
commit 8de862c139
11 changed files with 722 additions and 74 deletions
+7
View File
@@ -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 {
+3 -1
View File
@@ -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::{
+366
View File
@@ -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<T>(config: &Config, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
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<MeetingSession> {
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<Option<MeetingSession>> {
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<Option<MeetingSession>> {
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<Vec<MeetingSession>> {
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::<std::result::Result<Vec<_>, _>>()
.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());
}
}
+143 -4
View File
@@ -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<String>,
pub calendar_event_id: Option<String>,
pub status: MeetingSessionStatus,
pub source: AutoJoinSource,
pub thread_id: Option<String>,
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<String>,
pub assignee: Option<String>,
}
/// Structured post-call summary.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeetingSummary {
pub meeting_id: MeetingId,
pub headline: String,
pub key_points: Vec<String>,
pub action_items: Vec<ActionItem>,
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<String>,
/// 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<String>,
/// Optional Rive mascot color palette overrides.
#[serde(default)]
pub rive_colors: Option<RiveColors>,
/// 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<String>,
/// 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<String>,
/// Correlation ID linking this join to a `MeetingSession`.
#[serde(default)]
pub correlation_id: Option<String>,
/// Join in listen-only mode (mic muted, no bot replies).
#[serde(default)]
pub listen_only: Option<bool>,
}
/// 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());
}
}
+21 -21
View File
@@ -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,
+99 -47
View File
@@ -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);
}
}
+1 -1
View File
@@ -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::{
+5
View File
@@ -79,6 +79,7 @@ pub fn event_to_notification(event: &DomainEvent) -> Option<CoreNotificationEven
},
deep_link: Some("/settings/cron-jobs".into()),
timestamp_ms: ts,
actions: None,
}),
DomainEvent::WebhookProcessed {
skill_id,
@@ -105,6 +106,7 @@ pub fn event_to_notification(event: &DomainEvent) -> Option<CoreNotificationEven
},
deep_link: Some("/settings/webhooks-triggers".into()),
timestamp_ms: ts,
actions: None,
})
}
DomainEvent::SubagentCompleted {
@@ -120,6 +122,7 @@ pub fn event_to_notification(event: &DomainEvent) -> Option<CoreNotificationEven
body: format!("{agent_id} produced {output_chars} chars of output."),
deep_link: Some("/chat".into()),
timestamp_ms: ts,
actions: None,
}),
DomainEvent::SubagentFailed {
parent_session,
@@ -136,6 +139,7 @@ pub fn event_to_notification(event: &DomainEvent) -> Option<CoreNotificationEven
),
deep_link: Some("/chat".into()),
timestamp_ms: ts,
actions: None,
}),
DomainEvent::NotificationTriaged {
id,
@@ -162,6 +166,7 @@ pub fn event_to_notification(event: &DomainEvent) -> Option<CoreNotificationEven
},
deep_link: Some("/notifications".into()),
timestamp_ms: ts,
actions: None,
})
}
_ => None,
+2
View File
@@ -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;
+74
View File
@@ -39,6 +39,23 @@ pub struct CoreNotificationEvent {
pub deep_link: Option<String>,
/// 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<Vec<CoreNotificationAction>>,
}
/// 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<serde_json::Value>,
}
// ---------------------------------------------------------------------------
@@ -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"));
}
}
@@ -152,6 +152,7 @@ pub async fn evaluate_and_dispatch(config: &Config, now: DateTime<Utc>) -> 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 {