From f9de38d6f9bc252501ef79f772b96aedf3926a4d Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sun, 17 May 2026 02:38:10 -0700 Subject: [PATCH] Add WeChat embedded webview support (#1991) --- app/src-tauri/src/webview_accounts/mod.rs | 42 ++++++++++++++++--- app/src/components/OpenhumanLinkModal.tsx | 1 + .../OpenhumanLinkModal.accounts.test.tsx | 1 + app/src/components/accounts/WebviewHost.tsx | 1 + app/src/components/accounts/providerIcons.tsx | 5 ++- app/src/lib/notificationRouter.test.ts | 2 +- app/src/lib/notificationRouter.ts | 1 + app/src/types/accounts.ts | 7 ++++ docs/wechat-message-scraping-issue.md | 35 ++++++++++++++++ 9 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 docs/wechat-message-scraping-issue.md diff --git a/app/src-tauri/src/webview_accounts/mod.rs b/app/src-tauri/src/webview_accounts/mod.rs index 4cbcda49e..aada3efc1 100644 --- a/app/src-tauri/src/webview_accounts/mod.rs +++ b/app/src-tauri/src/webview_accounts/mod.rs @@ -49,6 +49,7 @@ const GOOGLE_MEET_RECIPE_JS: &str = include_str!("../../recipes/google-meet/reci fn provider_url(provider: &str) -> Option<&'static str> { match provider { "whatsapp" => Some("https://web.whatsapp.com/"), + "wechat" => Some("https://web.wechat.com/"), "telegram" => Some("https://web.telegram.org/k/"), "linkedin" => Some("https://www.linkedin.com/messaging/"), "slack" => Some("https://app.slack.com/client/"), @@ -87,6 +88,12 @@ fn provider_is_supported(provider: &str) -> bool { fn provider_allowed_hosts(provider: &str) -> &'static [&'static str] { match provider { "whatsapp" => &["whatsapp.com", "whatsapp.net", "wa.me"], + "wechat" => &[ + "wechat.com", + "wx.qq.com", + "weixin.qq.com", + "login.weixin.qq.com", + ], "telegram" => &["telegram.org", "t.me"], "linkedin" => &[ "linkedin.com", @@ -649,6 +656,7 @@ async fn post_provider_surfaces_event(args: &RecipeEventArgs) -> Result<(), Stri pub fn provider_display_name(provider: &str) -> &'static str { match provider { "whatsapp" => "WhatsApp", + "wechat" => "WeChat", "telegram" => "Telegram", "linkedin" => "LinkedIn", "slack" => "Slack", @@ -1751,12 +1759,13 @@ fn data_directory_for(app: &AppHandle, account_id: &str) -> Resul /// Produce the `initialization_script` payload for this webview. /// -/// Empty for the 5 migrated providers (whatsapp, telegram, slack, discord, -/// browserscan) — they load with ZERO injected JS; their scraping runs via -/// CDP, and the per-account CDP session opener (`cdp::session`) injects the -/// notification-permission shim via `Page.addScriptToEvaluateOnNewDocument` -/// before the real provider URL loads. The 2 deferred providers -/// (linkedin, google-meet) still get the JS recipe bridge. +/// Empty for the 6 zero-injection providers (whatsapp, wechat, telegram, +/// slack, discord, browserscan) — they load with ZERO injected JS. Some have +/// native/CDP scraper paths; WeChat is shell-only for now. The per-account +/// CDP session opener (`cdp::session`) still injects the notification-permission +/// shim via `Page.addScriptToEvaluateOnNewDocument` before the real provider +/// URL loads. The 2 deferred providers (linkedin, google-meet) still get the +/// JS recipe bridge. fn build_init_script(account_id: &str, provider: &str) -> String { let Some(recipe_js) = provider_recipe_js(provider) else { return String::new(); @@ -3370,6 +3379,27 @@ mod tests { assert_eq!(provider_url("zoom"), Some("https://zoom.us/")); } + #[test] + fn wechat_registered_in_provider_url() { + assert_eq!(provider_url("wechat"), Some("https://web.wechat.com/")); + } + + #[test] + fn wechat_has_no_recipe_js_injection() { + assert!(provider_recipe_js("wechat").is_none()); + } + + #[test] + fn wechat_allowed_hosts_cover_web_and_login_domains() { + let hosts = provider_allowed_hosts("wechat"); + assert!(hosts.contains(&"wechat.com"), "wechat.com in allowlist"); + assert!(hosts.contains(&"wx.qq.com"), "wx.qq.com in allowlist"); + assert!( + hosts.contains(&"login.weixin.qq.com"), + "login.weixin.qq.com in allowlist" + ); + } + #[test] fn zoom_has_no_recipe_js_injection() { // Per the CLAUDE.md "no new JS injection" rule for CEF child diff --git a/app/src/components/OpenhumanLinkModal.tsx b/app/src/components/OpenhumanLinkModal.tsx index f26c5890a..588328b5f 100644 --- a/app/src/components/OpenhumanLinkModal.tsx +++ b/app/src/components/OpenhumanLinkModal.tsx @@ -379,6 +379,7 @@ const DiscordBody = ({ close }: { close: () => void }) => { */ const ACCOUNTS_SETUP_PROVIDERS: readonly AccountProvider[] = [ 'whatsapp', + 'wechat', 'telegram', 'slack', 'discord', diff --git a/app/src/components/__tests__/OpenhumanLinkModal.accounts.test.tsx b/app/src/components/__tests__/OpenhumanLinkModal.accounts.test.tsx index 5d224cc0f..0523e100c 100644 --- a/app/src/components/__tests__/OpenhumanLinkModal.accounts.test.tsx +++ b/app/src/components/__tests__/OpenhumanLinkModal.accounts.test.tsx @@ -66,6 +66,7 @@ describe('OpenhumanLinkModal accounts setup', () => { openAccountsModal(); expect(screen.getByLabelText('Connect WhatsApp Web')).toBeInTheDocument(); + expect(screen.getByLabelText('Connect WeChat Web')).toBeInTheDocument(); expect(screen.getByLabelText('Connect Telegram Web')).toBeInTheDocument(); expect(screen.getByLabelText('Connect Slack')).toBeInTheDocument(); expect(screen.getByLabelText('Connect Discord')).toBeInTheDocument(); diff --git a/app/src/components/accounts/WebviewHost.tsx b/app/src/components/accounts/WebviewHost.tsx index bb0cadf55..077f1636f 100644 --- a/app/src/components/accounts/WebviewHost.tsx +++ b/app/src/components/accounts/WebviewHost.tsx @@ -22,6 +22,7 @@ const LOADING_STATUSES: ReadonlySet = new Set(['pending', 'loadin const PROVIDER_COPY: Record = { whatsapp: 'WhatsApp', + wechat: 'WeChat', telegram: 'Telegram', linkedin: 'LinkedIn', slack: 'Slack', diff --git a/app/src/components/accounts/providerIcons.tsx b/app/src/components/accounts/providerIcons.tsx index 78be42b32..15c284220 100644 --- a/app/src/components/accounts/providerIcons.tsx +++ b/app/src/components/accounts/providerIcons.tsx @@ -1,4 +1,4 @@ -import { FaLinkedin } from 'react-icons/fa'; +import { FaLinkedin, FaWeixin } from 'react-icons/fa'; import { SiDiscord, SiGooglemeet, SiSlack, SiTelegram, SiWhatsapp, SiZoom } from 'react-icons/si'; import { TbRobot } from 'react-icons/tb'; @@ -11,6 +11,7 @@ import type { AccountProvider } from '../../types/accounts'; */ const PROVIDER_COLOR: Record = { whatsapp: '#25D366', + wechat: '#07C160', telegram: '#229ED9', linkedin: '#0A66C2', slack: '#4A154B', @@ -36,6 +37,8 @@ export const ProviderIcon = ({ switch (provider) { case 'whatsapp': return ; + case 'wechat': + return ; case 'telegram': return ; case 'linkedin': diff --git a/app/src/lib/notificationRouter.test.ts b/app/src/lib/notificationRouter.test.ts index 6092d95a4..64eac3e52 100644 --- a/app/src/lib/notificationRouter.test.ts +++ b/app/src/lib/notificationRouter.test.ts @@ -41,7 +41,7 @@ describe('resolveIntegrationRoute', () => { expect(resolveIntegrationRoute(n)).toBe('/chat?account=abc'); }); - it.each(['gmail', 'slack', 'whatsapp', 'telegram', 'discord', 'linkedin'])( + it.each(['gmail', 'slack', 'whatsapp', 'wechat', 'telegram', 'discord', 'linkedin'])( 'routes %s provider to /chat', provider => { expect(resolveIntegrationRoute(makeIntegration({ provider }))).toBe('/chat'); diff --git a/app/src/lib/notificationRouter.ts b/app/src/lib/notificationRouter.ts index 9ff8b6543..b9dc1e073 100644 --- a/app/src/lib/notificationRouter.ts +++ b/app/src/lib/notificationRouter.ts @@ -24,6 +24,7 @@ const MESSAGE_PROVIDERS = new Set([ 'gmail', 'slack', 'whatsapp', + 'wechat', 'telegram', 'discord', 'linkedin', diff --git a/app/src/types/accounts.ts b/app/src/types/accounts.ts index 6f62b3ed8..c12893157 100644 --- a/app/src/types/accounts.ts +++ b/app/src/types/accounts.ts @@ -2,6 +2,7 @@ import { IS_DEV } from '../utils/config'; export type AccountProvider = | 'whatsapp' + | 'wechat' | 'telegram' | 'linkedin' | 'slack' @@ -74,6 +75,12 @@ const BASE_PROVIDERS: ProviderDescriptor[] = [ description: 'Open web.whatsapp.com inside the app and stream chat updates.', serviceUrl: 'https://web.whatsapp.com/', }, + { + id: 'wechat', + label: 'WeChat Web', + description: 'Open WeChat in-app for QR sign-in and desktop chat access.', + serviceUrl: 'https://web.wechat.com/', + }, { id: 'telegram', label: 'Telegram Web', diff --git a/docs/wechat-message-scraping-issue.md b/docs/wechat-message-scraping-issue.md new file mode 100644 index 000000000..147fba202 --- /dev/null +++ b/docs/wechat-message-scraping-issue.md @@ -0,0 +1,35 @@ +# Add WeChat message scraping into context and memory + +## Summary + +Ingest messages from the embedded WeChat webview into OpenHuman so the agent can use recent WeChat conversations as context and persist useful history into memory. + +## Problem + +We can embed WeChat Web in the Tauri shell for sign-in and manual use, but OpenHuman does not yet extract message data from that surface. That leaves a gap for users who coordinate through WeChat, especially for China-based communities and teams. The work needs to respect platform limits in WeChat Web, avoid broad DOM scraping that breaks easily, and keep private message content scoped to explicit product surfaces and consented storage paths. + +## Solution (optional) + +Add a dedicated WeChat webview ingestion path, following the existing Franz-style account model but with provider-specific extraction for chats, messages, unread state, and stable account/chat identifiers. Scope should cover: + +- Tauri/app shell: + Add a WeChat-specific extractor path for the embedded webview, with clear load-state handling and bounded retries when login/session state changes. +- Core: + Define a WeChat webview ingest contract that can write normalized message snapshots into context/memory namespaces, similar to other webview-account providers. +- Product safeguards: + Gate persistence behind existing memory/write surfaces, document what is stored, and make it easy to disable or purge. + +## Acceptance criteria + +- [ ] **Embedded WeChat extraction** — OpenHuman can detect the active WeChat chat surface and extract normalized message data from the embedded webview. +- [ ] **Context + memory ingestion** — extracted WeChat messages can be written into the existing context/memory pipeline with provider/account provenance. +- [ ] **Unread/respond surfaces** — unread or actionable WeChat activity appears in the same provider-surface/respond-queue flows used by other messaging integrations where applicable. +- [ ] **Privacy + control** — users can understand, disable, and purge stored WeChat-derived data using existing account/memory controls or clearly-scoped additions. +- [ ] **Tests** — add focused Vitest and Rust coverage for the WeChat provider path, including at least one failure/edge case. +- [ ] **Diff coverage ≥ 80%** — the implementing PR meets the changed-lines coverage gate (Vitest + cargo-llvm-cov, enforced by [`.github/workflows/coverage.yml`](../.github/workflows/coverage.yml)). + +- Evaluate whether WeChat needs a native scanner path, injected bridge, or hybrid approach after validating what the embedded web client exposes at runtime. + +## Related + +- Follows the initial Tauri WeChat webview support added in `feat/wechat-webview-support`.