Skip to content

feat(remote): WeChat iLink adapter with one-step QR pairing (#1188) - #1404

Merged
Astro-Han merged 21 commits into
devfrom
claude/i1188-wechat
Jun 20, 2026
Merged

feat(remote): WeChat iLink adapter with one-step QR pairing (#1188)#1404
Astro-Han merged 21 commits into
devfrom
claude/i1188-wechat

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Jun 19, 2026

Copy link
Copy Markdown
Owner

Summary

Adds WeChat as a remote-control channel for the mobile-companion bridge, over Tencent's official iLink Bot API (bot_type=3, the WeChat ClawBot slot) — a NAT-friendly HTTP long-poll, structurally like the Telegram adapter (no SDK, no public IP, no relay we operate). It is a pure adapter against the post-#1390 PlatformPairer contract: no engine/supervisor/gateway change.

  • remote-bridge (platforms/wechat/): client.ts (iLink long-poll), login.ts (QR login → bot token + base URL + paired user id), platform.ts (the Platform; no reconstructReplyCtx — iLink has no proactive push, so a restored push is logged and skipped).
  • desktop-electron: WeChatPairer (one-step QR pairing, main-process QR encoding via qrcode), RemoteAccount/isAccount wechat variant, widened PairingProgress.
  • app: WeChat channel row + brand mark, QR sign-in connect dialog (qr pairing-event phase; auto-approves on confirm because the in-WeChat tap already authorized), en/zh copy.

Why

Second of three flat PRs re-landing mobile-companion Wave 1, after the #1390 multi-channel foundation. The iLink wire contract was verified live against the real service first (a bot_type=3 QR login + a getupdates/sendmessage round-trip with a real WeChat account), which corrected three things a prior spike got wrong:

spike assumed live reality fix
QR qrcode_img_content is an image it's a liteapp.weixin.qq.com URL main process QR-encodes it (new qrcode dep)
login poll one-shot, 15s timeout get_qrcode_status long-polls ~30s client timeout 40s
binding needs a "message the bot" step confirm returns ilink_user_id (== inbound from_user_id) one-step pairing: scan + confirm IS the binding

Confirmed correct as-is: all field names, channel_version=1.0.2, status confirmed, the message_type/message_state/item enums, and the context_token reply mechanism.

Related Issue

Part of #1188 (mobile companion). Follows #1390.

Human Review Status

Pending

Review Focus

  • One-step pairing in remote-connect-dialog.tsx: WeChat auto-approves on the captured event (the scan + in-WeChat confirm already authorized), where Telegram keeps the explicit approval step.
  • Contract additions: the qr RemotePairingEvent phase and the widened PairingProgress; main-process QR encoding so the renderer stays a dumb <img>.
  • Calibrated protocol constants in wechat/client.ts (40s status long-poll timeout, channel_version=1.0.2) and the expired → re-mint loop in WeChatPairer.
  • The new qrcode dependency (see Risk Notes).

Risk Notes

  • New dependency: qrcode (+@types/qrcode) in desktop-electron. Required because iLink returns a login URL, not an image, so the QR must be encoded client-side; encoded in the main process so the renderer needs no new dep. Pure JS, no native module; it is also the lib the Feishu path (PR3) will reuse. This overturns the earlier "no new deps" expectation for this PR.
  • Platform (macOS + Windows): qrcode bundles into the Electron main process (electron-vite); no native bindings, so no per-OS packaging concern. The safeStorage credential store gains a wechat account variant — encryption already required on both OSes, unchanged.
  • External protocol: depends on Tencent's iLink service; regional rollout (mainland + select regions) and ~24h bot re-auth apply. The ret:-14 session-expiry re-login path is implemented defensively but could not be live-tested (no real expiry observed).
  • Verification gap: the dev:desktop end-to-end in the real Electron app (click Connect → scan → confirm → message) is not yet run — it needs a live WeChat scan. Recommended before merge. Everything below it (renderer, runtime, credentials) is covered by snap + unit tests, and the iLink protocol itself was proven live.

How To Verify

typecheck (remote-bridge, desktop-electron, app): all exit 0
lint (eslint, changed app + desktop-electron files): 0 errors
unit remote-bridge: 149 pass (incl. 8 new wechat client/platform)
unit desktop-electron remote: 27 pass (test-only "probe" channel replaced with a real telegram+wechat multi-channel test; + wechat credential round-trip)
unit app (remote + i18n): 14 pass
snap remote-surface: pass — grid now covers the WeChat channel row and the WeChat QR sign-in dialog (real QR rendered)
live iLink protocol: real bot_type=3 login + getupdates/sendmessage round-trip against production WeChat
NOT RUN: dev:desktop real-app end-to-end (needs a live WeChat scan) — see Risk Notes

Screenshots or Recordings

remote-surface

Checklist

  • Type label — this PR carries exactly one of bug, enhancement, task, documentation. Type labels are author-added; the labeler bot does NOT assign them. Add the label in the GitHub UI, then tick this.
  • Routing labels — this PR carries at least one of app, ui, platform, harness, ci. The labeler bot assigns these on PR open based on changed paths. Confirm the bot's choice (or override if wrong), then tick this.
  • Priority label — this PR carries exactly one of P0, P1, P2, P3. The priority-triage bot suggests one on PR open. Confirm or override, then tick this.
  • Human Review Status above is set to Pending, Approved by @<reviewer>, or Not required: <reason> (default is Pending; "not required" is restricted to bot-authored low-risk PRs).
  • I linked the related issue, or stated in Summary why there is no issue.
  • I described the review focus and any meaningful risks.
  • I replaced the example block in How To Verify with the real verification steps and the key result for each.
  • I did not introduce unrelated refactors, dependencies, generated files, or file changes beyond the stated scope.
  • (conditional) I manually checked visible UI or copy changes when needed, with screenshots or recordings. Leave unticked only if no visible UI or copy changed.
  • (conditional) I considered macOS and Windows impact for platform, packaging, updater, signing, paths, shell, or permissions changes. Leave unticked only if no platform/packaging surface was touched.
  • (conditional) I called out docs, release notes, dependencies, permissions, credentials, deletion behavior, generated content, or local file changes when relevant. Leave unticked only if none of those surfaces was touched.
  • I reviewed the final diff for unrelated changes and suspicious dependency changes.
  • I am targeting dev, and my PR title and commit messages use Conventional Commits in English.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added WeChat remote control integration with QR code-based pairing.
    • Enabled simultaneous connection to Telegram and WeChat.
    • Added WeChat QR flow with automatic pairing authorization.
  • UI/UX Improvements

    • Updated the remote channels list with card-style platform rows and clearer status pills.
    • Extended platform-specific pairing screens, including WeChat “preparing/QR” states and localized text.
  • Bug Fixes

    • Improved multi-provider status handling and reduced visual status blinking during channel rebuilds/reconnections.

Second of three flat PRs re-landing mobile-companion Wave 1: a WeChat
channel for the remote-control bridge, over Tencent's official iLink Bot
API (bot_type=3, the WeChat ClawBot slot). Pure adapter against the
post-#1390 PlatformPairer contract — no engine/supervisor/gateway change.

Protocol verified live against the real iLink service (a bot_type=3 login
plus a getupdates/sendmessage round-trip), which corrected three things a
prior spike got wrong: qrcode_img_content is a liteapp URL, not an image,
so the main process QR-encodes it (new qrcode dep); get_qrcode_status
long-polls ~30s, so its client timeout is 40s; and the confirm response
returns ilink_user_id (== inbound from_user_id), so pairing is one step —
the scan + confirm in WeChat IS the binding, no "message the bot" round-trip.

- remote-bridge: wechat/{client,login,platform}.ts iLink long-poll adapter
- desktop: WeChatPairer (one-step), RemoteAccount/isAccount wechat variant,
  qr pairing-event phase, main-process QR encoding via qrcode
- app: WeChat channel row + mark, QR sign-in connect dialog, en/zh copy
- tests: real telegram+wechat multi-channel (replaces the test-only probe),
  wechat credential round-trip; snap covers the WeChat row + QR dialog

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
@Astro-Han Astro-Han added enhancement New feature or request P1 High priority app Application behavior and product flows desktop labels Jun 19, 2026
@github-actions github-actions Bot added ci Continuous integration / GitHub Actions ui Design system and user interface platform Electron shell, OS integration, packaging, updater, signing, paths, and permissions harness Model harness, prompts, tool descriptions, and session mechanics labels Jun 19, 2026
@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c0c74f71-7b50-4c6b-add8-570c40135fec

📥 Commits

Reviewing files that changed from the base of the PR and between 4922696 and 65fda0c.

📒 Files selected for processing (6)
  • packages/app/src/pages/remote/remote-connect-dialog.test.ts
  • packages/desktop-electron/src/main/remote-pairers.test.ts
  • packages/desktop-electron/src/main/remote-pairers.ts
  • packages/remote-bridge/src/platforms/wechat/client.test.ts
  • packages/remote-bridge/src/platforms/wechat/client.ts
  • packages/remote-bridge/src/platforms/wechat/platform.test.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • packages/desktop-electron/src/main/remote-pairers.test.ts
  • packages/app/src/pages/remote/remote-connect-dialog.test.ts
  • packages/remote-bridge/src/platforms/wechat/client.ts
  • packages/desktop-electron/src/main/remote-pairers.ts
  • packages/remote-bridge/src/platforms/wechat/platform.test.ts
  • packages/remote-bridge/src/platforms/wechat/client.test.ts

📝 Walkthrough

Walkthrough

Adds WeChat as a second remote platform alongside Telegram. Introduces a new iLink API client (WeChatClient), a long-poll platform bridge (WeChatPlatform), and a QR-based pairer (WeChatPairer). The IPC contract, credential store, connect dialog phase machine, and RemoteSurface are all expanded to support both platforms. E2E snapshots and unit tests cover the new flows.

Changes

WeChat Remote Platform

Layer / File(s) Summary
IPC contract, account types, and credential validation
packages/app/src/desktop-api-contract.ts, packages/desktop-electron/src/main/remote-bridge.ts, packages/desktop-electron/src/main/remote-credentials.ts, packages/desktop-electron/src/main/remote-credentials.test.ts, packages/desktop-electron/package.json
RemotePlatform is widened to "telegram" | "wechat", RemotePairingEvent gains a qr phase with image payload, RemoteAccount becomes a discriminated union with a WeChat arm, PairingProgress is widened to accept qr events, isAccount validates wechat credentials, and qrcode is added as a runtime dependency.
WeChat iLink API client
packages/remote-bridge/src/platforms/wechat/client.ts, packages/remote-bridge/src/platforms/wechat/client.test.ts
Adds WeChatClient with methods for QR minting, QR status polling, long-poll message fetching, context-token replies, and notifyStart. Also defines WeChatApiError, isFatalWeChatError, protocol constants, payload normalization, and withTimeout. Covered by a full mock-server test suite validating request/response flows, error cases, and field validation.
WeChat platform bridge and polling loop
packages/remote-bridge/src/platforms/wechat/platform.ts, packages/remote-bridge/src/platforms/wechat/platform.test.ts
Adds WeChatPlatform with an abortable runLoop that calls notifyStart once, retries transient errors with backoff, dispatches filtered inbound messages via inboundMessage, and sends replies using the stored contextToken. Exports WeChatTransport, WeChatReplyContext, and inboundMessage. Tested with FakeTransport mock for lifecycle, handler isolation, and error cases.
WeChatPairer and desktop pairing wiring
packages/desktop-electron/src/main/remote-pairers.ts, packages/desktop-electron/src/main/remote-pairers.test.ts, packages/desktop-electron/src/main/remote-bridge.ts, packages/desktop-electron/src/main/remote-bridge.test.ts
Adds WeChatPairer implementing PlatformPairer with a QR mint → poll → re-mint-on-expire → confirm loop, emitting qr progress events. buildRemotePairers() now returns both TelegramPairer and WeChatPairer. Bridge runtime prevents connected channels from flapping to "connecting" during rebuilds. Tests cover QR re-mint, API errors, transient retries, abort-before-confirmation, and multi-channel Telegram+WeChat independence.
Connect dialog WeChat QR flow, platform marks, and i18n
packages/app/src/pages/remote/remote-connect-dialog.tsx, packages/app/src/pages/remote/remote-connect-dialog.test.ts, packages/app/src/pages/remote/platform-marks.tsx, packages/app/src/i18n/en.ts, packages/app/src/i18n/zh.ts
DialogConnectRemote gains starting and qr phases; WeChat starts immediately, auto-confirms on captured, and renders a QR image UI branch. Telegram keeps manual confirmation. PlatformMark adds WeChatMark SVG and exports platformNameKey. WeChat UI strings added in English and Simplified Chinese. Browser-subprocess tests verify WeChat auto-confirm and rejected startPairing handling.
RemoteSurface multi-platform rendering
packages/app/src/pages/remote/remote-surface.tsx
PLATFORMS expands to include "wechat", the channel status store and applyStatus mapping track both platforms, and ChannelRow is refactored from a border/left-rule layout to a rounded card with a new StatusPill component for platform-agnostic status display.
E2E snapshot tests and fixtures
packages/app/e2e/snap/remote-surface.snap.ts, packages/app/e2e/snap/wechat-qr.fixture.txt
Adds a WECHAT_QR PNG fixture, extends the snapshot test to exercise the WeChat QR dialog step and a multi-provider (Telegram connected + WeChat degraded) state, and captures the Telegram disconnect confirmation.

Sequence Diagram(s)

sequenceDiagram
  participant App as DialogConnectRemote
  participant Bridge as RemoteBridge
  participant Pairer as WeChatPairer
  participant Client as WeChatClient
  participant iLink as iLink API

  App->>Bridge: startPairing("wechat")
  Bridge->>Pairer: pair(signal, onProgress)
  Pairer->>Client: getBotQrcode()
  Client->>iLink: GET /getbotqrcode
  iLink-->>Client: qrcode, qrcodeUrl
  Pairer->>Pairer: qrDataUrl(qrcode)
  Pairer->>Bridge: onProgress phase=qr, image
  Bridge->>App: onPairing event phase=qr
  Note over App: display QR image
  loop until confirmed or expired
    Pairer->>Client: getQrcodeStatus(qrcode)
    Client->>iLink: long-poll GET
    iLink-->>Client: waiting | expired | confirmed
  end
  Pairer->>Bridge: resolve RemoteAccount
  Bridge->>App: onPairing event phase=captured
  Note over App: WeChat auto-allow, no manual confirm
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related issues

  • Astro-Han/pawwork#1414: The PR addresses flapping-prevention in multi-channel bridge rebuilds, while this issue proposes eliminating rebuilds entirely via incremental platform add/remove architecture.

Possibly related PRs

  • Astro-Han/pawwork#1339: Established the Telegram-first mobile companion foundation including remote bridge pairing contracts, Electron runtime, and connect dialog that this PR extends with WeChat support.
  • Astro-Han/pawwork#1390: Introduced the multi-channel remote IPC, DialogConnectRemote phase machine, and RemoteSurface per-channel rendering that this PR builds upon to add the WeChat qr phase and wechat platform.

Poem

🐰 A QR code blooms in the morning light,
WeChat joins Telegram, side by side just right.
The rabbit scans quickly, no token to type,
Two bridges now dance with their iLink in sight.
Hop hop hooray, the platforms align,
Multi-provider magic—this code sure is fine! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(remote): WeChat iLink adapter with one-step QR pairing' clearly and specifically describes the primary change—adding WeChat support with QR pairing—which is well-reflected throughout the changeset.
Description check ✅ Passed The PR description comprehensively covers all required template sections: summary of changes, rationale with live verification details, related issue link, human review status, detailed review focus addressing architectural decisions, thorough risk notes including dependency and protocol concerns, and complete verification steps with results. The description is well-structured and provides substantial context for reviewers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/i1188-wechat

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested priority: P2 (includes user-path files (packages/app/src/desktop-api-contract.ts, packages/app/src/i18n/en.ts, packages/app/src/i18n/zh.ts, packages/app/src/pages/remote/platform-marks.tsx, packages/app/src/pages/remote/remote-connect-dialog.tsx, packages/app/src/pages/remote/remote-surface.tsx, packages/desktop-electron/src/main/remote-bridge.test.ts, packages/desktop-electron/src/main/remote-bridge.ts, packages/desktop-electron/src/main/remote-credentials.test.ts, packages/desktop-electron/src/main/remote-credentials.ts, packages/desktop-electron/src/main/remote-pairers.ts)).

P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.

iLink has no short "delivery window" — confirmed against Tencent's official
@tencent-weixin/openclaw-weixin SDK and the Kun reference client, which send a
plain FINISH reply minutes after the inbound message and still have it land.
The real reason replies after the first per connection were silently dropped
was missing protocol fields the server uses to consider the bot online:

- iLink-App-Id / iLink-App-ClientVersion headers on every request
- base_info on every POST (previously only on getupdates)
- notifyStart before the first poll; notifyStop on stop
- from_user_id + a fresh client_id per message; always message_state FINISH

Removes the GENERATING placeholder-bubble streaming, which was built on the
mistaken delivery-window theory and left a stuck "…" bubble in the chat.

Verified live: short and long replies both deliver, including the second
message in a connection (the case that previously failed).

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
@Astro-Han

Copy link
Copy Markdown
Owner Author

修复:微信回复投递(真机端到端已验证)

真机测试暴露的 bug:能发消息,但一轮连接里只有第一条消息能收到回复,之后的回复(尤其慢/长回复)被静默丢弃(send 返回 ret=0 但收不到)。

根因(之前的「投递窗口」假设是错的)

对照腾讯官方包 @tencent-weixin/openclaw-weixin@2.4.4(npm)和 Kun 参考实现(KunAgent/Kunsrc/main/weixin-bridge-runtime.ts)读源码定案:

iLink 根本没有「投递窗口」。官方/Kun 都是 agent 跑完(可达几分钟)再直接发一条 message_state=FINISH、fire-and-forget、轮询不等回复,照样投递成功。所以之前基于「窗口」假设做的 typing keepalive、GENERATING 占位气泡流式都是错方向,已全部删除(那套还会留个卡住的「…」气泡)。

真正的原因是缺了几个协议件,服务器据此把 bot 当「未上线」,于是每轮连接只投第一条回复:

  1. 每个请求要带 iLink-App-IdiLink-App-ClientVersion 两个头 —— 我们之前一个都没发
  2. 每个 POST body 要带 base_info —— 之前只在 getupdates 带了
  3. 连接后要先调 ilink/bot/msg/notifystart 注册上线,停止时调 notifystop —— 之前完全没有
  4. send 要带 from_user_id、每条一个新 client_idmessage_state 恒为 FINISH

改动

  • client.ts:加 app-id/client-version 头;post() 统一注入 base_info;sendMessage 回到 positional 三参(FINISH + from_user_id + 每条新 client_id);新增 notifyStart/notifyStop;channel_version 对齐官方包 2.4.4。
  • platform.ts:删掉整套 streaming(GENERATING 占位气泡、定时器、streams map);轮询前 notifyStart、停止时 notifyStop(best-effort);reply 直接发 FINISH。净减代码。
  • platform.test.ts:fake transport 补 notifyStart/notifyStop

验证

  • 单测 8/8;remote-bridge + desktop-electron typecheck 通过。
  • 真机端到端(bot_type=3「微信ClawBot」,2026-06-19):扫码一步配对后,你是什么模型(短)和写一段三百字的散文(长)在同一连接里先后发送,两条回复都正常收到,无卡住气泡 —— 正是之前「第二条/长回复必丢」的场景。

仍 deferred

重启 app 后保存的 botToken 失效(login-session 绑定,ret:-14 会话过期),当前需重新扫码配对;自动重登路径未做。

notifyStart is what marks the bot online; iLink delivers only the first reply
per connection to a bot it hasn't seen it from. The previous best-effort
`.catch(() => {})` meant a failed notifyStart silently reproduced the
delivery-drop bug while the UI still reported "connected". Fold notifyStart into
the poll loop's existing discipline: a fatal token error rejects start(), a
transient one backs off and retries, and the ready/"connected" signal fires only
once the bot is actually online. notifyStop stays best-effort.

Tests lock the ordering (notifyStart precedes the first poll, notifyStop on stop)
and that a fatal notifyStart rejects start() without polling.

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
The connect dialog auto-confirmed pairing for any non-Telegram platform
(`isQr = platform !== "telegram"`), which also gated skipping the token field.
A future third platform would silently inherit auto-approve — granting a remote
connection with no human vetting. Switch to an explicit `isWeChat = platform ===
"wechat"` so a new platform defaults to the safe manual-Allow path until it opts
in here. No capability abstraction for two platforms; explicitness is the guard.

Adds a render test locking the asymmetry: a `captured` event auto-confirms once
for WeChat and never for Telegram.

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
The fields that fixed reply delivery were untested (the prior assertions used a
lenient toMatchObject). Lock them so a regression can't silently re-drop replies:
iLink-App-Id / iLink-App-ClientVersion headers and base_info on every POST, a
send envelope with from_user_id and message_state FINISH, a fresh client_id per
send, and notifyStart/notifyStop hitting their endpoints with base_info.

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
pollWeChatLogin treated every non-API error as "pending", so a real defect would
spin the QR forever with no signal. Keep the long-poll's expected exits (abort /
client-side timeout) silent, surface API errors, and warn on anything else while
still retrying. Adds login.ts unit tests (the previously untested pairing
primitive): QR mint + empty/unreachable failures, and poll mapping for
confirmed / expired / waiting / API error / timeout.

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
@Astro-Han

Copy link
Copy Markdown
Owner Author

Review response (P2 / P3a / P3b + a fresh delivery-path catch)

Ran the review items past two independent reviewers (a fresh-eye agent + codex) plus my own read. Consensus, and what shipped (4 atomic commits on top of the delivery fix):

New finding (codex, beyond the original review) — notifyStart failure was swallowed. platform.ts did notifyStart(...).catch(() => {}). Since notifyStart is what marks the bot online, a failed call silently reproduces the "only first reply delivers" bug while the UI shows connected. Fixed: folded into the poll loop's retry/readiness — a fatal token error rejects start(), a transient one backs off and retries, and "connected" only fires once the bot is actually online. (ee00a7ffa6)

P2 — auto-approve test: DONE. Both reviewers agreed it's the highest-value gap (the captured -> confirmPairing path is security-sensitive and untested). Added a render test locking the asymmetry: WeChat captured auto-confirms exactly once, Telegram captured stays at manual Allow. (ad871182d0)

P3b — explicit allowlist: DONE. isQr = platform !== \"telegram\" -> isWeChat = platform === \"wechat\", so a future third platform defaults to the safe manual-approve path instead of silently inheriting auto-approve. (ad871182d0)

P3a — delete login.ts: SKIPPED (kept), as both reviewers + I concluded. It's the structural twin of Telegram's captureFirstSender (a remote-bridge pairing primitive), holds real timeout-vs-error semantics, and is the injectable seam the pairer otherwise lacks — deleting it would break symmetry and leak protocol logic into the Electron layer. The fresh-eye reviewer's honest catch: the "testable seam" justification was aspirational (it had zero tests). So instead of deleting, I made it real — added login.ts unit tests, and narrowed pollWeChatLogin's catch-all so an unexpected error warns + retries instead of spinning the QR forever silently. (06f42fa63e, b8101304e2)

Protocol regression tests (codex's "missed" note): DONE. The fields that fixed delivery had no assertions (lenient toMatchObject). Locked them: iLink app headers, base_info on every POST, from_user_id, fresh client_id, FINISH, notifyStart/notifyStop. (06f42fa63e)

Effect architecture — evaluated, not adopted here. remote-bridge has zero effect usage and no dep; its sibling telegram.ts is plain async, and desktop-electron main is also Effect-free. Effect lives in the opencode server core (215 files). Routing one WeChat adapter through Effect would make it the lone异类 in a 0-Effect transport layer and pull a heavy dep into a deliberately-portable package. Kept plain async to match the sibling.

Verification: remote-bridge 160 unit tests + app dialog render test pass; remote-bridge / desktop-electron / app typecheck clean; eslint clean on changed app files.

A throwing MessageHandler previously propagated out of the dispatch loop and
killed the poll, silently ending the channel after one bad message. Wrap each
dispatch in try/catch, mirroring TelegramPoller. Also lock the reconnect
semantics: a transient getUpdates blip backs off and retries without re-sending
notifyStart (the `started` flag stays set), so "online" is asserted once per
connection, not per poll.

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
getQrcodeStatus accepted a "confirmed" payload as soon as a bot token was
present, even if ilink_user_id was empty. That empty id becomes allowFrom — a
saved account that accepts no one, persisted before the bridge starts. Require
both fields; an incomplete confirm reports "waiting" so the poll keeps going.

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
Lock the pairer's control flow: mint a QR, re-mint on expiry rather than
dead-end, resolve to the scanned account on confirm, surface a login error as a
throw, and return null on abort. Also note why the WeChat account carries no
userName (iLink hands back only the user id, so identity() shows the raw id).

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
@Astro-Han

Copy link
Copy Markdown
Owner Author

第二轮双重评审 + 落地

按"是否最简洁、最优雅、最安心、最彻底"再评审了一轮(fresh-eye 子代理 + 自核),抓到 2 个真问题 + 2 处测试缺口,已全部修复。每个改动一个原子提交。

真问题

1. handler 抛异常会拖垮整个 poll 循环 (c80e0c3e85)
runLoopdispatch 没有 try/catch,一个 MessageHandler 抛错就会冒泡出循环、悄无声息地结束整条通道(此后所有消息都收不到)。给每次 dispatch 包了 try/catch,和 sibling 的 TelegramPoller 一致。

2. confirm 缺 user id 仍被当成功 (010f7d64c0)
getQrcodeStatus 只要有 bot_token 就当 "confirmed",ilink_user_id 为空也放行。空 id 会变成 allowFrom:""——一个谁都不接受、却已经在 bridge 启动前落盘的坏账号。改成两个字段都必须有,缺一个就报 "waiting",poll 继续等。

测试补强 (67be856c1d + 前两个提交里的用例)

  • WeChatPairer 的 QR 循环:首次出码 → 过期重新出码(不 dead-end)→ 扫码确认落账号 → 登录报错抛出 → abort 返回 null。新建 remote-pairers.test.ts,用 mock.module 桩掉 login 原语和 qrcode 编码,纯单测。
  • poll 循环:handler 抛错后下一条消息仍被处理(通道存活);一次 transient getUpdates 抖动后不会重复发 notifyStart(started 标志只在每次连接置一次)。
  • client:confirmed 但缺 ilink_user_id → 返回 waiting

顺带

WeChat 账号不带 userName(iLink 只回 user id),identity() 回退展示原始 id——加了注释说明,免得后面有人以为是漏填。

验证

  • remote-bridge:163 测试通过,typecheck 通过
  • desktop-electron:573 测试通过(确认 mock.module 无跨文件泄漏),typecheck 通过
  • app:remote 用例 + 全量通过,typecheck 通过
  • eslint:改动的源文件 0 error

Several connected providers read better as distinct cards than as a packed
hairline list. Each channel is now its own boxed row (logo · name · status pill ·
paired identity · action), stacked with a gap; the logo is enlarged and status
moves into a colored pill (green connected, red degraded, outlined otherwise) to
match the Integrations status idiom. PlatformMark takes an optional size class
(default unchanged, so the connect dialog stays as-is). The snap's headline shot
now drives two channels at once (Telegram connected + WeChat degraded) so the
multi-provider layout is what the preview grid captures.

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
The bridge rebuilds on any channel change: connecting or disconnecting a
*different* platform tears every channel down and back up. WeChat's stop() sent a
notifyStop with no signal, so it flew detached from the abort (up to 15s) while
stopBridge waited only 3s before the rebuild's notifyStart. An old "offline"
could land after the new "online" and silently re-mark the bot offline — the same
silent-drop the delivery fix removed, reintroduced from the other side.

notifyStart is the only required lifecycle call (iLink treats a bot it has not
seen start from as inactive); notifyStop is mere courtesy — iLink drops a bot
that stops polling on its own. So stop() now just stops polling, and notifyStop
is gone from the client, the transport interface, and the tests.

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
login.ts was a thin wrapper pair (start/poll) over WeChatClient — a status rename
plus long-poll error triage. Unlike Telegram's captureFirstSender (a stateful
backlog-drain + capture primitive), it carried no state worth its own module, so
it's removed and its logic lands where it belongs:

- WeChatClient owns the protocol detail: getBotQrcode rejects an empty QR, and
  getQrcodeStatus maps its own long-poll TimeoutError to "waiting" (no state change
  yet) while real API/HTTP errors and caller aborts propagate.
- WeChatPairer.pair owns the orchestration loop (confirm → account, expired →
  re-mint, API error → surface, transient → keep polling), mirroring how the
  Telegram pairer inlines captureFirstSender + its error wrap.

Tests move with the logic: the empty-QR and timeout-as-waiting cases join
client.test.ts; the loop/error/abort cases are covered by remote-pairers.test.ts
(now spying WeChatClient's two network methods). login.test.ts is deleted.

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
@Astro-Han

Copy link
Copy Markdown
Owner Author

代码评审处置(逐条核查后)

核查了四条,两条修复、一条已在前一轮修复、一条证据不成立 push back。

P1-a:notifyStop 反杀新连接 — 成立,已修(4f6421ee28)

证据核实无误:notifyStopREQUEST_TIMEOUT_MS=15sstopBridge 只 race STOP_TIMEOUT_MS=3s,而 disconnect(其他平台)/confirmPairing(其他平台) 在 WeChat 已连时都会 startBridge → stopBridge → 重建 WeChatstop() 里的 notifyStop 不带 signal、脱离 abort 飞行,可能晚于新 notifyStart 落地把 bot 标离线 —— 正是投递 bug 的反向。

修法没用屏障,而是根除 notifyStop:notifyStart 是唯一必需的生命周期调用(服务器据此认 bot 在线),notifyStop 只是礼貌通知 —— iLink 对停止 getupdates 的 bot 会自然判离线。stop() 现在只 abort 轮询,notifyStop 从 client、transport 接口、测试里整个删掉。竞态从根上消除,代码也更少。

P1-b:dev-dep-audit 失败 — 证据不成立,push back

当前 PR #1404 的 CI:dev-dep-audit pass(31s)dependency-review pass(6s)。本地 bun audit 报的 26 条全是既有 transitive 漏洞(ip-address / postcss / turbo / @babel/core),没有一条来自新增的 qrcode(qrcode 根本不在 audit 输出里)。评审引用的 failure run 可能是旧的或别分支的。当前无失败 check,不构成阻塞。如果你那边看到的是另一个具体 run,贴 URL 我再核。

P2:QR 确认只校验 bot_token — 已在前一轮修复(010f7d64c0)

当前 getQrcodeStatus(client.ts)已要求 botToken !== "" && userId !== "" 才返回 confirmed,缺 userId 降级为 waiting 继续轮询;baseURL|| this.baseURL fallback 永不为空,无需额外校验。评审读的是该提交之前的代码。client.test.ts 有对应回归(confirmed 缺 ilink_user_id → waiting)。

P3:login.ts 薄包装可删 — 成立,已删(6ed691a1e9)

我上一轮"对称于 captureFirstSender"的保留理由偏弱:captureFirstSender 是 45 行有状态原语(drain backlog + 等首个 sender + ack),login.ts 只是状态名翻译 + 长轮询错误分流,没有值得独立成模块的状态。已删,逻辑各归其位:

  • WeChatClient 收下协议细节:getBotQrcode 拒空 QR;getQrcodeStatus 把自己长轮询的 TimeoutError 映射成 waiting(状态没变),真实 API/HTTP 错误和调用方 abort 继续抛。
  • WeChatPairer.pair 收下编排循环(confirm→账号、expired→重出码、API 错→surface、瞬时错→继续轮询),和 Telegram pairer 内联 captureFirstSender + 错误包装的写法对齐。

测试随逻辑走:空 QR、timeout→waiting 进 client.test.ts;循环/错误/abort 由 remote-pairers.test.ts 覆盖(改用 spyOn 桩 client 的两个网络方法)。login.test.ts 删除。净减 82 行。

验证

remote-bridge 158 测试 + typecheck;desktop-electron 575 测试(确认 spyOn 无跨文件泄漏)+ typecheck;eslint 改动源文件 0 error。

待补(评审要求的真机验证):真实 Electron 扫码 → 确认 → 收发,以及 WeChat 已连时 connect/disconnect 另一平台触发重建后仍能收发(P1-a 的实机回归)。这步要等有真机环境。

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/app/src/desktop-api-contract.ts (1)

116-117: ⚡ Quick win

Narrow phase/platform combinations in RemotePairingEvent.

These variants currently allow impossible combinations at type level. Make phase-specific platform literals so invalid event shapes are rejected at compile time.

Proposed type tightening
 export type RemotePairingEvent =
-  | { phase: "qr"; platform: RemotePlatform; image: string }
-  | { phase: "awaitingBind"; platform: RemotePlatform; hint: "message" }
+  | { phase: "qr"; platform: "wechat"; image: string }
+  | { phase: "awaitingBind"; platform: "telegram"; hint: "message" }
   | { phase: "captured"; platform: RemotePlatform; identity: { id: string; name: string } }
   | { phase: "error"; platform: RemotePlatform; message: string }
   | { phase: "cancelled"; platform: RemotePlatform }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/app/src/desktop-api-contract.ts` around lines 116 - 117, The
RemotePairingEvent type variants are using a generic RemotePlatform type that
allows impossible phase/platform combinations at compile time. For each variant
in RemotePairingEvent (such as the "qr" phase and "awaitingBind" phase shown in
the diff), replace the RemotePlatform type annotation with phase-specific
platform literal types to restrict which platforms are valid for each phase,
ensuring only valid event shapes can be created.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/app/src/pages/remote/remote-connect-dialog.tsx`:
- Around line 83-85: The fire-and-forget call to api.startPairing(platform) in
the WeChat auto-start path does not handle promise rejections, which causes the
dialog to remain stuck in the "starting" phase if the IPC call fails. Add proper
error handling to catch any rejection from startPairing and set the phase state
to "error" when a failure occurs. This applies to the immediate WeChat flow
(isWeChat condition at line 83-85) and also to the other startPairing invocation
mentioned in the comment (lines 127-130).

In `@packages/desktop-electron/src/main/remote-pairers.ts`:
- Around line 89-93: The baseURL returned from the Tencent iLink API status (on
line 92 in the "wechat" platform handler) is accepted and persisted without
validation, which could allow malicious or compromised responses to inject
non-HTTPS URLs. Before returning the object with baseURL in the platform
"wechat" case, validate that status.baseURL is a properly formatted HTTPS URL
(check it starts with https:// and is a valid URL format). If validation fails,
reject the response or throw an error rather than persisting the invalid
baseURL.

In `@packages/remote-bridge/src/platforms/wechat/client.ts`:
- Around line 253-259: The parse method currently catches JSON parsing failures
and defaults to an empty object, which masks API failures when iLink returns
non-JSON 2xx responses and causes silent failures in polling logic. Instead of
catching the error from res.json() and returning an empty object, allow the
parse error to propagate or throw a descriptive WeChatApiError when JSON parsing
fails. This ensures that malformed responses are treated as actual API failures
rather than successful empty responses.

---

Nitpick comments:
In `@packages/app/src/desktop-api-contract.ts`:
- Around line 116-117: The RemotePairingEvent type variants are using a generic
RemotePlatform type that allows impossible phase/platform combinations at
compile time. For each variant in RemotePairingEvent (such as the "qr" phase and
"awaitingBind" phase shown in the diff), replace the RemotePlatform type
annotation with phase-specific platform literal types to restrict which
platforms are valid for each phase, ensuring only valid event shapes can be
created.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4c91c361-6c8c-451e-92fa-8a59655a4185

📥 Commits

Reviewing files that changed from the base of the PR and between a660288 and 6ed691a.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (20)
  • packages/app/e2e/snap/remote-surface.snap.ts
  • packages/app/e2e/snap/wechat-qr.fixture.txt
  • packages/app/src/desktop-api-contract.ts
  • packages/app/src/i18n/en.ts
  • packages/app/src/i18n/zh.ts
  • packages/app/src/pages/remote/platform-marks.tsx
  • packages/app/src/pages/remote/remote-connect-dialog.test.ts
  • packages/app/src/pages/remote/remote-connect-dialog.tsx
  • packages/app/src/pages/remote/remote-surface.tsx
  • packages/desktop-electron/package.json
  • packages/desktop-electron/src/main/remote-bridge.test.ts
  • packages/desktop-electron/src/main/remote-bridge.ts
  • packages/desktop-electron/src/main/remote-credentials.test.ts
  • packages/desktop-electron/src/main/remote-credentials.ts
  • packages/desktop-electron/src/main/remote-pairers.test.ts
  • packages/desktop-electron/src/main/remote-pairers.ts
  • packages/remote-bridge/src/platforms/wechat/client.test.ts
  • packages/remote-bridge/src/platforms/wechat/client.ts
  • packages/remote-bridge/src/platforms/wechat/platform.test.ts
  • packages/remote-bridge/src/platforms/wechat/platform.ts

Comment thread packages/app/src/pages/remote/remote-connect-dialog.tsx
Comment thread packages/desktop-electron/src/main/remote-pairers.ts
Comment thread packages/remote-bridge/src/platforms/wechat/client.ts
Adding or removing one channel restarts the shared event stream for every
channel, so startBridge re-marked all of them "connecting" — making every
already-connected channel blink "connecting" when the user merely connected a
second platform. It reads as "everything broke" even though nothing did.

Keep a channel that is already connected as-is across a rebuild: skip it in the
pre-mark, and suppress a supervisor "connecting" while it is connected. It updates
only when it is serving again (confirmed) or degraded (a real failure), so adding
one channel never makes the others look broken.

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
The zh strings called the product "你的智能体" (your agent) — vague, and off-brand
next to the rest of the app, which says 爪印 (the brand test bans a standalone
PawWork in zh). Name it: connect toast, page description, capabilities, the bind
note, and the confirm body now say 爪印. Also smoothed two awkward lines and fixed
a half-width comma in the bind body. English keeps its own "your agent" voice.

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
操控 collided with the feature name 远程控制 and read oddly. Match the feature:
控制. The toast drops the redundant 远程 ("从 {{platform}} 控制" already implies it).

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
Three ways a malformed response slipped through:
- parse() did res.json().catch(() => ({})), so a 2xx that isn't JSON (a proxy
  login page, an HTML error) passed as an empty success. It now throws
  "invalid JSON response"; non-2xx still throws with httpStatus so a 401/403 stays
  fatal.
- A "confirmed" status missing the bot token or user id fell through to "waiting",
  so the scanning user waited forever on a confirm that won't improve by polling.
  It now throws — confirmed is terminal, so an incomplete one is an error.
- The confirmed baseurl (the host every later call trusts, then persisted) was
  taken as-is. It's now required to be a well-formed https origin, rejecting
  http/javascript:/malformed hosts centrally in the client.

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
The WeChat connect flow void-ed startPairing on mount/retry, so an IPC
reject or main-side throw left the dialog stuck on "Preparing…" with the
rejection unhandled. Funnel all three call sites through a beginPairing
helper that awaits and lands a rejection on the error step.

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
…ccess

parse() accepted anything `typeof "object"`, but a JSON array satisfies
that while not being the keyed response body the iLink API returns — it
slipped through as a fake empty success. Exclude arrays so they fall to
the same "invalid JSON response" throw as any other non-object body.

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
…guards

The render shim is one-shot, so the test asserts the real regression — a
rejected startPairing is caught, not leaked into an unhandled rejection
that hangs the dialog — rather than the reactive error-phase transition
the harness can't observe. Rename to match.

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
@Astro-Han

Copy link
Copy Markdown
Owner Author

第四 + 第五轮评审处置

逐条核查后的结论与改动(无 inline 线程,集中回这条):

P1 — Windows advisory「失败」:push back(无关的瞬时安装错误)

本分支 windows-advisory 最近 8 次跑 7 次 success,唯一一次失败(run 27837631913,commit 1825b82b54)失败在 bun install --frozen-lockfile exit 7,job 是 unit-windows-opencode-tool-runtime —— 是 registry/网络瞬时错误,不是测试失败,且属 opencode 核心 job,与本 PR 的纯 TS 改动(remote-bridge / desktop-electron / app)无关。前后相邻 commit 的同一 workflow 都 success,当前 head 重新跑也全绿(pending=0 fail=0)。dev-dep-auditdependency-review 均 pass。无需改动。

P2-a/b/c — 校验 iLink 响应再信任(已修 d998845ac9

  • confirmed 缺字段不再误判 waiting,改报错;
  • baseURL 必须是合法 https origin 才持久化;
  • 2xx 非 JSON body 报 invalid JSON response,不当空成功。

P2-e — startPairing reject 没 catch(已修 492269677c

beginPairing(start?) 统一 await + catch,三处调用点(onMount / submitToken / retry)都走它;reject 落 error step,不再卡 Preparing。

P2(第五轮)— parse 把 2xx JSON array 当成功(已修 b5374f3e09

typeof [] === "object" 会让数组冒充 keyed body。加 !Array.isArray(json),数组落到与其它非对象 body 相同的 invalid JSON response 抛错。补了 200 + [] 的 getUpdates / getQrcodeStatus 测试。

P3(第五轮)— startPairing-reject 测试名名实不符(已改名 21a093ea38

属实:原名说「surfaces the error step」,断言其实只锁「reject 被 catch、不泄漏 unhandled rejection」。reviewer 给的两个选项里,「换可响应的测试真正断言进 error」在这个 harness 不可行——runBrowserCheckbun -e 子进程用 React 风格 createElement shim 一次性渲染,Solid 的细粒度反应性依赖编译期 transform(babel-plugin-jsx-dom-expressions),子进程没跑,所以异步 setStore 后 DOM 不会重渲(同包的 auto-approve 测试也因此只断言副作用、从不断言状态变化后的 DOM)。取选项一:改名为 “catches a rejected startPairing…”,名实相符。该测试仍能锁住真 fix——把 beginPairing 退回 void startPairing 会重新泄漏 unhandled rejection → 测试失败。

P2-d — 删重建时的 connecting 抑制:defer 到 Wave 2(issue #1414

不在本 PR 单删。理由:连/断一个渠道会整桥重启所有渠道RemoteBridgeRuntime.startBridge 每次 stopBridge→重建),这是 supervisor.ts:10-13 写死的 Wave 1 简化。闪烁抑制只是让 UI 说出 Wave 1 本就想表达的真话(没被动到的渠道还连着),degraded / 真故障照常显示。现在单删抑制只会把吓人的闪烁放回来,还修不了真正的「重建窗口可能丢消息」——那个只有「不重启未受影响的渠道」才能解。根因 = App 支持增量增删渠道(per-platform AbortController + addPlatform/removePlatform,共享事件流不断),已拆成 #1414 单独 PR;落地后抑制随之删除。

—— 验证:remote-bridge 161 / app remote+i18n / desktop-electron remote 全过,typecheck 9/9,lint clean,CI 当前无失败检查。

…t retry

isFatalWeChatError only flagged HTTP 401/403, so a body-level session-
expired ret (iLink's -14, returned 200) was retried forever — the channel
either sat on "connecting" or kept showing "connected" while dead, with no
prompt to re-scan. Classify -14 as fatal so the poll loop rejects start()
and the supervisor degrades the channel.

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
isHttpsUrl checked the scheme but kept the value verbatim, so a confirmed
baseurl carrying embedded credentials or an extra path/query/fragment would
be stored and concatenated with the bot token's request path — a redirect
of authenticated calls. Reduce it to url.origin (rejecting userinfo / path /
query / fragment) at confirm, and re-check the persisted value in
makePlatform so a tampered credentials file is refused too.

Claude-Session: https://claude.ai/code/session_01PeCKAMTYMvjEkuxixd52AQ
@Astro-Han

Copy link
Copy Markdown
Owner Author

第六轮评审处置

P1 — 会话失效被当瞬时错误无限重试(已修 1c835dd34d

属实。isFatalWeChatError 只认 HTTP 401/403,body-level 的 session-expired ret(iLink 的 -14,HTTP 仍是 200)会被当瞬时错误无限 backoff——渠道要么卡在 connecting、要么 token 失效后还显示 connected。改:把已验证的不可恢复码 -14 归为 fatal(FATAL_RET_CODES set),poll loop reject start() → supervisor degrade → 提示重新扫码。补测试:client 层 getUpdates / notifyStart 返回 200+-14isFatalWeChatError 为 true;platform 层 fatal getUpdates 让 start() reject 而非空转。

  • 不枚举没核实过的码:-14 是 2026-06-19 实机验证的 session-expired,invalid-token 走 401/403(已覆盖)。其它码确认后再加,乱猜会把瞬时错误误判成 fatal。
  • 关于「不要声称 re-login path implemented」:代码里没有这个声称,STATUS 也一直标注重登路径 deferred。这次只做「失效即 degrade + 提示重扫」,自动重登仍是后续。

P2 — baseURL 只校验 https、不规范化(已修 65fda0cf91

属实。isHttpsUrl 只看 scheme、原样保存,https://user:pass@host/evil?x#y 会被存下并和 bot token 的请求路径拼接,可能把已鉴权请求导去别处。改成 normalizeHttpsOrigin:解析后只保留 url.origin,拒绝 userinfo / path / query / fragment;confirm 处存规范化 origin,makePlatform 也复用同一校验(被改的 credentials 文件同样拒绝)。补测试覆盖 https://user:pass@host/path?x#x 全部拒绝,干净 origin + 末尾斜杠归一。

P2 — 连/断一个渠道实际重启所有渠道,suppression 让已连渠道继续显示 connected:defer 到 Wave 2(issue #1414

不在本 PR 单删 suppression。整桥重启是 supervisor.ts:10-13 写死的 Wave 1 简化;suppression 让 UI 说出 Wave 1 本就想表达的真话(没被动到的渠道还连着),degraded / 真故障照常透出。单删只会把吓人闪烁放回来、还修不了「重建窗口可能丢消息」——那只有「不重启未受影响渠道」能解。根因 = App 增量增删渠道(per-platform AbortController + addPlatform/removePlatform),已拆成 #1414 单独 PR,落地后 suppression 随之删除。flap 测试锁的就是这个有意的 UX(已连渠道在无关重建中保持 connected),不是「假装」。这是产品方已确认的取舍。

P3 — RemoteSurface 卡片化 + StatusPill 是无关视觉重构:push back(产品方明确要求)

这不是接入 WeChat 的附带改动,而是产品方本轮明确要求的重设计——原 hairline 列表「多个 Provider 呈现不好看」,给了多个 HTML 方案后选定卡片行(方案 B)并已用 snapshot 确认。把它砍掉等于回退产品方要的工作。视觉升级正是这次迭代的目的之一,不另开 PR。

P3 — RemotePairingEvent 没按平台收窄 union:push back(通用形状是有意的,收窄反而降低扩展性)

qr / awaitingBind平台无关的能力阶段,不是平台专属。把 qr 收窄成 wechat 等于写死「只有微信用扫码」,下一个扫码类平台就得把它再放宽回来——通用形状才是更可扩展的设计。当前松类型也没有真实误用风险:事件只由 pairer 产生(各自只发对的阶段),dialog 按 phase 分支;安全敏感的「仅微信自动批准」已用显式 isWeChat allowlist 收窄并有渲染测试锁住,不依赖事件类型。所以保持现状。

—— 验证:remote-bridge 164 / desktop-electron remote 26 / typecheck 9/9 全过,lint clean(remote-pairers)。

@Astro-Han

Copy link
Copy Markdown
Owner Author

第七轮(待验证项)已满足

  • CI:最新 head 44 项检查全绿。之前 e2e-artifacts 的失败是 app-home 首页 session 的时序 flake(TestLLMServer still has 1 queued response + URL 未跳 /session/),与本 PR 改动无关,该 workflow 此前连续 7 次绿;重跑已通过。
  • 真机端到端:在含本轮 P1/P2 的最新代码上复测通过 —— Connect → 扫码 → 确认 → 发消息收到智能体回复 → 连发第二条同样收到回复。P2 的 baseURL 规范化没有误拒真实 Tencent baseurl,确认链路正常。

评审项已全部处置(P1 会话失效转 fatal、P2 baseURL 规范化为真问题已修;suppression / UI 卡片化 / 事件 union 收窄已 push back)。自动重登 + 渠道增量增删(不再整桥重启)为 Wave 2 #1414 单独跟进。

@Astro-Han
Astro-Han merged commit f88fa32 into dev Jun 20, 2026
45 of 47 checks passed
@Astro-Han
Astro-Han deleted the claude/i1188-wechat branch June 20, 2026 03:30
Astro-Han added a commit that referenced this pull request Jun 21, 2026
…ing every channel) (#1454)

Connecting or disconnecting one remote channel no longer restarts the shared
PawWork event stream or the other channels — only the affected channel starts or
stops. Per-channel lifecycle becomes a first-class operation across the
supervisor, gateway, and desktop runtime, and #1404's interim UI flap-suppression
is removed. Prepare-first / swap-after-success on re-pair and disconnect, a
beforeCommit credential-commit hook, a BridgeClosedError teardown/liveness
contract, and best-effort session-pointer cleanup close the commit-order seams
surfaced across review. Closes #1414. Part of #1188.

Claude-Session: https://claude.ai/code/session_01Y3Z6Hbny6bzGoZJg8Xrjjr
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app Application behavior and product flows ci Continuous integration / GitHub Actions desktop enhancement New feature or request harness Model harness, prompts, tool descriptions, and session mechanics P1 High priority platform Electron shell, OS integration, packaging, updater, signing, paths, and permissions ui Design system and user interface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant