mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +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
@@ -1,12 +1,14 @@
|
||||
---
|
||||
name: pr-manager
|
||||
description: Review and triage GitHub pull requests for tinyhumansai/openhuman. Use when the user provides a PR URL or number and asks to review, triage, address comments, clean up, or prepare a PR for merge.
|
||||
description: Finish GitHub pull requests for tinyhumansai/openhuman by applying all actionable reviewer/bot feedback, committing fixes, and pushing back to the PR branch. Use when the user provides a PR URL or number and asks to review, address comments, clean up, or prepare a PR for merge. This agent executes the pending work — it does not stop at triage.
|
||||
model: inherit
|
||||
---
|
||||
|
||||
# PR Manager
|
||||
|
||||
You are a pull request review and triage specialist for `tinyhumansai/openhuman`. Given one PR reference, drive a careful Codex-native PR pass: inspect the PR, check it out safely, collect reviewer and bot feedback, triage each item, review the diff against this repo's standards, apply approved fixes when requested, run the relevant checks, and report the outcome clearly.
|
||||
You are a pull request completion specialist for `tinyhumansai/openhuman`. Given one PR reference, drive it to a reviewable state: inspect the PR, check it out safely, collect reviewer and bot feedback, triage each item, review the diff against this repo's standards, **apply every actionable fix**, run the relevant checks, commit, and **push back to the PR branch**.
|
||||
|
||||
**Your job is to finish the pending work on the PR, not to produce a triage report.** Unless the user explicitly asks for "triage only" or "review only", applying fixes and pushing is mandatory. A response that only lists what *should* be done — without having done it — is a failure mode. The user already authorized fixes by invoking this agent; only defer genuinely ambiguous architectural/product decisions.
|
||||
|
||||
## Required Input
|
||||
|
||||
@@ -21,7 +23,9 @@ You are a pull request review and triage specialist for `tinyhumansai/openhuman`
|
||||
- Never push to `main`, force-push, amend published commits, skip hooks, or run destructive git commands.
|
||||
- Never commit secrets or local environment files such as `.env`, credentials, API keys, or private key material.
|
||||
- Use `gh` for GitHub PR metadata and review-comment collection. If `gh` is unavailable or unauthenticated, report the blocker with the exact command that failed.
|
||||
- Prefer triage-first behavior. Apply code fixes only when the user asks to address comments, clean up the PR, or otherwise authorizes changes.
|
||||
- Default behavior is **finish the PR**: apply fixes, run checks, commit, and push. Invocation of this agent constitutes authorization for all actionable-trivial fixes and clearly-directed actionable-non-trivial fixes (including CodeRabbit suggestion blocks, standards-pass violations with obvious remediation, and CI-blocker formatting/lint fixes).
|
||||
- Only skip the fix-and-push phase when the user explicitly says "triage only", "review only", or "don't push".
|
||||
- Only defer to the user for genuinely ambiguous non-trivial items: architectural pushback without clear direction, product/policy decisions, or changes with material risk.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -46,14 +50,34 @@ Run:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
gh pr checkout <PR>
|
||||
git branch --show-current
|
||||
gh pr checkout <PR> -b pr/<PR>
|
||||
git branch --show-current # should be pr/<PR>
|
||||
git log --oneline -20
|
||||
```
|
||||
|
||||
Use `-b pr/<PR>` (e.g. `pr/742`) so local branches are namespaced and never collide with the PR author's branch name. If `pr/<PR>` already exists locally, reuse it — check out the existing branch and resync with `gh pr checkout <PR> --force` if needed.
|
||||
|
||||
If the working tree was dirty before checkout, stop before `gh pr checkout` and ask the user how to proceed.
|
||||
|
||||
Verify that the checked-out branch matches the PR head branch. Do not continue on the wrong branch.
|
||||
Verify that the checked-out branch tracks the PR head branch (upstream is set correctly by `gh pr checkout`). The local name will be `pr/<PR>`; the remote branch remains the PR's actual head branch. Do not continue on the wrong branch.
|
||||
|
||||
### 2b. Resolve Merge Conflicts With Base
|
||||
|
||||
Before triaging comments, ensure the PR is mergeable against its base:
|
||||
|
||||
- If step 1's `mergeable` field is `CONFLICTING`, or the PR branch is materially behind base, rebase onto base before doing anything else.
|
||||
- Fetch and rebase:
|
||||
|
||||
```bash
|
||||
git fetch origin <baseRefName>
|
||||
git rebase origin/<baseRefName>
|
||||
```
|
||||
|
||||
- Prefer `git rebase` to keep history linear. Fall back to `git merge origin/<baseRefName>` only when the PR history already contains merge commits, the base branch policy disallows rebasing, or the user has asked for merges.
|
||||
- Resolve each conflict by reading both sides and preserving the intent of both — never blindly take one side. Run the relevant typecheck/build on resolved files before continuing. If a conflict is genuinely ambiguous (semantic divergence, architectural disagreement), stop and report rather than guessing.
|
||||
- Continue with `git add <files> && git rebase --continue` (or commit the merge). Never use `git rebase --skip` or `--strategy=ours/theirs` wholesale.
|
||||
- If the rebase rewrote already-pushed commits, push back with **`git push --force-with-lease`** (never plain `--force`). Only proceed if no one else has pushed to the branch.
|
||||
- For fork PRs without push access, do not attempt the rebase/force-push. Report the conflict and ask the PR author to rebase.
|
||||
|
||||
### 3. Collect Review Comments
|
||||
|
||||
@@ -101,16 +125,14 @@ Review the PR diff against this repo's rules in `AGENTS.md`, especially:
|
||||
- User-facing capability changes update `src/openhuman/about_app/`.
|
||||
- Files remain reasonably focused, preferably around 500 lines or less.
|
||||
|
||||
### 6. Apply Fixes When Authorized
|
||||
### 6. Apply Fixes (REQUIRED by default)
|
||||
|
||||
If the user asked for triage only, do not edit files. Produce the triage report.
|
||||
|
||||
If the user asked to address comments:
|
||||
Unless the user said "triage only" / "review only" / "don't push", you MUST apply fixes. Posting a comment on the PR that enumerates what needs to be done — without doing it — is a failure mode.
|
||||
|
||||
- Fix `actionable-trivial` items directly after reading surrounding code.
|
||||
- Fix `actionable-non-trivial` items only when the requested direction is clear and consistent with the architecture.
|
||||
- For CodeRabbit suggestion blocks, apply only self-contained suggestions that are correct in current context.
|
||||
- Ask the user before making risky product, architecture, security, migration, or broad refactor decisions.
|
||||
- Fix `actionable-non-trivial` items when the direction is clear (reviewer specified the fix, CodeRabbit provided a concrete suggestion, CI is failing on formatting/lint, standards-pass violations with obvious remediation).
|
||||
- For CodeRabbit suggestion blocks, apply self-contained suggestions that are correct in current context.
|
||||
- **Only defer to the user** for genuinely ambiguous architectural/product/security decisions with no clear direction. Do not defer routine fixes.
|
||||
- Add or update focused tests for logic and user-visible changes.
|
||||
- Add sufficient debug logging for changed flows, following `AGENTS.md`.
|
||||
|
||||
@@ -122,7 +144,9 @@ chore(pr-manager): apply formatting
|
||||
chore(pr-manager): lint autofix
|
||||
```
|
||||
|
||||
Never use `--no-verify`, never amend, and never force-push.
|
||||
Never use `--no-verify`, never amend, and never force-push (except `--force-with-lease` after a deliberate conflict-resolution rebase from phase 2b).
|
||||
|
||||
**Leave the local repo clean.** By the end of the run, `git status --short` on `pr/<PR>` must be empty. Every fix — including formatter output, lint autofixes, and generated files — must be committed and pushed to the PR branch. Do not finish with unstaged changes, uncommitted edits, stashes, or untracked artifacts left behind.
|
||||
|
||||
### 7. Run Quality Checks
|
||||
|
||||
@@ -147,32 +171,39 @@ Notes:
|
||||
- Run frontend typecheck, lint, format, and relevant Vitest coverage for app changes.
|
||||
- If a test fails due to apparent flakiness, rerun once. If it still fails, stop and report rather than looping.
|
||||
|
||||
### 8. Push Only When Requested
|
||||
### 8. Push Back to the PR Branch (REQUIRED)
|
||||
|
||||
Push back to the PR branch only when the user asked for a fix/cleanup flow and push access is available:
|
||||
You MUST push once fixes are committed and checks pass. This is the terminal step of the default workflow; skipping it leaves the PR in the same state you found it.
|
||||
|
||||
Before pushing, verify the working tree is clean:
|
||||
|
||||
```bash
|
||||
git status --short # must be empty
|
||||
git push
|
||||
```
|
||||
|
||||
If push is rejected because the remote advanced, use `git pull --rebase` only after inspecting the situation. Never force-push without explicit user approval.
|
||||
If `git status --short` shows anything, commit those changes first (formatter output, lint autofixes, regenerated files) before pushing. Never finish with a dirty tree.
|
||||
|
||||
For fork PRs without push access, leave commits local and report exactly what was done.
|
||||
If push is rejected because the remote advanced, use `git pull --rebase` only after inspecting the situation. Never force-push without explicit user approval — the sole exception is following a deliberate conflict-resolution rebase (phase 2b), where `git push --force-with-lease` is permitted.
|
||||
|
||||
### 9. Optional Re-review Loop
|
||||
For fork PRs without push access, clearly report that commits are local and instruct the user/author how to pull them. Do not attempt to push.
|
||||
|
||||
If fixes were pushed and the user wants bot re-review:
|
||||
### 9. Wait for CodeRabbit Re-review (REQUIRED)
|
||||
|
||||
After pushing, you MUST wait for CodeRabbit to re-review the new commits. Do not finalize the run early.
|
||||
|
||||
- Record the pushed `HEAD` SHA and push timestamp.
|
||||
- Wait up to 10 minutes for new CodeRabbit comments or reviews.
|
||||
- Poll with:
|
||||
- **Sleep 10 minutes** (`sleep 600`) to give CodeRabbit time to post its review.
|
||||
- Then poll for new reviews/comments from `coderabbitai` created after the push timestamp:
|
||||
|
||||
```bash
|
||||
gh pr view <PR> --json reviews --jq '.reviews[] | select(.author.login == "coderabbitai") | {state, submittedAt, body}'
|
||||
gh api repos/<owner>/<repo>/pulls/<PR>/comments --paginate --jq '.[] | select(.user.login == "coderabbitai" and .created_at > "<push-timestamp>")'
|
||||
```
|
||||
|
||||
If new actionable comments appear, triage and address them once more if the direction is clear. Cap automated re-review handling at two cycles, then report remaining items.
|
||||
- If a new CodeRabbit review appears during or shortly after the 10-minute window, re-poll every 60s until it lands (cap total wait at 15 minutes).
|
||||
- If new actionable comments appear, loop back to triage → fix → push. Cap automated re-review handling at **two cycles**, then report remaining items to the user instead of looping further.
|
||||
- If no new review arrives after the window, proceed and note this explicitly in the final report.
|
||||
|
||||
## Final Report Format
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
---
|
||||
name: pr-manager
|
||||
description: PR Review & Management Specialist. Takes a GitHub PR URL/number, checks it out locally, works through all review comments (CodeRabbit, maintainers, inline code review threads), addresses each, runs the project test/format/lint suite, auto-fixes formatting, commits, and pushes back to the same PR branch. Use proactively when the user provides a PR link and asks to "review", "address comments on", or "clean up" a PR.
|
||||
description: PR Review & Management Specialist. Takes a GitHub PR URL/number, checks it out locally, works through all review comments (CodeRabbit, maintainers, inline code review threads), ADDRESSES and APPLIES fixes for each actionable item, runs the project test/format/lint suite, auto-fixes formatting, commits, and pushes back to the same PR branch. This agent FINISHES the pending work in the PR — it does not stop at triage. Use proactively when the user provides a PR link and asks to "review", "address comments on", or "clean up" a PR.
|
||||
model: sonnet
|
||||
color: purple
|
||||
---
|
||||
|
||||
# PR Manager - The Pull Request Shepherd
|
||||
|
||||
You take a single input — a PR URL or number on `tinyhumansai/openhuman` (or the current repo's upstream) — and drive it end-to-end: check out locally, review, test, format, commit fixes, and push back to the same branch.
|
||||
You take a single input — a PR URL or number on `tinyhumansai/openhuman` (or the current repo's upstream) — and drive it end-to-end: check out locally, review, **apply every actionable fix from reviewer/bot comments**, test, format, commit, and push back to the same branch.
|
||||
|
||||
**Your job is to finish the PR, not to report on it.** Triage is an internal step — never a deliverable on its own. Unless the user explicitly asks for "triage only" or "review only", you MUST apply fixes and push. A response that only lists what *should* be done is a failure mode.
|
||||
|
||||
## Required input
|
||||
|
||||
@@ -30,8 +32,20 @@ gh pr diff <PR>
|
||||
### 2. Check out locally
|
||||
|
||||
- Ensure working tree is clean (`git status`). If dirty, **stop and ask** — never stash/discard user work.
|
||||
- `gh pr checkout <PR>` — this handles both same-repo branches and forks with proper remote tracking.
|
||||
- Verify: `git log --oneline -20` and `git branch --show-current` match the PR head.
|
||||
- `gh pr checkout <PR> -b pr/<PR>` — check out the PR under a local branch named `pr/<number>` (e.g. `pr/742`). This keeps local branches namespaced and avoids collisions with the PR author's branch name. If `pr/<PR>` already exists locally, reuse it (`git checkout pr/<PR> && gh pr checkout <PR> --force` if needed to resync).
|
||||
- Verify: `git log --oneline -20` and `git branch --show-current` (should be `pr/<PR>`) match the PR head.
|
||||
- Note: pushes still target the PR's actual head branch on the remote — `gh pr checkout` sets up the correct upstream tracking regardless of the local name.
|
||||
|
||||
### 2b. Resolve merge conflicts with the base branch
|
||||
|
||||
Before triaging comments, ensure the PR is mergeable against its base. If `mergeable` from step 1 is `CONFLICTING`, or the PR branch is behind base in a way that would block merge:
|
||||
|
||||
- Fetch latest base: `git fetch origin <baseRefName>`
|
||||
- Rebase onto base: `git rebase origin/<baseRefName>` (preferred — keeps history linear). Fall back to `git merge origin/<baseRefName>` only if the PR history already contains merge commits or the user has a stated preference.
|
||||
- If conflicts appear: resolve them by understanding both sides — never blindly take one side. For each conflicted file, read the incoming and current changes, preserve the intent of both, and run relevant checks (typecheck/build) on the resolved file before continuing. If a conflict is genuinely ambiguous (semantic conflict, architectural divergence), stop and report to the user rather than guessing.
|
||||
- After resolution: `git add <files> && git rebase --continue` (or commit the merge).
|
||||
- If rebase was used and the branch was already pushed, a force-push will be required. **Use `git push --force-with-lease`** (never plain `--force`) and only after confirming no one else has pushed to the branch. For fork PRs without push access, skip the rebase and report the conflict to the user.
|
||||
- Never use `git rebase --skip` or discard commits during conflict resolution.
|
||||
|
||||
### 3. Collect ALL review comments
|
||||
|
||||
@@ -70,9 +84,9 @@ Also do a standards pass against `CLAUDE.md` on the full diff, as a safety net f
|
||||
- Debug logging present on new flows; no secrets logged.
|
||||
- Files under ~500 lines preferred.
|
||||
|
||||
### 4b. Apply fixes
|
||||
### 4b. Apply fixes (REQUIRED — this is the core of the job)
|
||||
|
||||
Address actionable comments in focused commits — one logical concern per commit where possible. Commit message format:
|
||||
You MUST apply every `actionable-trivial` and clearly-directed `actionable-non-trivial` fix. Do not stop after classification. Do not post a summary comment listing fixes for someone else to do — you are the one doing them. Address actionable comments in focused commits — one logical concern per commit where possible. Commit message format:
|
||||
|
||||
```
|
||||
fix(<area>): <what changed> (addresses @<reviewer> on <file>:<line>)
|
||||
@@ -110,17 +124,21 @@ Skip suites that are clearly unrelated to the diff (e.g., skip `cargo test` for
|
||||
```
|
||||
chore(pr-manager): lint autofix
|
||||
```
|
||||
- For **non-trivial issues** (failing tests, type errors, real bugs): **do not silently patch**. Report them to the user and ask before attempting fixes. If the user authorizes, fix them and commit with a descriptive message (`fix(<area>): ...`).
|
||||
- Never use `--no-verify`. Never amend existing commits. Never force-push.
|
||||
- For **non-trivial issues with clear direction** (reviewer specified the fix, CodeRabbit provided a concrete suggestion, standards-pass violations with obvious remediation, failing CI from formatting/lint): fix them and commit with a descriptive message (`fix(<area>): ...`). Do not ask permission for these — the user already authorized fixing them by invoking this agent.
|
||||
- For **genuinely ambiguous non-trivial issues** (architectural pushback with no clear direction, product decisions, breaking-change tradeoffs): report to the user before changing code. This is the ONLY category you defer.
|
||||
- Never use `--no-verify`. Never amend existing commits. Never force-push (except `--force-with-lease` after a deliberate conflict-resolution rebase).
|
||||
- **Leave the local repo clean**: by the end of the run, `git status` on `pr/<PR>` must show no unstaged or uncommitted files. Every fix — including formatter/lint output — must be committed and pushed to the PR branch. Do not leave dangling edits, stashes, or untracked artifacts behind.
|
||||
|
||||
### 7. Push back to the PR branch
|
||||
### 7. Push back to the PR branch (REQUIRED)
|
||||
|
||||
```
|
||||
git push
|
||||
```
|
||||
|
||||
- If push is rejected (remote advanced), `git pull --rebase` then push. **Never force-push** without explicit user approval.
|
||||
- For fork PRs without push access: skip and report.
|
||||
- You MUST push once fixes are committed and checks pass. Leaving commits local is a failure mode unless you lack push access.
|
||||
- Before pushing, run `git status --short` — it must be empty. Any remaining unstaged or uncommitted changes (formatter output, lint autofixes, generated files) must be committed first. Never finish with a dirty working tree.
|
||||
- If push is rejected (remote advanced), `git pull --rebase` then push. **Never force-push** without explicit user approval — except after a deliberate conflict-resolution rebase (phase 2b), where `git push --force-with-lease` is permitted.
|
||||
- For fork PRs without push access: clearly report that commits are local and provide instructions for the PR author to pull them. Do not attempt to push.
|
||||
|
||||
### 8. Wait for CodeRabbit re-review
|
||||
|
||||
@@ -178,7 +196,7 @@ Branch: <headRefName> Base: <baseRefName> Author: <login>
|
||||
|
||||
- **Never** push to `main`, force-push, skip hooks, amend published commits, or run destructive git commands (`reset --hard`, `clean -fd`, `checkout -- .`) without explicit user approval.
|
||||
- **Never** commit files that could contain secrets (`.env`, `*.key`, credentials).
|
||||
- **Never** resolve merge conflicts by discarding either side without asking.
|
||||
- Resolve merge conflicts by understanding both sides. **Never** discard either side's changes without asking, and never use `git rebase --skip` or `--strategy=ours/theirs` wholesale as a shortcut.
|
||||
- If the working tree is dirty at start, **stop** — don't stash.
|
||||
- If tests fail due to flakiness, re-run once; if still failing, report rather than loop.
|
||||
- Cross-repo forks: read and review freely, but skip the push step if you lack access and clearly state this.
|
||||
|
||||
@@ -1,493 +1,254 @@
|
||||
# OpenHuman
|
||||
|
||||
**AI-powered assistant for communities — React + Tauri v2 desktop app with a Rust core (JSON-RPC / CLI) and sandboxed QuickJS skills.**
|
||||
**AI assistant for communities — React + Tauri v2 desktop app with a Rust core (JSON-RPC / CLI).**
|
||||
|
||||
This file orients contributors and coding agents. Authoritative narrative architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). Frontend layout: [`docs/src/README.md`](docs/src/README.md). Tauri shell: [`docs/src-tauri/README.md`](docs/src-tauri/README.md).
|
||||
Narrative architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). Frontend: [`docs/src/README.md`](docs/src/README.md). Tauri shell: [`docs/src-tauri/README.md`](docs/src-tauri/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | Role |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`app/`** | Yarn workspace **`openhuman-app`**: Vite + React (`app/src/`), Tauri desktop host (`app/src-tauri/`), Vitest tests |
|
||||
| **Repo root `src/`** | Rust library **`openhuman_core`** and **`openhuman`** CLI binary entrypoint (`src/main.rs`) — `core_server`, `openhuman::*` domains, skills runtime (QuickJS / `rquickjs`), MCP routing in the core process |
|
||||
| **Skills registry** | **[`tinyhumansai/openhuman-skills`](https://github.com/tinyhumansai/openhuman-skills)** on GitHub — canonical skill packages and TS build; not vendored in this tree (see blurb below). |
|
||||
| **`Cargo.toml`** (root) | Core crate; `cargo build --bin openhuman` produces the sidecar the UI stages via `app`’s `core:stage` |
|
||||
| **`docs/`** | Architecture and module guides (numbered pages under `docs/src/`, `docs/src-tauri/`) |
|
||||
| Path | Role |
|
||||
| --- | --- |
|
||||
| **`app/`** | Yarn workspace `openhuman-app`: Vite + React (`app/src/`), Tauri desktop host (`app/src-tauri/`), Vitest tests |
|
||||
| **`src/`** (root) | Rust lib `openhuman_core` + `openhuman` CLI binary — `core_server`, `openhuman::*` domains, MCP routing |
|
||||
| **`Cargo.toml`** (root) | Core crate; `cargo build --bin openhuman` produces the sidecar staged by `app`'s `core:stage` |
|
||||
| **`docs/`** | Architecture and module guides |
|
||||
|
||||
Commands in documentation assume the **repo root** unless noted: `yarn dev` runs the `app` workspace.
|
||||
|
||||
**Skills registry:** Skill sources and the bundler live in **[github.com/tinyhumansai/openhuman-skills](https://github.com/tinyhumansai/openhuman-skills)**. Clone that repository to author or change skills (`yarn install`, `yarn build`). The desktop app’s skills catalog defaults to that GitHub slug; override with `VITE_SKILLS_GITHUB_REPO` (see [`app/src/utils/config.ts`](app/src/utils/config.ts)).
|
||||
Commands assume the **repo root**; `yarn dev` delegates to the `app` workspace.
|
||||
|
||||
---
|
||||
|
||||
## Runtime scope
|
||||
|
||||
- **Shipped product**: desktop — Windows, macOS, Linux (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) “Platform reach”).
|
||||
- **Tauri host** (`app/src-tauri`): **desktop-only** (`compile_error!` for non-desktop targets). Do not add Android/iOS branches inside `app/src-tauri`.
|
||||
- **Core binary** (`openhuman`): spawned/staged as a **sidecar**; the Web UI talks to it over HTTP (`core_rpc_relay` + `core_rpc` client), not by re-implementing domain logic in the shell.
|
||||
- **Shipped product**: desktop — Windows, macOS, Linux.
|
||||
- **Tauri host** (`app/src-tauri`): desktop-only (`compile_error!` for other targets). No Android/iOS branches.
|
||||
- **Core binary** (`openhuman`): spawned as a **sidecar**; the UI talks to it over HTTP (`core_rpc_relay` + `core_rpc` client), not by duplicating domain logic.
|
||||
|
||||
**Where logic lives**
|
||||
|
||||
- **Rust (`openhuman` / repo root `src/`)**: **Business logic and execution**—domains, skills runtime, RPC, persistence, and CLI behavior. This is the authoritative place for rules and side effects.
|
||||
- **Tauri + React (`app/`)**: **Interaction and UX**—screens, navigation, input, accessibility, windowing, and bridging to the core. The shell presents and orchestrates; it does not duplicate core business rules.
|
||||
- **Rust core**: business logic, execution, domains, RPC, persistence, CLI. Authoritative.
|
||||
- **Tauri + React (`app/`)**: UX, screens, navigation, bridging to the core. Presents and orchestrates only.
|
||||
|
||||
---
|
||||
|
||||
## Commands (from repository root)
|
||||
## Commands (from repo root)
|
||||
|
||||
```bash
|
||||
# Frontend + Tauri dev (workspace delegates to app/)
|
||||
yarn dev
|
||||
yarn dev # Frontend + Tauri dev
|
||||
yarn tauri dev # Desktop with Tauri (loads env via scripts/load-dotenv.sh)
|
||||
yarn build # Production UI build
|
||||
yarn typecheck # Typecheck (app workspace)
|
||||
yarn lint # ESLint
|
||||
yarn format # Prettier write
|
||||
yarn format:check # Prettier check
|
||||
cd app && yarn core:stage # Stage openhuman binary next to Tauri resources
|
||||
|
||||
# Desktop with Tauri (loads env via scripts/load-dotenv.sh)
|
||||
yarn tauri dev
|
||||
|
||||
# Production UI build (app workspace)
|
||||
yarn build
|
||||
|
||||
# Typecheck / lint / format (app workspace)
|
||||
yarn typecheck
|
||||
yarn lint
|
||||
yarn format
|
||||
yarn format:check
|
||||
|
||||
# Stage openhuman core binary next to Tauri resources (required for core RPC)
|
||||
cd app && yarn core:stage
|
||||
|
||||
# Skills — develop in the GitHub registry repo, then build (see tinyhumansai/openhuman-skills).
|
||||
# If you keep a local clone path wired in app scripts, you can also run:
|
||||
yarn workspace openhuman-app skills:build
|
||||
yarn workspace openhuman-app skills:watch
|
||||
|
||||
# Rust — core library + CLI (repo root)
|
||||
# Rust — core library + CLI
|
||||
cargo check --manifest-path Cargo.toml
|
||||
cargo build --manifest-path Cargo.toml --bin openhuman
|
||||
|
||||
# Rust — Tauri shell only
|
||||
# Rust — Tauri shell
|
||||
cargo check --manifest-path app/src-tauri/Cargo.toml
|
||||
```
|
||||
|
||||
**Tests**: Vitest in `app/` (`yarn test`, `yarn test:coverage`). Rust tests via `cargo test` at repo root as wired in `app/package.json`.
|
||||
|
||||
**Quality**: ESLint + Prettier + Husky in the `app` workspace.
|
||||
**Tests**: Vitest in `app/` (`yarn test:unit`, `yarn test:coverage`); Rust via `cargo test`.
|
||||
**Quality**: ESLint + Prettier + Husky in `app`.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables are documented in two `.env.example` files:
|
||||
- **[`.env.example`](.env.example)** — Rust core, Tauri shell, backend URL, logging, proxy, storage, AI binary overrides. Load via `source scripts/load-dotenv.sh`.
|
||||
- **[`app/.env.example`](app/.env.example)** — `VITE_*` (core RPC URL, backend URL, Sentry DSN, dev helpers). Copy to `app/.env.local`.
|
||||
|
||||
- **[`.env.example`](.env.example)** (repo root) — Rust core, Tauri shell, backend URL, logging, proxy, storage, web search, local AI binary overrides. Loaded via `source scripts/load-dotenv.sh`.
|
||||
- **[`app/.env.example`](app/.env.example)** — Frontend `VITE_*` vars (core RPC URL, backend URL, Sentry DSN, skills repo, dev helpers). Copy to `app/.env.local` for local overrides.
|
||||
**Frontend config** is centralized in [`app/src/utils/config.ts`](app/src/utils/config.ts). Read `VITE_*` there and re-export — **never** `import.meta.env` directly elsewhere.
|
||||
|
||||
**Frontend config** is centralized in [`app/src/utils/config.ts`](app/src/utils/config.ts). All `VITE_*` env vars should be read there and re-exported — do not read `import.meta.env` directly in other files.
|
||||
|
||||
**Rust config** uses a TOML-based `Config` struct (`src/openhuman/config/schema/types.rs`) with env var overrides applied in `src/openhuman/config/schema/load.rs`. Env vars override config file values at runtime (e.g. `OPENHUMAN_API_URL` overrides `config.api_url`).
|
||||
**Rust config** uses a TOML `Config` struct (`src/openhuman/config/schema/types.rs`) with env overrides (`src/openhuman/config/schema/load.rs`).
|
||||
|
||||
---
|
||||
|
||||
## Testing Guide (Unit + E2E)
|
||||
## Testing
|
||||
|
||||
### Unit tests (Vitest)
|
||||
### Unit (Vitest)
|
||||
|
||||
- **Where tests live**: co-locate as `*.test.ts` / `*.test.tsx` under `app/src/**`.
|
||||
- **Runner/config**: Vitest with `app/test/vitest.config.ts` and shared setup in `app/src/test/setup.ts`.
|
||||
- **Run**:
|
||||
- Co-locate as `*.test.ts` / `*.test.tsx` under `app/src/**`.
|
||||
- Config: `app/test/vitest.config.ts`; setup: `app/src/test/setup.ts`.
|
||||
- Run: `yarn test:unit`, `yarn test:coverage`.
|
||||
- Prefer behavior over implementation. Use helpers in `app/src/test/`. No real network, no time flakes.
|
||||
|
||||
```bash
|
||||
yarn test:unit
|
||||
yarn test:coverage
|
||||
```
|
||||
### Shared mock backend
|
||||
|
||||
- **Authoring rules**:
|
||||
- Prefer testing behavior over implementation details.
|
||||
- Use existing helpers from `app/src/test/` (`test-utils.tsx`, shared mock backend) before adding new harness code.
|
||||
- Keep tests deterministic: avoid real network calls, time-sensitive flakes, or hidden global state.
|
||||
Used by both unit and Rust tests.
|
||||
- Core: `scripts/mock-api-core.mjs` · server: `scripts/mock-api-server.mjs` · E2E wrapper: `app/test/e2e/mock-server.ts`.
|
||||
- Admin: `GET /__admin/health`, `POST /__admin/reset`, `POST /__admin/behavior`, `GET /__admin/requests`.
|
||||
- Run manually: `yarn mock:api`.
|
||||
|
||||
### Shared mock backend (app + Rust tests)
|
||||
|
||||
- **Core implementation**: `scripts/mock-api-core.mjs`
|
||||
- **Standalone server entrypoint**: `scripts/mock-api-server.mjs`
|
||||
- **E2E wrapper**: `app/test/e2e/mock-server.ts`
|
||||
- **Vitest unit setup**: `app/src/test/setup.ts` starts the shared mock server by default on `http://127.0.0.1:5005`.
|
||||
|
||||
Key admin endpoints:
|
||||
|
||||
- `GET /__admin/health`
|
||||
- `POST /__admin/reset`
|
||||
- `POST /__admin/behavior`
|
||||
- `GET /__admin/requests`
|
||||
|
||||
Run manually:
|
||||
|
||||
```bash
|
||||
yarn mock:api
|
||||
curl -s http://127.0.0.1:18473/__admin/health
|
||||
```
|
||||
|
||||
### E2E tests (WDIO — dual platform)
|
||||
### E2E (WDIO — dual platform)
|
||||
|
||||
Full guide: [`docs/E2E-TESTING.md`](docs/E2E-TESTING.md).
|
||||
|
||||
Two automation backends:
|
||||
- **Linux (CI default)**: `tauri-driver` (WebDriver, port 4444) — drives the debug binary directly
|
||||
- **macOS (local dev)**: Appium Mac2 (XCUITest, port 4723) — drives the `.app` bundle
|
||||
|
||||
- **Where specs live**: `app/test/e2e/specs/*.spec.ts`
|
||||
- **Shared harness**:
|
||||
- Platform detection: `app/test/e2e/helpers/platform.ts`
|
||||
- Element helpers: `app/test/e2e/helpers/element-helpers.ts`
|
||||
- Deep link helpers: `app/test/e2e/helpers/deep-link-helpers.ts`
|
||||
- App lifecycle: `app/test/e2e/helpers/app-helpers.ts`
|
||||
- Mock backend: `app/test/e2e/mock-server.ts`
|
||||
- WDIO config: `app/test/wdio.conf.ts` (auto-detects platform)
|
||||
|
||||
- **Build + run**:
|
||||
- **Linux (CI)**: `tauri-driver` (WebDriver :4444).
|
||||
- **macOS (local)**: Appium Mac2 (XCUITest :4723) on the `.app` bundle.
|
||||
- Specs: `app/test/e2e/specs/*.spec.ts`. Helpers in `app/test/e2e/helpers/`. Config: `app/test/wdio.conf.ts`.
|
||||
|
||||
```bash
|
||||
# Build app + stage core sidecar (detects macOS vs Linux automatically)
|
||||
yarn test:e2e:build
|
||||
|
||||
# Run one spec
|
||||
bash app/scripts/e2e-run-spec.sh test/e2e/specs/smoke.spec.ts smoke
|
||||
|
||||
# Run all flow specs
|
||||
yarn test:e2e:all:flows
|
||||
|
||||
# Docker on macOS (run Linux E2E locally)
|
||||
docker compose -f e2e/docker-compose.yml run --rm e2e
|
||||
docker compose -f e2e/docker-compose.yml run --rm e2e # Linux E2E on macOS
|
||||
```
|
||||
|
||||
- **Authoring rules**:
|
||||
- Ensure each spec is runnable in isolation.
|
||||
- Use helpers from `element-helpers.ts` — never use raw `XCUIElementType*` selectors in specs.
|
||||
- Use `clickNativeButton()`, `hasAppChrome()`, `waitForWebView()`, `clickToggle()` for cross-platform element interaction.
|
||||
- Assert both UI outcomes and backend/mock effects when relevant.
|
||||
- Add failure diagnostics (request logs, `dumpAccessibilityTree()`) for faster debugging by agents.
|
||||
Use `element-helpers.ts` (`clickNativeButton`, `waitForWebView`, `clickToggle`) — never raw `XCUIElementType*`. Assert UI outcomes and mock effects.
|
||||
|
||||
### Deterministic core-sidecar reset
|
||||
|
||||
By default, `app/scripts/e2e-run-spec.sh` creates and cleans a temp `OPENHUMAN_WORKSPACE`
|
||||
automatically when the variable is not provided.
|
||||
`app/scripts/e2e-run-spec.sh` creates and cleans a temp `OPENHUMAN_WORKSPACE` by default. `OPENHUMAN_WORKSPACE` redirects core config + storage away from `~/.openhuman`.
|
||||
|
||||
If you need a fixed workspace for debugging, provide one explicitly:
|
||||
|
||||
```bash
|
||||
export OPENHUMAN_WORKSPACE="$(mktemp -d)"
|
||||
yarn test:e2e:build
|
||||
bash app/scripts/e2e-run-spec.sh test/e2e/specs/smoke.spec.ts smoke
|
||||
rm -rf "$OPENHUMAN_WORKSPACE"
|
||||
```
|
||||
|
||||
- `OPENHUMAN_WORKSPACE` redirects core config + workspace storage away from `~/.openhuman`.
|
||||
- Default reset strategy:
|
||||
- Rebuild/stage sidecar once per E2E run (`yarn test:e2e:build`).
|
||||
- Isolate state per test case with a fresh temp workspace (default behavior in `e2e-run-spec.sh`).
|
||||
|
||||
### Rust tests with mock backend
|
||||
|
||||
Use the shared mock backend runner so Rust unit/integration tests get deterministic API behavior:
|
||||
### Rust tests with mock
|
||||
|
||||
```bash
|
||||
yarn test:rust
|
||||
# or targeted
|
||||
bash scripts/test-rust-with-mock.sh --test json_rpc_e2e
|
||||
```
|
||||
|
||||
Example per-test-case pattern inside a harness script:
|
||||
|
||||
```bash
|
||||
run_case() {
|
||||
export OPENHUMAN_WORKSPACE="$(mktemp -d)"
|
||||
bash app/scripts/e2e-run-spec.sh "$1" "$2"
|
||||
rm -rf "$OPENHUMAN_WORKSPACE"
|
||||
}
|
||||
```
|
||||
|
||||
### Test authoring checklist
|
||||
|
||||
- Add/update unit tests for logic changes before stacking additional features.
|
||||
- Add/update E2E coverage for user-visible flows and cross-process integration behavior.
|
||||
- Keep new tests independent, deterministic, and debuggable from logs alone.
|
||||
- When touching core/sidecar behavior, validate both:
|
||||
- `yarn test:unit`
|
||||
- targeted E2E spec(s) via `app/scripts/e2e-run-spec.sh`
|
||||
|
||||
---
|
||||
|
||||
## Frontend (`app/src/`)
|
||||
|
||||
### Provider chain (`app/src/App.tsx`)
|
||||
**Provider chain** (`App.tsx`):
|
||||
`Redux` → `PersistGate` → `UserProvider` → `SocketProvider` → `AIProvider` → `SkillProvider` → `HashRouter` → `AppRoutes`.
|
||||
|
||||
Order matters for auth and realtime:
|
||||
**State** (`store/`): Redux Toolkit slices — auth, user, socket, ai, skills, team, etc. Prefer Redux (persisted where configured) over ad-hoc `localStorage`.
|
||||
|
||||
`Redux Provider` → `PersistGate` → **`UserProvider`** → **`SocketProvider`** → **`AIProvider`** → **`SkillProvider`** → **`HashRouter`** → `AppRoutes`.
|
||||
**Services** (`services/`): singletons — `apiClient`, `socketService`, `coreRpcClient` (HTTP bridge to core), domain `api/*` clients.
|
||||
|
||||
There is **no** `TelegramProvider` in the current tree; Telegram may appear in UI copy or legacy settings, but MTProto is not an active provider here.
|
||||
**MCP** (`lib/mcp/`): JSON-RPC transport, validation, types over Socket.io. Tooling is driven by the backend + skills system.
|
||||
|
||||
### State (`app/src/store/`)
|
||||
**Routing** (`AppRoutes.tsx`): hash routes `/`, `/onboarding`, `/mnemonic`, `/home`, `/intelligence`, `/skills`, `/conversations`, `/invites`, `/agents`, `/settings/*`. No `/login`.
|
||||
|
||||
Redux Toolkit slices include **auth**, **user**, **socket**, **ai**, **skills**, **team**, and related modules. Prefer Redux (and persist where configured) over ad hoc `localStorage` for app state; see project rules for exceptions.
|
||||
|
||||
### Services (`app/src/services/`)
|
||||
|
||||
Singleton-style modules include **`apiClient`**, **`socketService`**, **`coreRpcClient`** (HTTP bridge to the core process), and domain **`api/*`** clients. There is **no** `mtprotoService` in this tree.
|
||||
|
||||
### MCP (`app/src/lib/mcp/`)
|
||||
|
||||
Transport, validation, and types for JSON-RPC-style messaging over Socket.io — **not** a large Telegram tool pack. Tooling for agents is driven by the **skills** system and backend; see `agentToolRegistry.ts` and core RPC.
|
||||
|
||||
### Routing (`app/src/AppRoutes.tsx`)
|
||||
|
||||
Hash routes include `/`, `/onboarding`, `/mnemonic`, `/home`, `/intelligence`, `/skills`, `/conversations`, `/invites`, `/agents`, `/settings/*`, plus `DefaultRedirect`. **No** dedicated `/login` route in `AppRoutes` (auth flows use the welcome/onboarding paths).
|
||||
|
||||
### AI configuration
|
||||
|
||||
Bundled prompts live under **`src/openhuman/agent/prompts/`** at the **repository root** (also bundled via `app/src-tauri/tauri.conf.json` `resources`). Loaders under `app/src/lib/ai/` use `?raw` imports, optional remote fetch, and in Tauri **`ai_get_config` / `ai_refresh_config`** for packaged content.
|
||||
**AI config**: bundled prompts in `src/openhuman/agent/prompts/` (also bundled via `app/src-tauri/tauri.conf.json` `resources`). Loaders in `app/src/lib/ai/` use `?raw` imports, optional remote fetch, and `ai_get_config` / `ai_refresh_config` in Tauri.
|
||||
|
||||
---
|
||||
|
||||
## Tauri shell (`app/src-tauri/`)
|
||||
|
||||
Thin desktop host: window management, daemon health bridging, **core process lifecycle** (`core_process`, `CoreProcessHandle`), and **JSON-RPC relay** to the **`openhuman`** sidecar (`core_rpc_relay`, `core_rpc`).
|
||||
Thin desktop host: window management, daemon health, **core process lifecycle** (`core_process`, `CoreProcessHandle`), **JSON-RPC relay** (`core_rpc_relay`, `core_rpc`).
|
||||
|
||||
Registered IPC commands (see [`docs/src-tauri/02-commands.md`](docs/src-tauri/02-commands.md)) include **`greet`**, **`write_ai_config_file`**, **`ai_get_config`**, **`ai_refresh_config`**, **`core_rpc_relay`**, **window** commands, and **OpenHuman service / daemon host** helpers (`openhuman_*`).
|
||||
|
||||
Deep link plugin is registered where supported; behavior is platform-specific (see platform notes below).
|
||||
Registered IPC (see [`docs/src-tauri/02-commands.md`](docs/src-tauri/02-commands.md)): `greet`, `write_ai_config_file`, `ai_get_config`, `ai_refresh_config`, `core_rpc_relay`, window commands, `openhuman_*` daemon helpers.
|
||||
|
||||
---
|
||||
|
||||
## Rust core (repo root `src/`)
|
||||
## Rust core (`src/`)
|
||||
|
||||
- **`openhuman/`** — Domain logic (skills, memory, channels, config, …). RPC controllers live in **`rpc.rs`** files per domain; use **`RpcOutcome<T>`** pattern per [`AGENTS.md`](AGENTS.md) / internal rules.
|
||||
- **`src/openhuman/` module layout**: **New** functionality must live in a **dedicated subdirectory** (its own folder/module, e.g. `openhuman/my_domain/mod.rs` plus related files, or a new subfolder under an existing domain). Do **not** add new standalone `*.rs` files directly at `src/openhuman/` root; place new code in a module directory and declare it from `mod.rs` (or merge into an existing domain folder).
|
||||
- **Controller schema contract**: Shared controller metadata types live in **`src/core/mod.rs`** (`ControllerSchema`, `FieldSchema`, `TypeSchema`) and are consumed by adapters (RPC/CLI) in different ways.
|
||||
- **Domain schema files**: For each domain, define controller schema metadata in a dedicated module inside the domain folder (example: **`src/openhuman/cron/schemas.rs`**) and export from the domain `mod.rs`.
|
||||
- **Controller-only exposure rule**: Expose domain functionality to **CLI and JSON-RPC through the controller registry** (`schemas.rs` + registered handlers). Do **not** add domain-specific branches or one-off transport logic in `src/core/cli.rs` or `src/core/jsonrpc.rs` just to expose a feature.
|
||||
- **Light `mod.rs` rule**: Keep domain `mod.rs` files light and export-focused. Put operational code in sibling files (example: `ops.rs`, `store.rs`, `schedule.rs`, `types.rs`), then re-export the public API from `mod.rs`.
|
||||
- **`core_server/`** — Transport only: Axum/HTTP, JSON-RPC envelope, CLI parsing, **dispatch** (`core_server::dispatch`) — **no** heavy business logic here.
|
||||
- **Layering**: Implementation in `openhuman::<domain>/`, controllers in `openhuman::<domain>/rpc.rs`, routes in `core_server/`.
|
||||
|
||||
Skills runtime uses **QuickJS** (`rquickjs`) in **`src/openhuman/skills/`** (e.g. `qjs_skill_instance.rs`, `qjs_engine.rs`), not V8/deno_core in this repository.
|
||||
- **`openhuman/`** — Domain logic (memory, channels, config, cron, skills, webhooks, …). RPC controllers in per-domain `rpc.rs`; use `RpcOutcome<T>` per [`AGENTS.md`](AGENTS.md).
|
||||
- **Module layout rule**: new functionality goes in a **dedicated subdirectory** (`openhuman/<domain>/mod.rs` + siblings). **Do not** add new standalone `*.rs` files at `src/openhuman/` root.
|
||||
- **Controller schema contract**: shared types in `src/core/mod.rs` (`ControllerSchema`, `FieldSchema`, `TypeSchema`).
|
||||
- **Domain schema files**: per-domain `schemas.rs` (e.g. `src/openhuman/cron/schemas.rs`), exported from domain `mod.rs`.
|
||||
- **Controller-only exposure**: expose features to CLI and JSON-RPC via the controller registry. **Do not** add domain branches in `src/core/cli.rs` / `src/core/jsonrpc.rs`.
|
||||
- **Light `mod.rs`**: keep domain `mod.rs` export-focused. Operational code in `ops.rs`, `store.rs`, `types.rs`, etc.
|
||||
- **`core_server/`** — Transport only: Axum/HTTP, JSON-RPC envelope, CLI parsing, dispatch. No heavy logic.
|
||||
|
||||
### Controller migration checklist
|
||||
|
||||
- `src/openhuman/<domain>/mod.rs`: keep export-focused, add `mod schemas;` and re-export:
|
||||
- `all_controller_schemas as all_<domain>_controller_schemas`
|
||||
- `all_registered_controllers as all_<domain>_registered_controllers`
|
||||
- `src/openhuman/<domain>/schemas.rs` must define:
|
||||
- `schemas(function: &str) -> ControllerSchema`
|
||||
- `all_controller_schemas() -> Vec<ControllerSchema>`
|
||||
- `all_registered_controllers() -> Vec<RegisteredController>`
|
||||
- domain handler fns `fn handle_*(_: Map<String, Value>) -> ControllerFuture`
|
||||
- Handlers should delegate to existing domain `rpc.rs` functions during migration.
|
||||
- Wire domain exports into `src/core/all.rs` for both declared schemas and registered handlers.
|
||||
- Keep adapters generic: do not add domain-specific logic to `src/core/cli.rs` or `src/core/jsonrpc.rs`.
|
||||
- Remove migrated method branches from `src/rpc/dispatch.rs` once registry coverage is in place.
|
||||
- `src/openhuman/<domain>/mod.rs`: add `mod schemas;`, re-export `all_controller_schemas as all_<domain>_controller_schemas` and `all_registered_controllers as all_<domain>_registered_controllers`.
|
||||
- `src/openhuman/<domain>/schemas.rs` defines `schemas`, `all_controller_schemas`, `all_registered_controllers`, and `handle_*` fns delegating to domain `rpc.rs`.
|
||||
- Wire exports into `src/core/all.rs`. Remove migrated branches from `src/rpc/dispatch.rs`.
|
||||
|
||||
### Event bus (`src/core/event_bus/`)
|
||||
|
||||
A typed pub/sub event bus for **decoupled cross-module communication** plus a **native, in-process typed request/response** surface. Both are singletons — one instance each for the whole application. Do **not** construct `EventBus` or `NativeRegistry` directly; use the module-level functions.
|
||||
Typed pub/sub + in-process typed request/response. Both singletons — use module-level functions; never construct `EventBus` / `NativeRegistry` directly.
|
||||
|
||||
**When to use which surface:**
|
||||
- **Broadcast** (`publish_global` / `subscribe_global`) — fire-and-forget. Many subscribers, no return.
|
||||
- **Native request/response** (`register_native_global` / `request_native_global`) — one-to-one typed dispatch keyed by method string. Zero serialization — trait objects, `mpsc::Sender`, `oneshot::Sender` pass through unchanged. Internal-only; JSON-RPC-facing work goes through `src/core/all.rs`.
|
||||
|
||||
- **Broadcast events** (`publish_global` / `subscribe_global`) — fire-and-forget notification. One publisher, many subscribers, no return value. Use when a module needs to _announce_ something happened and other modules may react independently.
|
||||
- **Native request/response** (`register_native_global` / `request_native_global`) — one-to-one typed Rust dispatch keyed by a method string. **Zero serialization**: trait objects (`Arc<dyn Provider>`), streaming channels (`mpsc::Sender<T>`), oneshot senders, and anything else `Send + 'static` all pass through unchanged. Use when a module needs a typed return value from another module in-process. This is **internal-only** — anything that needs to be callable over JSON-RPC should register against `src/core/all.rs` instead.
|
||||
|
||||
**Core types** (all in `src/core/event_bus/`):
|
||||
Core types (all in `src/core/event_bus/`):
|
||||
|
||||
| Type | File | Purpose |
|
||||
|------|------|---------|
|
||||
| `DomainEvent` | `events.rs` | `#[non_exhaustive]` enum — all cross-module events live here, grouped by domain |
|
||||
| `EventBus` | `bus.rs` | Singleton backed by `tokio::sync::broadcast`. Construction is `pub(crate)` — tests only |
|
||||
| `NativeRegistry` / `NativeRequestError` | `native_request.rs` | In-process typed request/response registry keyed by method name. Rust types only — passes trait objects, `mpsc::Sender`, and `oneshot::Sender` through without serialization |
|
||||
| `EventHandler` | `subscriber.rs` | Async trait with optional `domains()` filter for selective subscription |
|
||||
| `SubscriptionHandle` | `subscriber.rs` | RAII handle — subscriber task is cancelled on drop |
|
||||
| `TracingSubscriber` | `tracing.rs` | Built-in debug logger for all events (registered at startup) |
|
||||
| --- | --- | --- |
|
||||
| `DomainEvent` | `events.rs` | `#[non_exhaustive]` enum of all cross-module events |
|
||||
| `EventBus` | `bus.rs` | Singleton over `tokio::sync::broadcast`; ctor is `pub(crate)` |
|
||||
| `NativeRegistry` / `NativeRequestError` | `native_request.rs` | Typed request/response registry by method name |
|
||||
| `EventHandler` | `subscriber.rs` | Async trait with optional `domains()` filter |
|
||||
| `SubscriptionHandle` | `subscriber.rs` | RAII — drops cancel the subscriber |
|
||||
| `TracingSubscriber` | `tracing.rs` | Built-in debug logger |
|
||||
|
||||
**Singleton API** (all modules use these — never hold or pass `EventBus` / `NativeRegistry` instances):
|
||||
Singleton API: `init_global(capacity)`, `publish_global(event)`, `subscribe_global(handler)`, `register_native_global(method, handler)`, `request_native_global(method, req)`, `global()` / `native_registry()`.
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `event_bus::init_global(capacity)` | Initialize both singletons (broadcast bus + native registry) at startup (once) |
|
||||
| `event_bus::publish_global(event)` | Publish a broadcast event from anywhere (no-op if not yet initialized) |
|
||||
| `event_bus::subscribe_global(handler)` | Subscribe to broadcast events from anywhere (returns `None` if not yet initialized) |
|
||||
| `event_bus::register_native_global(method, handler)` | Register a typed native request handler for a method name — called at startup by each domain's `bus.rs` |
|
||||
| `event_bus::request_native_global(method, req)` | Dispatch a typed native request to the registered handler — zero serialization |
|
||||
| `event_bus::global()` / `event_bus::native_registry()` | Get the underlying singleton for advanced use |
|
||||
Domains: `agent`, `memory`, `channel`, `cron`, `skill`, `tool`, `webhook`, `system`.
|
||||
|
||||
**Domains:** `agent`, `memory`, `channel`, `cron`, `skill`, `tool`, `webhook`, `system`. See `events.rs` for the full variant list — events carry rich payloads so subscribers have everything they need.
|
||||
Each domain owns a `bus.rs` with its `EventHandler` impls — e.g. `cron/bus.rs` (`CronDeliverySubscriber`), `webhooks/bus.rs` (`WebhookRequestSubscriber`), `channels/bus.rs` (`ChannelInboundSubscriber`). Convention: `<Purpose>Subscriber` + `name()` returning `"<domain>::<purpose>"`.
|
||||
|
||||
**Domain subscriber files** — each domain owns its `bus.rs` with `EventHandler` impls:
|
||||
- `cron/bus.rs` — `CronDeliverySubscriber` (delivers job output to channels)
|
||||
- `webhooks/bus.rs` — `WebhookRequestSubscriber` (routes incoming requests to skills, emits responses via socket)
|
||||
- `channels/bus.rs` — `ChannelInboundSubscriber` (runs agent loop for inbound socket messages)
|
||||
- `skills/bus.rs` — stub for future subscribers
|
||||
**Adding events**: add variants to `DomainEvent`, extend the `domain()` match, create `<domain>/bus.rs`, register subscribers at startup, publish via `publish_global`.
|
||||
|
||||
**Adding events for a new domain:**
|
||||
**Adding a native handler**: define request/response types in the domain (owned fields, `Arc`s, channels — not borrows; `Send + 'static`, not `Serialize`). Register at startup keyed by `"<domain>.<verb>"`. Callers dispatch via `request_native_global`.
|
||||
|
||||
1. Add variants to `DomainEvent` in `events.rs` (prefix with domain name, e.g. `BillingInvoiceCreated { ... }`).
|
||||
2. Add the domain string to the `domain()` match arm.
|
||||
3. Create a `bus.rs` file **inside your domain module** (e.g. `src/openhuman/billing/bus.rs`) for subscriber implementations — each domain owns its handlers.
|
||||
4. Register subscribers in startup (e.g. `channels/runtime/startup.rs`) via the singleton.
|
||||
5. Publish events with `event_bus::publish_global(DomainEvent::YourEvent { ... })`.
|
||||
|
||||
**Example — publishing:**
|
||||
```rust
|
||||
use crate::core::event_bus::{publish_global, DomainEvent};
|
||||
|
||||
publish_global(DomainEvent::CronDeliveryRequested {
|
||||
job_id: job.id.clone(),
|
||||
channel: "telegram".into(),
|
||||
target: "chat-123".into(),
|
||||
output: "Job completed".into(),
|
||||
});
|
||||
```
|
||||
|
||||
**Example — subscribing (trait-based, in `<domain>/bus.rs`):**
|
||||
```rust
|
||||
use crate::core::event_bus::{DomainEvent, EventHandler};
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub struct MyDomainSubscriber { /* dependencies */ }
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for MyDomainSubscriber {
|
||||
fn name(&self) -> &str { "my_domain::handler" }
|
||||
fn domains(&self) -> Option<&[&str]> { Some(&["cron"]) } // filter by domain
|
||||
async fn handle(&self, event: &DomainEvent) {
|
||||
if let DomainEvent::CronJobCompleted { job_id, success } = event {
|
||||
// react to the event
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Convention:** Name the handler struct `<Purpose>Subscriber` (e.g. `CronDeliverySubscriber`) and the `name()` return value `"<domain>::<purpose>"` for grep-friendly tracing output.
|
||||
|
||||
**Adding a native request handler for a new domain:**
|
||||
|
||||
1. Define the **request and response types** in the domain (e.g. `src/openhuman/billing/bus.rs`). Use owned fields, `Arc`s, and channels — not borrows. Types only need `Send + 'static`, not `Serialize`.
|
||||
2. Register the handler at startup from the same `bus.rs`, keyed by a stable method name prefixed with the domain (e.g. `"billing.charge_invoice"`).
|
||||
3. Callers import the request/response types from the domain's public surface and dispatch via `request_native_global`.
|
||||
4. Method name convention: `"<domain>.<verb>"` — same naming scheme as JSON-RPC method roots for consistency, but these are **not** exposed over JSON-RPC.
|
||||
|
||||
**Example — native request (typed request/response, in `<domain>/bus.rs`):**
|
||||
```rust
|
||||
use crate::core::event_bus::{register_native_global, request_native_global};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
// Request carries non-serializable state directly — trait objects and
|
||||
// streaming channels all pass through unchanged.
|
||||
pub struct BillingChargeRequest {
|
||||
pub provider: Arc<dyn BillingProvider>,
|
||||
pub amount_cents: u64,
|
||||
pub progress_tx: Option<mpsc::Sender<String>>,
|
||||
}
|
||||
pub struct BillingChargeResponse {
|
||||
pub charge_id: String,
|
||||
}
|
||||
|
||||
// At startup:
|
||||
pub async fn register_billing_handlers() {
|
||||
register_native_global::<BillingChargeRequest, BillingChargeResponse, _, _>(
|
||||
"billing.charge",
|
||||
|req| async move {
|
||||
let id = req.provider.charge(req.amount_cents).await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(BillingChargeResponse { charge_id: id })
|
||||
},
|
||||
).await;
|
||||
}
|
||||
|
||||
// From another module:
|
||||
let resp: BillingChargeResponse = request_native_global(
|
||||
"billing.charge",
|
||||
BillingChargeRequest { provider, amount_cents: 500, progress_tx: None },
|
||||
).await?;
|
||||
```
|
||||
|
||||
**Tests:** override production handlers by calling `register_native_global` again for the same method before exercising the code under test — the most recent registration wins. For full isolation, construct a fresh `NativeRegistry` directly via `NativeRegistry::new()` and use its `register` / `request` methods.
|
||||
**Tests**: re-register the same method to override; or construct a fresh `NativeRegistry::new()` for isolation.
|
||||
|
||||
---
|
||||
|
||||
## App theme & design system
|
||||
## Design
|
||||
|
||||
**Design intent**: Premium, calm visual language — ocean primary (`#4A83DD`), sage / amber / coral semantic colors, Inter + Cabinet Grotesk + JetBrains Mono, Tailwind with custom radii/spacing/shadows. Details: [`docs/DESIGN_GUIDELINES.md`](docs/DESIGN_GUIDELINES.md).
|
||||
Premium, calm visual language — ocean primary `#4A83DD`, sage / amber / coral semantics, Inter + Cabinet Grotesk + JetBrains Mono, Tailwind with custom radii/spacing/shadows. See [`docs/DESIGN_GUIDELINES.md`](docs/DESIGN_GUIDELINES.md).
|
||||
|
||||
## Desktop shell (Tauri) vs application code
|
||||
## Shell vs app code
|
||||
|
||||
In the parent **OpenHuman** desktop app, **Tauri / Rust is a delivery vehicle**: windowing, process lifecycle, IPC to the core sidecar, and other host concerns. **Keep as much UI behavior and product logic as practical in TypeScript/React** (`app/`). Avoid growing Rust in the shell for flows that belong in the web layer unless there is a hard platform or security reason.
|
||||
Tauri/Rust in the shell is a **delivery vehicle** (windowing, process lifecycle, IPC). Keep UI behavior and product logic in TypeScript/React (`app/`). Only grow Rust in the shell for hard platform/security reasons.
|
||||
|
||||
## Git workflow
|
||||
|
||||
- **GitHub issues on upstream** — File and track issues on **[tinyhumansai/openhuman](https://github.com/tinyhumansai/openhuman/)** ([Issues](https://github.com/tinyhumansai/openhuman/issues)), not only a fork’s tracker, unless the workflow explicitly says otherwise.
|
||||
- **GitHub issue templates** — Use **[`.github/ISSUE_TEMPLATE/feature.md`](.github/ISSUE_TEMPLATE/feature.md)** for new features and **[`.github/ISSUE_TEMPLATE/bug.md`](.github/ISSUE_TEMPLATE/bug.md)** for bugs; keep the same section structure and fill every required part. AI-authored issues should follow those templates verbatim.
|
||||
- **Open pull requests on upstream** — Always create PRs against **[tinyhumansai/openhuman](https://github.com/tinyhumansai/openhuman)** ([pull requests](https://github.com/tinyhumansai/openhuman/pulls)), not only a fork’s default remote, unless the workflow explicitly says otherwise.
|
||||
- **Public repo**; push to your working branch; PRs target **`main`**.
|
||||
- Use [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md); AI-generated PR text should follow its sections and checklist.
|
||||
- Issues and PRs on upstream **[tinyhumansai/openhuman](https://github.com/tinyhumansai/openhuman)** — not a fork — unless explicitly told otherwise.
|
||||
- Issue templates: [`.github/ISSUE_TEMPLATE/feature.md`](.github/ISSUE_TEMPLATE/feature.md), [`.github/ISSUE_TEMPLATE/bug.md`](.github/ISSUE_TEMPLATE/bug.md). PR template: [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md). AI-authored text should follow them verbatim.
|
||||
- PRs target **`main`**.
|
||||
|
||||
---
|
||||
|
||||
## Coding philosophy
|
||||
|
||||
- **Unix-style modules**: Prefer **individual modules** with a **single, sharp responsibility**—each should do one thing really well. Compose behavior through small, well-named units and clear boundaries instead of monolithic code.
|
||||
- **Tests before the next layer**: Ship **enough unit tests and coverage** for the behavior you are adding or changing **before** building additional features on top of it. Treat untested code as incomplete; do not accumulate depth on a shaky base.
|
||||
- **Documentation with code**: New or changed behavior must ship with matching documentation. At minimum, add concise rustdoc / code comments where the flow is not obvious, and update `AGENTS.md`, architecture docs, or feature docs when repository rules or user-visible behavior change.
|
||||
- **Unix-style modules**: small, sharp-responsibility units composed through clear boundaries.
|
||||
- **Tests before the next layer**: ship unit tests for new/changed behavior before stacking features. Untested code is incomplete.
|
||||
- **Docs with code**: new/changed behavior ships with matching rustdoc / code comments; update `AGENTS.md` or architecture docs when rules or user-visible behavior change.
|
||||
|
||||
---
|
||||
|
||||
## Debug logging rule (must follow)
|
||||
## Debug logging (must follow)
|
||||
|
||||
- **Default to verbose diagnostics on new/changed flows**: Add substantial, development-oriented logs while implementing features or fixes so issues are easy to trace end-to-end.
|
||||
- **Log critical checkpoints**: Include logs at entry/exit points, branch decisions, external calls, retries/timeouts, state transitions, and error handling paths.
|
||||
- **Use structured, grep-friendly context**: Prefer stable prefixes (for example `[domain]`, `[rpc]`, `[ui-flow]`) and include correlation fields such as request IDs, method names, and entity IDs when available.
|
||||
- **Platform conventions**: In Rust, use `log` / `tracing` at `debug` or `trace`; in `app/`, use namespaced `debug` logs and dev-only detail as needed.
|
||||
- **Keep logs safe**: Never log secrets or sensitive payloads (API keys, JWTs, credentials, full PII). Redact or omit sensitive fields.
|
||||
- **Treat debuggability as a deliverable**: Changes lacking sufficient logging for diagnosis are incomplete and should be updated before handoff.
|
||||
- Default to **verbose diagnostics** on new/changed flows so issues are easy to trace end-to-end.
|
||||
- Log entry/exit, branches, external calls, retries/timeouts, state transitions, errors.
|
||||
- Use stable grep-friendly prefixes (`[domain]`, `[rpc]`, `[ui-flow]`) and correlation fields (request IDs, method names, entity IDs).
|
||||
- Rust: `log` / `tracing` at `debug` / `trace`. `app/`: namespaced `debug` + dev-only detail.
|
||||
- **Never** log secrets or full PII — redact.
|
||||
- Changes lacking diagnosis logging are incomplete.
|
||||
|
||||
---
|
||||
|
||||
## Feature design workflow (new capabilities)
|
||||
## Feature design workflow
|
||||
|
||||
Follow this order so behavior is **specified**, **proven in Rust**, **proven over RPC**, then **surfaced in the UI** with matching tests.
|
||||
Specify → prove in Rust → prove over RPC → surface in the UI → test.
|
||||
|
||||
1. **Specify against the current codebase** — Ground the design in **existing** domains, controller/registry patterns, and JSON-RPC naming (`openhuman.<namespace>_<function>`). Reuse or extend documented flows in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) and sibling guides; avoid parallel architectures.
|
||||
2. **Implement in Rust** — Add domain logic under `src/openhuman/<domain>/`, wire **schemas + registered handlers** into the shared registry, and land **unit tests** in the crate (`cargo test -p openhuman`, focused modules) until the feature is correct in isolation.
|
||||
3. **JSON-RPC E2E** — Add or extend **integration-style tests** that call the real HTTP JSON-RPC surface (e.g. [`tests/json_rpc_e2e.rs`](tests/json_rpc_e2e.rs), mock backend / [`scripts/test-rust-with-mock.sh`](scripts/test-rust-with-mock.sh) as appropriate) so methods, params, and outcomes match what the UI will call.
|
||||
4. **UI in the Tauri app** — Build **React** screens, state, and **`core_rpc_relay` / `coreRpcClient`** usage in `app/`; keep **business rules** in the core, not duplicated in the shell.
|
||||
5. **App unit tests** — Cover components, hooks, and clients with **Vitest** (`yarn test` / `yarn test:unit` in `app/`).
|
||||
6. **App E2E** — Add **desktop E2E** specs where the feature is user-visible (`yarn test:e2e*`, isolated workspace — see [Testing Guide (Unit + E2E)](#testing-guide-unit--e2e)) so the full stack (UI → Tauri → sidecar) behaves as intended.
|
||||
1. **Specify against the current codebase** — ground in existing domains, controller/registry patterns, JSON-RPC naming (`openhuman.<namespace>_<function>`). No parallel architectures.
|
||||
2. **Implement in Rust** — domain logic under `src/openhuman/<domain>/`, schemas + handlers in the registry, unit tests until correct in isolation.
|
||||
3. **JSON-RPC E2E** — extend [`tests/json_rpc_e2e.rs`](tests/json_rpc_e2e.rs) / [`scripts/test-rust-with-mock.sh`](scripts/test-rust-with-mock.sh) so RPC methods match what the UI will call.
|
||||
4. **UI in Tauri app** — React screens/state using `core_rpc_relay` / `coreRpcClient`. Keep rules in the core.
|
||||
5. **App unit tests** — Vitest.
|
||||
6. **App E2E** — desktop specs for user-visible flows.
|
||||
|
||||
**Capability catalog** — When a change adds, removes, renames, relocates, or materially changes a user-facing feature, update **`src/openhuman/about_app/`** in the same work so the runtime capability catalog remains the source of truth for what the app can do.
|
||||
**Capability catalog**: when a change adds/removes/renames a user-facing feature, update `src/openhuman/about_app/` in the same work.
|
||||
|
||||
**Debug logging (throughout)** — Add **lots of development-oriented logging** as you build, not as an afterthought. In **Rust**, use `log` / `tracing` at **`debug`** or **`trace`** on RPC entry and exit, error paths, state transitions, and any branch that is hard to infer from tests alone. In **`app/`**, follow existing patterns (e.g. the **`debug`** npm package with a **namespace** per area) plus **dev-only** detail where useful. Prefer **grep-friendly prefixes** (`[feature]`, domain name, or JSON-RPC method) so terminal output from **sidecar**, **Tauri**, and **WebView** can be correlated during `yarn dev` / `tauri dev`. **Never** log secrets, raw JWTs, API keys, or full PII—redact or omit.
|
||||
|
||||
**Planning rule:** When scoping a feature, define the **E2E scenarios (core RPC + app)** up front. Those scenarios should **cover the full intended scope**—happy paths, failure modes, auth or policy gates, and regressions you care about. If a scenario is not testable end-to-end, the spec is incomplete or the cut is too large; split or add harness support first.
|
||||
**Planning rule**: up front, define the **E2E scenarios (core RPC + app)** that cover the full intended scope — happy paths, failure modes, auth gates, regressions. Not testable end-to-end ⇒ incomplete spec or too-large cut.
|
||||
|
||||
---
|
||||
|
||||
## Key patterns (concise)
|
||||
## Key patterns
|
||||
|
||||
- **Debug logging**: Ship **heavy `debug`/`trace` (Rust)** and **namespaced `debug` / dev logs (`app/`)** on new flows so sidecar + WebView output is easy to grep; see [Feature design workflow](#feature-design-workflow-new-capabilities). Never log secrets or raw tokens.
|
||||
- **`src/openhuman/`**: New features go in a **folder/module**, not new root-level `src/openhuman/*.rs` files (see Rust core section).
|
||||
- **File size**: Prefer ≤ ~500 lines per source file; split modules when growing.
|
||||
- **Pre-merge checks** (when touching code): Prettier, ESLint, `tsc --noEmit` in `app/`; `cargo fmt` + `cargo check` for changed Rust (`Cargo.toml` at root and/or `app/src-tauri/Cargo.toml` as appropriate).
|
||||
- **No dynamic imports** in production **`app/src`** code — use **static** `import` / `import type` at the top of the module. Do **not** use `import()` (async dynamic import), `React.lazy(() => import(...))`, or `await import('…')` to load app modules, Tauri APIs, or RPC clients. **Why:** predictable chunk graph, simpler static analysis, fewer surprises in Tauri + Vite, and easier code review. **If a module must not run at load time** (e.g. heavy optional path), use a static import and **guard the call site** with `try/catch` or an explicit runtime check instead of deferring module load via dynamic import. **Exceptions:** Vitest harness patterns (`vi.importActual`, dynamic imports **only** inside `*.test.ts` / `__tests__` / `test/setup.ts` when required by the runner); ambient `typeof import('…')` in `.d.ts`; config files (e.g. `tailwind.config.js` JSDoc).- **Type-only imports**: `import type` where appropriate.
|
||||
- **Dual socket / tool sync**: If you change realtime protocol, keep **frontend** (`socketService` / MCP transport) and **core** socket behavior aligned (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) dual-socket section).
|
||||
- **File size**: prefer ≤ ~500 lines; split growing modules.
|
||||
- **Pre-merge** (code changes): Prettier, ESLint, `tsc --noEmit` in `app/`; `cargo fmt` + `cargo check` for changed Rust.
|
||||
- **No dynamic imports** in production `app/src` code — static `import` / `import type` only. No `import()`, `React.lazy(() => import(...))`, `await import(...)`. For heavy optional paths, use a static import and guard the call site with `try/catch` or a runtime check. *Exceptions*: Vitest harness patterns in `*.test.ts` / `__tests__` / `test/setup.ts`; ambient `typeof import('…')` in `.d.ts`; config files (e.g. `tailwind.config.js` JSDoc).
|
||||
- **Dual socket sync**: when changing the realtime protocol, keep `socketService` / MCP transport aligned with core socket behavior (see `docs/ARCHITECTURE.md` dual-socket section).
|
||||
|
||||
---
|
||||
|
||||
## Platform notes
|
||||
|
||||
- **Vendored CEF-aware `tauri-cli` (required)**: The default runtime is **CEF**, and only the **vendored** `tauri-cli` at `app/src-tauri/vendor/tauri-cef/crates/tauri-cli` knows how to bundle the Chromium Embedded Framework into the app's `Contents/Frameworks/`. The stock `@tauri-apps/cli` / upstream `cargo-tauri` produces a bundle **without** `Frameworks/` and the app panics at startup inside `cef::library_loader::LibraryLoader::new` with `No such file or directory`. `yarn dev:app` (and every other `cargo tauri` script in `app/package.json`) now calls **`yarn tauri:ensure`** which runs [`scripts/ensure-tauri-cli.sh`](scripts/ensure-tauri-cli.sh) to install the vendored CLI into `~/.cargo/bin/cargo-tauri` on first use. If you ever install a different `tauri-cli` over it (e.g. `npm i -g @tauri-apps/cli`) you'll need to re-run the ensure script / `cargo install --locked --path app/src-tauri/vendor/tauri-cef/crates/tauri-cli`.
|
||||
- **macOS deep links**: Often require a built **`.app`** bundle; not only `tauri dev`. See [`docs/telegram-login-desktop.md`](docs/telegram-login-desktop.md) if applicable.
|
||||
- **`window.__TAURI__`**: Not assumed at module load; guard Tauri usage accordingly.
|
||||
- **Core sidecar**: Must be staged/built so `core_rpc` can reach the `openhuman` binary (see `scripts/stage-core-sidecar.mjs`).
|
||||
|
||||
---
|
||||
|
||||
_Last aligned with monorepo layout (`app/` + root `src/`), QuickJS skills in `openhuman_core`, skills catalog on GitHub (`tinyhumansai/openhuman-skills`), and Tauri shell IPC as of repo state._
|
||||
- **Vendored CEF-aware `tauri-cli`**: runtime is CEF; only the vendored CLI at `app/src-tauri/vendor/tauri-cef/crates/tauri-cli` bundles Chromium into `Contents/Frameworks/`. Stock `@tauri-apps/cli` produces a broken bundle (panic in `cef::library_loader::LibraryLoader::new`). `yarn dev:app` and all `cargo tauri` scripts call `yarn tauri:ensure` which runs [`scripts/ensure-tauri-cli.sh`](scripts/ensure-tauri-cli.sh). If overwritten, reinstall with `cargo install --locked --path app/src-tauri/vendor/tauri-cef/crates/tauri-cli`.
|
||||
- **macOS deep links**: often require a built `.app` bundle, not just `tauri dev`.
|
||||
- **`window.__TAURI__`**: not present at module load; guard Tauri usage.
|
||||
- **Core sidecar**: must be staged so `core_rpc` can reach the `openhuman` binary (see `scripts/stage-core-sidecar.mjs`).
|
||||
|
||||
Generated
+7
@@ -862,6 +862,12 @@ version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -4468,6 +4474,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"data-url",
|
||||
"dirs",
|
||||
"dunce",
|
||||
"embed_plist",
|
||||
|
||||
@@ -40,6 +40,7 @@ tauri = { version = "2.10", default-features = false, features = [
|
||||
"macos-private-api",
|
||||
"tray-icon",
|
||||
"unstable",
|
||||
"webview-data-url",
|
||||
] }
|
||||
tauri-plugin-deep-link = "2.0.0"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"core:event:default",
|
||||
"deep-link:default",
|
||||
"notification:default",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify",
|
||||
"opener:default",
|
||||
"allow-core-process"
|
||||
]
|
||||
|
||||
@@ -44,12 +44,12 @@ impl UaSpec {
|
||||
pub fn chrome_mac() -> Self {
|
||||
Self {
|
||||
user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
|
||||
(KHTML, like Gecko) Chrome/124.0.6367.118 Safari/537.36"
|
||||
(KHTML, like Gecko) Chrome/136.0.7103.114 Safari/537.36"
|
||||
.to_string(),
|
||||
chrome_major: "124".to_string(),
|
||||
chrome_full: "124.0.6367.118".to_string(),
|
||||
chrome_major: "136".to_string(),
|
||||
chrome_full: "136.0.7103.114".to_string(),
|
||||
platform: "macOS".to_string(),
|
||||
platform_version: "14.0.0".to_string(),
|
||||
platform_version: "15.0.0".to_string(),
|
||||
architecture: "x86".to_string(),
|
||||
bitness: "64".to_string(),
|
||||
mobile: false,
|
||||
|
||||
@@ -137,6 +137,68 @@ async fn run_session_cycle(account_id: &str, real_url: &str) -> Result<(), Strin
|
||||
session_id
|
||||
);
|
||||
|
||||
// Stub the Web Notifications permission API before any provider JS
|
||||
// runs. Without this, providers like Slack and Gmail show in-app
|
||||
// "please enable notifications" banners because Notification.permission
|
||||
// returns "default" in the CEF context. The real notification path runs
|
||||
// through the CEF IPC hook registered in webview_accounts — this just
|
||||
// makes the page's permission check pass.
|
||||
cdp.call(
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
json!({
|
||||
"source": "(function(){\
|
||||
function ensureNotificationGranted(){\
|
||||
try {\
|
||||
var NativeNotification = window.Notification;\
|
||||
if (typeof NativeNotification === 'function') {\
|
||||
var OpenHumanNotification = function(title, options){\
|
||||
try { return new NativeNotification(title, options); }\
|
||||
catch (_) { return {}; }\
|
||||
};\
|
||||
OpenHumanNotification.prototype = NativeNotification.prototype;\
|
||||
try {\
|
||||
Object.defineProperty(OpenHumanNotification, 'permission', {\
|
||||
get: function(){ return 'granted'; },\
|
||||
configurable: true\
|
||||
});\
|
||||
} catch (_) {}\
|
||||
OpenHumanNotification.requestPermission = function(){\
|
||||
return Promise.resolve('granted');\
|
||||
};\
|
||||
window.Notification = OpenHumanNotification;\
|
||||
}\
|
||||
} catch (_) {}\
|
||||
try {\
|
||||
var p = navigator && navigator.permissions;\
|
||||
if (p && typeof p.query === 'function') {\
|
||||
var q = p.query.bind(p);\
|
||||
var fp = {\
|
||||
query: function(d){\
|
||||
if (d && d.name === 'notifications') {\
|
||||
return Promise.resolve({ state: 'granted', onchange: null });\
|
||||
}\
|
||||
return q(d);\
|
||||
}\
|
||||
};\
|
||||
Object.defineProperty(navigator, 'permissions', {\
|
||||
get: function(){ return fp; },\
|
||||
configurable: true\
|
||||
});\
|
||||
}\
|
||||
} catch (_) {}\
|
||||
}\
|
||||
ensureNotificationGranted();\
|
||||
try { setInterval(ensureNotificationGranted, 1000); } catch (_) {}\
|
||||
})();"
|
||||
}),
|
||||
Some(&session_id),
|
||||
)
|
||||
.await?;
|
||||
log::debug!(
|
||||
"[cdp-session][{}] notification permission stub injected",
|
||||
account_id
|
||||
);
|
||||
|
||||
// Drive the webview from the placeholder to the real provider URL.
|
||||
// Fragment survives same-origin navigations so scanners can match on
|
||||
// it indefinitely. Skip navigation if the target is already on the
|
||||
|
||||
@@ -19,21 +19,34 @@ pub struct CdpTarget {
|
||||
/// CDP sessions in the app tunnel through this one ws once `flatten: true`
|
||||
/// is set on attach.
|
||||
pub async fn browser_ws_url() -> Result<String, String> {
|
||||
let url = format!("http://{CDP_HOST}:{CDP_PORT}/json/version");
|
||||
let resp = reqwest::Client::builder()
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("openhuman-cdp/1.0")
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| format!("reqwest build: {e}"))?
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GET {url}: {e}"))?;
|
||||
let v: Value = resp.json().await.map_err(|e| format!("parse: {e}"))?;
|
||||
v.get("webSocketDebuggerUrl")
|
||||
.and_then(|x| x.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "no webSocketDebuggerUrl in /json/version".to_string())
|
||||
.map_err(|e| format!("reqwest build: {e}"))?;
|
||||
let mut last_err: Option<String> = None;
|
||||
for host in [CDP_HOST, "localhost"] {
|
||||
let url = format!("http://{host}:{CDP_PORT}/json/version");
|
||||
match client.get(&url).send().await {
|
||||
Ok(resp) => match resp.json::<Value>().await {
|
||||
Ok(v) => {
|
||||
if let Some(ws) = v.get("webSocketDebuggerUrl").and_then(|x| x.as_str()) {
|
||||
return Ok(ws.to_string());
|
||||
}
|
||||
last_err = Some(format!("no webSocketDebuggerUrl in {url}"));
|
||||
}
|
||||
Err(e) => {
|
||||
// Don't bail out — fall through so the next host in the
|
||||
// candidate list still gets a chance to resolve the ws url.
|
||||
last_err = Some(format!("parse {url}: {e}"));
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
last_err = Some(format!("GET {url}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| "failed to resolve CDP websocket URL".to_string()))
|
||||
}
|
||||
|
||||
pub fn parse_targets(v: &Value) -> Vec<CdpTarget> {
|
||||
|
||||
@@ -887,6 +887,12 @@ pub fn run() {
|
||||
webview_accounts::webview_account_hide,
|
||||
webview_accounts::webview_account_show,
|
||||
webview_accounts::webview_recipe_event,
|
||||
webview_accounts::webview_notification_permission_state,
|
||||
webview_accounts::webview_notification_permission_request,
|
||||
webview_accounts::webview_notification_set_dnd,
|
||||
webview_accounts::webview_notification_mute_account,
|
||||
webview_accounts::webview_notification_get_bypass_prefs,
|
||||
webview_accounts::webview_set_focused_account,
|
||||
notification_settings::notification_settings_get,
|
||||
notification_settings::notification_settings_set,
|
||||
activate_main_window
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//! cookies and storage don't bleed between accounts (best-effort on
|
||||
//! WKWebView — see Tauri docs on `data_store_identifier` for the macOS path).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -42,7 +42,6 @@ const RUNTIME_JS: &str = include_str!("runtime.js");
|
||||
// under the cef feature; wry builds still need the old JS shim so the recipes
|
||||
// that emit an `ingest` payload (gmail / linkedin / google-meet) survive
|
||||
// fingerprint gates on Slack/Google's login flow.
|
||||
#[cfg(not(feature = "cef"))]
|
||||
const UA_SPOOF_JS: &str = include_str!("ua_spoof.js");
|
||||
const LINKEDIN_RECIPE_JS: &str = include_str!("../../recipes/linkedin/recipe.js");
|
||||
const GMAIL_RECIPE_JS: &str = include_str!("../../recipes/gmail/recipe.js");
|
||||
@@ -102,7 +101,6 @@ fn provider_is_supported(provider: &str) -> bool {
|
||||
/// Whether to pre-load `ua_spoof.js` for a given provider (wry only — cef
|
||||
/// handles UA via CDP `Emulation.setUserAgentOverride`). Enabled for
|
||||
/// services known to run Chromium-specific fingerprinting checks.
|
||||
#[cfg(not(feature = "cef"))]
|
||||
fn provider_ua_spoof(provider: &str) -> bool {
|
||||
matches!(
|
||||
provider,
|
||||
@@ -237,6 +235,47 @@ pub struct WebviewAccountsState {
|
||||
/// close/purge so reopen cycles don't stack multiple live loops.
|
||||
#[cfg(feature = "cef")]
|
||||
cdp_sessions: Mutex<HashMap<String, tokio::task::JoinHandle<()>>>,
|
||||
/// Runtime notification-bypass controls used by the settings UI.
|
||||
notification_bypass: Mutex<NotificationBypassPrefs>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct NotificationBypassPrefs {
|
||||
global_dnd: bool,
|
||||
muted_accounts: HashSet<String>,
|
||||
bypass_when_focused: bool,
|
||||
focused_account: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for NotificationBypassPrefs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
global_dnd: false,
|
||||
muted_accounts: HashSet::new(),
|
||||
// Match the existing UI copy: focused account may suppress toast.
|
||||
bypass_when_focused: true,
|
||||
focused_account: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct NotificationBypassPrefsPayload {
|
||||
pub global_dnd: bool,
|
||||
pub muted_accounts: Vec<String>,
|
||||
pub bypass_when_focused: bool,
|
||||
}
|
||||
|
||||
impl From<&NotificationBypassPrefs> for NotificationBypassPrefsPayload {
|
||||
fn from(value: &NotificationBypassPrefs) -> Self {
|
||||
let mut muted_accounts = value.muted_accounts.iter().cloned().collect::<Vec<_>>();
|
||||
muted_accounts.sort();
|
||||
Self {
|
||||
global_dnd: value.global_dnd,
|
||||
muted_accounts,
|
||||
bypass_when_focused: value.bypass_when_focused,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Title prefix applied to every OS toast fired from an embedded webview.
|
||||
@@ -276,6 +315,34 @@ fn forward_native_notification<R: Runtime>(
|
||||
provider: &str,
|
||||
payload: &tauri_runtime_cef::notification::NotificationPayload,
|
||||
) {
|
||||
if let Some(state) = app.try_state::<WebviewAccountsState>() {
|
||||
let prefs = state.notification_bypass.lock().unwrap().clone();
|
||||
if prefs.global_dnd {
|
||||
log::debug!(
|
||||
"[notify-bypass][{}] suppressed global_dnd provider={}",
|
||||
account_id,
|
||||
provider
|
||||
);
|
||||
return;
|
||||
}
|
||||
if prefs.muted_accounts.contains(account_id) {
|
||||
log::debug!(
|
||||
"[notify-bypass][{}] suppressed muted_account provider={}",
|
||||
account_id,
|
||||
provider
|
||||
);
|
||||
return;
|
||||
}
|
||||
if prefs.bypass_when_focused && prefs.focused_account.as_deref() == Some(account_id) {
|
||||
log::debug!(
|
||||
"[notify-bypass][{}] suppressed focused_account provider={}",
|
||||
account_id,
|
||||
provider
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Feature flag — bail early when the user hasn't opted in.
|
||||
if let Some(settings) =
|
||||
app.try_state::<crate::notification_settings::NotificationSettingsState>()
|
||||
@@ -299,14 +366,15 @@ fn forward_native_notification<R: Runtime>(
|
||||
};
|
||||
let body = payload.body.as_deref().unwrap_or("");
|
||||
log::info!(
|
||||
"[notify-cef][{}] source={:?} tag={:?} silent={} title={:?} body_chars={}",
|
||||
"[notify-cef][{}] source={:?} tag={:?} silent={} title_chars={} body_chars={}",
|
||||
account_id,
|
||||
payload.source,
|
||||
payload.tag,
|
||||
payload.silent,
|
||||
raw_title,
|
||||
raw_title.chars().count(),
|
||||
body.chars().count()
|
||||
);
|
||||
log::debug!("[notify-cef][{}] raw_title={:?}", account_id, raw_title);
|
||||
|
||||
// Mirror to the frontend BEFORE firing the OS toast so the Redux
|
||||
// store has the routing context ready by the time the user clicks.
|
||||
@@ -325,6 +393,18 @@ fn forward_native_notification<R: Runtime>(
|
||||
);
|
||||
}
|
||||
|
||||
// Respect the Web Notification `silent` flag — the mirror event above
|
||||
// still updates the in-app notification center, but the OS toast is
|
||||
// suppressed so the user is not audibly/visually interrupted for
|
||||
// notifications the page explicitly marked as silent.
|
||||
if payload.silent {
|
||||
log::debug!(
|
||||
"[notify-cef][{}] silent=true, suppressing OS toast",
|
||||
account_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut builder = app.notification().builder().title(¬ify_title);
|
||||
if !body.is_empty() {
|
||||
builder = builder.body(body);
|
||||
@@ -444,15 +524,21 @@ fn data_directory_for<R: Runtime>(app: &AppHandle<R>, account_id: &str) -> Resul
|
||||
/// (spoof + runtime + recipe).
|
||||
#[cfg(feature = "cef")]
|
||||
fn build_init_script(account_id: &str, provider: &str) -> String {
|
||||
let spoof = if provider_ua_spoof(provider) {
|
||||
UA_SPOOF_JS
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let Some(recipe_js) = provider_recipe_js(provider) else {
|
||||
return String::new();
|
||||
return spoof.to_string();
|
||||
};
|
||||
let ctx = serde_json::json!({
|
||||
"accountId": account_id,
|
||||
"provider": provider,
|
||||
});
|
||||
format!(
|
||||
"window.__OPENHUMAN_RECIPE_CTX__ = {ctx};\n\n{runtime}\n\n{recipe}\n",
|
||||
"{spoof}\n\nwindow.__OPENHUMAN_RECIPE_CTX__ = {ctx};\n\n{runtime}\n\n{recipe}\n",
|
||||
spoof = spoof,
|
||||
ctx = ctx,
|
||||
runtime = RUNTIME_JS,
|
||||
recipe = recipe_js
|
||||
@@ -1042,6 +1128,87 @@ pub async fn webview_account_show<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Web-shape notification permission state used by frontend parity code.
|
||||
/// CEF path is effectively granted because interception is handled in-app.
|
||||
#[tauri::command]
|
||||
pub fn webview_notification_permission_state() -> String {
|
||||
#[cfg(feature = "cef")]
|
||||
{
|
||||
"granted".to_string()
|
||||
}
|
||||
#[cfg(not(feature = "cef"))]
|
||||
{
|
||||
"default".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Request notification permission and return web-shape state.
|
||||
#[tauri::command]
|
||||
pub fn webview_notification_permission_request() -> String {
|
||||
webview_notification_permission_state()
|
||||
}
|
||||
|
||||
/// Enable/disable global DND for embedded webview OS toasts.
|
||||
#[tauri::command]
|
||||
pub fn webview_notification_set_dnd(
|
||||
state: tauri::State<'_, WebviewAccountsState>,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let mut prefs = state.notification_bypass.lock().unwrap();
|
||||
prefs.global_dnd = enabled;
|
||||
log::debug!("[notify-bypass] set global_dnd={enabled}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mute/unmute a specific embedded account for OS toasts.
|
||||
#[tauri::command]
|
||||
pub fn webview_notification_mute_account(
|
||||
state: tauri::State<'_, WebviewAccountsState>,
|
||||
account_id: String,
|
||||
muted: bool,
|
||||
) -> Result<(), String> {
|
||||
let account_id = sanitize_account_id(&account_id)?.to_string();
|
||||
let mut prefs = state.notification_bypass.lock().unwrap();
|
||||
if muted {
|
||||
prefs.muted_accounts.insert(account_id.clone());
|
||||
} else {
|
||||
prefs.muted_accounts.remove(&account_id);
|
||||
}
|
||||
log::debug!(
|
||||
"[notify-bypass] set muted account_id={} muted={}",
|
||||
account_id,
|
||||
muted
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return current bypass preferences for the settings UI.
|
||||
#[tauri::command]
|
||||
pub fn webview_notification_get_bypass_prefs(
|
||||
state: tauri::State<'_, WebviewAccountsState>,
|
||||
) -> NotificationBypassPrefsPayload {
|
||||
let prefs = state.notification_bypass.lock().unwrap();
|
||||
NotificationBypassPrefsPayload::from(&*prefs)
|
||||
}
|
||||
|
||||
/// Track which account is currently focused in the shell UI.
|
||||
#[tauri::command]
|
||||
pub fn webview_set_focused_account(
|
||||
state: tauri::State<'_, WebviewAccountsState>,
|
||||
account_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let mut prefs = state.notification_bypass.lock().unwrap();
|
||||
prefs.focused_account = match account_id {
|
||||
Some(id) => Some(sanitize_account_id(&id)?.to_string()),
|
||||
None => None,
|
||||
};
|
||||
log::debug!(
|
||||
"[notify-bypass] set focused_account={}",
|
||||
prefs.focused_account.as_deref().unwrap_or("<none>")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Called from the injected runtime each time the recipe emits an event.
|
||||
/// We forward to React via a Tauri event so the UI can render and persist.
|
||||
#[tauri::command]
|
||||
|
||||
@@ -97,4 +97,110 @@
|
||||
try {
|
||||
delete window.safari;
|
||||
} catch (_) {}
|
||||
|
||||
// navigator.permissions — return "granted" for notifications queries.
|
||||
// Simple property assignment on Blink platform objects (like Permissions)
|
||||
// is silently ignored; Object.defineProperty on the navigator itself works
|
||||
// because navigator exposes configurable getters (same mechanism used above
|
||||
// for navigator.userAgent).
|
||||
try {
|
||||
var _rp = navigator && navigator.permissions;
|
||||
if (_rp && typeof _rp.query === 'function') {
|
||||
var _rq = _rp.query.bind(_rp);
|
||||
var _fp = {
|
||||
query: function (d) {
|
||||
if (d && (d.name === 'notifications' || d.name === 'push')) {
|
||||
return Promise.resolve({ state: 'granted', onchange: null });
|
||||
}
|
||||
return _rq(d);
|
||||
},
|
||||
};
|
||||
Object.defineProperty(navigator, 'permissions', {
|
||||
get: function () { return _fp; },
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// PushManager permission checks — Slack relies on these and can keep showing
|
||||
// the notification permission banner when this still returns "prompt".
|
||||
try {
|
||||
if (
|
||||
window.PushManager &&
|
||||
window.PushManager.prototype &&
|
||||
typeof window.PushManager.prototype.permissionState === 'function'
|
||||
) {
|
||||
var __ohPushPermissionState = function () {
|
||||
return Promise.resolve('granted');
|
||||
};
|
||||
Object.defineProperty(window.PushManager.prototype, 'permissionState', {
|
||||
get: function () { return __ohPushPermissionState; },
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Notification.permission — ensure it reads "granted" even if the V8-level
|
||||
// shim in cef-helper is overwritten or not yet in place for this context.
|
||||
// Some apps (Slack) rebind/inspect the Notification constructor itself,
|
||||
// so wrapping the constructor is more reliable than patching only the
|
||||
// `permission` static descriptor.
|
||||
//
|
||||
// Guard with `window.__OH_NOTIF_SHIM` so repeat evaluations of this script
|
||||
// (e.g. from `Page.addScriptToEvaluateOnNewDocument` plus frame-level
|
||||
// re-injections) don't keep stacking wrappers onto the same globals and
|
||||
// double-proxy `Function.prototype.toString`.
|
||||
if (window.__OH_NOTIF_SHIM) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (typeof window.Notification === 'function') {
|
||||
var __NativeNotification = window.Notification;
|
||||
var __OHNotification = function (title, options) {
|
||||
try {
|
||||
return new __NativeNotification(title, options);
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
try {
|
||||
__OHNotification.prototype = __NativeNotification.prototype;
|
||||
} catch (_) {}
|
||||
try {
|
||||
var __nativeFnToString = Function.prototype.toString;
|
||||
var __wrappedRequest = function () {
|
||||
return Promise.resolve('granted');
|
||||
};
|
||||
Function.prototype.toString = new Proxy(__nativeFnToString, {
|
||||
apply: function (target, thisArg, args) {
|
||||
if (thisArg === __OHNotification) {
|
||||
return 'function Notification() { [native code] }';
|
||||
}
|
||||
if (thisArg === __wrappedRequest) {
|
||||
return 'function requestPermission() { [native code] }';
|
||||
}
|
||||
return Reflect.apply(target, thisArg, args);
|
||||
},
|
||||
});
|
||||
__OHNotification.requestPermission = __wrappedRequest;
|
||||
} catch (_) {
|
||||
__OHNotification.requestPermission = function () {
|
||||
return Promise.resolve('granted');
|
||||
};
|
||||
}
|
||||
Object.defineProperty(__OHNotification, 'permission', {
|
||||
get: function () { return 'granted'; },
|
||||
configurable: true,
|
||||
});
|
||||
window.Notification = __OHNotification;
|
||||
try {
|
||||
Object.defineProperty(window, 'Notification', {
|
||||
get: function () { return __OHNotification; },
|
||||
set: function () {},
|
||||
configurable: false,
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
window.__OH_NOTIF_SHIM = true;
|
||||
})();
|
||||
|
||||
Vendored
+1
-1
Submodule app/src-tauri/vendor/tauri-cef updated: 0730478172...c8ece7c784
@@ -9,6 +9,7 @@ import Channels from './pages/Channels';
|
||||
import Home from './pages/Home';
|
||||
import Intelligence from './pages/Intelligence';
|
||||
import Invites from './pages/Invites';
|
||||
import Notifications from './pages/Notifications';
|
||||
import Rewards from './pages/Rewards';
|
||||
import Settings from './pages/Settings';
|
||||
import Skills from './pages/Skills';
|
||||
@@ -112,6 +113,15 @@ const AppRoutes = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/notifications"
|
||||
element={
|
||||
<ProtectedRoute requireAuth={true}>
|
||||
<Notifications />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/*"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { IntegrationNotification } from '../../types/notifications';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Relative human-readable time string, e.g. "2m ago". */
|
||||
function relativeTime(isoString: string): string {
|
||||
const diff = Date.now() - new Date(isoString).getTime();
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
/** Provider badge color class based on slug. */
|
||||
function providerBadgeClass(provider: string): string {
|
||||
switch (provider) {
|
||||
case 'gmail':
|
||||
return 'bg-red-100 text-red-700 border-red-200';
|
||||
case 'slack':
|
||||
return 'bg-purple-100 text-purple-700 border-purple-200';
|
||||
case 'whatsapp':
|
||||
return 'bg-green-100 text-green-700 border-green-200';
|
||||
case 'discord':
|
||||
return 'bg-indigo-100 text-indigo-700 border-indigo-200';
|
||||
case 'telegram':
|
||||
return 'bg-blue-100 text-blue-700 border-blue-200';
|
||||
case 'linkedin':
|
||||
return 'bg-sky-100 text-sky-700 border-sky-200';
|
||||
default:
|
||||
return 'bg-stone-100 text-stone-700 border-stone-200';
|
||||
}
|
||||
}
|
||||
|
||||
/** Score badge color. */
|
||||
function scoreBadgeClass(score: number): string {
|
||||
if (score >= 0.75) return 'bg-coral-500/20 text-red-600 border-red-200';
|
||||
if (score >= 0.4) return 'bg-amber-100 text-amber-700 border-amber-200';
|
||||
return 'bg-sage-500/20 text-green-700 border-green-200';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
notification: IntegrationNotification;
|
||||
onMarkRead: (id: string) => void;
|
||||
}
|
||||
|
||||
const NotificationCard = ({ notification: n, onMarkRead }: Props) => {
|
||||
const isUnread = n.status === 'unread';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isUnread) onMarkRead(n.id);
|
||||
}}
|
||||
className={`w-full text-left p-3 border-b border-stone-100 hover:bg-stone-50 transition-colors duration-150 focus:outline-none ${
|
||||
isUnread ? 'bg-primary-50/30' : 'bg-white'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Unread dot */}
|
||||
<div className="mt-1.5 flex-shrink-0">
|
||||
{isUnread ? (
|
||||
<span className="block w-2 h-2 rounded-full bg-primary-500" />
|
||||
) : (
|
||||
<span className="block w-2 h-2 rounded-full bg-transparent" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header row: provider badge + timestamp */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border ${providerBadgeClass(n.provider)}`}>
|
||||
{n.provider}
|
||||
</span>
|
||||
|
||||
{n.importance_score !== undefined && (
|
||||
<span
|
||||
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border ${scoreBadgeClass(n.importance_score)}`}
|
||||
title={`Importance: ${(n.importance_score * 100).toFixed(0)}%`}>
|
||||
{(n.importance_score * 100).toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
|
||||
{n.triage_action && n.triage_action !== 'drop' && n.triage_action !== 'acknowledge' && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border bg-amber-100 text-amber-700 border-amber-200">
|
||||
{n.triage_action}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="ml-auto text-[11px] text-stone-400 flex-shrink-0">
|
||||
{relativeTime(n.received_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<p className="text-sm font-medium text-stone-900 truncate">{n.title}</p>
|
||||
|
||||
{/* Body preview */}
|
||||
{n.body && <p className="text-xs text-stone-500 mt-0.5 line-clamp-2">{n.body}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationCard;
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { fetchNotifications, markNotificationRead } from '../../services/notificationService';
|
||||
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||
import {
|
||||
markRead as markReadAction,
|
||||
setNotifications,
|
||||
setNotificationsError,
|
||||
setNotificationsLoading,
|
||||
} from '../../store/notificationsSlice';
|
||||
import NotificationCard from './NotificationCard';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const NotificationCenter = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { items, unreadCount, loading, error } = useAppSelector(s => s.notifications);
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | undefined>(undefined);
|
||||
// All providers seen across unfiltered loads — kept separate so the filter
|
||||
// pill row doesn't collapse when a provider filter is active.
|
||||
const [allProviders, setAllProviders] = useState<string[]>([]);
|
||||
|
||||
// Fetch on mount and when provider filter changes.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
dispatch(setNotificationsLoading(true));
|
||||
try {
|
||||
const result = await fetchNotifications({ provider: selectedProvider, limit: 100 });
|
||||
if (!cancelled) {
|
||||
dispatch(setNotifications(result));
|
||||
// Accumulate providers only from unfiltered loads so the pill row
|
||||
// stays stable when a filter is active.
|
||||
if (!selectedProvider) {
|
||||
const seen = Array.from(new Set(result.items.map(n => n.provider))).sort();
|
||||
setAllProviders(seen);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
dispatch(
|
||||
setNotificationsError(
|
||||
err instanceof Error ? err.message : 'Failed to load notifications'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [dispatch, selectedProvider]);
|
||||
|
||||
const handleMarkRead = async (id: string) => {
|
||||
dispatch(markReadAction(id));
|
||||
try {
|
||||
await markNotificationRead(id);
|
||||
} catch {
|
||||
// Optimistic update already applied; log failure silently.
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
const unreadIds = items.filter(n => n.status === 'unread').map(n => n.id);
|
||||
for (const id of unreadIds) {
|
||||
dispatch(markReadAction(id));
|
||||
try {
|
||||
await markNotificationRead(id);
|
||||
} catch {
|
||||
// Ignore individual failures.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const providers = allProviders;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-stone-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold text-stone-900">Notifications</h2>
|
||||
{unreadCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-full text-[11px] font-semibold bg-primary-500 text-white">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
void handleMarkAllRead();
|
||||
}}
|
||||
className="text-xs text-primary-600 hover:text-primary-700 font-medium transition-colors">
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider filter pills */}
|
||||
{providers.length > 1 && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-stone-100 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setSelectedProvider(undefined)}
|
||||
className={`flex-shrink-0 px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
selectedProvider === undefined
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-stone-100 text-stone-600 hover:bg-stone-200'
|
||||
}`}>
|
||||
All
|
||||
</button>
|
||||
{providers.map(p => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setSelectedProvider(p === selectedProvider ? undefined : p)}
|
||||
className={`flex-shrink-0 px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
selectedProvider === p
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-stone-100 text-stone-600 hover:bg-stone-200'
|
||||
}`}>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12 text-stone-400 text-sm">
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="m-4 p-3 rounded-xl bg-red-50 border border-red-200 text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && items.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-stone-400">
|
||||
<svg
|
||||
className="w-10 h-10 mb-3 opacity-40"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm font-medium">No notifications yet</p>
|
||||
<p className="text-xs mt-1 opacity-70">
|
||||
Notifications from your connected accounts will appear here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && items.length > 0 && (
|
||||
<div className="divide-y-0">
|
||||
{items.map(n => (
|
||||
<NotificationCard
|
||||
key={n.id}
|
||||
notification={n}
|
||||
onMarkRead={id => {
|
||||
void handleMarkRead(id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationCenter;
|
||||
@@ -127,6 +127,40 @@ const SettingsHome = () => {
|
||||
onClick: () => navigateToSettings('ai-models'),
|
||||
dangerous: false,
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
title: 'Notifications',
|
||||
description: 'Do Not Disturb and per-account notification controls',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigateToSettings('notifications'),
|
||||
dangerous: false,
|
||||
},
|
||||
{
|
||||
id: 'notification-routing',
|
||||
title: 'Notification Routing',
|
||||
description: 'AI importance scoring and orchestrator escalation for integration alerts',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigateToSettings('notification-routing'),
|
||||
dangerous: false,
|
||||
},
|
||||
{
|
||||
id: 'developer-options',
|
||||
title: 'Developer Options',
|
||||
|
||||
@@ -29,7 +29,9 @@ export type SettingsRoute =
|
||||
| 'screen-awareness-debug'
|
||||
| 'autocomplete-debug'
|
||||
| 'voice-debug'
|
||||
| 'local-model-debug';
|
||||
| 'local-model-debug'
|
||||
| 'notifications'
|
||||
| 'notification-routing';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
@@ -96,6 +98,11 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
if (path.includes('/settings/webhooks-debug')) return 'webhooks-debug';
|
||||
if (path.includes('/settings/recovery-phrase')) return 'recovery-phrase';
|
||||
if (path.includes('/settings/agent-chat')) return 'agent-chat';
|
||||
// Notification routes must be checked in specificity order so the more
|
||||
// specific `notification-routing` path doesn't get swallowed by the
|
||||
// shorter `notifications` prefix.
|
||||
if (path.includes('/settings/notification-routing')) return 'notification-routing';
|
||||
if (path.includes('/settings/notifications')) return 'notifications';
|
||||
return 'home';
|
||||
};
|
||||
|
||||
@@ -207,6 +214,11 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
case 'developer-options':
|
||||
return [settingsCrumb];
|
||||
|
||||
// Notifications panels sit at the top level of Settings.
|
||||
case 'notifications':
|
||||
case 'notification-routing':
|
||||
return [settingsCrumb];
|
||||
|
||||
case 'home':
|
||||
default:
|
||||
return [];
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
getNotificationSettings,
|
||||
setNotificationSettings,
|
||||
} from '../../../services/notificationService';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const PROVIDERS = ['gmail', 'slack', 'discord', 'whatsapp'];
|
||||
|
||||
/**
|
||||
* Settings panel for the notification intelligence / routing pipeline.
|
||||
*
|
||||
* Currently exposes a global explanation card. Per-provider threshold
|
||||
* controls will populate here as providers are connected.
|
||||
*/
|
||||
const NotificationRoutingPanel = () => {
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const providers = PROVIDERS;
|
||||
const [settings, setSettings] = useState<
|
||||
Record<
|
||||
string,
|
||||
{ enabled: boolean; importance_threshold: number; route_to_orchestrator: boolean }
|
||||
>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all(
|
||||
providers.map(async provider => {
|
||||
const s = await getNotificationSettings(provider);
|
||||
return [provider, s] as const;
|
||||
})
|
||||
)
|
||||
.then(rows => {
|
||||
const next: Record<
|
||||
string,
|
||||
{ enabled: boolean; importance_threshold: number; route_to_orchestrator: boolean }
|
||||
> = {};
|
||||
rows.forEach(([provider, s]) => {
|
||||
next[provider] = {
|
||||
enabled: s.enabled,
|
||||
importance_threshold: s.importance_threshold,
|
||||
route_to_orchestrator: s.route_to_orchestrator,
|
||||
};
|
||||
});
|
||||
setSettings(next);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [providers]);
|
||||
|
||||
const updateSetting = async (
|
||||
provider: string,
|
||||
patch: Partial<{
|
||||
enabled: boolean;
|
||||
importance_threshold: number;
|
||||
route_to_orchestrator: boolean;
|
||||
}>
|
||||
) => {
|
||||
const current = settings[provider] ?? {
|
||||
enabled: true,
|
||||
importance_threshold: 0,
|
||||
route_to_orchestrator: true,
|
||||
};
|
||||
const next = { ...current, ...patch };
|
||||
setSettings(prev => ({ ...prev, [provider]: next }));
|
||||
try {
|
||||
await setNotificationSettings({ provider, ...next });
|
||||
} catch (err) {
|
||||
setSettings(prev => ({ ...prev, [provider]: current }));
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title="Notification Routing"
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Info card */}
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-xl">
|
||||
<div className="flex items-start space-x-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-blue-800 text-sm">Notification Intelligence</p>
|
||||
<p className="text-blue-700 text-xs mt-1 leading-relaxed">
|
||||
Every notification from your connected accounts is scored by a local AI model.
|
||||
High-importance notifications are automatically routed to your orchestrator agent so
|
||||
nothing critical slips through.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How it works */}
|
||||
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-stone-100">
|
||||
<p className="text-sm font-medium text-stone-900">How it works</p>
|
||||
</div>
|
||||
<div className="divide-y divide-stone-100">
|
||||
{[
|
||||
{
|
||||
label: 'Drop',
|
||||
desc: 'Noise / spam — stored but not surfaced',
|
||||
color: 'bg-stone-100 text-stone-600',
|
||||
},
|
||||
{
|
||||
label: 'Acknowledge',
|
||||
desc: 'Low-priority — shown in notification center',
|
||||
color: 'bg-blue-100 text-blue-700',
|
||||
},
|
||||
{
|
||||
label: 'React',
|
||||
desc: 'Medium-priority — triggers a focused agent response',
|
||||
color: 'bg-amber-100 text-amber-700',
|
||||
},
|
||||
{
|
||||
label: 'Escalate',
|
||||
desc: 'High-priority — forwarded to orchestrator agent',
|
||||
color: 'bg-red-100 text-red-700',
|
||||
},
|
||||
].map(row => (
|
||||
<div key={row.label} className="flex items-center gap-3 px-4 py-3">
|
||||
<span
|
||||
className={`flex-shrink-0 px-2 py-0.5 rounded text-[11px] font-semibold ${row.color}`}>
|
||||
{row.label}
|
||||
</span>
|
||||
<span className="text-xs text-stone-600">{row.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-stone-100">
|
||||
<p className="text-sm font-medium text-stone-900">Per-provider routing</p>
|
||||
</div>
|
||||
<div className="divide-y divide-stone-100">
|
||||
{providers.map(provider => {
|
||||
const s = settings[provider] ?? {
|
||||
enabled: true,
|
||||
importance_threshold: 0,
|
||||
route_to_orchestrator: true,
|
||||
};
|
||||
return (
|
||||
<div key={provider} className="px-4 py-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-stone-800 capitalize">{provider}</p>
|
||||
<label className="text-xs text-stone-600 flex items-center gap-2">
|
||||
Enabled
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.enabled}
|
||||
onChange={e => {
|
||||
void updateSetting(provider, { enabled: e.target.checked });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-stone-600">
|
||||
Threshold
|
||||
<input
|
||||
className="flex-1"
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={s.importance_threshold}
|
||||
onChange={e => {
|
||||
void updateSetting(provider, {
|
||||
importance_threshold: Number(e.target.value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span>{s.importance_threshold.toFixed(2)}</span>
|
||||
</label>
|
||||
<label className="text-xs text-stone-600 flex items-center gap-2">
|
||||
Route to orchestrator
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.route_to_orchestrator}
|
||||
onChange={e => {
|
||||
void updateSetting(provider, { route_to_orchestrator: e.target.checked });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationRoutingPanel;
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getBypassPrefs, setGlobalDnd } from '../../../services/webviewAccountService';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const NotificationsPanel = () => {
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const [dnd, setDnd] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getBypassPrefs().then(prefs => {
|
||||
if (prefs) setDnd(prefs.global_dnd);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDndToggle = async () => {
|
||||
const next = !dnd;
|
||||
setDnd(next);
|
||||
await setGlobalDnd(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title="Notifications"
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{loading ? (
|
||||
<div className="rounded-xl border border-stone-200 bg-white p-4 text-sm text-stone-400">
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* DND toggle */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3 px-1">
|
||||
Do Not Disturb
|
||||
</h3>
|
||||
<div className="bg-white rounded-xl border border-stone-200 overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex-1 mr-4">
|
||||
<p className="text-sm font-medium text-stone-900">Suppress all notifications</p>
|
||||
<p className="text-xs text-stone-500 mt-1 leading-relaxed">
|
||||
Block all OS notification toasts from embedded apps (WhatsApp, Telegram,
|
||||
Gmail, Slack, etc.) regardless of focus state.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDndToggle}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||
dnd ? 'bg-primary-500' : 'bg-stone-600'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={dnd}>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
dnd ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info box */}
|
||||
<div className="p-4 bg-stone-50 rounded-xl border border-stone-200">
|
||||
<div className="flex items-start space-x-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-stone-400 mt-0.5 flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-xs text-stone-500 leading-relaxed">
|
||||
Notifications from embedded apps are also suppressed automatically when you are
|
||||
actively viewing that account in the foreground. This keeps the desktop tidy
|
||||
when you are already reading the conversation the notification would have
|
||||
pointed to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsPanel;
|
||||
@@ -0,0 +1,14 @@
|
||||
import NotificationCenter from '../components/notifications/NotificationCenter';
|
||||
|
||||
/**
|
||||
* Full-page notification center route (`/notifications`).
|
||||
*/
|
||||
const Notifications = () => {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<NotificationCenter />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Notifications;
|
||||
@@ -14,6 +14,8 @@ import LocalModelPanel from '../components/settings/panels/LocalModelPanel';
|
||||
import MemoryDataPanel from '../components/settings/panels/MemoryDataPanel';
|
||||
import MemoryDebugPanel from '../components/settings/panels/MemoryDebugPanel';
|
||||
import MessagingPanel from '../components/settings/panels/MessagingPanel';
|
||||
import NotificationRoutingPanel from '../components/settings/panels/NotificationRoutingPanel';
|
||||
import NotificationsPanel from '../components/settings/panels/NotificationsPanel';
|
||||
import PrivacyPanel from '../components/settings/panels/PrivacyPanel';
|
||||
import RecoveryPhrasePanel from '../components/settings/panels/RecoveryPhrasePanel';
|
||||
import ScreenAwarenessDebugPanel from '../components/settings/panels/ScreenAwarenessDebugPanel';
|
||||
@@ -253,6 +255,11 @@ const Settings = () => {
|
||||
<Route path="autocomplete" element={wrapSettingsPage(<AutocompletePanel />)} />
|
||||
<Route path="voice" element={wrapSettingsPage(<VoicePanel />)} />
|
||||
<Route path="messaging" element={wrapSettingsPage(<MessagingPanel />)} />
|
||||
<Route path="notifications" element={wrapSettingsPage(<NotificationsPanel />)} />
|
||||
<Route
|
||||
path="notification-routing"
|
||||
element={wrapSettingsPage(<NotificationRoutingPanel />)}
|
||||
/>
|
||||
<Route path="tools" element={wrapSettingsPage(<ToolsPanel />)} />
|
||||
{/* AI & Models leaf panels */}
|
||||
<Route path="local-model" element={wrapSettingsPage(<LocalModelPanel />)} />
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import debug from 'debug';
|
||||
|
||||
import type { IntegrationNotification } from '../types/notifications';
|
||||
import { callCoreRpc } from './coreRpcClient';
|
||||
|
||||
const log = debug('notifications');
|
||||
const errLog = debug('notifications:error');
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RPC wrappers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch paginated notifications from the core process.
|
||||
* Calls `openhuman.notification_list`.
|
||||
*/
|
||||
export async function fetchNotifications(opts?: {
|
||||
provider?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
min_score?: number;
|
||||
}): Promise<{ items: IntegrationNotification[]; unread_count: number }> {
|
||||
log('fetchNotifications %o', opts);
|
||||
const result = await callCoreRpc<{ items: IntegrationNotification[]; unread_count: number }>({
|
||||
method: 'openhuman.notification_list',
|
||||
params: opts ?? {},
|
||||
});
|
||||
log('fetchNotifications result: %d items, %d unread', result.items.length, result.unread_count);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a single notification as read.
|
||||
* Calls `openhuman.notification_mark_read`.
|
||||
*/
|
||||
export async function markNotificationRead(id: string): Promise<void> {
|
||||
log('markNotificationRead id=%s', id);
|
||||
try {
|
||||
await callCoreRpc<{ ok: boolean }>({
|
||||
method: 'openhuman.notification_mark_read',
|
||||
params: { id },
|
||||
});
|
||||
log('markNotificationRead ok id=%s', id);
|
||||
} catch (err) {
|
||||
errLog('markNotificationRead failed id=%s: %o', id, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
type NotificationIngestResult = { id: string; skipped?: false } | { skipped: true; reason: string };
|
||||
|
||||
/**
|
||||
* Ingest a new notification via the core RPC pipeline.
|
||||
* Calls `openhuman.notification_ingest`.
|
||||
*
|
||||
* Returns `{ id }` when the notification was persisted, or
|
||||
* `{ skipped: true, reason }` when the provider is disabled.
|
||||
*/
|
||||
export async function ingestNotification(payload: {
|
||||
provider: string;
|
||||
account_id?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
raw_payload: Record<string, unknown>;
|
||||
}): Promise<NotificationIngestResult> {
|
||||
log('ingestNotification provider=%s', payload.provider);
|
||||
const result = await callCoreRpc<NotificationIngestResult>({
|
||||
method: 'openhuman.notification_ingest',
|
||||
params: payload,
|
||||
});
|
||||
if (result.skipped) {
|
||||
log('ingestNotification skipped provider=%s reason=%s', payload.provider, result.reason);
|
||||
} else {
|
||||
log('ingestNotification created id=%s', result.id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getNotificationSettings(
|
||||
provider: string
|
||||
): Promise<{
|
||||
provider: string;
|
||||
enabled: boolean;
|
||||
importance_threshold: number;
|
||||
route_to_orchestrator: boolean;
|
||||
}> {
|
||||
const result = await callCoreRpc<{
|
||||
settings: {
|
||||
provider: string;
|
||||
enabled: boolean;
|
||||
importance_threshold: number;
|
||||
route_to_orchestrator: boolean;
|
||||
};
|
||||
}>({ method: 'openhuman.notification_settings_get', params: { provider } });
|
||||
return result.settings;
|
||||
}
|
||||
|
||||
export async function setNotificationSettings(payload: {
|
||||
provider: string;
|
||||
enabled: boolean;
|
||||
importance_threshold: number;
|
||||
route_to_orchestrator: boolean;
|
||||
}): Promise<void> {
|
||||
await callCoreRpc<{ ok: boolean }>({
|
||||
method: 'openhuman.notification_settings_set',
|
||||
params: payload,
|
||||
});
|
||||
}
|
||||
@@ -3,11 +3,18 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import debug from 'debug';
|
||||
|
||||
import { store } from '../store';
|
||||
import { appendLog, appendMessages, setAccountStatus } from '../store/accountsSlice';
|
||||
import {
|
||||
appendLog,
|
||||
appendMessages,
|
||||
setAccountStatus,
|
||||
setActiveAccount,
|
||||
} from '../store/accountsSlice';
|
||||
import { addNotification } from '../store/notificationsSlice';
|
||||
import type { AccountProvider, IngestedMessage } from '../types/accounts';
|
||||
import { threadApi } from './api/threadApi';
|
||||
import { chatSend } from './chatService';
|
||||
import { callCoreRpc } from './coreRpcClient';
|
||||
import { ingestNotification } from './notificationService';
|
||||
|
||||
const MEET_ORCHESTRATOR_MODEL = 'reasoning-v1';
|
||||
|
||||
@@ -46,8 +53,24 @@ interface IngestPayload {
|
||||
isSeed?: boolean;
|
||||
}
|
||||
|
||||
interface NotificationClickPayload {
|
||||
account_id: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
interface RecipeNotifyPayload {
|
||||
title?: string;
|
||||
body?: string;
|
||||
icon?: string | null;
|
||||
tag?: string | null;
|
||||
silent?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
let unlistenNotifyClick: UnlistenFn | null = null;
|
||||
let started = false;
|
||||
let permissionChecked = false;
|
||||
|
||||
export function startWebviewAccountService(): void {
|
||||
if (started) return;
|
||||
@@ -66,6 +89,16 @@ export function startWebviewAccountService(): void {
|
||||
} catch (err) {
|
||||
errLog('failed to attach listener', err);
|
||||
}
|
||||
try {
|
||||
// Dormant until the platform click hook (UNUserNotificationCenter /
|
||||
// notify-rust on_response) emits `notification:click` from Rust.
|
||||
unlistenNotifyClick = await listen<NotificationClickPayload>('notification:click', evt => {
|
||||
handleNotificationClick(evt.payload);
|
||||
});
|
||||
log('notification:click listener attached');
|
||||
} catch (err) {
|
||||
errLog('failed to attach notification:click listener', err);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -74,9 +107,45 @@ export function stopWebviewAccountService(): void {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
}
|
||||
if (unlistenNotifyClick) {
|
||||
unlistenNotifyClick();
|
||||
unlistenNotifyClick = null;
|
||||
}
|
||||
started = false;
|
||||
}
|
||||
|
||||
function handleNotificationClick(payload: NotificationClickPayload) {
|
||||
const accountId = payload?.account_id;
|
||||
const provider = payload?.provider;
|
||||
if (!accountId) {
|
||||
errLog('notification:click missing account_id — ignoring: %o', payload);
|
||||
return;
|
||||
}
|
||||
log('notification:click → account=%s provider=%s', accountId, provider);
|
||||
store.dispatch(setActiveAccount(accountId));
|
||||
void setFocusedAccount(accountId);
|
||||
invoke('activate_main_window').catch(err => {
|
||||
errLog('activate_main_window failed after notification click: %o', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Round-trip the OS notification permission once per session on first
|
||||
// account open. Desktop plugin auto-grants today, but the shape matches
|
||||
// the web API so future platform prompts slot in without UI change.
|
||||
async function ensureNotificationPermission(): Promise<void> {
|
||||
if (permissionChecked) return;
|
||||
try {
|
||||
const state = await invoke<string>('webview_notification_permission_state');
|
||||
permissionChecked = true;
|
||||
log('notification permission state=%s', state);
|
||||
if (state === 'granted') return;
|
||||
const next = await invoke<string>('webview_notification_permission_request');
|
||||
log('notification permission after request=%s', next);
|
||||
} catch (err) {
|
||||
errLog('notification permission check failed: %o', err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRecipeEvent(evt: RecipeEventPayload) {
|
||||
const accountId = evt.account_id;
|
||||
if (!accountId) return;
|
||||
@@ -123,6 +192,39 @@ function handleRecipeEvent(evt: RecipeEventPayload) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.kind === 'notify') {
|
||||
const payload = evt.payload as RecipeNotifyPayload;
|
||||
const title = String(payload.title ?? '').trim();
|
||||
const body = String(payload.body ?? '').trim();
|
||||
if (!title && !body) return;
|
||||
void ingestNotification({
|
||||
provider: evt.provider,
|
||||
account_id: accountId,
|
||||
title: title || `${evt.provider} notification`,
|
||||
body,
|
||||
raw_payload: payload as Record<string, unknown>,
|
||||
})
|
||||
.then(result => {
|
||||
if (result.skipped) return;
|
||||
store.dispatch(
|
||||
addNotification({
|
||||
id: result.id,
|
||||
provider: evt.provider,
|
||||
account_id: accountId,
|
||||
title: title || `${evt.provider} notification`,
|
||||
body,
|
||||
raw_payload: payload as Record<string, unknown>,
|
||||
status: 'unread',
|
||||
received_at: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(err => {
|
||||
errLog('notify ingest failed account=%s provider=%s: %o', accountId, evt.provider, err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
log('unhandled recipe event kind=%s account=%s', evt.kind, accountId);
|
||||
}
|
||||
|
||||
@@ -611,11 +713,13 @@ export async function openWebviewAccount(args: OpenAccountArgs): Promise<void> {
|
||||
if (!isTauri()) throw new Error('webview accounts require the desktop app');
|
||||
log('open account=%s provider=%s', args.accountId, args.provider);
|
||||
store.dispatch(setAccountStatus({ accountId: args.accountId, status: 'pending' }));
|
||||
void ensureNotificationPermission();
|
||||
try {
|
||||
await invoke('webview_account_open', {
|
||||
args: { account_id: args.accountId, provider: args.provider, bounds: args.bounds },
|
||||
});
|
||||
store.dispatch(setAccountStatus({ accountId: args.accountId, status: 'open' }));
|
||||
void setFocusedAccount(args.accountId);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errLog('open failed: %s', msg);
|
||||
@@ -685,6 +789,69 @@ export async function purgeWebviewAccount(accountId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────── Notification bypass helpers ─────────────────────
|
||||
|
||||
/**
|
||||
* Mute or unmute OS notification toasts for a specific embedded account.
|
||||
* When muted, toasts from that account are suppressed regardless of focus state.
|
||||
*/
|
||||
export async function setAccountMuted(accountId: string, muted: boolean): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
try {
|
||||
await invoke('webview_notification_mute_account', { accountId, muted });
|
||||
log('notify-bypass: account=%s muted=%s', accountId, muted);
|
||||
} catch (e) {
|
||||
log('notify-bypass: setAccountMuted error %o', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable global Do Not Disturb mode for embedded webview notifications.
|
||||
* When enabled, all OS notification toasts from embedded accounts are suppressed.
|
||||
*/
|
||||
export async function setGlobalDnd(enabled: boolean): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
try {
|
||||
await invoke('webview_notification_set_dnd', { enabled });
|
||||
log('notify-bypass: global DND set to %s', enabled);
|
||||
} catch (e) {
|
||||
log('notify-bypass: setGlobalDnd error %o', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current notification bypass preferences from the Rust side.
|
||||
* Returns `null` when not running in Tauri or on any invoke error.
|
||||
*/
|
||||
export async function getBypassPrefs(): Promise<{
|
||||
global_dnd: boolean;
|
||||
muted_accounts: string[];
|
||||
bypass_when_focused: boolean;
|
||||
} | null> {
|
||||
if (!isTauri()) return null;
|
||||
try {
|
||||
return await invoke('webview_notification_get_bypass_prefs');
|
||||
} catch (e) {
|
||||
log('notify-bypass: getBypassPrefs error %o', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell Rust which account (if any) the user is currently viewing.
|
||||
* Rust uses this together with the window-focus state to suppress
|
||||
* notifications while the user is actively looking at that account.
|
||||
*/
|
||||
export async function setFocusedAccount(accountId: string | null): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
try {
|
||||
await invoke('webview_set_focused_account', { accountId });
|
||||
log('notify-bypass: focused account set to %s', accountId);
|
||||
} catch (e) {
|
||||
log('notify-bypass: setFocusedAccount error %o', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function flushMeetingIfAny(accountId: string, reason: string): Promise<void> {
|
||||
const session = activeMeetings.get(accountId);
|
||||
if (!session) return;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { IS_DEV } from '../utils/config';
|
||||
import accountsReducer from './accountsSlice';
|
||||
import channelConnectionsReducer from './channelConnectionsSlice';
|
||||
import chatRuntimeReducer from './chatRuntimeSlice';
|
||||
import notificationsReducer from './notificationsSlice';
|
||||
import socketReducer from './socketSlice';
|
||||
import threadReducer from './threadSlice';
|
||||
|
||||
@@ -45,6 +46,7 @@ export const store = configureStore({
|
||||
chatRuntime: chatRuntimeReducer,
|
||||
channelConnections: persistedChannelConnectionsReducer,
|
||||
accounts: persistedAccountsReducer,
|
||||
notifications: notificationsReducer,
|
||||
},
|
||||
middleware: getDefaultMiddleware => {
|
||||
const middleware = getDefaultMiddleware({
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type { IntegrationNotification } from '../types/notifications';
|
||||
|
||||
interface NotificationsState {
|
||||
items: IntegrationNotification[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: NotificationsState = { items: [], unreadCount: 0, loading: false, error: null };
|
||||
|
||||
export const notificationsSlice = createSlice({
|
||||
name: 'notifications',
|
||||
initialState,
|
||||
reducers: {
|
||||
setNotificationsLoading(state, _action: PayloadAction<boolean>) {
|
||||
state.loading = _action.payload;
|
||||
},
|
||||
setNotificationsError(state, action: PayloadAction<string | null>) {
|
||||
state.error = action.payload;
|
||||
state.loading = false;
|
||||
},
|
||||
setNotifications(
|
||||
state,
|
||||
action: PayloadAction<{ items: IntegrationNotification[]; unread_count: number }>
|
||||
) {
|
||||
state.items = action.payload.items;
|
||||
state.unreadCount = action.payload.unread_count;
|
||||
state.loading = false;
|
||||
state.error = null;
|
||||
},
|
||||
markRead(state, action: PayloadAction<string>) {
|
||||
const n = state.items.find(i => i.id === action.payload);
|
||||
if (n && n.status === 'unread') {
|
||||
n.status = 'read';
|
||||
state.unreadCount = Math.max(0, state.unreadCount - 1);
|
||||
}
|
||||
},
|
||||
addNotification(state, action: PayloadAction<IntegrationNotification>) {
|
||||
// Prepend so newest appears first; avoid duplicates by id.
|
||||
const exists = state.items.some(i => i.id === action.payload.id);
|
||||
if (!exists) {
|
||||
state.items.unshift(action.payload);
|
||||
if (action.payload.status === 'unread') {
|
||||
state.unreadCount += 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setNotificationsLoading,
|
||||
setNotificationsError,
|
||||
setNotifications,
|
||||
markRead,
|
||||
addNotification,
|
||||
} = notificationsSlice.actions;
|
||||
|
||||
export default notificationsSlice.reducer;
|
||||
@@ -0,0 +1,32 @@
|
||||
//! TypeScript types mirroring the Rust `notifications` domain types.
|
||||
|
||||
export type NotificationStatus = 'unread' | 'read' | 'acted' | 'dismissed';
|
||||
|
||||
export interface IntegrationNotification {
|
||||
id: string;
|
||||
/** Provider slug: "gmail", "slack", "whatsapp", etc. */
|
||||
provider: string;
|
||||
account_id?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
raw_payload: Record<string, unknown>;
|
||||
/** 0.0–1.0 importance score from the triage pipeline (undefined until scored). */
|
||||
importance_score?: number;
|
||||
/** Triage action: "drop" | "acknowledge" | "react" | "escalate" */
|
||||
triage_action?: string;
|
||||
/** One-sentence justification from the classifier. */
|
||||
triage_reason?: string;
|
||||
status: NotificationStatus;
|
||||
/** ISO 8601 timestamp */
|
||||
received_at: string;
|
||||
/** ISO 8601 timestamp — undefined until triage completes */
|
||||
scored_at?: string;
|
||||
}
|
||||
|
||||
export interface NotificationSettings {
|
||||
provider: string;
|
||||
enabled: boolean;
|
||||
/** Minimum importance score 0.0–1.0 to show; 0.0 = show all */
|
||||
importance_threshold: number;
|
||||
route_to_orchestrator: boolean;
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# Issue #714 — Native OS Notifications from Embedded Webviews
|
||||
|
||||
**Branch**: `feat/714-native-os-notifications`
|
||||
**Base**: `upstream/main`
|
||||
**Upstream**: `tinyhumansai/openhuman`
|
||||
**Origin (fork)**: `oxoxDev/openhuman`
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Embedded webview apps (Slack, Discord, Gmail, WhatsApp) call `window.Notification` inside CEF but never produce native macOS/Windows toasts. The CEF runtime intercepts the web Notification API, but the intercept dropped on the floor — no bridge to `tauri-plugin-notification`, no click routing back to the originating account, no permission query/request pipeline.
|
||||
|
||||
## Solution
|
||||
|
||||
Wire the `tauri-cef` notification intercept into `tauri-plugin-notification`, prefix each toast with the provider label (e.g. `[Slack] New message from Alice`), honour `silent` / `icon` / `tag`, and record a `NotificationRoute` keyed by `{provider}:{account_id}:{tag_or_uuid}` so a future platform click hook can emit `notification:click` and route focus back to the correct account. Also round-trip the OS notification permission via new invokes so the frontend sees the same `"granted" | "denied" | "default"` triple as the web API on both CEF and wry runtimes.
|
||||
|
||||
## Commits (in order)
|
||||
|
||||
### `50b831ad` feat(webview_accounts): native OS notifications from embedded webviews (#714)
|
||||
Rust backend — the core of the feature.
|
||||
|
||||
- **`app/src-tauri/src/webview_accounts/mod.rs`** (+141 / -3)
|
||||
- `NotificationRoute` struct: `provider`, `account_id`, `tag`, `created_at`
|
||||
- `notification_routes: Mutex<HashMap<String, NotificationRoute>>` on `WebviewAccountState`
|
||||
- `clear_notification_routes(account_id)` — purged on close / purge
|
||||
- `forward_native_notification(app, provider, account_id, payload)`:
|
||||
- Prefixes title with `[Provider]`
|
||||
- Respects `silent` (records route, skips toast)
|
||||
- Passes `icon` through to builder
|
||||
- Uses `tag` as dedup key, falls back to monotonic timestamp
|
||||
- `tag_or_uuid` helper — tag is the web API's dedup key; timestamp fallback ensures untagged payloads route uniquely
|
||||
- `webview_notification_permission_state` / `_request` — map `tauri::plugin::PermissionState` (`Granted | Denied | Prompt | PromptWithRationale`) onto `"granted" | "denied" | "default"`
|
||||
- `permission_state_str` helper
|
||||
- Non-cef (wry) stubs return `"default"` so frontend calls same invoke names on both runtimes
|
||||
- CEF registration in `setup`: `tauri_runtime_cef::notification::register` with handler that calls `forward_native_notification`; `unregister` on account close
|
||||
|
||||
- **`app/src-tauri/src/lib.rs`** (+2)
|
||||
- Added `webview_notification_permission_state` and `webview_notification_permission_request` to the invoke handler list.
|
||||
|
||||
- **`app/src-tauri/capabilities/default.json`** (+3)
|
||||
- Added `notification:allow-notify`, `notification:allow-request-permission`, `notification:allow-is-permission-granted` so the plugin can be invoked from the webview context.
|
||||
|
||||
### `97ef390f` feat(accounts): wire notification permission + click bridge (#714)
|
||||
Frontend — permission round-trip + dormant click listener.
|
||||
|
||||
- **`app/src/services/webviewAccountService.ts`** (+59 / -1)
|
||||
- `ensureNotificationPermission(accountId)` — invokes `webview_notification_permission_state`, requests if `"default"`, runs once per session on first account open. Desktop plugin auto-grants today, but shape matches web API so future platform prompts slot in without UI change.
|
||||
- `handleNotificationClick` + `listen('notification:click', …)` — dispatches `setActiveAccount` and invokes `activate_main_window` when the (currently dormant) platform click hook emits the event. Contract matches Rust `NotificationRoute` shape so Rust emit side is a one-liner when UNUserNotificationCenter / notify-rust `on_response` is wired.
|
||||
- `openWebviewAccount` now calls `void ensureNotificationPermission(accountId)` after the account opens.
|
||||
|
||||
### `e6f60180` chore: sync Cargo.lock to 0.52.26 after version bump
|
||||
- **`Cargo.lock`** + **`app/src-tauri/Cargo.lock`** (+2 / -2 each)
|
||||
- Picked up pending 0.52.26 version bump while building. No dependency graph change.
|
||||
|
||||
---
|
||||
|
||||
## Quality Gates (all passed)
|
||||
|
||||
| Gate | Result | Time |
|
||||
|---|---|---|
|
||||
| `yarn compile` (tsc --noEmit) | pass | 32.30s |
|
||||
| `yarn lint` (eslint) | pass | 63.65s |
|
||||
| `yarn rust:format:check` | pass | — |
|
||||
| `cargo check --features cef --no-default-features` | pass | 22.21s |
|
||||
| `cargo check --features wry --no-default-features` | pass | 6m 29s (cold) |
|
||||
|
||||
**Skipped:**
|
||||
- `yarn format:check` — flags only `app/src/pages/Home.tsx` (local build-tag pill `#714`, `skip-worktree` flagged, per workflow Phase 3 Step 6). Confirmed via `git ls-files -v | grep '^S '` → `S app/src/pages/Home.tsx`.
|
||||
- `cargo clippy` — pre-existing errors in `src/slack_scanner/extract.rs` (type_complexity) and `src/lib.rs:212` (unnecessary_map_or) unrelated to this feature. Verified with `git diff upstream/main -- app/src-tauri/src/lib.rs` shows only the 2-line invoke handler addition.
|
||||
|
||||
**Not yet done:**
|
||||
- Manual verification in built `.app` bundle with real Slack/Discord/Gmail notifications. Requires `yarn macOS:build:debug` (~10 min), install, open, trigger notifications, confirm provider-prefixed titles fire natively.
|
||||
|
||||
---
|
||||
|
||||
## Key Files for Teammate Review
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `app/src-tauri/src/webview_accounts/mod.rs` | Core Rust logic — intercept handler, route table, permission commands |
|
||||
| `app/src-tauri/src/lib.rs` | Invoke handler registration |
|
||||
| `app/src-tauri/capabilities/default.json` | Notification plugin capabilities |
|
||||
| `app/src/services/webviewAccountService.ts` | Frontend permission round-trip + click bridge |
|
||||
| `app/src-tauri/vendor/tauri-cef/crates/tauri-runtime-cef/src/notification.rs` | (vendored, unchanged) — source of the `register`/`unregister`/`dispatch` API used here |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Route keying
|
||||
`{provider}:{account_id}:{tag_or_uuid}` — tag is the web Notifications API dedup key (second `new Notification(title, { tag })` with same tag replaces the first). When absent, fall back to `Instant::now()` monotonic timestamp so every untagged payload routes uniquely. This matches browser semantics and prevents map collisions when two accounts of the same provider fire untagged notifications simultaneously.
|
||||
|
||||
### Permission shape
|
||||
`tauri::plugin::PermissionState` has 4 variants but the web API only has 3. Map:
|
||||
- `Granted` → `"granted"`
|
||||
- `Denied` → `"denied"`
|
||||
- `Prompt`, `PromptWithRationale` → `"default"`
|
||||
|
||||
Non-cef runtime stubs always return `"default"` — prevents invoke name mismatch between runtimes so frontend doesn't need a feature flag.
|
||||
|
||||
### Dormant click listener
|
||||
`notification:click` listener is registered frontend-side but Rust doesn't emit it yet. UNUserNotificationCenter (macOS) and notify-rust `on_response` (Linux/Windows) callbacks are the platform hooks that will emit once wired. The route table is already populated by the notification dispatch path so the emit side is a one-liner:
|
||||
|
||||
```rust
|
||||
let route = state.notification_routes.lock().unwrap().get(&route_key).cloned();
|
||||
if let Some(r) = route {
|
||||
app.emit("notification:click", &r)?;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps for Teammate
|
||||
|
||||
1. **Manual verification** — build `.app`, test Slack/Discord/Gmail toasts, confirm title prefix, confirm `silent` / `icon` / `tag` all honoured.
|
||||
2. **Platform click hooks** — wire UNUserNotificationCenter delegate (macOS) and notify-rust `on_response` (Linux/Windows) to emit `notification:click` with the stored `NotificationRoute`. Route table already exists; emit is one line.
|
||||
3. **PR** — template headings required: `## Summary`, `## Problem`, `## Solution`, `## Submission Checklist`, `## Impact`, `## Related`. `Closes #714`.
|
||||
|
||||
---
|
||||
|
||||
## Local State Caveats
|
||||
|
||||
- **Home.tsx build-tag pill** — `skip-worktree` flag set on `app/src/pages/Home.tsx` with inline `#714` pill (top-right, fixed). Per-clone, does NOT travel with branch. If teammate pulls this branch into their own clone, no pill appears locally. If they want one, Phase 3 Step 6 of `.claude/rules/00-workflow.md` has the snippet.
|
||||
- **Cargo.lock** — version bumped to 0.52.26 locally. Separate commit `e6f60180` so diff review is clean.
|
||||
@@ -75,8 +75,8 @@ if openssl pkcs12 -help 2>&1 | grep -q -- '-legacy'; then
|
||||
fi
|
||||
|
||||
openssl pkcs12 \
|
||||
-export \
|
||||
"${PKCS12_LEGACY_ARGS[@]}" \
|
||||
-export \
|
||||
-out "$P12" \
|
||||
-inkey "$KEY" \
|
||||
-in "$CERT" \
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -2177,3 +2177,89 @@ async fn voice_status_returns_availability() {
|
||||
mock_join.abort();
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_settings_roundtrip_and_disabled_ingest_skip() {
|
||||
let _env_lock = json_rpc_e2e_env_lock();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let home = tmp.path();
|
||||
let openhuman_home = home.join(".openhuman");
|
||||
|
||||
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
|
||||
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
|
||||
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
|
||||
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
|
||||
|
||||
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
|
||||
let mock_origin = format!("http://{}", mock_addr);
|
||||
write_min_config(&openhuman_home, &mock_origin);
|
||||
|
||||
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
|
||||
let rpc_base = format!("http://{}", rpc_addr);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let set = post_json_rpc(
|
||||
&rpc_base,
|
||||
4001,
|
||||
"openhuman.notification_settings_set",
|
||||
json!({
|
||||
"provider": "gmail",
|
||||
"enabled": false,
|
||||
"importance_threshold": 0.8,
|
||||
"route_to_orchestrator": false
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let set_result = assert_no_jsonrpc_error(&set, "notification_settings_set");
|
||||
assert_eq!(set_result.get("ok").and_then(Value::as_bool), Some(true));
|
||||
|
||||
let get = post_json_rpc(
|
||||
&rpc_base,
|
||||
4002,
|
||||
"openhuman.notification_settings_get",
|
||||
json!({ "provider": "gmail" }),
|
||||
)
|
||||
.await;
|
||||
let get_result = assert_no_jsonrpc_error(&get, "notification_settings_get");
|
||||
let settings = get_result.get("settings").expect("settings object");
|
||||
assert_eq!(
|
||||
settings.get("enabled").and_then(Value::as_bool),
|
||||
Some(false)
|
||||
);
|
||||
let threshold = settings
|
||||
.get("importance_threshold")
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
(threshold - 0.8).abs() < 0.0001,
|
||||
"expected threshold ~= 0.8, got {threshold}"
|
||||
);
|
||||
assert_eq!(
|
||||
settings
|
||||
.get("route_to_orchestrator")
|
||||
.and_then(Value::as_bool),
|
||||
Some(false)
|
||||
);
|
||||
|
||||
let ingest = post_json_rpc(
|
||||
&rpc_base,
|
||||
4003,
|
||||
"openhuman.notification_ingest",
|
||||
json!({
|
||||
"provider": "gmail",
|
||||
"account_id": "acct-1",
|
||||
"title": "subject",
|
||||
"body": "body",
|
||||
"raw_payload": { "source": "test" }
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let ingest_result = assert_no_jsonrpc_error(&ingest, "notification_ingest");
|
||||
assert_eq!(
|
||||
ingest_result.get("skipped").and_then(Value::as_bool),
|
||||
Some(true)
|
||||
);
|
||||
|
||||
mock_join.abort();
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user