diff --git a/.gitignore b/.gitignore index 70d8b2f739..1f21fb67db 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,9 @@ docs/*.pdf !.github/scripts/** !web/public/install.sh !packaging/winget/** +# Bridge launchers are shipped entry points (`npm run bridge`), not local dev +# scripts; `!scripts/**` above only re-includes the repo-root scripts/ dir. +!integrations/*/scripts/** test.txt TODO*.md todo*.md @@ -93,6 +96,10 @@ project_overhaul_prompt.md .deepseek/ **/session_*.json *.db + +# Chat bridge local state. `npm run bridge` writes the Weixin bot login token +# (account.json) and the long-poll cursor here; these are live credentials. +integrations/*-bridge/.state/ npm/*/bin/downloads/ # Companion app (tracked separately) diff --git a/docs/assets/weixin-bridge-qr-login.png b/docs/assets/weixin-bridge-qr-login.png new file mode 100644 index 0000000000..c20a3bdf0e Binary files /dev/null and b/docs/assets/weixin-bridge-qr-login.png differ diff --git a/docs/assets/weixin-bridge-status-verify.jpg b/docs/assets/weixin-bridge-status-verify.jpg new file mode 100644 index 0000000000..b7034cb553 Binary files /dev/null and b/docs/assets/weixin-bridge-status-verify.jpg differ diff --git a/integrations/weixin-bridge/README.md b/integrations/weixin-bridge/README.md index 4a853231d2..de3420c8fc 100644 --- a/integrations/weixin-bridge/README.md +++ b/integrations/weixin-bridge/README.md @@ -1,11 +1,62 @@ # Weixin Bot Bridge 此 bridge 让微信个人账号通过扫码登录控制本地 `codewhale serve --http` runtime。 -使用腾讯 iLink Bot 协议(参考 `@tencent-weixin/openclaw-weixin`), -无需公众号注册即可工作。 +使用腾讯 iLink Bot 协议(参考 `@tencent-weixin/openclaw-weixin`)。 + +此 bridge 直接使用**个人微信账号**扫码登录授权,通过长轮询 `getUpdates` 收发消息。 + + +## Quick Start + +### 终端发起 + +#### 方式一、双终端启动 + +第一个终端启动 runtime: + +```bash +export CODEWHALE_RUNTIME_TOKEN="$(openssl rand -hex 32)" +codewhale serve --http --host 127.0.0.1 --port 7878 --auth-token "$CODEWHALE_RUNTIME_TOKEN" +``` + +第二个终端启动 bridge: + +```bash +cd integrations/weixin-bridge +export CODEWHALE_RUNTIME_TOKEN="<与上面相同的 token>" +export WEIXIN_ALLOW_UNLISTED=true +npm start +``` + + + +#### 方式二、单终端启动 + +一条命令同时启动 runtime 和 bridge,自动生成并共用 token: + +```bash +cd integrations/weixin-bridge +npm run bridge +``` + +按 `Ctrl-C` 同时停止两者。 + +### 微信端扫码接应 + +首次启动会打印文本二维码,用微信扫码登录: + +![终端打印的登录二维码](../../docs/assets/weixin-bridge-qr-login.png) + +二维码下方同时打印原始 URL,二维码显示异常时可手动打开。扫码窗口 5 分钟。 + +### 微信端验证信道效果 + +登录成功后,在微信里给这个 bot 发一条 `/status`。收到任何回复即表示链路已打通。 + +![微信端 /status 验证](../../docs/assets/weixin-bridge-status-verify.jpg) + + -与现有的 `integrations/wechat-bridge`(公众号客服消息模式)不同, -此 bridge 直接登录**个人微信账号**,通过长轮询 `getUpdates` 收发消息。 ## 安全模型 @@ -16,19 +67,25 @@ - 工具审批通过文本命令:`/allow ` 或 `/deny `。 - bridge 主动向微信服务器发起长轮询请求,无需公网端口。 + ## 设置 +登录凭证保存在 `WEIXIN_STATE_DIR`,再次启动无需重新扫码。线程映射与长轮询游标 +写入同一目录,启动时会自动创建并探测可写性 —— 不可写则立即报错退出。 + +注意:bridge **不读取 `.env` 文件**,手动运行时环境变量必须通过 `export` 传入, +或使用 `node --env-file=.env src/index.mjs`。 + +systemd 部署时把变量写入 env 文件并由单元引用: + ```bash -cd /opt/codewhale/weixin-bot-bridge +cd integrations/weixin-bridge npm install --omit=dev -cp .env.example /etc/codewhale/weixin-bot-bridge.env -sudoedit /etc/codewhale/weixin-bot-bridge.env +cp .env.example /etc/codewhale/weixin-bridge.env +sudoedit /etc/codewhale/weixin-bridge.env node src/index.mjs ``` -首次启动时会显示一个二维码,用微信扫描以完成登录授权。 -登录凭证会自动保存,后续启动无需重新扫码。 - ## 命令 - `/status` @@ -47,7 +104,7 @@ node src/index.mjs 1. 设置 `WEIXIN_ALLOW_UNLISTED=true` 启动 bridge。 2. 扫码登录后,在微信中发送 `/status`。 -3. Bridge 会将你的 `user_id` 返回给你(若白名单为空则显示在拒绝消息中)。 +3. Bridge 返回 runtime 状态;若你不在白名单,则返回拒绝消息,其中带有你的 `user_id`。 4. 将 `user_id` 加入 `WEIXIN_CHAT_ALLOWLIST`。 5. 将 `WEIXIN_ALLOW_UNLISTED` 改回 `false` 并重启 bridge。 @@ -62,8 +119,8 @@ node src/index.mjs | `CODEWHALE_MODE` | 否 | 运行模式(默认 `agent`) | | `WEIXIN_CHAT_ALLOWLIST` | 否 | 逗号分隔的允许用户 ID | | `WEIXIN_ALLOW_UNLISTED` | 否 | 首次配对模式(默认 `false`) | -| `WEIXIN_STATE_DIR` | 否 | 状态持久化目录 | -| `WEIXIN_THREAD_MAP_PATH` | 否 | 线程映射文件路径 | +| `WEIXIN_STATE_DIR` | 否 | 状态持久化目录(默认 `/var/lib/codewhale-weixin-bot-bridge`) | +| `WEIXIN_THREAD_MAP_PATH` | 否 | 线程映射文件路径(默认 `/thread-map.json`) | | `WEIXIN_MAX_REPLY_CHARS` | 否 | 单条回复最大字符数(默认 `3500`) | | `CODEWHALE_TURN_TIMEOUT_MS` | 否 | Turn 超时(默认 `900000`) | | `WEIXIN_LONGPOLL_TIMEOUT_MS` | 否 | 长轮询超时(默认 `35000`) | diff --git a/integrations/weixin-bridge/package.json b/integrations/weixin-bridge/package.json index 5556b33514..a61ac6d214 100644 --- a/integrations/weixin-bridge/package.json +++ b/integrations/weixin-bridge/package.json @@ -7,7 +7,8 @@ "main": "src/index.mjs", "scripts": { "start": "node src/index.mjs", - "check": "node --check src/index.mjs && node --check src/lib.mjs", + "bridge": "bash scripts/dev-up.sh", + "check": "node --check src/index.mjs && node --check src/lib.mjs && node --check src/qr.mjs && bash -n scripts/dev-up.sh", "test": "node --test test/*.test.mjs" }, "engines": { diff --git a/integrations/weixin-bridge/scripts/dev-up.sh b/integrations/weixin-bridge/scripts/dev-up.sh new file mode 100755 index 0000000000..8537924acc --- /dev/null +++ b/integrations/weixin-bridge/scripts/dev-up.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Start a local Codewhale runtime and the Weixin bridge in one terminal. +# +# Generates a shared CODEWHALE_RUNTIME_TOKEN, starts `codewhale serve --http` in +# the background, waits for it to answer /health, then runs the bridge in the +# foreground. Both processes share the generated token, so no copy/paste. +# +# Ctrl-C stops both. Override defaults with the env vars below: +# CODEWHALE_RUNTIME_PORT runtime port (default 7878) +# CODEWHALE_RUNTIME_TOKEN reuse an existing token (default: generated) +# WEIXIN_ALLOW_UNLISTED first-pairing mode (default true) +# WEIXIN_STATE_DIR state directory (default: ./.state) + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +bridge_dir="$(cd "$script_dir/.." && pwd)" + +port="${CODEWHALE_RUNTIME_PORT:-7878}" +runtime_url="http://127.0.0.1:${port}" + +if [[ -z "${CODEWHALE_RUNTIME_TOKEN:-}" ]]; then + CODEWHALE_RUNTIME_TOKEN="$(openssl rand -hex 32)" + echo "Generated CODEWHALE_RUNTIME_TOKEN for this session." +fi +export CODEWHALE_RUNTIME_TOKEN + +export CODEWHALE_RUNTIME_URL="$runtime_url" +export WEIXIN_ALLOW_UNLISTED="${WEIXIN_ALLOW_UNLISTED:-true}" +export WEIXIN_STATE_DIR="${WEIXIN_STATE_DIR:-$bridge_dir/.state}" + +runtime_pid="" + +cleanup() { + if [[ -n "$runtime_pid" ]] && kill -0 "$runtime_pid" 2>/dev/null; then + echo "" + echo "Stopping runtime (pid $runtime_pid)..." + kill "$runtime_pid" 2>/dev/null || true + wait "$runtime_pid" 2>/dev/null || true + fi +} +trap cleanup EXIT INT TERM + +echo "Starting runtime on $runtime_url ..." +codewhale serve --http \ + --host 127.0.0.1 \ + --port "$port" \ + --auth-token "$CODEWHALE_RUNTIME_TOKEN" & +runtime_pid=$! + +# 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 + break + fi + if ! kill -0 "$runtime_pid" 2>/dev/null; then + echo "Runtime exited before becoming healthy." >&2 + exit 1 + fi + sleep 0.5 +done + +if ! curl -fsS "$runtime_url/health" >/dev/null 2>&1; then + echo "Runtime did not become healthy at $runtime_url/health within 30s." >&2 + exit 1 +fi + +echo "Runtime is healthy. Starting Weixin bridge..." +echo "Allow-unlisted (first pairing): $WEIXIN_ALLOW_UNLISTED" +echo "State dir: $WEIXIN_STATE_DIR" +echo "" + +cd "$bridge_dir" +node src/index.mjs diff --git a/integrations/weixin-bridge/src/index.mjs b/integrations/weixin-bridge/src/index.mjs index 15a7c09b3a..a3110e0aad 100644 --- a/integrations/weixin-bridge/src/index.mjs +++ b/integrations/weixin-bridge/src/index.mjs @@ -24,6 +24,7 @@ import { activeTurnBlock, helpText, } from "./lib.mjs"; +import { renderQrToText } from "./qr.mjs"; import { ThreadStore as CoreThreadStore } from "../../bridge-core/src/lib.mjs"; // ============================================================================ @@ -163,9 +164,15 @@ const config = { stateDir: weixinEnv("WEIXIN_STATE_DIR") || "/var/lib/codewhale-weixin-bot-bridge", + // Defaults inside stateDir rather than to a second absolute path: setting + // only WEIXIN_STATE_DIR must not leave the thread map pointing at /var/lib, + // which fails with EACCES on every incoming message and silently drops it. threadMapPath: weixinEnv("WEIXIN_THREAD_MAP_PATH") || - "/var/lib/codewhale-weixin-bot-bridge/thread-map.json", + path.join( + weixinEnv("WEIXIN_STATE_DIR") || "/var/lib/codewhale-weixin-bot-bridge", + "thread-map.json" + ), maxReplyChars: Number(weixinEnv("WEIXIN_MAX_REPLY_CHARS") || 3500), longPollTimeoutMs: Number( weixinEnv("WEIXIN_LONGPOLL_TIMEOUT_MS") || 35000 @@ -708,6 +715,9 @@ async function loadSyncBuf(stateDir) { async function saveSyncBuf(stateDir, buf) { const p = resolveSyncBufPath(stateDir); + // The state dir may not exist yet on a first run whose first persisted write + // is the poll cursor rather than account.json. + await fs.mkdir(path.dirname(p), { recursive: true, mode: 0o700 }); const tmp = `${p}.tmp`; await fs.writeFile(tmp, buf, { mode: 0o600 }); await fs.rename(tmp, p); @@ -870,8 +880,26 @@ async function main() { console.log(`Runtime: ${config.runtimeUrl}`); console.log(`Workspace: ${config.workspace}`); console.log(`State dir: ${config.stateDir}`); + console.log(`Thread map: ${config.threadMapPath}`); - // 初始化 ThreadStore + // 初始化 ThreadStore。`open()` 只读,真正的写入发生在第一条消息到达时; + // 那时失败会被 getUpdates 的 catch 吞掉,表现为“微信没有回应”。所以这里 + // 先建目录并真实写一次探针文件,把问题在启动时就暴露出来。 + try { + 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 }); + } catch (error) { + console.error( + `Thread map directory is not writable: ${path.dirname(config.threadMapPath)} (${error.message})` + ); + console.error( + "Set WEIXIN_STATE_DIR (or WEIXIN_THREAD_MAP_PATH) to a writable directory." + ); + process.exit(1); + } threadStore = await ThreadStore.open(config.threadMapPath); // 尝试加载已有账号 @@ -888,6 +916,13 @@ async function main() { const { qrcodeUrl, sessionKey } = await getLoginQR(); console.log("请用微信扫描以下二维码登录:"); + // Render the login URL as a scannable terminal QR. The URL is printed too, + // so a terminal that mangles the half-block glyphs still has a way through. + try { + if (qrcodeUrl) console.log(renderQrToText(qrcodeUrl)); + } catch (error) { + console.warn(`Could not render QR in terminal: ${error.message}`); + } console.log(qrcodeUrl); console.log(""); diff --git a/integrations/weixin-bridge/src/qr.mjs b/integrations/weixin-bridge/src/qr.mjs new file mode 100644 index 0000000000..a19cbd1b3f --- /dev/null +++ b/integrations/weixin-bridge/src/qr.mjs @@ -0,0 +1,495 @@ +// 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. + +// --- Galois field GF(256) tables for Reed-Solomon ------------------------- + +const EXP = new Uint8Array(512); +const LOG = new Uint8Array(256); +(() => { + let x = 1; + for (let i = 0; i < 255; i++) { + EXP[i] = x; + LOG[x] = i; + x <<= 1; + if (x & 0x100) x ^= 0x11d; + } + for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255]; +})(); + +function gfMul(a, b) { + if (a === 0 || b === 0) return 0; + return EXP[LOG[a] + LOG[b]]; +} + +/** Reed-Solomon generator polynomial of the given degree. */ +function rsGenerator(degree) { + let poly = [1]; + for (let i = 0; i < degree; i++) { + const next = new Array(poly.length + 1).fill(0); + for (let j = 0; j < poly.length; j++) { + next[j] ^= gfMul(poly[j], 1); + next[j + 1] ^= gfMul(poly[j], EXP[i]); + } + poly = next; + } + return poly; +} + +/** Compute `ecLength` Reed-Solomon error correction bytes for `data`. */ +function rsEncode(data, ecLength) { + const gen = rsGenerator(ecLength); + const result = new Array(ecLength).fill(0); + for (const byte of data) { + const factor = byte ^ result[0]; + result.shift(); + result.push(0); + for (let i = 0; i < ecLength; i++) { + result[i] ^= gfMul(gen[i + 1], factor); + } + } + return result; +} + +// --- Version tables -------------------------------------------------------- + +// [ecCodewordsPerBlock, [[blockCount, dataCodewordsPerBlock], ...]] +// Error correction level L only. +const VERSION_TABLE = { + 1: [7, [[1, 19]]], + 2: [10, [[1, 34]]], + 3: [15, [[1, 55]]], + 4: [20, [[1, 80]]], + 5: [26, [[1, 108]]], + 6: [18, [[2, 68]]], + 7: [20, [[2, 78]]], + 8: [24, [[2, 97]]], + 9: [30, [[2, 116]]], + 10: [18, [[2, 68], [2, 69]]], +}; + +const ALIGNMENT_POSITIONS = { + 1: [], + 2: [6, 18], + 3: [6, 22], + 4: [6, 26], + 5: [6, 30], + 6: [6, 34], + 7: [6, 22, 38], + 8: [6, 24, 42], + 9: [6, 26, 46], + 10: [6, 28, 50], +}; + +/** Total data codewords available at level L for a version. */ +function dataCapacity(version) { + const [, blocks] = VERSION_TABLE[version]; + return blocks.reduce((sum, [count, size]) => sum + count * size, 0); +} + +// --- Bit buffer ------------------------------------------------------------ + +class BitBuffer { + constructor() { + this.bits = []; + } + put(value, length) { + for (let i = length - 1; i >= 0; i--) { + this.bits.push((value >>> i) & 1); + } + } + get length() { + return this.bits.length; + } +} + +// --- Encoding -------------------------------------------------------------- + +function chooseVersion(byteLength) { + for (const version of Object.keys(VERSION_TABLE).map(Number).sort((a, b) => a - b)) { + // 4 bits mode + 8 or 16 bits length + payload, then terminator headroom. + const lengthBits = version < 10 ? 8 : 16; + const needed = 4 + lengthBits + byteLength * 8; + if (needed <= dataCapacity(version) * 8) return version; + } + throw new Error(`qr: payload too long for supported versions (${byteLength} bytes)`); +} + +function buildCodewords(bytes, version) { + const capacity = dataCapacity(version); + const buf = new BitBuffer(); + buf.put(0b0100, 4); // byte mode + buf.put(bytes.length, version < 10 ? 8 : 16); + for (const byte of bytes) buf.put(byte, 8); + + // Terminator, then pad to a byte boundary. + const maxBits = capacity * 8; + buf.put(0, Math.min(4, maxBits - buf.length)); + while (buf.length % 8 !== 0) buf.bits.push(0); + + const data = []; + for (let i = 0; i < buf.length; i += 8) { + let byte = 0; + for (let j = 0; j < 8; j++) byte = (byte << 1) | buf.bits[i + j]; + data.push(byte); + } + // Alternating pad bytes. + const pads = [0xec, 0x11]; + for (let i = 0; data.length < capacity; i++) data.push(pads[i % 2]); + + // Split into blocks, add EC, then interleave. + const [ecPerBlock, blockSpec] = VERSION_TABLE[version]; + const dataBlocks = []; + const ecBlocks = []; + let offset = 0; + for (const [count, size] of blockSpec) { + for (let b = 0; b < count; b++) { + const block = data.slice(offset, offset + size); + offset += size; + dataBlocks.push(block); + ecBlocks.push(rsEncode(block, ecPerBlock)); + } + } + + const out = []; + const maxData = Math.max(...dataBlocks.map((b) => b.length)); + for (let i = 0; i < maxData; i++) { + for (const block of dataBlocks) if (i < block.length) out.push(block[i]); + } + for (let i = 0; i < ecPerBlock; i++) { + for (const block of ecBlocks) out.push(block[i]); + } + return out; +} + +// --- Matrix construction --------------------------------------------------- + +function makeMatrix(version) { + const size = version * 4 + 17; + const modules = Array.from({ length: size }, () => new Array(size).fill(null)); + const reserved = Array.from({ length: size }, () => new Array(size).fill(false)); + + const setFinder = (row, col) => { + for (let r = -1; r <= 7; r++) { + for (let c = -1; c <= 7; c++) { + const rr = row + r; + const cc = col + c; + if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue; + const inRing = r >= 0 && r <= 6 && c >= 0 && c <= 6; + const dark = + inRing && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4)); + modules[rr][cc] = dark ? 1 : 0; + reserved[rr][cc] = true; + } + } + }; + + setFinder(0, 0); + setFinder(0, size - 7); + setFinder(size - 7, 0); + + // Alignment patterns. + const positions = ALIGNMENT_POSITIONS[version]; + for (const row of positions) { + for (const col of positions) { + // Skip the three finder corners. + const nearFinder = + (row <= 8 && col <= 8) || + (row <= 8 && col >= size - 9) || + (row >= size - 9 && col <= 8); + if (nearFinder) continue; + for (let r = -2; r <= 2; r++) { + for (let c = -2; c <= 2; c++) { + const dark = Math.max(Math.abs(r), Math.abs(c)) !== 1; + modules[row + r][col + c] = dark ? 1 : 0; + reserved[row + r][col + c] = true; + } + } + } + } + + // Timing patterns. + for (let i = 8; i < size - 8; i++) { + if (!reserved[6][i]) { + modules[6][i] = i % 2 === 0 ? 1 : 0; + reserved[6][i] = true; + } + if (!reserved[i][6]) { + modules[i][6] = i % 2 === 0 ? 1 : 0; + reserved[i][6] = true; + } + } + + // Dark module + reserve format areas. + modules[size - 8][8] = 1; + reserved[size - 8][8] = true; + for (let i = 0; i < 9; i++) { + if (!reserved[8][i]) reserved[8][i] = true; + if (!reserved[i][8]) reserved[i][8] = true; + } + for (let i = 0; i < 8; i++) { + reserved[8][size - 1 - i] = true; + reserved[size - 1 - i][8] = true; + } + + // Reserve version info for version >= 7. + if (version >= 7) { + for (let i = 0; i < 6; i++) { + for (let j = 0; j < 3; j++) { + reserved[size - 11 + j][i] = true; + reserved[i][size - 11 + j] = true; + } + } + } + + return { size, modules, reserved }; +} + +function placeData(matrix, codewords) { + const { size, modules, reserved } = matrix; + let bitIndex = 0; + const totalBits = codewords.length * 8; + const nextBit = () => { + if (bitIndex >= totalBits) return 0; + const byte = codewords[bitIndex >> 3]; + const bit = (byte >>> (7 - (bitIndex & 7))) & 1; + bitIndex++; + return bit; + }; + + // Two-module-wide columns, right to left, alternating up/down. The vertical + // timing column (6) is skipped by shifting the whole pair left by one, which + // is why the guard is `<= 6` rather than `=== 6`: once the pair straddles the + // timing column every subsequent column is offset. + let upward = true; + for (let col = size - 1; col > 0; col -= 2) { + if (col <= 6) col -= 1; + for (let i = 0; i < size; i++) { + const row = upward ? size - 1 - i : i; + for (const c of [col, col - 1]) { + if (reserved[row][c]) continue; + modules[row][c] = nextBit(); + } + } + upward = !upward; + } +} + +function applyMask(modules, reserved, maskId) { + const size = modules.length; + return modules.map((row, r) => + row.map((value, c) => { + if (reserved[r][c]) return value; + let invert; + switch (maskId) { + case 0: invert = (r + c) % 2 === 0; break; + case 1: invert = r % 2 === 0; break; + case 2: invert = c % 3 === 0; break; + case 3: invert = (r + c) % 3 === 0; break; + case 4: invert = (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0; break; + case 5: invert = ((r * c) % 2) + ((r * c) % 3) === 0; break; + case 6: invert = (((r * c) % 2) + ((r * c) % 3)) % 2 === 0; break; + default: invert = (((r + c) % 2) + ((r * c) % 3)) % 2 === 0; break; + } + return invert ? value ^ 1 : value; + }) + ); +} + +function penalty(modules) { + const size = modules.length; + let score = 0; + // Rule 1: runs of 5+ same-colour modules. + for (let r = 0; r < size; r++) { + for (const dir of ["row", "col"]) { + let run = 1; + for (let i = 1; i < size; i++) { + const prev = dir === "row" ? modules[r][i - 1] : modules[i - 1][r]; + const cur = dir === "row" ? modules[r][i] : modules[i][r]; + if (cur === prev) { + run++; + } else { + if (run >= 5) score += 3 + (run - 5); + run = 1; + } + } + if (run >= 5) score += 3 + (run - 5); + } + } + // Rule 2: 2x2 blocks. + for (let r = 0; r < size - 1; r++) { + for (let c = 0; c < size - 1; c++) { + const v = modules[r][c]; + if (v === modules[r][c + 1] && v === modules[r + 1][c] && v === modules[r + 1][c + 1]) { + score += 3; + } + } + } + // Rule 3: the two ISO/IEC 18004 finder-like patterns, each 11 modules long: + // pattern1: 10111010000 + // pattern2: 00001011101 + // Matching these exactly matters — a looser check selects a different mask + // than reference encoders, producing a valid-looking but different matrix. + const PATTERN1 = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0]; + const PATTERN2 = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1]; + const scanForFinderLike = (get) => { + let hits = 0; + for (let start = 0; start + 11 <= size; start++) { + let p1 = true; + let p2 = true; + for (let i = 0; i < 11; i++) { + const v = get(start + i); + if (v !== PATTERN1[i]) p1 = false; + if (v !== PATTERN2[i]) p2 = false; + if (!p1 && !p2) break; + } + if (p1 || p2) hits++; + } + return hits; + }; + for (let r = 0; r < size; r++) { + score += 40 * scanForFinderLike((i) => modules[r][i]); + } + for (let c = 0; c < size; c++) { + score += 40 * scanForFinderLike((i) => modules[i][c]); + } + // Rule 4: dark-module balance. + let dark = 0; + for (const row of modules) for (const v of row) dark += v; + const percent = (dark * 100) / (size * size); + score += Math.floor(Math.abs(percent - 50) / 5) * 10; + return score; +} + +function formatBits(maskId) { + // ECC level L (0b01) + mask, BCH(15,5) with the standard generator. + let data = (0b01 << 3) | maskId; + let rem = data << 10; + for (let i = 14; i >= 10; i--) { + if ((rem >>> i) & 1) rem ^= 0b10100110111 << (i - 10); + } + return ((data << 10) | rem) ^ 0b101010000010010; +} + +function placeFormat(matrix, maskId) { + const { size, modules } = matrix; + const bits = formatBits(maskId); + for (let i = 0; i < 15; i++) { + const bit = (bits >>> i) & 1; + // First copy: bits 0-5 down the left of the top-left finder (column 8), + // bit 6 at (8,7), bits 7-8 at (8,5)-(8,6)... then along row 8 to the right. + if (i < 6) { + modules[i][8] = bit; + } else if (i < 8) { + modules[i + 1][8] = bit; + } else if (i === 8) { + modules[8][7] = bit; + } else { + modules[8][14 - i] = bit; + } + // Second copy: bits 0-7 along the bottom of the top-right finder, + // bits 8-14 down the right of the bottom-left finder. + if (i < 8) { + modules[8][size - 1 - i] = bit; + } else { + modules[size - 15 + i][8] = bit; + } + } + modules[size - 8][8] = 1; +} + +function versionBits(version) { + let rem = version << 12; + for (let i = 17; i >= 12; i--) { + if ((rem >>> i) & 1) rem ^= 0b1111100100101 << (i - 12); + } + return (version << 12) | rem; +} + +function placeVersion(matrix, version) { + if (version < 7) return; + const { size, modules } = matrix; + const bits = versionBits(version); + for (let i = 0; i < 18; i++) { + const bit = (bits >>> i) & 1; + const row = Math.floor(i / 3); + const col = i % 3; + modules[size - 11 + col][row] = bit; + modules[row][size - 11 + col] = bit; + } +} + +// --- Public API ------------------------------------------------------------ + +/** + * Encode `text` as a QR module matrix (1 = dark). + * @param {string} text + * @returns {number[][]} + */ +export function encodeQr(text) { + const bytes = [...Buffer.from(text, "utf8")]; + const version = chooseVersion(bytes.length); + const codewords = buildCodewords(bytes, version); + const matrix = makeMatrix(version); + placeData(matrix, codewords); + + let best = null; + for (let maskId = 0; maskId < 8; maskId++) { + const masked = applyMask(matrix.modules, matrix.reserved, maskId); + const score = penalty(masked); + if (!best || score < best.score) best = { score, masked, maskId }; + } + placeFormat({ size: matrix.size, modules: best.masked }, best.maskId); + placeVersion({ size: matrix.size, modules: best.masked }, version); + return best.masked; +} + +/** + * Render `text` as a terminal QR code using half-block glyphs, matching the + * Rust side's `Dense1x2` renderer. + * + * @param {string} text + * @param {{invert?: boolean, quietZone?: number}} [options] + * @returns {string} + */ +export function renderQrToText(text, options = {}) { + const { invert = false, quietZone = 2 } = options; + const matrix = encodeQr(text); + const size = matrix.length; + const padded = size + quietZone * 2; + + // Pad so the matrix has an even number of rows for half-block pairing. + const totalRows = padded % 2 === 0 ? padded : padded + 1; + + const dark = (r, c) => { + const rr = r - quietZone; + const cc = c - quietZone; + if (rr < 0 || rr >= size || cc < 0 || cc >= size) return false; + return matrix[rr][cc] === 1; + }; + + const lines = []; + for (let r = 0; r < totalRows; r += 2) { + let line = ""; + for (let c = 0; c < padded; c++) { + const top = dark(r, c); + const bottom = dark(r + 1, c); + // Half-block glyphs: each text cell covers two module rows. + if (top && bottom) line += "\u2588"; // █ + else if (top) line += "\u2580"; // ▀ + else if (bottom) line += "\u2584"; // ▄ + else line += " "; + } + // Dark modules must be dark ink; when the terminal draws light-on-dark this + // is already correct, but allow the caller to flip for dark-on-light. + lines.push(invert ? line.replace(/[\u2580\u2584\u2588 ]/g, (ch) => + ch === " " ? "\u2588" : " ") : line); + } + return lines.join("\n"); +} diff --git a/integrations/weixin-bridge/test/qr.test.mjs b/integrations/weixin-bridge/test/qr.test.mjs new file mode 100644 index 0000000000..95cc75d047 --- /dev/null +++ b/integrations/weixin-bridge/test/qr.test.mjs @@ -0,0 +1,99 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { encodeQr, renderQrToText } from "../src/qr.mjs"; + +// 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. + +function finderOk(matrix, row, col) { + for (let r = 0; r < 7; r++) { + for (let c = 0; c < 7; c++) { + const expected = + r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4); + if (Boolean(matrix[row + r][col + c]) !== expected) return false; + } + } + return true; +} + +const SAMPLE = "https://liteapp.weixin.qq.com/q/7GiQu1?qrcode=c09677d820dc2b705c2ba8c89dee2b4c&bot_type=3"; + +test("encodeQr picks a valid version size", () => { + // Version 1 is 21x21 and each version adds 4 modules. + for (const text of ["A", "HELLO", SAMPLE]) { + const size = encodeQr(text).length; + assert.equal((size - 17) % 4, 0, `size ${size} is not a valid QR size`); + assert.ok(size >= 21, `size ${size} below version 1`); + } +}); + +test("encodeQr places the three finder patterns", () => { + const matrix = encodeQr(SAMPLE); + const size = matrix.length; + assert.ok(finderOk(matrix, 0, 0), "top-left finder"); + assert.ok(finderOk(matrix, 0, size - 7), "top-right finder"); + assert.ok(finderOk(matrix, size - 7, 0), "bottom-left finder"); +}); + +test("encodeQr writes the alternating timing patterns", () => { + const matrix = encodeQr(SAMPLE); + const size = matrix.length; + for (let i = 8; i < size - 8; i++) { + assert.equal(matrix[6][i], i % 2 === 0 ? 1 : 0, `row timing at ${i}`); + assert.equal(matrix[i][6], i % 2 === 0 ? 1 : 0, `column timing at ${i}`); + } +}); + +test("encodeQr sets the fixed dark module", () => { + const matrix = encodeQr(SAMPLE); + assert.equal(matrix[matrix.length - 8][8], 1); +}); + +test("format information is mirrored between its two copies", () => { + const matrix = encodeQr(SAMPLE); + const size = matrix.length; + // Bits 0-5 run down column 8; the second copy of bits 8-14 runs down column 8 + // near the bottom-left finder. Pin that the split exists and is populated. + const firstCopy = [0, 1, 2, 3, 4, 5].map((r) => matrix[r][8]); + const secondCopy = [8, 9, 10, 11, 12, 13, 14].map((i) => matrix[size - 15 + i][8]); + assert.ok( + firstCopy.every((v) => v === 0 || v === 1), + "first format copy must be fully written" + ); + assert.ok( + secondCopy.every((v) => v === 0 || v === 1), + "second format copy must be fully written" + ); +}); + +test("encodeQr round-trips a UTF-8 payload without throwing", () => { + // 18 UTF-8 bytes needs version 2 (25x25), not version 1. + const matrix = encodeQr("微信扫码测试"); + assert.equal(matrix.length, 25); + assert.equal((matrix.length - 17) % 4, 0); +}); + +test("encodeQr rejects payloads beyond the supported versions", () => { + assert.throws(() => encodeQr("x".repeat(400)), /too long/); +}); + +test("renderQrToText produces half-block rows covering the whole matrix", () => { + const text = renderQrToText(SAMPLE); + const lines = text.split("\n"); + const matrix = encodeQr(SAMPLE); + const quietZone = 2; + const padded = matrix.length + quietZone * 2; + const expectedRows = padded % 2 === 0 ? padded : padded + 1; + + assert.equal(lines.length, expectedRows / 2, "one text row per two module rows"); + for (const line of lines) { + assert.equal([...line].length, padded, "every row is padded to the same width"); + } +}); + +test("renderQrToText emits only half-block glyphs and spaces", () => { + const text = renderQrToText(SAMPLE); + assert.match(text, /^[\u2580\u2584\u2588 \n]+$/); +});