Skip to content

fix(weixin-bridge): make the Weixin bridge runnable and simplify its Quick Start - #6170

Open
VincentCorleone wants to merge 2 commits into
Hmbown:mainfrom
VincentCorleone:fix/weixin-bridge-quick-start
Open

VincentCorleone wants to merge 2 commits into
Hmbown:mainfrom
VincentCorleone:fix/weixin-bridge-quick-start

Conversation

@VincentCorleone

@VincentCorleone VincentCorleone commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary

The Weixin bridge could not be used as documented. integrations/weixin-bridge/README.md pointed at a non-existent /opt/codewhale/weixin-bot-bridge path, gave no runtime startup command, and named an env file that nothing reads. Worse, once it did run, the first incoming WeChat message failed with EACCES and was dropped silently: the thread map defaulted to /var/lib/codewhale-weixin-bot-bridge regardless of WEIXIN_STATE_DIR, so every message failed on write inside the getUpdates catch. A first-time user saw a scannable QR, a successful login, and then no reply and no actionable error.

This PR defines a Quick Start in the README (modeled on integrations/wecom-bridge/DEPLOYMENT.md) and implements the behavior that section describes.

Fix the write path and make the failures visible

  • Default WEIXIN_THREAD_MAP_PATH inside WEIXIN_STATE_DIR. Setting only WEIXIN_STATE_DIR left the map at the old absolute path, so every incoming message failed on write and the message was silently dropped.
  • Create the state dir in saveSyncBuf, matching saveAccount. The poll cursor can be the first persisted write on a fresh install.
  • Probe thread-map writability at startup and exit with a readable message, instead of failing later inside the message loop.
  • Log the resolved thread map path next to the state dir.

Make the login QR scannable in place

  • Add src/qr.mjs: a zero-dependency byte-mode QR encoder (versions 1-10, ECC level L) rendered with half-block glyphs, matching the Rust side's Dense1x2 renderer in crates/tui/src/runtime_api.rs. The bridge keeps its no-npm-deps property.
  • Keep printing the URL below the QR so a terminal that mangles half-block glyphs still has a path through.

Add the missing local setup path

  • scripts/dev-up.sh plus npm run bridge start the runtime and the bridge in one terminal, generate a shared CODEWHALE_RUNTIME_TOKEN, wait for /health, and clean up the runtime on Ctrl-C.
  • .gitignore: ignore integrations/*-bridge/.state/ (it holds the live bot token in account.json), and re-include integrations/*/scripts/**, which the global *.sh rule was hiding — without that exception package.json would ship pointing at a file that never landed.

Simplify the Quick Start and add screen captures

  • Restructure around the two stages a user performs: 终端发起 (two-terminal and single-terminal) and the two WeChat-side steps, 微信端扫码接应 and 微信端验证信道效果.
  • Add two captures under docs/assets/ showing the QR login and a real /status exchange. Both are redacted before publishing: the local username is removed from the logged paths, the one-time qrcode= login token is masked, and the QR's bottom-right corner is blacked out so the published code can no longer be scanned.
  • Correct the 首次配对 step 3 wording: the bridge returns runtime status when the sender is authorized, and returns the refusal carrying the user_id only when they are not allowlisted.

Testing

  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets --all-features --locked (warning-free under the CI allow list)
  • cargo test --workspace --all-features --locked

The Rust gate boxes above are not applicable to this change and were not run: no Rust source is touched. The diff is confined to integrations/weixin-bridge/ and .gitignore. I did not run them because they would answer nothing about this diff.

Commands actually run, all from integrations/weixin-bridge/:

npm run check    # node --check on index.mjs, lib.mjs, qr.mjs + bash -n scripts/dev-up.sh
npm test         # node --test test/*.test.mjs -> tests 12, pass 12, fail 0

Direct product evidence, not assertions over internals:

  • npm run bridge completed QR login against the real iLink endpoint, and the same bridge then answered /status from a real WeChat account with runtime=ok. That exchange is the second added capture.
  • The terminal QR output was decoded back to the exact login URL with OpenCV (cv2.QRCodeDetector), 9/9 payloads including UTF-8, so the printed code is machine-readable rather than merely QR-shaped.
  • Startup fail-fast verified against an unwritable path: the bridge exits immediately with Thread map directory is not writable: ... instead of dropping messages later.

Checklist

  • This PR adds a new layer/module/abstraction — it names or deletes the layer it replaces
  • Updated docs or comments as needed
  • Added or updated tests where relevant
  • Verified TUI behavior manually if UI changes
  • Harvested/co-authored credit uses a GitHub numeric noreply address

Notes on the unchecked boxes:

  • New layer/module: src/qr.mjs is a new module, but it is not a layer over an existing one. It has no predecessor to name or delete, and the bridge has no prior QR dependency (package.json has no dependencies field at all). It is documented as matching the existing Rust-side renderer so the two stay consistent.
  • TUI behavior: no TUI code changed, so there is nothing to verify there. The added screenshots are captures of the bridge's own terminal output, not a UI change.
  • Co-authored-by: the commit carries Co-Authored-By: DeepSeek-V41-Flash <service@deepseek.com>, which is DeepSeek's published Contact Email from its Terms of Use. It is not a GitHub numeric noreply address, so I left this box unchecked rather than claim credit registration that will not happen. Maintainers may drop the trailer at merge time if they prefer it gone.

Notes for the reviewer

  • I am not in .github/APPROVED_CONTRIBUTORS, so the PR gate may hold the workflow runs at action_required. A /lgtm on this PR should clear it.
  • Scope note: I fixed the bridge to the point where it runs and answers, and stopped there. The bridge is still not registered in crates/tui/src/remote_setup/registry.rs, so /setup bridge and codewhale remote-setup --bridge weixin do not list it. That is a separate change against the Rust registry and I did not bundle it here.

Devin Review

No-Issue: fixes a local setup defect in integrations/weixin-bridge found by running it; no tracking issue exists.

VincentCorleone and others added 2 commits September 14, 2026 15:46
Define a Quick Start section in integrations/weixin-bridge/README.md modeled on
integrations/wecom-bridge/DEPLOYMENT.md, and then implement the behavior that
section describes. The README previously pointed at a non-existent
/opt/codewhale/weixin-bot-bridge path, gave no runtime startup command, and named
an env file that nothing reads; a first-time user could not get the bridge
running, and once it did run the first incoming message failed with EACCES and
was dropped without an actionable error.

README:

- Add Quick Start with the two-terminal flow (start `codewhale serve --http`,
  then the bridge) and a single-terminal launcher, following the WeCom
  deployment guide's shape.
- Add "在微信中验证": send /status, and document both real outcomes — the
  authorized reply and the refusal that carries the user_id needed for pairing.
- Document the message dedupe (`user_id:message_id`) so re-testing uses a new
  message.
- Correct the fabricated /opt/codewhale/weixin-bot-bridge path and env filename,
  and note that the bridge reads process.env rather than a .env file.

Implement the definition:

- Default WEIXIN_THREAD_MAP_PATH inside WEIXIN_STATE_DIR. Setting only
  WEIXIN_STATE_DIR left the thread map at /var/lib/codewhale-weixin-bot-bridge,
  so every incoming message failed on write; the exception was swallowed by the
  getUpdates catch and the message was silently dropped.
- Create the state dir in saveSyncBuf, matching saveAccount. The poll cursor can
  be the first persisted write on a fresh install.
- Probe thread-map writability at startup and exit with a readable message
  instead of failing later inside the message loop.
- Log the resolved thread map path next to the state dir.

Make the login QR scannable in place:

- Add src/qr.mjs, a zero-dependency byte-mode QR encoder (versions 1-10, ECC
  level L) rendered with half-block glyphs, matching the Rust side's Dense1x2
  renderer (crates/tui/src/runtime_api.rs). The bridge keeps its no-npm-deps
  property.
- Keep printing the URL below the QR so a terminal that mangles half-block
  glyphs still has a path through.

Single-terminal launcher:

- scripts/dev-up.sh plus `npm run bridge` start the runtime and the bridge in
  one terminal, generate a shared CODEWHALE_RUNTIME_TOKEN, wait for /health,
  and clean up the runtime on Ctrl-C.

Ignore bridge state and re-include bridge launchers:

- integrations/*-bridge/.state/ holds the live bot token (account.json) and the
  poll cursor.
- `*.sh` was hiding integrations/*/scripts/, which would have shipped a
  package.json pointing at a file that never landed.

Verification:

- npm --prefix integrations/weixin-bridge run check
- npm --prefix integrations/weixin-bridge test (12 passed)
- npm run bridge logged in by QR and replied to /status from a real WeChat
  account; the thread map and poll cursor landed under WEIXIN_STATE_DIR.
- Terminal QR output decoded back to the exact login URL with OpenCV (9/9
  payloads, including UTF-8).

Co-Authored-By: DeepSeek-V41-Flash <service@deepseek.com>
@github-actions

Copy link
Copy Markdown
Contributor

Thanks @VincentCorleone for taking the time to contribute.

This repository is observing a maintainer-managed PR intake gate in dry-run mode, so this pull request is staying open. This note helps maintainers prepare the allowlist before any enforcement is considered.

Please read CONTRIBUTING.md for the expected contribution shape. A maintainer can grant recurring PR access by commenting /lgtm on a pull request.

@devin-ai-integration devin-ai-integration 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.

Devin Review found 8 potential issues.

Devin Review

// timing column every subsequent column is offset.
let upward = true;
for (let col = size - 1; col > 0; col -= 2) {
if (col <= 6) col -= 1;

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.

🔴 Login QR drops data columns

For every payload, placeData shifts each pair after column six and skips required modules. The QR truncates codewords, so login scanning fails.

Learn more

QR data placement traverses two-column stripes from right to left. Only the stripe that reaches timing column 6 needs shifting; later stripes must continue as columns 5/4, 3/2, and 1/0. The <= 6 condition shifts again at column 3, processes columns 2/1, and ends the loop without visiting columns 3 or 0. Codeword bits remain unplaced, so the format metadata describes a matrix whose payload and error-correction bytes are incomplete.

Example: A version-1 matrix reaches col = 6, correctly shifts to 5, and fills columns 5/4. The next iteration reaches 3, but the current guard shifts it to 2; columns 3 and 0 are never filled.

Recommended fix: Shift only when col === 6, matching the QR placement traversal around the fixed timing column.

Suggested change
if (col <= 6) col -= 1;
if (col === 6) col -= 1;
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +27 to +28
export WEIXIN_ALLOW_UNLISTED=true
npm start

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.

🟡 Two-terminal startup requires root

A non-root user running npm start leaves WEIXIN_STATE_DIR under /var/lib. The startup probe gets EACCES and exits before login.

Suggested change
export WEIXIN_ALLOW_UNLISTED=true
npm start
export WEIXIN_ALLOW_UNLISTED=true
export WEIXIN_STATE_DIR="$PWD/.state"
npm start
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +889 to +893
const dir = path.dirname(config.threadMapPath);
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
const probe = path.join(dir, ".write-probe");
await fs.writeFile(probe, "", { mode: 0o600 });
await fs.rm(probe, { force: true });

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.

🟡 State directory escapes startup probe

When WEIXIN_THREAD_MAP_PATH uses another directory, the probe never checks WEIXIN_STATE_DIR. saveAccount or saveSyncBuf then fails after startup.

Learn more

The bridge writes the thread map to WEIXIN_THREAD_MAP_PATH, but it always writes account credentials and the polling cursor beneath WEIXIN_STATE_DIR. These locations differ whenever the thread-map override is configured. Probing only the thread-map parent therefore cannot establish the documented state-directory writability guarantee.

Example: Set WEIXIN_STATE_DIR=/var/lib/codewhale-weixin-bot-bridge and WEIXIN_THREAD_MAP_PATH=/tmp/weixin/thread-map.json as an unprivileged user. Startup passes the /tmp/weixin probe, but the first QR login fails while saving account.json under /var/lib.

Recommended fix: Probe both path.dirname(config.threadMapPath) and config.stateDir, deduplicating them when equal. Report which directory failed rather than always labeling it the thread-map directory.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +491 to +492
lines.push(invert ? line.replace(/[\u2580\u2584\u2588 ]/g, (ch) =>
ch === " " ? "\u2588" : " ") : line);

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.

🟡 Inverted QR loses half modules

With invert: true, renderQrToText replaces every half-block with a space. Mixed cells lose one module, producing a malformed QR.

Suggested change
lines.push(invert ? line.replace(/[\u2580\u2584\u2588 ]/g, (ch) =>
ch === " " ? "\u2588" : " ") : line);
lines.push(invert ? line.replace(/[\u2580\u2584\u2588 ]/g, (ch) =>
ch === " " ? "\u2588" : ch === "\u2580" ? "\u2584" : ch === "\u2584" ? "\u2580" : " ") : line);
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +6 to +8
// The rendered QR is the login credential, so a wrong matrix is worse than no
// QR at all. These checks pin the structural invariants a scanner relies on:
// finder patterns, timing patterns, and the format-information copies.

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.

🔍 QR tests miss payload decoding

The suite checks QR structure but never decodes the matrix. Data-placement regressions can pass while login scanning fails.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1 to +9
// QR code encoder — byte mode, versions 1-10, error correction level L.
//
// Zero-dependency, so the Weixin bridge keeps its no-npm-deps property. The
// login URL is ASCII (~70 bytes), which fits version 4-5 at ECC L; versions up
// to 10 are supported for headroom.
//
// Rendered the same way the Rust side does it
// (`qrcode::render::unicode::Dense1x2` in crates/tui/src/runtime_api.rs):
// two QR rows per text row using half-block glyphs.

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.

🔍 Duplicate QR implementation needs review

The new encoder mirrors the existing Rust qrcode path instead of reusing it. This adds a second protocol implementation under the ponytail rule.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

export CODEWHALE_RUNTIME_TOKEN

export CODEWHALE_RUNTIME_URL="$runtime_url"
export WEIXIN_ALLOW_UNLISTED="${WEIXIN_ALLOW_UNLISTED:-true}"

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.

🟨 Bridge defaults to unrestricted chat access

npm run bridge sets WEIXIN_ALLOW_UNLISTED=true on every launch. Any user reaching the bot can control its agent runtime until explicitly restricted.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

# Wait for /health before handing over to the bridge, so the first pairing
# message does not race a runtime that has not bound its port yet.
for _ in $(seq 1 60); do
if curl -fsS "$runtime_url/health" >/dev/null 2>&1; then

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.

🟨 Unauthenticated health check trusts wrong service

Any successful /health response satisfies startup without verifying the generated token. A different local service can receive subsequent authenticated bridge requests.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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