Skip to content

feat(channels): @openhermit/channel-wechat (iLink, text-only v0)#91

Merged
williamwa merged 4 commits into
mainfrom
plan/channel-wechat
May 15, 2026
Merged

feat(channels): @openhermit/channel-wechat (iLink, text-only v0)#91
williamwa merged 4 commits into
mainfrom
plan/channel-wechat

Conversation

@williamwa
Copy link
Copy Markdown
Collaborator

@williamwa williamwa commented May 15, 2026

Summary

Layout

apps/channels/wechat/
├── src/
│   ├── ilink/{api,login,types}.ts   # iLink HTTP transport + QR state machine
│   ├── bridge.ts                    # ChannelOutbound + inbound dispatch
│   ├── bot.ts                       # long-poll getUpdates loop
│   ├── setup.ts                     # ChannelSetup → returns qrText
│   ├── manifest.ts                  # default export
│   └── index.ts
├── package.json                     # carries top-level `ilink_appid` field
├── tsconfig*.json
└── README.md

Setup flow

  1. UI calls POST /api/agents/:id/channels/wechat/setup/begin → plugin fetches a QR from ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode, kicks off a background long-poll on get_qrcode_status, returns awaiting_external with qrText set to the QR URL string.
  2. UI polls GET /api/agents/:id/channels/wechat/setup/:sessionId. Server pushes status transitions (waitscanedconfirmed); on confirmed returns done with config: { bot_token, base_url, ilink_bot_id, ilink_user_id? }.
  3. UI POSTs that config to /api/agents/:id/channels to persist the row; the manifest's start() boots the long-poll bot.

need_verifycode and verify_code_blocked are not supported in v0 (Tencent's CLI handles them via stdin) — the wizard surfaces an error and the user re-scans.

Notes

  • iLink-App-Id is read from the package's own package.json (ilink_appid) — matches Tencent's convention. Operators with their own iLink app can patch the field or set OPENHERMIT_WECHAT_APP_ID.
  • Wire format and QR state machine were reverse-engineered from @tencent-weixin/openclaw-weixin (MIT-licensed); none of that package's code is depended on at runtime.

Test plan

  • npm run typecheck (workspace)
  • Verify external plugin loads via channelPackages against test gateway
  • End-to-end QR link with a real WeChat account
  • Inbound text → agent → outbound text round-trip

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added WeChat channel integration with secure QR code-based setup wizard
    • Interactive channel setup flows supporting both user input and external authentication methods
    • Enhanced channel management to categorize and display built-in, package-installed, and external token channels

Review Change Stack

First external-default channel built on the ChannelManifest contract.
Talks iLink HTTP (Tencent's WeChat protocol) — QR-link wizard via
ChannelSetup, long-poll inbound, text-only outbound.

- Wire types and HTTP client (`src/ilink/`)
- `WechatBridge` (ChannelOutbound) + `WechatBot` long-poll loop
- `ChannelSetup` adapter that returns the QR url as `qrText` for the
  admin UI to render
- Loaded via `channelPackages` in gateway config, not bundled

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 15, 2026

📝 Walkthrough

Walkthrough

Adds a complete WeChat channel adapter for iLink integration, including long-poll messaging, QR-code login setup, manifest registration, and gateway/web UI orchestration for interactive channel linking. Extends the protocol registry to track manifest origins (built-in vs external) and enables dual-mode channel creation through both manifest keys and external namespaces.

Changes

WeChat Channel Adapter Implementation

Layer / File(s) Summary
iLink wire types and discriminators
apps/channels/wechat/src/ilink/types.ts
TypeScript interfaces for WeChat messages, message items, text payloads, request/response DTOs (getUpdates, sendMessage, notifyStart/Stop), and QR login types (QrLoginStatus, QrCodeResponse, QrStatusResponse) that form the foundation for transport and business logic.
iLink HTTP transport layer
apps/channels/wechat/src/ilink/api.ts
Package.json resolution to derive iLink app-id and client version, fetchWithTimeout wrapper with AbortController, request header construction, apiGet/apiPost helpers, and endpoint implementations for QR fetching, long-poll message retrieval, message sending, and bot notification callbacks.
QR login state machine
apps/channels/wechat/src/ilink/login.ts
WeixinQrLogin class managing per-session state in an in-memory map, async polling with TTL enforcement, status transitions, redirect-host handling, credential validation, and surfacing outcomes as snapshot types (pending/done/error).
Message bridge & agent integration
apps/channels/wechat/src/bridge.ts
WechatBridge implementing ChannelOutbound, handling outbound text sending via iLink, inbound message processing with per-peer serialization locks, session management, and SSE event stream parsing for agent streaming responses (text_delta, text_final, error, agent_end).
Bot long-poll lifecycle
apps/channels/wechat/src/bot.ts
WechatBot managing start/stop lifecycle, notifyStart/notifyStop callbacks, async runLoop with persistent get_updates_buf cursor, error retry/delay, cursor reset on errcode -14, and sequential message dispatch to bridge.handleMessage.
Channel manifest & setup adapter
apps/channels/wechat/src/manifest.ts, src/setup.ts
ChannelManifest with config validation, bridge/bot instantiation, and lifecycle control; createWechatSetup mapping QR snapshots to protocol states (awaiting_external, done, error) and persisting config fields (bot_token, base_url, ilink_bot_id, ilink_user_id).
WeChat package metadata & docs
apps/channels/wechat/package.json, tsconfig.json, tsconfig.typecheck.json, README.md
Package configuration, TypeScript build/typecheck settings, and comprehensive README documenting v0 scope (text-only), plugin loading via channelPackages, setup flow, config keys, and iLink-App-Id sourcing.
Protocol: manifest origin tracking
packages/protocol/src/index.ts
ChannelManifestOrigin type ('built-in' | 'external'), ChannelManifestRegistry extended with originByKey map and updated register/replace signatures accepting optional origin parameter, plus new query methods originOf and keysByOrigin.
Gateway: manifest registry & channel endpoints
apps/gateway/src/app.ts, src/channel-manifests.ts
Agent creation pre-seeds disabled rows for all registered manifest keys; GET /api/channel-manifests returns catalog with origin, setup support, secret keys, and builtin templates; POST /api/agents/:agentId/channels extended for dual-mode creation (channelType for builtin, namespace for external) with duplicate checking and registry wiring.
Gateway UI: setup wizard & channels panel
apps/gateway/ui/src/components/ChannelSetupWizard.tsx, ChannelsPanel.tsx
ChannelSetupWizard component managing state machine (awaiting_user_input, awaiting_external, done, error), QR/external waiting UI, dynamic form rendering; ChannelsPanel fetches manifests, groups channels (built-in/package-installed/external), enables setup for packages, patches config on completion, and refactors external token issuance to dedicated dialog.
Web app: client API & setup component
apps/web/ui/src/api.ts, components/ChannelSetupWizard.tsx
Client API exports createBuiltinChannel, fetchChannelManifests, and setup workflow (beginChannelSetup, pollChannelSetup, submitChannelSetup, cancelChannelSetup). ChannelSetupWizard web component with mount/polling effects, form submission/cancellation, and ExternalStep/QrBox helpers using qrcode library for SVG rendering.
Web app: ChannelsPanel refactor
apps/web/ui/src/components/ChannelsPanel.tsx
ChannelsPanel fetches manifests, groups channels by origin, conditionally renders setup action (canSetup/onSetup), handles setup completion with config PATCH and enable, refactors external token issuance flow, and updates ChannelCard with new props for manifest-driven display name and setup enablement.
Dependencies & build artifacts
apps/gateway/package.json, apps/web/package.json, apps/web/public/assets/*, apps/web/public/index.html
Added qrcode and @types/qrcode to gateway and web packages; updated Vite bundle hashes for react-vendor, index, ManagePanel, and ChatShell; removed old ManagePanel bundle.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • HCF-STUDIOS/openhermit#87: The gateway manifest loading and origin tracking depend on the ChannelManifestRegistry contract changes (register/replace with origin parameter) introduced in that PR.
  • HCF-STUDIOS/openhermit#88: Both PRs are tied to the channel manifest plugin architecture; this PR's WeChat package and gateway UI depend on the manifest registry override behavior and origin-aware handling in apps/gateway/src/channel-manifests.ts.

Poem

🐰 A whisker'd leap through WeChat's door,
QR codes scan—the gateway's lore!
iLink flows swift, from bot to brain,
Sessions bloom, no text in vain.
Setup spins its manifold dance,
Origin's tracked with a glance! 🌙

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.26% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: introducing a new WeChat channel plugin package using iLink protocol with text-only v0 support, which is the primary objective of this PR.
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 plan/channel-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 skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🤖 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 `@apps/channels/wechat/src/bot.ts`:
- Around line 91-99: The loop currently awaits each message sequentially (await
this.opts.bridge.handleMessage(msg)), causing unrelated peers to be serialized;
change it to fire-and-forget each handleMessage call instead: inside the for
(const msg of msgs) loop (and checking this.running), call
this.opts.bridge.handleMessage(msg) without await and attach a .catch handler to
log errors (use the same logging used currently), so failures are recorded but
do not block processing of subsequent msgs or cursor advancement.

In `@apps/channels/wechat/src/bridge.ts`:
- Around line 221-301: waitForAgentResponse currently blocks indefinitely; wrap
the fetch/stream read in an AbortController with a deadline and pass
controller.signal to fetch and any async read operations in the loop so the
stream can be aborted; catch AbortError inside waitForAgentResponse and convert
it into a TurnResult error (e.g., "stream aborted/timeout") so handleMessage and
the per-peer lock (peerLocks) can recover, still ensuring the reader is
cancelled in the finally block and this.lastEventIds.set(sessionId,
nextLastEventId) is performed before returning; update references inside
waitForAgentResponse (reader.read(), fetch(...), and the try/catch around the
loop) to respect the abort signal and to treat aborts as a normal turn error
rather than leaving the promise unresolved.
- Around line 179-196: The code currently generates and memoizes a synthetic
session id after listSessions() throws, which can pin a peer to a new thread
during transient control-plane failures; change getSessionId() so that when
listSessions() fails you generate and return a fallback sessionId but do NOT
call this.peerSessions.set(peer, sessionId). Instead, memoize the sessionId only
after a successful openSession()/ensureSession() call (i.e., move the
this.peerSessions.set(peer, sessionId) into the success path of
ensureSession()/openSession()), or have ensureSession() accept the fallback id
and set peerSessions only on success.
- Around line 107-113: The handler currently only ignores messages with
message_type === MessageType.BOT, but it must also suppress messages originating
from the bridge's own user id; update handleMessage (async handleMessage(msg:
WeixinMessage)) to return early if msg.from_user_id === this.runtime.ilinkBotId
(or equivalent ilinkBotId field on WechatBridgeRuntime) in addition to the
existing BOT-type check, and ensure this check runs before computing peer so
self-echoes from non-BOT messages are dropped.

In `@apps/channels/wechat/src/ilink/api.ts`:
- Around line 67-68: The current ILINK_APP_ID fallback to '' hides
misconfiguration; replace the silent empty default by validating after computing
ILINK_APP_ID (from process.env.OPENHERMIT_WECHAT_APP_ID ?? pkg.ilink_appid) and
fail fast when it's falsy: throw a clear error or terminate startup with a
descriptive message that includes which env/key is missing (reference
ILINK_APP_ID, process.env.OPENHERMIT_WECHAT_APP_ID, and pkg.ilink_appid) so the
process refuses to start rather than sending requests with an empty
iLink-App-Id.
- Around line 96-109: The GET requests currently use buildCommonHeaders() which
omits the X-WECHAT-UIN header causing get_qrcode_status QR polling to fail; fix
by including X-WECHAT-UIN for GETs as well — either add 'X-WECHAT-UIN':
randomWechatUin() into buildCommonHeaders() or create a buildGetHeaders() that
returns {...buildCommonHeaders(), 'X-WECHAT-UIN': randomWechatUin()} and have
apiGet()/get_qrcode_status call that so GET transports include the same iLink
identity header used by buildPostHeaders().
- Around line 191-205: The catch block around the apiGet call (the block that
calls apiGet with endpoint `ilink/bot/get_qrcode_status?qrcode=...`, timeoutMs
and label 'pollQrStatus') currently converts every failure into { status: 'wait'
}; change it so only AbortError/timeouts are treated as transient and return {
status: 'wait' }, and all other errors (HTTP 4xx/5xx, network/gateway errors,
JSON parse failures) are propagated (rethrow) so the caller can surface/fail
fast; to do this, leave the apiGet call and the existing AbortError branch
intact but remove the blanket fallback return and instead rethrow err for
non-AbortError cases (or catch JSON.parse separately around JSON.parse(raw) and
rethrow parse errors) so QrStatusResponse is not faked on real failures.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 18147cfb-5248-498e-a5a2-2d85e0682b7f

📥 Commits

Reviewing files that changed from the base of the PR and between f52a06c and 1572cce.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (12)
  • apps/channels/wechat/README.md
  • apps/channels/wechat/package.json
  • apps/channels/wechat/src/bot.ts
  • apps/channels/wechat/src/bridge.ts
  • apps/channels/wechat/src/ilink/api.ts
  • apps/channels/wechat/src/ilink/login.ts
  • apps/channels/wechat/src/ilink/types.ts
  • apps/channels/wechat/src/index.ts
  • apps/channels/wechat/src/manifest.ts
  • apps/channels/wechat/src/setup.ts
  • apps/channels/wechat/tsconfig.json
  • apps/channels/wechat/tsconfig.typecheck.json

Comment on lines +91 to +99
const msgs = resp.msgs ?? [];
for (const msg of msgs) {
if (!this.running) break;
try {
await this.opts.bridge.handleMessage(msg);
} catch (err) {
this.log(`handleMessage error: ${err instanceof Error ? err.message : String(err)}`);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't serialize unrelated chats in the poll loop.

WechatBridge.handleMessage() already queues by peer, but this loop awaits every message one-by-one. One slow turn now delays every later message in the batch and slows cursor advancement for other peers.

Suggested fix
       const msgs = resp.msgs ?? [];
-      for (const msg of msgs) {
-        if (!this.running) break;
-        try {
-          await this.opts.bridge.handleMessage(msg);
-        } catch (err) {
-          this.log(`handleMessage error: ${err instanceof Error ? err.message : String(err)}`);
-        }
-      }
+      await Promise.allSettled(
+        msgs.map(async (msg) => {
+          if (!this.running) return;
+          try {
+            await this.opts.bridge.handleMessage(msg);
+          } catch (err) {
+            this.log(`handleMessage error: ${err instanceof Error ? err.message : String(err)}`);
+          }
+        }),
+      );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const msgs = resp.msgs ?? [];
for (const msg of msgs) {
if (!this.running) break;
try {
await this.opts.bridge.handleMessage(msg);
} catch (err) {
this.log(`handleMessage error: ${err instanceof Error ? err.message : String(err)}`);
}
}
const msgs = resp.msgs ?? [];
await Promise.allSettled(
msgs.map(async (msg) => {
if (!this.running) return;
try {
await this.opts.bridge.handleMessage(msg);
} catch (err) {
this.log(`handleMessage error: ${err instanceof Error ? err.message : String(err)}`);
}
}),
);
🤖 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 `@apps/channels/wechat/src/bot.ts` around lines 91 - 99, The loop currently
awaits each message sequentially (await this.opts.bridge.handleMessage(msg)),
causing unrelated peers to be serialized; change it to fire-and-forget each
handleMessage call instead: inside the for (const msg of msgs) loop (and
checking this.running), call this.opts.bridge.handleMessage(msg) without await
and attach a .catch handler to log errors (use the same logging used currently),
so failures are recorded but do not block processing of subsequent msgs or
cursor advancement.

Comment on lines +107 to +113
/** Entry point for the bot loop; serializes per-peer. */
async handleMessage(msg: WeixinMessage): Promise<void> {
// Skip our own outbound echoes.
if (msg.message_type === MessageType.BOT) return;

const peer = msg.group_id?.trim() || msg.from_user_id?.trim();
if (!peer) return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use ilinkBotId when suppressing self-echoes.

WechatBridgeRuntime.ilinkBotId is documented as the bot's own user id, but this path never reads it. That makes the runtime field inert and leaves a self-reply loop if iLink ever echoes outbound traffic as a non-BOT message.

Suggested fix
 async handleMessage(msg: WeixinMessage): Promise<void> {
+  const fromUserId = msg.from_user_id?.trim();
   // Skip our own outbound echoes.
   if (msg.message_type === MessageType.BOT) return;
+  if (this.runtime.ilinkBotId && fromUserId === this.runtime.ilinkBotId) return;
 
-  const peer = msg.group_id?.trim() || msg.from_user_id?.trim();
+  const peer = msg.group_id?.trim() || fromUserId;
   if (!peer) return;
🤖 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 `@apps/channels/wechat/src/bridge.ts` around lines 107 - 113, The handler
currently only ignores messages with message_type === MessageType.BOT, but it
must also suppress messages originating from the bridge's own user id; update
handleMessage (async handleMessage(msg: WeixinMessage)) to return early if
msg.from_user_id === this.runtime.ilinkBotId (or equivalent ilinkBotId field on
WechatBridgeRuntime) in addition to the existing BOT-type check, and ensure this
check runs before computing peer so self-echoes from non-BOT messages are
dropped.

Comment on lines +179 to +196
try {
const sessions = await this.client.listSessions({
channel: 'wechat',
metadata: { [isGroup ? 'wechat_group_id' : 'wechat_peer_id']: peer },
limit: 1,
});
if (sessions.length > 0) {
const sessionId = sessions[0]!.sessionId;
this.peerSessions.set(peer, sessionId);
return sessionId;
}
} catch {
// Server unavailable — fall through.
}

const sessionId = WechatBridge.generateSessionId();
this.peerSessions.set(peer, sessionId);
return sessionId;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't memoize a synthetic session id after a lookup failure.

Lines 190-196 cache a fresh sessionId even when listSessions() only failed transiently. Once the control plane recovers, that peer is pinned to a new thread and loses the existing conversation history.

Only cache the fallback id after openSession() succeeds, or return it uncached from getSessionId() and memoize it in ensureSession() on success.

🤖 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 `@apps/channels/wechat/src/bridge.ts` around lines 179 - 196, The code
currently generates and memoizes a synthetic session id after listSessions()
throws, which can pin a peer to a new thread during transient control-plane
failures; change getSessionId() so that when listSessions() fails you generate
and return a fallback sessionId but do NOT call this.peerSessions.set(peer,
sessionId). Instead, memoize the sessionId only after a successful
openSession()/ensureSession() call (i.e., move the this.peerSessions.set(peer,
sessionId) into the success path of ensureSession()/openSession()), or have
ensureSession() accept the fallback id and set peerSessions only on success.

Comment on lines +221 to +301
private async waitForAgentResponse(sessionId: string): Promise<TurnResult> {
const eventsUrl = this.client.buildEventsUrl(sessionId);
const lastEventId = this.lastEventIds.get(sessionId) ?? 0;

const response = await fetch(eventsUrl, {
headers: { authorization: `Bearer ${this.clientToken}` },
});

if (!response.ok || !response.body) {
return { text: undefined, error: `Failed to open event stream (${response.status})` };
}

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let nextLastEventId = lastEventId;
let sequenceResetChecked = false;
let accumulatedText = '';
let finalText: string | undefined;
let error: string | undefined;

try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parsed = parseSseFrames(buffer);
buffer = parsed.remainder;
let sawAgentEnd = false;

for (const frame of parsed.frames) {
if (frame.id !== undefined && frame.id <= nextLastEventId) continue;
if (frame.id !== undefined) nextLastEventId = frame.id;

if (frame.event === 'ready') {
if (!sequenceResetChecked) {
sequenceResetChecked = true;
try {
const data = frame.data.length > 0
? (JSON.parse(frame.data) as { nextEventId?: number })
: {};
if (typeof data.nextEventId === 'number' && data.nextEventId <= nextLastEventId) {
nextLastEventId = 0;
}
} catch { /* ignore */ }
}
continue;
}
if (frame.event === 'ping') continue;

const payload =
frame.data.length > 0
? (JSON.parse(frame.data) as Record<string, unknown>)
: {};

if (frame.event === 'text_delta') {
accumulatedText += String(payload.text ?? '');
continue;
}
if (frame.event === 'text_final') {
finalText = String(payload.text ?? '').trim();
continue;
}
if (frame.event === 'error') {
error = String(payload.message ?? 'Unknown error');
continue;
}
if (frame.event === 'agent_end') {
sawAgentEnd = true;
continue;
}
}
if (sawAgentEnd) break;
}
} finally {
await reader.cancel().catch(() => undefined);
}

this.lastEventIds.set(sessionId, nextLastEventId);
const text = finalText ?? (accumulatedText.trim() || undefined);
return { text, error };
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bound the SSE wait under the per-peer lock.

waitForAgentResponse() has no timeout or abort path. If the stream stalls before agent_end, handleMessage() never resolves and every later message for that peer stays blocked behind peerLocks.

Use an AbortController deadline around fetch()/reader.read() and convert AbortError into a turn error so the queue can recover.

🤖 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 `@apps/channels/wechat/src/bridge.ts` around lines 221 - 301,
waitForAgentResponse currently blocks indefinitely; wrap the fetch/stream read
in an AbortController with a deadline and pass controller.signal to fetch and
any async read operations in the loop so the stream can be aborted; catch
AbortError inside waitForAgentResponse and convert it into a TurnResult error
(e.g., "stream aborted/timeout") so handleMessage and the per-peer lock
(peerLocks) can recover, still ensuring the reader is cancelled in the finally
block and this.lastEventIds.set(sessionId, nextLastEventId) is performed before
returning; update references inside waitForAgentResponse (reader.read(),
fetch(...), and the try/catch around the loop) to respect the abort signal and
to treat aborts as a normal turn error rather than leaving the promise
unresolved.

Comment on lines +67 to +68
const ILINK_APP_ID = process.env.OPENHERMIT_WECHAT_APP_ID ?? pkg.ilink_appid ?? '';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail fast when ilink_appid is missing.

Falling back to '' sends every request with an empty iLink-App-Id, so a packaging/config mistake becomes opaque upstream failures instead of a clear startup error.

💡 Proposed fix
-const ILINK_APP_ID = process.env.OPENHERMIT_WECHAT_APP_ID ?? pkg.ilink_appid ?? '';
+const ILINK_APP_ID = (process.env.OPENHERMIT_WECHAT_APP_ID ?? pkg.ilink_appid ?? '').trim();
+
+if (!ILINK_APP_ID) {
+  throw new Error(
+    'Missing iLink app id. Set OPENHERMIT_WECHAT_APP_ID or define ilink_appid in this package.json.',
+  );
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const ILINK_APP_ID = process.env.OPENHERMIT_WECHAT_APP_ID ?? pkg.ilink_appid ?? '';
const ILINK_APP_ID = (process.env.OPENHERMIT_WECHAT_APP_ID ?? pkg.ilink_appid ?? '').trim();
if (!ILINK_APP_ID) {
throw new Error(
'Missing iLink app id. Set OPENHERMIT_WECHAT_APP_ID or define ilink_appid in this package.json.',
);
}
🤖 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 `@apps/channels/wechat/src/ilink/api.ts` around lines 67 - 68, The current
ILINK_APP_ID fallback to '' hides misconfiguration; replace the silent empty
default by validating after computing ILINK_APP_ID (from
process.env.OPENHERMIT_WECHAT_APP_ID ?? pkg.ilink_appid) and fail fast when it's
falsy: throw a clear error or terminate startup with a descriptive message that
includes which env/key is missing (reference ILINK_APP_ID,
process.env.OPENHERMIT_WECHAT_APP_ID, and pkg.ilink_appid) so the process
refuses to start rather than sending requests with an empty iLink-App-Id.

Comment on lines +96 to +109
const buildCommonHeaders = (): Record<string, string> => ({
'iLink-App-Id': ILINK_APP_ID,
'iLink-App-ClientVersion': String(ILINK_APP_CLIENT_VERSION),
});

const buildPostHeaders = (token?: string): Record<string, string> => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
AuthorizationType: 'ilink_bot_token',
'X-WECHAT-UIN': randomWechatUin(),
...buildCommonHeaders(),
};
if (token?.trim()) headers.Authorization = `Bearer ${token.trim()}`;
return headers;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Include X-WECHAT-UIN on GET requests too.

The file header treats X-WECHAT-UIN as part of the iLink identity header set, but apiGet() only gets buildCommonHeaders(), so get_qrcode_status is the one transport call that drops it. That can make QR polling fail while the POST endpoints work.

💡 Proposed fix
 const buildCommonHeaders = (): Record<string, string> => ({
   'iLink-App-Id': ILINK_APP_ID,
   'iLink-App-ClientVersion': String(ILINK_APP_CLIENT_VERSION),
+  'X-WECHAT-UIN': randomWechatUin(),
 });
 
 const buildPostHeaders = (token?: string): Record<string, string> => {
   const headers: Record<string, string> = {
     'Content-Type': 'application/json',
     AuthorizationType: 'ilink_bot_token',
-    'X-WECHAT-UIN': randomWechatUin(),
     ...buildCommonHeaders(),
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const buildCommonHeaders = (): Record<string, string> => ({
'iLink-App-Id': ILINK_APP_ID,
'iLink-App-ClientVersion': String(ILINK_APP_CLIENT_VERSION),
});
const buildPostHeaders = (token?: string): Record<string, string> => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
AuthorizationType: 'ilink_bot_token',
'X-WECHAT-UIN': randomWechatUin(),
...buildCommonHeaders(),
};
if (token?.trim()) headers.Authorization = `Bearer ${token.trim()}`;
return headers;
const buildCommonHeaders = (): Record<string, string> => ({
'iLink-App-Id': ILINK_APP_ID,
'iLink-App-ClientVersion': String(ILINK_APP_CLIENT_VERSION),
'X-WECHAT-UIN': randomWechatUin(),
});
const buildPostHeaders = (token?: string): Record<string, string> => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
AuthorizationType: 'ilink_bot_token',
...buildCommonHeaders(),
};
if (token?.trim()) headers.Authorization = `Bearer ${token.trim()}`;
return headers;
🤖 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 `@apps/channels/wechat/src/ilink/api.ts` around lines 96 - 109, The GET
requests currently use buildCommonHeaders() which omits the X-WECHAT-UIN header
causing get_qrcode_status QR polling to fail; fix by including X-WECHAT-UIN for
GETs as well — either add 'X-WECHAT-UIN': randomWechatUin() into
buildCommonHeaders() or create a buildGetHeaders() that returns
{...buildCommonHeaders(), 'X-WECHAT-UIN': randomWechatUin()} and have
apiGet()/get_qrcode_status call that so GET transports include the same iLink
identity header used by buildPostHeaders().

Comment on lines +191 to +205
try {
const raw = await apiGet({
baseUrl: apiBaseUrl,
endpoint: `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`,
timeoutMs,
label: 'pollQrStatus',
});
return JSON.parse(raw) as QrStatusResponse;
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
return { status: 'wait' };
}
// Treat gateway/network hiccups as transient.
return { status: 'wait' };
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't collapse hard QR polling failures into wait.

This currently turns 4xx/5xx responses and JSON parse failures into a fake pending state. The setup flow will keep spinning until TTL expiry even when the base URL is wrong or upstream is broken. Only treat abort/network timeouts as transient.

💡 Proposed fix
   } catch (err) {
     if (err instanceof Error && err.name === 'AbortError') {
       return { status: 'wait' };
     }
-    // Treat gateway/network hiccups as transient.
-    return { status: 'wait' };
+    if (err instanceof TypeError) {
+      // Treat fetch-level network hiccups as transient.
+      return { status: 'wait' };
+    }
+    throw err;
   }
 };
🤖 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 `@apps/channels/wechat/src/ilink/api.ts` around lines 191 - 205, The catch
block around the apiGet call (the block that calls apiGet with endpoint
`ilink/bot/get_qrcode_status?qrcode=...`, timeoutMs and label 'pollQrStatus')
currently converts every failure into { status: 'wait' }; change it so only
AbortError/timeouts are treated as transient and return { status: 'wait' }, and
all other errors (HTTP 4xx/5xx, network/gateway errors, JSON parse failures) are
propagated (rethrow) so the caller can surface/fail fast; to do this, leave the
apiGet call and the existing AbortError branch intact but remove the blanket
fallback return and instead rethrow err for non-AbortError cases (or catch
JSON.parse separately around JSON.parse(raw) and rethrow parse errors) so
QrStatusResponse is not faked on real failures.

Adds the runtime plumbing and UI for adding plugin-loaded channels
(like the new WeChat adapter) on demand, instead of pre-seeding rows
for every registered manifest.

Backend
- ChannelManifestRegistry now tracks `origin: 'built-in' | 'external'`
  per manifest; the channel-manifests loader passes this through.
- Agent create only auto-seeds rows for built-in channels (telegram,
  slack, discord). External plugins are added explicitly via the UI.
- New `GET /api/channel-manifests` returns the catalog the picker
  renders (key/displayName/origin/supportsSetup/secretKeys/defaultConfig).
- `POST /api/agents/:id/channels` accepts `channelType` to create a
  manifest-backed builtin row (previous `namespace` path still issues
  raw external tokens).

UI
- New ChannelSetupWizard (web + gateway) drives `ChannelManifest.setup`
  through the HTTP routes: renders QR for `awaiting_external`, a form
  for `awaiting_user_input`, surfaces errors, polls on its own clock.
- ChannelsPanel "Add channel" dialog: picker step (manifest list +
  "custom external" fallback) → branches into wizard for plugins that
  support setup, immediate create with `defaultConfig` for those that
  don't, or namespace+label form for raw external tokens.
- `qrcode` dep added to both UIs for client-side SVG rendering.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/channels/wechat/README.md (1)

63-63: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove accidental trailing 63 from README end.

Line 63 appears to contain stray numeric text, which will render as visible documentation noise.

🤖 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 `@apps/channels/wechat/README.md` at line 63, Remove the accidental trailing
"63" from the end of the apps/channels/wechat/README.md file so it no longer
appears as stray numeric noise in documentation; open README.md, locate the
trailing "63" (at the file end or line 63) and delete it, then save and commit
the cleaned README.
🧹 Nitpick comments (1)
apps/web/ui/src/components/ChannelSetupWizard.tsx (1)

28-28: ⚡ Quick win

Update return type annotations for React 19 compliance.

React 19 moved the JSX namespace from global to React.JSX. With @types/react 19.1.2, JSX.Element is not available globally. Update these return type annotations to React.JSX.Element or omit them (React will infer the type). This applies to:

  • Line 28: ChannelSetupWizard
  • Line 163: ExternalStep
  • Line 180: QrBox
🤖 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 `@apps/web/ui/src/components/ChannelSetupWizard.tsx` at line 28, The functions
ChannelSetupWizard, ExternalStep, and QrBox currently annotate their return type
as JSX.Element which is not available under React 19/@types/react 19.1.2; update
each signature to use React.JSX.Element (e.g. ChannelSetupWizard(...):
React.JSX.Element) or simply remove the explicit return type so TypeScript can
infer it, ensuring you import React if needed for the React namespace
references.
🤖 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 `@apps/gateway/src/app.ts`:
- Around line 2380-2392: The current duplicate-check only looks at channelType;
update the pre-create validation in the same block (before calling
store.createBuiltin) to also reject when any existing active channel (from
store.listForAgent(agentId) / existing.some) has the same manifest.namespace as
the incoming body (use body.manifest?.namespace) — i.e., if
body.manifest?.namespace is defined, check existing.some(ch =>
ch.manifest?.namespace === body.manifest.namespace && !ch.revokedAt) and throw a
ValidationError with a clear message; keep the existing channelType check and
only proceed to call store.createBuiltin if both checks pass.

In `@apps/gateway/ui/src/components/ChannelSetupWizard.tsx`:
- Around line 75-94: The current useEffect's tick catch block sets setError and
returns without scheduling the next poll, which can stall polling when
state.kind === 'awaiting_external'; update the tick implementation in the
useEffect so that whether the api call succeeds or fails it still schedules the
next poll (using pollTimerRef.current = setTimeout(() => { void tick(); },
interval)), ensuring you still check cancelledRef.current before applying resp
or setting error and use the same interval (state.pollIntervalMs ?? 2000); keep
the existing cleanup that clears pollTimerRef.current and leave the
cancelledRef, apply, setError logic intact.

In `@apps/web/ui/src/components/ChannelSetupWizard.tsx`:
- Around line 67-84: The current useEffect in ChannelSetupWizard stops polling
on the first pollChannelSetup() failure because the catch only sets setError and
doesn't schedule the next tick; update the tick function so that after either a
successful apply(resp) or after handling an error you schedule the next poll
(use pollTimerRef.current = setTimeout(() => { void tick(); }, interval)); keep
honoring cancelledRef.current and state.kind checks before scheduling, and
ensure the existing cleanup (clearTimeout in the return) still clears the
scheduled timer. This change touches the tick async function and the
pollTimerRef scheduling logic around pollChannelSetup, cancelledRef,
pollTimerRef, apply, and setError.
- Around line 49-64: The component currently starts a server-side setup via
beginChannelSetup but never cancels that session when the component unmounts
unless handleCancel() is invoked; update the effect cleanup to call the same
cancellation logic used by handleCancel (or call handleCancel directly) so the
server-side setup session is aborted on unmount: locate the effect using
cancelledRef, beginChannelSetup, apply and pollTimerRef and ensure the returned
cleanup sets cancelledRef.current = true, clears pollTimerRef, and triggers the
server-side cancellation function (handleCancel or the underlying cancel API
used by handleCancel) so the remote setup session is terminated when the dialog
is closed by Esc/backdrop/parent unmount.

In `@apps/web/ui/src/components/ChannelsPanel.tsx`:
- Around line 67-70: Change manifests from an empty array to a nullable state so
you can distinguish "loading" vs "empty": update the useState declaration
(manifests, setManifests) to use type ChannelManifestSummary[] | null and
initialize it to null; update any rendering logic (the manifest picker and the
other checks around the spots currently showing "No channel packages
registered.") to treat manifests === null as the loading state (show
spinner/placeholders) and only treat manifests.length === 0 as the true empty
state; ensure your fetch code calls setManifests(fetchedArray) or
setManifests([]) when complete so the UI leaves the loading state.

---

Outside diff comments:
In `@apps/channels/wechat/README.md`:
- Line 63: Remove the accidental trailing "63" from the end of the
apps/channels/wechat/README.md file so it no longer appears as stray numeric
noise in documentation; open README.md, locate the trailing "63" (at the file
end or line 63) and delete it, then save and commit the cleaned README.

---

Nitpick comments:
In `@apps/web/ui/src/components/ChannelSetupWizard.tsx`:
- Line 28: The functions ChannelSetupWizard, ExternalStep, and QrBox currently
annotate their return type as JSX.Element which is not available under React
19/@types/react 19.1.2; update each signature to use React.JSX.Element (e.g.
ChannelSetupWizard(...): React.JSX.Element) or simply remove the explicit return
type so TypeScript can infer it, ensuring you import React if needed for the
React namespace references.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d7891bfa-df95-4a58-8cdd-a27200e99eb6

📥 Commits

Reviewing files that changed from the base of the PR and between 1572cce and e347c72.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (18)
  • apps/channels/wechat/README.md
  • apps/gateway/package.json
  • apps/gateway/src/app.ts
  • apps/gateway/src/channel-manifests.ts
  • apps/gateway/ui/src/components/ChannelSetupWizard.tsx
  • apps/gateway/ui/src/components/ChannelsPanel.tsx
  • apps/web/package.json
  • apps/web/public/assets/ChatShell-CHLimlh_.js
  • apps/web/public/assets/ManagePanel-1wY7vfd2.js
  • apps/web/public/assets/ManagePanel-DXUe2m7i.js
  • apps/web/public/assets/index-CIUAGvj5.js
  • apps/web/public/assets/index-M1UGArc6.js
  • apps/web/public/assets/react-vendor-yfL0ty4i.js
  • apps/web/public/index.html
  • apps/web/ui/src/api.ts
  • apps/web/ui/src/components/ChannelSetupWizard.tsx
  • apps/web/ui/src/components/ChannelsPanel.tsx
  • packages/protocol/src/index.ts
💤 Files with no reviewable changes (1)
  • apps/web/public/assets/ManagePanel-1wY7vfd2.js
✅ Files skipped from review due to trivial changes (2)
  • apps/web/public/index.html
  • apps/web/public/assets/ChatShell-CHLimlh_.js

Comment thread apps/gateway/src/app.ts
Comment on lines +2380 to +2392
const existing = await store.listForAgent(agentId);
if (existing.some((ch) => ch.channelType === channelType && !ch.revokedAt)) {
throw new ValidationError(
`A channel of type "${channelType}" already exists on this agent.`,
);
}
const created = await store.createBuiltin({
agentId,
channelType,
...(body.label ? { label: body.label } : {}),
...(body.config ? { config: body.config } : {}),
...(typeof body.enabled === 'boolean' ? { enabled: body.enabled } : {}),
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent namespace collisions in manifest-backed channel creation.

Line 2380 only checks channelType duplicates. If an existing external row already uses manifest.namespace, this path can create two active rows with the same namespace.

Suggested fix
       const existing = await store.listForAgent(agentId);
       if (existing.some((ch) => ch.channelType === channelType && !ch.revokedAt)) {
         throw new ValidationError(
           `A channel of type "${channelType}" already exists on this agent.`,
         );
       }
+      if (existing.some((ch) => ch.namespace === manifest.namespace && !ch.revokedAt)) {
+        throw new ValidationError(
+          `A channel with namespace "${manifest.namespace}" already exists on this agent.`,
+        );
+      }
       const created = await store.createBuiltin({
         agentId,
         channelType,
🤖 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 `@apps/gateway/src/app.ts` around lines 2380 - 2392, The current
duplicate-check only looks at channelType; update the pre-create validation in
the same block (before calling store.createBuiltin) to also reject when any
existing active channel (from store.listForAgent(agentId) / existing.some) has
the same manifest.namespace as the incoming body (use body.manifest?.namespace)
— i.e., if body.manifest?.namespace is defined, check existing.some(ch =>
ch.manifest?.namespace === body.manifest.namespace && !ch.revokedAt) and throw a
ValidationError with a clear message; keep the existing channelType check and
only proceed to call store.createBuiltin if both checks pass.

Comment on lines +75 to +94
useEffect(() => {
if (!sessionId || !state || state.kind !== 'awaiting_external') return;
const interval = state.pollIntervalMs ?? 2000;
const tick = async (): Promise<void> => {
try {
const resp = await api<SetupResponse>(
`/api/agents/${encodeURIComponent(agentId)}/channels/${encodeURIComponent(channelType)}/setup/${encodeURIComponent(sessionId)}`,
);
if (cancelledRef.current) return;
apply(resp);
} catch (err) {
if (cancelledRef.current) return;
setError((err as Error).message);
}
};
pollTimerRef.current = setTimeout(() => { void tick(); }, interval);
return () => {
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
};
}, [sessionId, state, agentId, channelType, apply]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep polling alive after transient poll failures.

At Line 85, the error path updates UI error but stops further polling if state remains awaiting_external, so one temporary failure can stall the wizard.

Suggested fix
   useEffect(() => {
     if (!sessionId || !state || state.kind !== 'awaiting_external') return;
     const interval = state.pollIntervalMs ?? 2000;
     const tick = async (): Promise<void> => {
       try {
         const resp = await api<SetupResponse>(
           `/api/agents/${encodeURIComponent(agentId)}/channels/${encodeURIComponent(channelType)}/setup/${encodeURIComponent(sessionId)}`,
         );
         if (cancelledRef.current) return;
         apply(resp);
       } catch (err) {
         if (cancelledRef.current) return;
         setError((err as Error).message);
+        pollTimerRef.current = setTimeout(() => { void tick(); }, interval);
       }
     };
     pollTimerRef.current = setTimeout(() => { void tick(); }, interval);
     return () => {
       if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
     };
   }, [sessionId, state, agentId, channelType, apply]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (!sessionId || !state || state.kind !== 'awaiting_external') return;
const interval = state.pollIntervalMs ?? 2000;
const tick = async (): Promise<void> => {
try {
const resp = await api<SetupResponse>(
`/api/agents/${encodeURIComponent(agentId)}/channels/${encodeURIComponent(channelType)}/setup/${encodeURIComponent(sessionId)}`,
);
if (cancelledRef.current) return;
apply(resp);
} catch (err) {
if (cancelledRef.current) return;
setError((err as Error).message);
}
};
pollTimerRef.current = setTimeout(() => { void tick(); }, interval);
return () => {
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
};
}, [sessionId, state, agentId, channelType, apply]);
useEffect(() => {
if (!sessionId || !state || state.kind !== 'awaiting_external') return;
const interval = state.pollIntervalMs ?? 2000;
const tick = async (): Promise<void> => {
try {
const resp = await api<SetupResponse>(
`/api/agents/${encodeURIComponent(agentId)}/channels/${encodeURIComponent(channelType)}/setup/${encodeURIComponent(sessionId)}`,
);
if (cancelledRef.current) return;
apply(resp);
} catch (err) {
if (cancelledRef.current) return;
setError((err as Error).message);
pollTimerRef.current = setTimeout(() => { void tick(); }, interval);
}
};
pollTimerRef.current = setTimeout(() => { void tick(); }, interval);
return () => {
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
};
}, [sessionId, state, agentId, channelType, apply]);
🤖 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 `@apps/gateway/ui/src/components/ChannelSetupWizard.tsx` around lines 75 - 94,
The current useEffect's tick catch block sets setError and returns without
scheduling the next poll, which can stall polling when state.kind ===
'awaiting_external'; update the tick implementation in the useEffect so that
whether the api call succeeds or fails it still schedules the next poll (using
pollTimerRef.current = setTimeout(() => { void tick(); }, interval)), ensuring
you still check cancelledRef.current before applying resp or setting error and
use the same interval (state.pollIntervalMs ?? 2000); keep the existing cleanup
that clears pollTimerRef.current and leave the cancelledRef, apply, setError
logic intact.

Comment on lines +49 to +64
useEffect(() => {
cancelledRef.current = false;
(async () => {
try {
const resp = await beginChannelSetup(channelType);
if (cancelledRef.current) return;
apply(resp);
} catch (err) {
setError((err as Error).message);
}
})();
return () => {
cancelledRef.current = true;
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
};
}, [channelType, apply]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Cancel the server-side setup session during unmount.

Closing the dialog via Esc/backdrop or any parent-driven unmount skips handleCancel(), so the setup session can keep running until its TTL expires.

Suggested fix
 export function ChannelSetupWizard({ channelType, displayName, onDone, onCancel }: Props): JSX.Element {
   const [sessionId, setSessionId] = useState<string | null>(null);
   const [state, setState] = useState<ChannelSetupState | null>(null);
   const [error, setError] = useState('');
   const [formInput, setFormInput] = useState<Record<string, string>>({});
   const [submitting, setSubmitting] = useState(false);
   const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
   const cancelledRef = useRef(false);
+  const sessionIdRef = useRef<string | null>(null);

   const apply = useCallback((resp: ChannelSetupResponse) => {
+    sessionIdRef.current = resp.sessionId;
     setSessionId(resp.sessionId);
     setState(resp.state);
     setError('');
     if (resp.state.kind === 'awaiting_user_input') {
       const initial: Record<string, string> = {};
@@
     return () => {
       cancelledRef.current = true;
       if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
+      const sid = sessionIdRef.current;
+      if (sid) void cancelChannelSetup(channelType, sid).catch(() => {});
     };
   }, [channelType, apply]);
🤖 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 `@apps/web/ui/src/components/ChannelSetupWizard.tsx` around lines 49 - 64, The
component currently starts a server-side setup via beginChannelSetup but never
cancels that session when the component unmounts unless handleCancel() is
invoked; update the effect cleanup to call the same cancellation logic used by
handleCancel (or call handleCancel directly) so the server-side setup session is
aborted on unmount: locate the effect using cancelledRef, beginChannelSetup,
apply and pollTimerRef and ensure the returned cleanup sets cancelledRef.current
= true, clears pollTimerRef, and triggers the server-side cancellation function
(handleCancel or the underlying cancel API used by handleCancel) so the remote
setup session is terminated when the dialog is closed by Esc/backdrop/parent
unmount.

Comment on lines +67 to +84
useEffect(() => {
if (!sessionId || !state || state.kind !== 'awaiting_external') return;
const interval = state.pollIntervalMs ?? 2000;
const tick = async (): Promise<void> => {
try {
const resp = await pollChannelSetup(channelType, sessionId);
if (cancelledRef.current) return;
apply(resp);
} catch (err) {
if (cancelledRef.current) return;
setError((err as Error).message);
}
};
pollTimerRef.current = setTimeout(() => { void tick(); }, interval);
return () => {
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
};
}, [sessionId, state, channelType, apply]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep polling after transient failures.

One failed pollChannelSetup() turns the wizard into a terminal error and stops the loop, even though the server-side session may still be alive.

Suggested fix
     const tick = async (): Promise<void> => {
       try {
         const resp = await pollChannelSetup(channelType, sessionId);
         if (cancelledRef.current) return;
         apply(resp);
       } catch (err) {
         if (cancelledRef.current) return;
         setError((err as Error).message);
+        pollTimerRef.current = setTimeout(() => { void tick(); }, interval);
       }
     };
🤖 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 `@apps/web/ui/src/components/ChannelSetupWizard.tsx` around lines 67 - 84, The
current useEffect in ChannelSetupWizard stops polling on the first
pollChannelSetup() failure because the catch only sets setError and doesn't
schedule the next tick; update the tick function so that after either a
successful apply(resp) or after handling an error you schedule the next poll
(use pollTimerRef.current = setTimeout(() => { void tick(); }, interval)); keep
honoring cancelledRef.current and state.kind checks before scheduling, and
ensure the existing cleanup (clearTimeout in the return) still clears the
scheduled timer. This change touches the tick async function and the
pollTimerRef scheduling logic around pollChannelSetup, cancelledRef,
pollTimerRef, apply, and setError.

Comment thread apps/web/ui/src/components/ChannelsPanel.tsx Outdated
Reverted the "don't auto-seed plugins" rule from the prior bundled
commit. With manifest origin recorded at registration time, both the
gateway admin and web UIs can now split the channels list into:

  1. Built-in   — kind=builtin, manifest origin='built-in' (TG/Slack/Discord)
  2. Package    — kind=builtin, manifest origin='external' (e.g. WeChat).
                  Rows get a "Set up" button that drives the manifest's
                  ChannelSetup wizard, then PATCHes the existing row's
                  config + enables it.
  3. Token-only — kind=external (owner-issued tokens). Replaced the old
                  Add-channel manifest picker with a single "Issue new
                  token" button on that section.

Auto-seed at agent create + startup backfill again iterate over the full
registry (built-in + plugin), so adding a package via `channelPackages`
gets every agent a disabled row on the next restart, ready for Set up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
apps/gateway/ui/src/components/ChannelsPanel.tsx (1)

100-105: 💤 Low value

manifestByKey and groupOf are recomputed on every render.

These computations run on each render. For small manifest lists this is fine, but wrapping in useMemo would be cleaner. Low priority given the small data size.

🤖 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 `@apps/gateway/ui/src/components/ChannelsPanel.tsx` around lines 100 - 105,
Wrap the manifestByKey creation in React's useMemo to avoid rebuilding the Map
on every render (e.g. const manifestByKey = useMemo(() => new
Map(manifests.map(m => [m.key, m])), [manifests])); also memoize the groupOf
function with useCallback (e.g. const groupOf = useCallback((ch: ChannelRecord):
GroupKey => { ... }, [manifestByKey])) so it stays stable across renders; ensure
useMemo/useCallback are imported from React and keep the same logic that checks
ch.kind and manifestByKey.get(ch.channelType)?.origin.
🤖 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.

Nitpick comments:
In `@apps/gateway/ui/src/components/ChannelsPanel.tsx`:
- Around line 100-105: Wrap the manifestByKey creation in React's useMemo to
avoid rebuilding the Map on every render (e.g. const manifestByKey = useMemo(()
=> new Map(manifests.map(m => [m.key, m])), [manifests])); also memoize the
groupOf function with useCallback (e.g. const groupOf = useCallback((ch:
ChannelRecord): GroupKey => { ... }, [manifestByKey])) so it stays stable across
renders; ensure useMemo/useCallback are imported from React and keep the same
logic that checks ch.kind and manifestByKey.get(ch.channelType)?.origin.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c7c23d60-ee53-49c6-bd7d-14268e2d4e25

📥 Commits

Reviewing files that changed from the base of the PR and between e347c72 and c94dc82.

📒 Files selected for processing (7)
  • apps/gateway/src/app.ts
  • apps/gateway/ui/src/components/ChannelsPanel.tsx
  • apps/web/public/assets/ChatShell-CM1fo73L.js
  • apps/web/public/assets/ManagePanel-BD6QX2Pm.js
  • apps/web/public/assets/index-Bvg-01Kf.js
  • apps/web/public/index.html
  • apps/web/ui/src/components/ChannelsPanel.tsx
✅ Files skipped from review due to trivial changes (1)
  • apps/web/public/assets/ChatShell-CM1fo73L.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/public/index.html

Auto-seeded channel rows have `label: null`, so the card was rendering
the raw key (e.g. "wechat") instead of the manifest's human name (e.g.
"WeChat"). Fall through `ch.label → manifest.displayName → channelType`
in both the web and gateway admin UIs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
apps/gateway/ui/src/components/ChannelsPanel.tsx (2)

100-105: ⚡ Quick win

Optimize derived state and helpers with memoization.

The manifestByKey Map and groupOf function are recreated on every render. Since manifests changes infrequently, wrap them in useMemo and useCallback to avoid unnecessary recreations.

♻️ Proposed optimization
- const manifestByKey = new Map(manifests.map((m) => [m.key, m]));
+ const manifestByKey = useMemo(
+   () => new Map(manifests.map((m) => [m.key, m])),
+   [manifests]
+ );

- const groupOf = (ch: ChannelRecord): GroupKey => {
+ const groupOf = useCallback((ch: ChannelRecord): GroupKey => {
    if (ch.kind === 'external') return 'token';
    return manifestByKey.get(ch.channelType)?.origin === 'external' ? 'package' : 'builtin';
- };
+ }, [manifestByKey]);
🤖 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 `@apps/gateway/ui/src/components/ChannelsPanel.tsx` around lines 100 - 105, The
manifestByKey Map and groupOf helper are recreated on every render; wrap the Map
creation in useMemo (memoizing over manifests) and wrap groupOf in useCallback
(depending on manifestByKey) so they are stable across renders; update
references to useMemo/useCallback for manifestByKey and groupOf, ensuring you
import them from React and use manifests as the dependency for the memo and
manifestByKey for the callback.

167-185: 💤 Low value

Wrap renderCard in useCallback for consistency.

The renderCard helper is recreated on every render. While React's reconciliation will handle this correctly, wrapping it in useCallback matches the pattern used for other event handlers and avoids unnecessary function creations.

♻️ Proposed optimization
- const renderCard = (ch: ChannelRecord, group: GroupKey) => {
+ const renderCard = useCallback((ch: ChannelRecord, group: GroupKey) => {
    const manifest = manifestByKey.get(ch.channelType);
    const canSetup = group === 'package' && manifest?.supportsSetup === true;
    const displayName = ch.label ?? manifest?.displayName ?? ch.channelType;
    return (
      <ChannelCard
        key={ch.id}
        ch={ch}
        displayName={displayName}
        statusClass={statusClass(ch)}
        statusText={statusText(ch)}
        canSetup={canSetup}
        onSetup={() => setSetupFor(ch)}
        onEdit={() => setEditing(ch)}
        onToggle={() => void handleToggle(ch)}
        onDelete={() => void handleDelete(ch)}
      />
    );
- };
+ }, [manifestByKey, setSetupFor, setEditing, handleToggle, handleDelete]);
🤖 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 `@apps/gateway/ui/src/components/ChannelsPanel.tsx` around lines 167 - 185,
renderCard is recreated on every render; wrap it in React.useCallback to memoize
it. Convert the renderCard function into a useCallback that returns the same
ChannelCard, and include all external dependencies in the dependency array:
manifestByKey, setSetupFor, setEditing, handleToggle, handleDelete, statusClass,
statusText (and any other variables referenced, e.g., group if captured). Ensure
the callback signature stays (ch: ChannelRecord, group: GroupKey) => JSX and the
onSetup/onEdit/onToggle/onDelete handlers continue to call setSetupFor,
setEditing, handleToggle, handleDelete respectively.
🤖 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.

Nitpick comments:
In `@apps/gateway/ui/src/components/ChannelsPanel.tsx`:
- Around line 100-105: The manifestByKey Map and groupOf helper are recreated on
every render; wrap the Map creation in useMemo (memoizing over manifests) and
wrap groupOf in useCallback (depending on manifestByKey) so they are stable
across renders; update references to useMemo/useCallback for manifestByKey and
groupOf, ensuring you import them from React and use manifests as the dependency
for the memo and manifestByKey for the callback.
- Around line 167-185: renderCard is recreated on every render; wrap it in
React.useCallback to memoize it. Convert the renderCard function into a
useCallback that returns the same ChannelCard, and include all external
dependencies in the dependency array: manifestByKey, setSetupFor, setEditing,
handleToggle, handleDelete, statusClass, statusText (and any other variables
referenced, e.g., group if captured). Ensure the callback signature stays (ch:
ChannelRecord, group: GroupKey) => JSX and the onSetup/onEdit/onToggle/onDelete
handlers continue to call setSetupFor, setEditing, handleToggle, handleDelete
respectively.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d7f1d427-ad11-48ed-b022-5739dddea9ae

📥 Commits

Reviewing files that changed from the base of the PR and between c94dc82 and 0df83dd.

📒 Files selected for processing (6)
  • apps/gateway/ui/src/components/ChannelsPanel.tsx
  • apps/web/public/assets/ChatShell-CbYQSfDa.js
  • apps/web/public/assets/ManagePanel-eci-4VNG.js
  • apps/web/public/assets/index-6IS3qDyk.js
  • apps/web/public/index.html
  • apps/web/ui/src/components/ChannelsPanel.tsx
✅ Files skipped from review due to trivial changes (2)
  • apps/web/public/index.html
  • apps/web/public/assets/ChatShell-CbYQSfDa.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/ui/src/components/ChannelsPanel.tsx

@williamwa williamwa merged commit 7e68553 into main May 15, 2026
1 check passed
@williamwa williamwa deleted the plan/channel-wechat branch May 15, 2026 11:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant