Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Binary file added docs/assets/weixin-bridge-qr-login.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/weixin-bridge-status-verify.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
83 changes: 70 additions & 13 deletions integrations/weixin-bridge/README.md
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +27 to +28

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.

```



#### 方式二、单终端启动

一条命令同时启动 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` 收发消息。

## 安全模型

Expand All @@ -16,19 +67,25 @@
- 工具审批通过文本命令:`/allow <approval_id>` 或 `/deny <approval_id>`。
- 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`
Expand All @@ -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。

Expand All @@ -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` | 否 | 线程映射文件路径(默认 `<WEIXIN_STATE_DIR>/thread-map.json`) |
| `WEIXIN_MAX_REPLY_CHARS` | 否 | 单条回复最大字符数(默认 `3500`) |
| `CODEWHALE_TURN_TIMEOUT_MS` | 否 | Turn 超时(默认 `900000`) |
| `WEIXIN_LONGPOLL_TIMEOUT_MS` | 否 | 长轮询超时(默认 `35000`) |
Expand Down
3 changes: 2 additions & 1 deletion integrations/weixin-bridge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
75 changes: 75 additions & 0 deletions integrations/weixin-bridge/scripts/dev-up.sh
Original file line number Diff line number Diff line change
@@ -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}"

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.

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

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.

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
39 changes: 37 additions & 2 deletions integrations/weixin-bridge/src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ============================================================================
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
Comment on lines +889 to +893

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.

} 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);

// 尝试加载已有账号
Expand All @@ -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("");

Expand Down
Loading
Loading