mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(meetings): add config, types, store, and notification actions for Meeting Assistant (#3516)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user