mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
* feat(webview_accounts): native OS notifications from embedded webviews (#714) Forward CEF notification intercept payloads to tauri-plugin-notification, prefixing the title with the provider label so the source of each toast is obvious at a glance. Honour `silent` (skip toast, still record route), `icon` (passed through to the native builder), and `tag` (used as the dedup key, with a monotonic timestamp fallback for untagged payloads). Record a NotificationRoute keyed by `{provider}:{account_id}:{tag_or_uuid}` so a future click hook (UNUserNotificationCenter / notify-rust on_response) can route the OS click back to the source account. Entries are cleared on webview_account_close / _purge to bound map growth. Expose webview_notification_permission_state / _request commands mapping tauri::plugin::PermissionState onto the web API triple. Non-cef stubs return "default" so the frontend can call the same invoke names on both runtimes. Wire notification:allow-* capabilities so the plugin can be invoked from the webview. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(accounts): wire notification permission + click bridge (#714) Round-trip the OS notification permission once per session on first account open via the new invoke pair. Attach a dormant notification:click listener that dispatches setActiveAccount and brings the main window to front when a platform click hook starts emitting the event — contract matches the Rust NotificationRoute shape so the emit side is a one-liner. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: sync Cargo.lock to 0.52.26 after version bump Lockfile picked up the pending 0.52.26 version bump from Cargo.toml while building the notification feature. No dependency graph change. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(notifications): add notification bypass for embedded webview apps (#679) - Add NotificationBypassPrefs (global DND, per-account mute, bypass-when-focused) to WebviewAccountsState with thread-safe AtomicBool window focus tracking - Evaluate all three bypass conditions inside forward_native_notification before showing OS toast; each suppression path logs at debug with [notify-bypass] prefix - Add four new Tauri commands: webview_notification_set_dnd, webview_notification_mute_account, webview_notification_get_bypass_prefs, webview_set_focused_account - Wire window focus tracking in setup hook via on_window_event Focused handler - Frontend: add setAccountMuted, setGlobalDnd, getBypassPrefs, setFocusedAccount helpers in webviewAccountService; sync focused account on open + click - Add NotificationsPanel settings page with Global DND toggle - Register NotificationsPanel at /settings/notifications Closes #679 * feat(notifications): integrate notifications feature into app - Added Notifications page and routing to AppRoutes. - Introduced NotificationRoutingPanel in Settings for managing notification settings. - Updated SettingsHome to include navigation for notification routing. - Integrated notifications reducer into the store for state management. - Enhanced Rust backend to support notification handling from embedded webviews. This commit lays the groundwork for a comprehensive notification system within the application. * refactor(notifications): clean up code formatting and structure - Simplified JSX structure in NotificationCard for better readability. - Consolidated fetchNotifications call in NotificationCenter for cleaner syntax. - Improved formatting in NotificationRoutingPanel and notificationsSlice for consistency. - Enhanced Rust code readability by streamlining function signatures and logic. These changes enhance code maintainability and readability across the notifications feature. * refactor(webview_accounts): simplify webview_notification_set_dnd function signature - Removed unnecessary line breaks in the webview_notification_set_dnd function for improved readability. * feat(notifications): implement provider-level notification settings management - Added `getNotificationSettings` and `setNotificationSettings` functions to manage notification settings for providers. - Enhanced `NotificationRoutingPanel` to display and update settings for Gmail, Slack, Discord, and WhatsApp. - Introduced new RPC endpoints for retrieving and updating notification settings. - Updated database schema to store notification settings persistently. This commit establishes a robust system for managing notification preferences, improving user control over notifications. * refactor(notifications): improve code formatting and readability - Enhanced formatting in NotificationRoutingPanel for better clarity. - Streamlined function signatures in notificationService and Rust backend. - Improved readability of assertions in tests by adjusting line breaks. These changes contribute to a more maintainable and comprehensible codebase for the notifications feature. * chore(vendor): bump tauri-cef to fix Slack notification permission banner Updates the tauri-cef submodule to 55db2d6 which adds a navigator.permissions.query shim in the CEF render process. Slack checks this API (not just Notification.permission) to decide whether to show its "needs your permission" banner — the shim returns "granted" for notifications queries so the banner no longer appears. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(vendor): bump tauri-cef for cargo fmt fixes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(vendor): bump tauri-cef — native V8 permissions.query shim Switches from context.eval() to a proper PermissionsQueryV8Handler so the navigator.permissions.query fix actually runs in on_context_created. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(notifications): patch navigator.permissions.query in ua_spoof.js The V8 set_value_bykey approach in cef-helper's on_context_created does not stick on CEF platform objects (Chromium's V8 binding layer silently ignores property writes on native wrappers like Permissions). The init script path via frame.execute_java_script runs in the fully-initialised JS context where navigator.permissions IS writable, matching how ua_spoof.js already overrides navigator.userAgent successfully. Slack checks navigator.permissions.query({ name: 'notifications' }) before showing its "needs permission" banner — patching it here to return "granted" removes the banner. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(notifications): use Object.defineProperty to shim navigator.permissions Two-layer fix for the Slack "needs permission to enable notifications" banner: 1. cef-helper (submodule update to 99a2686): context.eval() in on_context_created installs Object.defineProperty(navigator, 'permissions', ...) before any page JS runs. 2. ua_spoof.js: same Object.defineProperty pattern as belt-and-suspenders for frames that reload or trigger permission checks after on_load_end. Simple property assignment on Blink platform objects is silently ignored; Object.defineProperty on the navigator wrapper itself (the same mechanism already used for navigator.userAgent) works correctly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(notifications): address CodeRabbit review issues on PR #727 - Move raw_title out of log::info! (PII risk) — log title_chars at info, raw_title at debug only - Fix permissionChecked set before async invoke in ensureNotificationPermission so transient failures allow retry on next account open * fix(cef): enable webview-data-url feature for CEF placeholder URL The CEF backend uses a data: URL as the initial webview location so CDP can attach before the real provider URL loads. Tauri's add_child rejects data: URLs unless the webview-data-url feature is enabled. * fix(notifications): address remaining CodeRabbit issues on PR #727 - cdp/emulation: bump Chrome UA 124→136 to pass Slack browser check - cdp/session: inject Page.addScriptToEvaluateOnNewDocument to stub Notification.permission as "granted" and silence provider banners - notifications/mod.rs + core/all.rs: wire notifications domain into the controller registry (fixes unknown-method in json_rpc_e2e tests) - notifications/schemas: add skipped bool output to ingest schema - notifications/store: add tracing::warn on datetime parse failure - notificationService: union return type for ingestNotification - webviewAccountService: narrow union before accessing result.id - NotificationCenter: drive loading/error from fetch effect; track allProviders separately so filter pills don't collapse on selection - NotificationRoutingPanel: rollback optimistic update on save failure - useSettingsNavigation: add notifications/notification-routing routes - scripts/install.sh: remove silent dry-run exit 0 on asset failure - scripts/setup-dev-codesign.sh: remove unconditional -legacy flag - docs/SUMMARY.md: remove worktree path, fix macOS capitalisation, remove self-referential deletion note * chore: apply prettier + cargo fmt + fix useEffect dep warning Auto-apply formatting changes flagged by the pre-push hook: - prettier reformatted NotificationCenter.tsx and notificationService.ts - cargo fmt reformatted all.rs, openhuman/mod.rs, notifications/schemas.rs - NotificationRoutingPanel: move providers array to module scope so useEffect dependency array is satisfied without exhaustive-deps warning * feat(notifications): enhance notification management and permissions - Added new commands for managing notification preferences, including setting global Do Not Disturb (DND), muting specific accounts, and retrieving current bypass preferences. - Implemented a notification permission state handler to ensure consistent behavior across different environments. - Updated the JavaScript shim for notification permissions to handle both Notification and PushManager states, ensuring compatibility with various providers. - Refactored the WebviewAccountsState to include a new structure for managing notification bypass preferences, improving the overall notification handling logic. * update agents * fix(notifications): complete schema + navigation metadata for ingest/settings routes - app/src/components/settings/hooks/useSettingsNavigation.ts: resolve the new `/settings/notifications` and `/settings/notification-routing` URLs to their SettingsRoute values and feed them into breadcrumbs so the new panels don't silently fall through to `'home'`. Addresses CodeRabbit on useSettingsNavigation.ts:34. - src/openhuman/notifications/schemas.rs: add the optional `reason` output on `notification.ingest` (populated alongside `skipped=true` by the runtime) and the normalized `settings` output on `notification.settings_set` so schema-driven clients see the full response shape. Addresses CodeRabbit on schemas.rs:103 and schemas.rs:217. - src/core/all.rs: add a `notification` namespace_description so CLI help covers the new controllers, plus a test assertion. Addresses CodeRabbit on src/core/all.rs:149. * fix(notifications): trace DB entry, surface empty update matches, warn on bad scored_at - Add `tracing::trace!` checkpoints around the `with_connection` DB open and schema migration so notification-delivery issues are reconstructible from logs. - `update_triage` and `mark_read` now inspect `Connection::execute`'s affected-row count: log a `warn!` when the update matched zero rows (row deleted between ingest and scoring / client passed a stale id), `debug!` on the normal path. - `scored_at` parsing no longer silently drops malformed values — log a `warn!` with the raw value and parse error before treating the row as unscored, matching the existing behavior for `received_at`. Addresses CodeRabbit on store.rs (lines 72, 172, 294). * fix(webview): respect silent notifications, multi-host CDP fallback, shim idempotency - webview_accounts/mod.rs: honor the Web Notification `silent` flag. Previously we only logged it and still called `builder.show()`, so pages that marked a notification silent still produced an OS toast. Mirror event still fires so the in-app center updates; only the OS toast is suppressed. Also picks up a prior cargo-fmt rewrap. - cdp/target.rs: `browser_ws_url()` now continues the host loop when `resp.json()` fails instead of early-returning via `?`. A malformed response from the first host (CDP_HOST) no longer prevents the `localhost` fallback from being tried. - webview_accounts/ua_spoof.js: guard the Notification wrapper behind `window.__OH_NOTIF_SHIM` so repeated evaluations of the script (Page.addScriptToEvaluateOnNewDocument + frame-level re-injections) don't stack wrappers onto the same page globals or re-proxy `Function.prototype.toString`. Addresses CodeRabbit on webview_accounts/mod.rs:377, cdp/target.rs:33, and ua_spoof.js:176. * update agents * update --------- Co-authored-by: oxoxDev <nikhil@tinyhumans.ai> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
oxoxDev
Claude Opus 4.6
Steven Enamakel
parent
340cbc04f2
commit
3bb714bf96
@@ -147,6 +147,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
|
||||
controllers.extend(
|
||||
crate::openhuman::webview_notifications::all_webview_notifications_registered_controllers(),
|
||||
);
|
||||
// Integration notification ingest, triage, and per-provider settings
|
||||
controllers.extend(crate::openhuman::notifications::all_notifications_registered_controllers());
|
||||
controllers
|
||||
}
|
||||
|
||||
@@ -200,6 +202,8 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas.extend(
|
||||
crate::openhuman::webview_notifications::all_webview_notifications_controller_schemas(),
|
||||
);
|
||||
// Integration notification ingest, triage, and per-provider settings
|
||||
schemas.extend(crate::openhuman::notifications::all_notifications_controller_schemas());
|
||||
schemas
|
||||
}
|
||||
|
||||
@@ -266,6 +270,10 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
|
||||
"learning" => Some(
|
||||
"User context enrichment — LinkedIn profile scraping and onboarding intelligence.",
|
||||
),
|
||||
"notification" => Some(
|
||||
"Integration notification ingest, triage scoring, listing, read-state, \
|
||||
and per-provider routing settings.",
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -535,6 +543,7 @@ mod tests {
|
||||
assert!(namespace_description("health").is_some());
|
||||
assert!(namespace_description("voice").is_some());
|
||||
assert!(namespace_description("webhooks").is_some());
|
||||
assert!(namespace_description("notification").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,6 +19,14 @@ pub enum TriggerSource {
|
||||
/// socket.io bridge. `toolkit` is the slug like `"gmail"`;
|
||||
/// `trigger` is the slug like `"GMAIL_NEW_GMAIL_MESSAGE"`.
|
||||
Composio { toolkit: String, trigger: String },
|
||||
/// A notification captured from an embedded webview integration
|
||||
/// (WhatsApp Web, Gmail, Slack, …) via the recipe event pipeline.
|
||||
/// `provider` is the slug like `"gmail"`; `account_id` is the
|
||||
/// webview account identifier.
|
||||
WebviewIntegration {
|
||||
provider: String,
|
||||
account_id: String,
|
||||
},
|
||||
// Cron / Webhook / … variants will be added in later commits as
|
||||
// those callers wire up the triage pipeline.
|
||||
}
|
||||
@@ -29,6 +37,7 @@ impl TriggerSource {
|
||||
pub fn slug(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Composio { .. } => "composio",
|
||||
Self::WebviewIntegration { .. } => "webview",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +132,7 @@ mod tests {
|
||||
assert_eq!(toolkit, "gmail");
|
||||
assert_eq!(trigger, "GMAIL_NEW_GMAIL_MESSAGE");
|
||||
}
|
||||
_ => panic!("expected Composio source"),
|
||||
}
|
||||
assert_eq!(env.payload["from"], "a@b.com");
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ pub mod local_ai;
|
||||
pub mod memory;
|
||||
pub mod migration;
|
||||
pub mod node_runtime;
|
||||
pub mod notifications;
|
||||
pub mod overlay;
|
||||
pub mod providers;
|
||||
pub mod referral;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Integration notification domain.
|
||||
//!
|
||||
//! Captures notifications from embedded webview integrations (WhatsApp Web,
|
||||
//! Gmail, Slack, …), runs them through the triage LLM pipeline, and stores
|
||||
//! them in a unified notification center accessible via the RPC surface.
|
||||
//!
|
||||
//! ## Module layout
|
||||
//!
|
||||
//! - [`types`] — `IntegrationNotification`, `NotificationStatus`, request/response types
|
||||
//! - [`store`] — SQLite persistence (one DB per workspace)
|
||||
//! - [`rpc`] — Async RPC handler functions: ingest, list, mark_read
|
||||
//! - [`schemas`] — Controller schema definitions and registered handler wrappers
|
||||
|
||||
pub mod rpc;
|
||||
pub mod schemas;
|
||||
pub mod store;
|
||||
pub mod types;
|
||||
|
||||
pub use schemas::{
|
||||
all_controller_schemas as all_notifications_controller_schemas,
|
||||
all_registered_controllers as all_notifications_registered_controllers,
|
||||
};
|
||||
pub use types::*;
|
||||
@@ -0,0 +1,256 @@
|
||||
//! JSON-RPC handler functions for the notifications domain.
|
||||
//!
|
||||
//! Three endpoints:
|
||||
//! - `notification_ingest` — write a new notification, kick off background triage
|
||||
//! - `notifications_list` — paginated query with optional provider / min-score filters
|
||||
//! - `notification_mark_read`— mark a single notification as read
|
||||
|
||||
use chrono::Utc;
|
||||
use serde_json::{json, Map, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::openhuman::agent::triage::{apply_decision, run_triage, TriggerEnvelope, TriggerSource};
|
||||
use crate::openhuman::config::rpc as config_rpc;
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
use super::store;
|
||||
use super::types::{
|
||||
IntegrationNotification, NotificationIngestRequest, NotificationSettings,
|
||||
NotificationSettingsUpsertRequest, NotificationStatus,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// notification_ingest
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Ingest a new notification from an embedded webview integration.
|
||||
///
|
||||
/// Writes the record immediately, returns the new `id`, then spawns a
|
||||
/// background task to run the triage pipeline and back-fill the score.
|
||||
pub async fn handle_ingest(params: Map<String, Value>) -> Result<Value, String> {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
|
||||
let req: NotificationIngestRequest = serde_json::from_value(Value::Object(params.clone()))
|
||||
.map_err(|e| format!("[notifications::rpc] invalid ingest params: {e}"))?;
|
||||
|
||||
let provider_settings = store::get_settings(&config, &req.provider)
|
||||
.map_err(|e| format!("[notifications::rpc] get_settings failed: {e}"))?;
|
||||
if !provider_settings.enabled {
|
||||
let outcome = RpcOutcome::new(
|
||||
json!({ "skipped": true, "reason": "provider_disabled" }),
|
||||
vec![],
|
||||
);
|
||||
return outcome.into_cli_compatible_json();
|
||||
}
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let notification = IntegrationNotification {
|
||||
id: id.clone(),
|
||||
provider: req.provider.clone(),
|
||||
account_id: req.account_id.clone(),
|
||||
title: req.title.clone(),
|
||||
body: req.body.clone(),
|
||||
raw_payload: req.raw_payload.clone(),
|
||||
importance_score: None,
|
||||
triage_action: None,
|
||||
triage_reason: None,
|
||||
status: NotificationStatus::Unread,
|
||||
received_at: Utc::now(),
|
||||
scored_at: None,
|
||||
};
|
||||
|
||||
store::insert(&config, ¬ification)
|
||||
.map_err(|e| format!("[notifications::rpc] insert failed: {e}"))?;
|
||||
|
||||
tracing::debug!(
|
||||
id = %id,
|
||||
provider = %req.provider,
|
||||
"[notifications::rpc] ingested notification, spawning triage"
|
||||
);
|
||||
|
||||
// Spawn background triage — the ingest RPC returns immediately.
|
||||
let id_for_triage = id.clone();
|
||||
let config_for_triage = config.clone();
|
||||
tokio::spawn(async move {
|
||||
let envelope = TriggerEnvelope {
|
||||
source: TriggerSource::WebviewIntegration {
|
||||
provider: req.provider.clone(),
|
||||
account_id: req.account_id.clone().unwrap_or_default(),
|
||||
},
|
||||
external_id: id_for_triage.clone(),
|
||||
display_label: format!(
|
||||
"webview/{}/{}",
|
||||
req.provider,
|
||||
req.account_id.as_deref().unwrap_or("default")
|
||||
),
|
||||
payload: serde_json::json!({
|
||||
"title": req.title,
|
||||
"body": req.body,
|
||||
"raw": req.raw_payload,
|
||||
}),
|
||||
received_at: Utc::now(),
|
||||
};
|
||||
|
||||
match run_triage(&envelope).await {
|
||||
Ok(triage_run) => {
|
||||
let action = triage_run.decision.action.as_str().to_string();
|
||||
let reason = triage_run.decision.reason.clone();
|
||||
// Map TriageAction → importance score heuristic.
|
||||
let score = triage_action_to_score(triage_run.decision.action);
|
||||
|
||||
tracing::info!(
|
||||
id = %id_for_triage,
|
||||
action = %action,
|
||||
score = score,
|
||||
"[notifications::rpc] triage complete"
|
||||
);
|
||||
|
||||
if let Err(e) = store::update_triage(
|
||||
&config_for_triage,
|
||||
&id_for_triage,
|
||||
score,
|
||||
&action,
|
||||
&reason,
|
||||
) {
|
||||
tracing::warn!(
|
||||
id = %id_for_triage,
|
||||
error = %e,
|
||||
"[notifications::rpc] failed to persist triage result"
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-escalate high-importance notifications to the orchestrator.
|
||||
if (action == "escalate" || action == "react")
|
||||
&& score >= provider_settings.importance_threshold
|
||||
&& provider_settings.route_to_orchestrator
|
||||
{
|
||||
if let Err(e) = apply_decision(triage_run, &envelope).await {
|
||||
tracing::warn!(
|
||||
id = %id_for_triage,
|
||||
error = %e,
|
||||
"[notifications::rpc] apply_decision failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
id = %id_for_triage,
|
||||
error = %e,
|
||||
"[notifications::rpc] triage pipeline failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let outcome = RpcOutcome::new(json!({ "id": id, "skipped": false }), vec![]);
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// notifications_list
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Return paginated notifications.
|
||||
///
|
||||
/// Optional params: `provider` (string), `limit` (u64), `offset` (u64),
|
||||
/// `min_score` (f64).
|
||||
pub async fn handle_list(params: Map<String, Value>) -> Result<Value, String> {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
|
||||
let provider = params
|
||||
.get("provider")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
let limit = params
|
||||
.get("limit")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as usize)
|
||||
.unwrap_or(50);
|
||||
let offset = params
|
||||
.get("offset")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as usize)
|
||||
.unwrap_or(0);
|
||||
let min_score = params
|
||||
.get("min_score")
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|v| v as f32);
|
||||
|
||||
let items = store::list(&config, limit, offset, provider.as_deref(), min_score)
|
||||
.map_err(|e| format!("[notifications::rpc] list failed: {e}"))?;
|
||||
|
||||
let unread = store::unread_count(&config)
|
||||
.map_err(|e| format!("[notifications::rpc] unread_count failed: {e}"))?;
|
||||
|
||||
let outcome = RpcOutcome::new(json!({ "items": items, "unread_count": unread }), vec![]);
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// notification_mark_read
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Mark a single notification as read.
|
||||
pub async fn handle_mark_read(params: Map<String, Value>) -> Result<Value, String> {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
|
||||
let id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "[notifications::rpc] missing required param 'id'".to_string())?
|
||||
.to_string();
|
||||
|
||||
store::mark_read(&config, &id)
|
||||
.map_err(|e| format!("[notifications::rpc] mark_read failed: {e}"))?;
|
||||
|
||||
tracing::debug!(id = %id, "[notifications::rpc] marked read");
|
||||
|
||||
let outcome = RpcOutcome::new(json!({ "ok": true }), vec![]);
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
/// Read notification routing settings for a provider.
|
||||
pub async fn handle_settings_get(params: Map<String, Value>) -> Result<Value, String> {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let provider = params
|
||||
.get("provider")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "[notifications::rpc] missing required param 'provider'".to_string())?;
|
||||
let settings = store::get_settings(&config, provider)
|
||||
.map_err(|e| format!("[notifications::rpc] settings_get failed: {e}"))?;
|
||||
let outcome = RpcOutcome::new(json!({ "settings": settings }), vec![]);
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
/// Upsert notification routing settings for a provider.
|
||||
pub async fn handle_settings_set(params: Map<String, Value>) -> Result<Value, String> {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let req: NotificationSettingsUpsertRequest = serde_json::from_value(Value::Object(params))
|
||||
.map_err(|e| format!("[notifications::rpc] invalid settings_set params: {e}"))?;
|
||||
let clamped = NotificationSettings {
|
||||
provider: req.provider,
|
||||
enabled: req.enabled,
|
||||
importance_threshold: req.importance_threshold.clamp(0.0, 1.0),
|
||||
route_to_orchestrator: req.route_to_orchestrator,
|
||||
};
|
||||
store::upsert_settings(&config, &clamped)
|
||||
.map_err(|e| format!("[notifications::rpc] settings_set failed: {e}"))?;
|
||||
let outcome = RpcOutcome::new(json!({ "ok": true, "settings": clamped }), vec![]);
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Map the triage decision to a 0.0–1.0 importance score so the frontend
|
||||
/// can sort/filter without understanding triage action semantics.
|
||||
fn triage_action_to_score(action: crate::openhuman::agent::triage::TriageAction) -> f32 {
|
||||
use crate::openhuman::agent::triage::TriageAction;
|
||||
match action {
|
||||
TriageAction::Drop => 0.1,
|
||||
TriageAction::Acknowledge => 0.35,
|
||||
TriageAction::React => 0.65,
|
||||
TriageAction::Escalate => 0.9,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
//! Controller schema definitions and registered handlers for the
|
||||
//! `notifications` domain.
|
||||
//!
|
||||
//! Follows the exact pattern from `src/openhuman/cron/schemas.rs`.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Schema registry
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![
|
||||
schemas("ingest"),
|
||||
schemas("list"),
|
||||
schemas("mark_read"),
|
||||
schemas("settings_get"),
|
||||
schemas("settings_set"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
vec![
|
||||
RegisteredController {
|
||||
schema: schemas("ingest"),
|
||||
handler: handle_ingest_wrap,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("list"),
|
||||
handler: handle_list_wrap,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("mark_read"),
|
||||
handler: handle_mark_read_wrap,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("settings_get"),
|
||||
handler: handle_settings_get_wrap,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("settings_set"),
|
||||
handler: handle_settings_set_wrap,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn schemas(function: &str) -> ControllerSchema {
|
||||
match function {
|
||||
"ingest" => ControllerSchema {
|
||||
namespace: "notification",
|
||||
function: "ingest",
|
||||
description: "Ingest a new notification from an embedded webview integration. \
|
||||
Immediately persists the record and kicks off background triage scoring.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "provider",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Provider slug, e.g. \"gmail\", \"slack\", \"whatsapp\".",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "account_id",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Webview account identifier (optional).",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "title",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Short notification title / subject.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "body",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Notification body or preview text.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "raw_payload",
|
||||
ty: TypeSchema::Ref("JsonObject"),
|
||||
comment: "Full raw event payload from the source for downstream use.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "id",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "UUID of the newly created notification record. Absent when skipped.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "skipped",
|
||||
ty: TypeSchema::Bool,
|
||||
comment:
|
||||
"True when the provider is disabled and the notification was not stored.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "reason",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Human-readable reason populated alongside `skipped=true` \
|
||||
(e.g. \"provider_disabled\").",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
"list" => ControllerSchema {
|
||||
namespace: "notification",
|
||||
function: "list",
|
||||
description: "Return a paginated list of ingested notifications with optional \
|
||||
provider and minimum-importance-score filters.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "provider",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Filter by provider slug. Omit to return all providers.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "limit",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
||||
comment: "Maximum number of records to return; defaults to 50.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "offset",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
||||
comment: "Number of records to skip for pagination; defaults to 0.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "min_score",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::F64)),
|
||||
comment: "Minimum importance score 0.0–1.0. Unscored items pass through.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "items",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("IntegrationNotification"))),
|
||||
comment: "Notification records ordered by received_at descending.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "unread_count",
|
||||
ty: TypeSchema::I64,
|
||||
comment: "Total count of unread notifications across all providers.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
"mark_read" => ControllerSchema {
|
||||
namespace: "notification",
|
||||
function: "mark_read",
|
||||
description: "Mark a single notification as read by its id.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "UUID of the notification to mark as read.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "ok",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when the update succeeded.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"settings_get" => ControllerSchema {
|
||||
namespace: "notification",
|
||||
function: "settings_get",
|
||||
description: "Get provider-level notification routing settings.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "provider",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Provider slug, e.g. \"gmail\".",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "settings",
|
||||
ty: TypeSchema::Ref("NotificationSettings"),
|
||||
comment: "Current settings for provider, defaulted if missing.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"settings_set" => ControllerSchema {
|
||||
namespace: "notification",
|
||||
function: "settings_set",
|
||||
description: "Upsert provider-level notification routing settings.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "provider",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Provider slug, e.g. \"gmail\".",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "enabled",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "Enable/disable ingestion for this provider.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "importance_threshold",
|
||||
ty: TypeSchema::F64,
|
||||
comment: "Minimum score 0.0..1.0 for routing decisions.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "route_to_orchestrator",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "When true, allow triage react/escalate to route to orchestrator.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "ok",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when settings were saved.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "settings",
|
||||
ty: TypeSchema::Ref("NotificationSettings"),
|
||||
comment: "The normalized (clamped) settings that were persisted.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
_other => ControllerSchema {
|
||||
namespace: "notification",
|
||||
function: "unknown",
|
||||
description: "Unknown notification controller function.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "function",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Unknown function requested.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "error",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Lookup error details.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Handler wrappers (delegate to rpc.rs)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn handle_ingest_wrap(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { super::rpc::handle_ingest(params).await })
|
||||
}
|
||||
|
||||
fn handle_list_wrap(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { super::rpc::handle_list(params).await })
|
||||
}
|
||||
|
||||
fn handle_mark_read_wrap(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { super::rpc::handle_mark_read(params).await })
|
||||
}
|
||||
|
||||
fn handle_settings_get_wrap(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { super::rpc::handle_settings_get(params).await })
|
||||
}
|
||||
|
||||
fn handle_settings_set_wrap(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { super::rpc::handle_settings_set(params).await })
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn all_controller_schemas_covers_three_functions() {
|
||||
let names: Vec<_> = all_controller_schemas()
|
||||
.into_iter()
|
||||
.map(|s| s.function)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"ingest",
|
||||
"list",
|
||||
"mark_read",
|
||||
"settings_get",
|
||||
"settings_set"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_registered_controllers_has_handler_per_schema() {
|
||||
let controllers = all_registered_controllers();
|
||||
assert_eq!(controllers.len(), 5);
|
||||
let names: Vec<_> = controllers.iter().map(|c| c.schema.function).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"ingest",
|
||||
"list",
|
||||
"mark_read",
|
||||
"settings_get",
|
||||
"settings_set"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_ingest_requires_provider_title_body_raw_payload() {
|
||||
let s = schemas("ingest");
|
||||
assert_eq!(s.namespace, "notification");
|
||||
let required: Vec<_> = s
|
||||
.inputs
|
||||
.iter()
|
||||
.filter(|f| f.required)
|
||||
.map(|f| f.name)
|
||||
.collect();
|
||||
assert!(required.contains(&"provider"));
|
||||
assert!(required.contains(&"title"));
|
||||
assert!(required.contains(&"body"));
|
||||
assert!(required.contains(&"raw_payload"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_list_all_inputs_optional() {
|
||||
let s = schemas("list");
|
||||
assert!(s.inputs.iter().all(|f| !f.required));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_mark_read_requires_id() {
|
||||
let s = schemas("mark_read");
|
||||
assert_eq!(s.inputs.len(), 1);
|
||||
assert_eq!(s.inputs[0].name, "id");
|
||||
assert!(s.inputs[0].required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_unknown_returns_placeholder() {
|
||||
let s = schemas("does-not-exist");
|
||||
assert_eq!(s.function, "unknown");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
//! SQLite persistence for `IntegrationNotification` records.
|
||||
//!
|
||||
//! Uses a synchronous `rusqlite::Connection` opened per call, following the
|
||||
//! same `with_connection` pattern as the cron domain.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
|
||||
use super::types::{IntegrationNotification, NotificationSettings, NotificationStatus};
|
||||
|
||||
/// SQL schema applied on every `with_connection` call (idempotent).
|
||||
const SCHEMA: &str = "
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_notifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
account_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
raw_payload TEXT NOT NULL,
|
||||
importance_score REAL,
|
||||
triage_action TEXT,
|
||||
triage_reason TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'unread',
|
||||
received_at TEXT NOT NULL,
|
||||
scored_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_integration_notifications_provider
|
||||
ON integration_notifications(provider);
|
||||
CREATE INDEX IF NOT EXISTS idx_integration_notifications_status
|
||||
ON integration_notifications(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_settings (
|
||||
provider TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
importance_threshold REAL NOT NULL DEFAULT 0.0,
|
||||
route_to_orchestrator INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
";
|
||||
|
||||
/// Open (and migrate) the notifications DB, then call `f` with the live
|
||||
/// connection. Mirrors the `with_connection` helper in `cron/store.rs`.
|
||||
fn with_connection<T>(config: &Config, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
|
||||
let db_path = config
|
||||
.workspace_dir
|
||||
.join("notifications")
|
||||
.join("notifications.db");
|
||||
|
||||
tracing::trace!(
|
||||
path = %db_path.display(),
|
||||
"[notifications::store] opening DB connection"
|
||||
);
|
||||
|
||||
if let Some(parent) = db_path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!(
|
||||
"[notifications::store] failed to create dir {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let conn = Connection::open(&db_path).with_context(|| {
|
||||
format!(
|
||||
"[notifications::store] failed to open DB at {}",
|
||||
db_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
conn.execute_batch(SCHEMA)
|
||||
.context("[notifications::store] schema migration failed")?;
|
||||
|
||||
tracing::trace!("[notifications::store] schema migration applied, running operation");
|
||||
f(&conn)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Persist a new notification to the store.
|
||||
pub fn insert(config: &Config, n: &IntegrationNotification) -> Result<()> {
|
||||
with_connection(config, |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO integration_notifications
|
||||
(id, provider, account_id, title, body, raw_payload,
|
||||
importance_score, triage_action, triage_reason, status,
|
||||
received_at, scored_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
params![
|
||||
n.id,
|
||||
n.provider,
|
||||
n.account_id,
|
||||
n.title,
|
||||
n.body,
|
||||
n.raw_payload.to_string(),
|
||||
n.importance_score,
|
||||
n.triage_action,
|
||||
n.triage_reason,
|
||||
n.status.as_str(),
|
||||
n.received_at.to_rfc3339(),
|
||||
n.scored_at.map(|t| t.to_rfc3339()),
|
||||
],
|
||||
)
|
||||
.context("[notifications::store] insert failed")?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// List notifications with optional filtering.
|
||||
pub fn list(
|
||||
config: &Config,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
provider_filter: Option<&str>,
|
||||
min_score: Option<f32>,
|
||||
) -> Result<Vec<IntegrationNotification>> {
|
||||
with_connection(config, |conn| {
|
||||
// Build a dynamic query instead of relying on nullable-aware WHERE
|
||||
// logic so the SQL stays readable for future contributors.
|
||||
let mut sql = String::from(
|
||||
"SELECT id, provider, account_id, title, body, raw_payload,
|
||||
importance_score, triage_action, triage_reason, status,
|
||||
received_at, scored_at
|
||||
FROM integration_notifications
|
||||
WHERE 1=1",
|
||||
);
|
||||
if provider_filter.is_some() {
|
||||
sql.push_str(" AND provider = ?1");
|
||||
}
|
||||
if min_score.is_some() {
|
||||
if provider_filter.is_some() {
|
||||
sql.push_str(" AND (importance_score IS NULL OR importance_score >= ?2)");
|
||||
} else {
|
||||
sql.push_str(" AND (importance_score IS NULL OR importance_score >= ?1)");
|
||||
}
|
||||
}
|
||||
sql.push_str(" ORDER BY received_at DESC");
|
||||
sql.push_str(&format!(" LIMIT {limit} OFFSET {offset}"));
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.context("[notifications::store] prepare list failed")?;
|
||||
|
||||
let rows = match (provider_filter, min_score) {
|
||||
(Some(p), Some(s)) => stmt.query(params![p, s]),
|
||||
(Some(p), None) => stmt.query(params![p]),
|
||||
(None, Some(s)) => stmt.query(params![s]),
|
||||
(None, None) => stmt.query([]),
|
||||
}
|
||||
.context("[notifications::store] list query failed")?;
|
||||
|
||||
rows_to_notifications(rows)
|
||||
})
|
||||
}
|
||||
|
||||
/// Update triage scoring fields in-place.
|
||||
pub fn update_triage(
|
||||
config: &Config,
|
||||
id: &str,
|
||||
score: f32,
|
||||
action: &str,
|
||||
reason: &str,
|
||||
) -> Result<()> {
|
||||
with_connection(config, |conn| {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let updated = conn
|
||||
.execute(
|
||||
"UPDATE integration_notifications
|
||||
SET importance_score = ?1, triage_action = ?2, triage_reason = ?3, scored_at = ?4
|
||||
WHERE id = ?5",
|
||||
params![score, action, reason, now, id],
|
||||
)
|
||||
.context("[notifications::store] update_triage failed")?;
|
||||
if updated == 0 {
|
||||
// The row may have been deleted between ingest and scoring.
|
||||
// Surface it at warn level so orphaned triage runs don't fail
|
||||
// silently.
|
||||
tracing::warn!(
|
||||
id = %id,
|
||||
action = %action,
|
||||
"[notifications::store] update_triage matched no rows"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
id = %id,
|
||||
action = %action,
|
||||
score = score,
|
||||
"[notifications::store] update_triage applied"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Transition a notification from `unread` to `read`.
|
||||
pub fn mark_read(config: &Config, id: &str) -> Result<()> {
|
||||
with_connection(config, |conn| {
|
||||
let updated = conn
|
||||
.execute(
|
||||
"UPDATE integration_notifications SET status = 'read' WHERE id = ?1",
|
||||
params![id],
|
||||
)
|
||||
.context("[notifications::store] mark_read failed")?;
|
||||
if updated == 0 {
|
||||
tracing::warn!(
|
||||
id = %id,
|
||||
"[notifications::store] mark_read matched no rows"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(id = %id, "[notifications::store] mark_read applied");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Count unread notifications.
|
||||
pub fn unread_count(config: &Config) -> Result<i64> {
|
||||
with_connection(config, |conn| {
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM integration_notifications WHERE status = 'unread'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.context("[notifications::store] unread_count failed")?;
|
||||
Ok(count)
|
||||
})
|
||||
}
|
||||
|
||||
/// Upsert provider-level notification settings.
|
||||
pub fn upsert_settings(config: &Config, settings: &NotificationSettings) -> Result<()> {
|
||||
with_connection(config, |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO notification_settings (provider, enabled, importance_threshold, route_to_orchestrator)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(provider) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
importance_threshold = excluded.importance_threshold,
|
||||
route_to_orchestrator = excluded.route_to_orchestrator",
|
||||
params![
|
||||
settings.provider,
|
||||
if settings.enabled { 1 } else { 0 },
|
||||
settings.importance_threshold,
|
||||
if settings.route_to_orchestrator { 1 } else { 0 }
|
||||
],
|
||||
)
|
||||
.context("[notifications::store] upsert_settings failed")?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Read provider-level notification settings with defaults when missing.
|
||||
pub fn get_settings(config: &Config, provider: &str) -> Result<NotificationSettings> {
|
||||
with_connection(config, |conn| {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT provider, enabled, importance_threshold, route_to_orchestrator
|
||||
FROM notification_settings
|
||||
WHERE provider = ?1",
|
||||
)
|
||||
.context("[notifications::store] prepare get_settings failed")?;
|
||||
let mut rows = stmt
|
||||
.query(params![provider])
|
||||
.context("[notifications::store] get_settings query failed")?;
|
||||
if let Some(row) = rows
|
||||
.next()
|
||||
.context("[notifications::store] get_settings row failed")?
|
||||
{
|
||||
return Ok(NotificationSettings {
|
||||
provider: row.get(0)?,
|
||||
enabled: row.get::<_, i64>(1)? != 0,
|
||||
importance_threshold: row.get(2)?,
|
||||
route_to_orchestrator: row.get::<_, i64>(3)? != 0,
|
||||
});
|
||||
}
|
||||
Ok(NotificationSettings {
|
||||
provider: provider.to_string(),
|
||||
..NotificationSettings::default()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Row conversion helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn rows_to_notifications(mut rows: rusqlite::Rows<'_>) -> Result<Vec<IntegrationNotification>> {
|
||||
let mut out = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.context("[notifications::store] row iteration failed")?
|
||||
{
|
||||
out.push(row_to_notification(row)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn row_to_notification(row: &rusqlite::Row<'_>) -> Result<IntegrationNotification> {
|
||||
let raw_payload_str: String = row.get(5)?;
|
||||
let raw_payload: serde_json::Value = serde_json::from_str(&raw_payload_str)
|
||||
.unwrap_or(serde_json::Value::String(raw_payload_str));
|
||||
|
||||
let status_str: String = row.get(9)?;
|
||||
let status = match status_str.as_str() {
|
||||
"read" => NotificationStatus::Read,
|
||||
"acted" => NotificationStatus::Acted,
|
||||
"dismissed" => NotificationStatus::Dismissed,
|
||||
_ => NotificationStatus::Unread,
|
||||
};
|
||||
|
||||
let received_at_str: String = row.get(10)?;
|
||||
let received_at: DateTime<Utc> = received_at_str.parse().unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
raw = %received_at_str,
|
||||
error = %e,
|
||||
"[notifications::store] invalid received_at, using now"
|
||||
);
|
||||
Utc::now()
|
||||
});
|
||||
|
||||
let scored_at_str: Option<String> = row.get(11)?;
|
||||
let scored_at: Option<DateTime<Utc>> = scored_at_str.and_then(|s| match s.parse() {
|
||||
Ok(t) => Some(t),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
raw = %s,
|
||||
error = %e,
|
||||
"[notifications::store] invalid scored_at, treating as unscored"
|
||||
);
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Ok(IntegrationNotification {
|
||||
id: row.get(0)?,
|
||||
provider: row.get(1)?,
|
||||
account_id: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
body: row.get(4)?,
|
||||
raw_payload,
|
||||
importance_score: row.get(6)?,
|
||||
triage_action: row.get(7)?,
|
||||
triage_reason: row.get(8)?,
|
||||
status,
|
||||
received_at,
|
||||
scored_at,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::config::Config;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_config(dir: &TempDir) -> Config {
|
||||
let mut config = Config::default();
|
||||
config.workspace_dir = dir.path().to_path_buf();
|
||||
config
|
||||
}
|
||||
|
||||
fn sample_notification(id: &str, provider: &str) -> IntegrationNotification {
|
||||
IntegrationNotification {
|
||||
id: id.to_string(),
|
||||
provider: provider.to_string(),
|
||||
account_id: None,
|
||||
title: "Test notification".to_string(),
|
||||
body: "Test body".to_string(),
|
||||
raw_payload: serde_json::json!({"test": true}),
|
||||
importance_score: None,
|
||||
triage_action: None,
|
||||
triage_reason: None,
|
||||
status: NotificationStatus::Unread,
|
||||
received_at: Utc::now(),
|
||||
scored_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_list_roundtrip() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let config = test_config(&dir);
|
||||
let n = sample_notification("n1", "gmail");
|
||||
insert(&config, &n).unwrap();
|
||||
|
||||
let items = list(&config, 10, 0, None, None).unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].id, "n1");
|
||||
assert_eq!(items[0].provider, "gmail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unread_count_increments_on_insert_and_decrements_on_read() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let config = test_config(&dir);
|
||||
|
||||
assert_eq!(unread_count(&config).unwrap(), 0);
|
||||
insert(&config, &sample_notification("a", "slack")).unwrap();
|
||||
insert(&config, &sample_notification("b", "slack")).unwrap();
|
||||
assert_eq!(unread_count(&config).unwrap(), 2);
|
||||
|
||||
mark_read(&config, "a").unwrap();
|
||||
assert_eq!(unread_count(&config).unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_triage_fills_scoring_fields() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let config = test_config(&dir);
|
||||
insert(&config, &sample_notification("t1", "gmail")).unwrap();
|
||||
update_triage(&config, "t1", 0.9, "escalate", "important email").unwrap();
|
||||
|
||||
let items = list(&config, 10, 0, None, None).unwrap();
|
||||
assert_eq!(items[0].importance_score, Some(0.9));
|
||||
assert_eq!(items[0].triage_action.as_deref(), Some("escalate"));
|
||||
assert_eq!(items[0].triage_reason.as_deref(), Some("important email"));
|
||||
assert!(items[0].scored_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_filter_works() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let config = test_config(&dir);
|
||||
insert(&config, &sample_notification("g1", "gmail")).unwrap();
|
||||
insert(&config, &sample_notification("s1", "slack")).unwrap();
|
||||
|
||||
let gmail = list(&config, 10, 0, Some("gmail"), None).unwrap();
|
||||
assert_eq!(gmail.len(), 1);
|
||||
assert_eq!(gmail[0].provider, "gmail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_roundtrip_defaults_and_upsert() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let config = test_config(&dir);
|
||||
|
||||
let defaults = get_settings(&config, "gmail").unwrap();
|
||||
assert_eq!(defaults.provider, "gmail");
|
||||
assert!(defaults.enabled);
|
||||
assert_eq!(defaults.importance_threshold, 0.0);
|
||||
assert!(defaults.route_to_orchestrator);
|
||||
|
||||
upsert_settings(
|
||||
&config,
|
||||
&NotificationSettings {
|
||||
provider: "gmail".to_string(),
|
||||
enabled: false,
|
||||
importance_threshold: 0.75,
|
||||
route_to_orchestrator: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let updated = get_settings(&config, "gmail").unwrap();
|
||||
assert!(!updated.enabled);
|
||||
assert_eq!(updated.importance_threshold, 0.75);
|
||||
assert!(!updated.route_to_orchestrator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Core types for the integration notification domain.
|
||||
//!
|
||||
//! `IntegrationNotification` is the central record that flows from webview
|
||||
//! recipe events → triage pipeline → notification center UI.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Lifecycle state for an ingested notification.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NotificationStatus {
|
||||
#[default]
|
||||
Unread,
|
||||
Read,
|
||||
Acted,
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
impl NotificationStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Unread => "unread",
|
||||
Self::Read => "read",
|
||||
Self::Acted => "acted",
|
||||
Self::Dismissed => "dismissed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single notification captured from an embedded webview integration.
|
||||
///
|
||||
/// Notifications are written on ingest and enriched in-place once the
|
||||
/// triage pipeline produces its score/action. The `importance_score`,
|
||||
/// `triage_action`, and `triage_reason` fields are `None` until the
|
||||
/// background triage task completes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IntegrationNotification {
|
||||
pub id: String,
|
||||
/// Provider slug: `"gmail"`, `"slack"`, `"whatsapp"`, etc.
|
||||
pub provider: String,
|
||||
/// Webview account id if the notification came from an embedded account.
|
||||
pub account_id: Option<String>,
|
||||
/// Short subject / title text.
|
||||
pub title: String,
|
||||
/// Body / preview text.
|
||||
pub body: String,
|
||||
/// Full raw event payload from the recipe for downstream use.
|
||||
pub raw_payload: serde_json::Value,
|
||||
/// 0.0–1.0 importance score produced by the triage pipeline (optional).
|
||||
pub importance_score: Option<f32>,
|
||||
/// Triage action string: `"drop"` / `"acknowledge"` / `"react"` / `"escalate"`.
|
||||
pub triage_action: Option<String>,
|
||||
/// One-sentence justification from the classifier.
|
||||
pub triage_reason: Option<String>,
|
||||
/// Lifecycle status.
|
||||
pub status: NotificationStatus,
|
||||
/// Wall-clock time the notification arrived.
|
||||
pub received_at: DateTime<Utc>,
|
||||
/// Wall-clock time triage completed.
|
||||
pub scored_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Per-provider user preference controlling which notifications surface.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationSettings {
|
||||
pub provider: String,
|
||||
/// Whether notifications from this provider should be ingested at all.
|
||||
pub enabled: bool,
|
||||
/// Minimum importance score (0.0–1.0) to display; 0.0 = show all.
|
||||
pub importance_threshold: f32,
|
||||
/// When `true`, triage-escalated notifications are also auto-forwarded to
|
||||
/// the orchestrator agent.
|
||||
pub route_to_orchestrator: bool,
|
||||
}
|
||||
|
||||
impl Default for NotificationSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
provider: String::new(),
|
||||
enabled: true,
|
||||
importance_threshold: 0.0,
|
||||
route_to_orchestrator: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload for the `notification_ingest` RPC endpoint.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationIngestRequest {
|
||||
/// Provider slug: `"gmail"`, `"slack"`, etc.
|
||||
pub provider: String,
|
||||
/// Webview account id (optional).
|
||||
pub account_id: Option<String>,
|
||||
/// Human-readable notification title.
|
||||
pub title: String,
|
||||
/// Notification body / preview.
|
||||
pub body: String,
|
||||
/// Full raw payload from the source.
|
||||
pub raw_payload: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Payload for `notification_settings_set`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationSettingsUpsertRequest {
|
||||
pub provider: String,
|
||||
pub enabled: bool,
|
||||
pub importance_threshold: f32,
|
||||
pub route_to_orchestrator: bool,
|
||||
}
|
||||
Reference in New Issue
Block a user