fix(weixin): stay inside WeChat's reply limits and answer approvals by code (CW-6770)

The Weixin bridge now follows what Tencent's ClawBot (iLink) allows. Each
point below is from Tencent's own client (@tencent-weixin/openclaw-weixin
2.4.10) or a maintainer answer in Tencent/openclaw-weixin.

Reliability
- An expired session (errcode -14 from getupdates, sendmessage or
  notifystart) pauses every iLink call for an hour, as Tencent's client
  does, instead of retrying forever.
- A rejected reply is never retried in a loop. It is held until the member
  writes again. A rate-limited reply backs off from 1 to 15 minutes.
- ret=0 without a message_id is recorded as "unconfirmed" and reported by
  /status. Tencent's maintainer confirms that such replies may be filtered
  (#278).
- Only the WeChat user who scanned the login QR may use the bridge unless
  WEIXIN_CHAT_ALLOWLIST says otherwise. Other chats get one refusal naming
  their user_id; before, they were dropped silently although the startup
  log promised a refusal.
- A bridge.lock in the state directory stops a second process on the same
  state.
- /interrupt with no active turn says so instead of reporting an ownership
  error.

WeChat's limits
- The bot may reply only within 24 hours of the member's last message (#156)
  and about 10 times per message (#81). Each member message now allows 8
  sends (WEIXIN_MAX_SENDS_PER_MESSAGE). A longer answer sends 7 parts and a
  "继续" notice; replying 继续 sends the rest.
- An answer that finishes outside the window waits for the member's next
  message. It is delivered first, with a note that the new message was not
  run.
- Replies are at least a second apart. A typing indicator runs while the
  agent writes and stops when it waits on the member.

Approvals and questions
- Approval messages carry a 6-character code: reply "1 k7m2fz" to allow or
  "2 k7m2fz" to deny. Codes are single-use and expire after 30 minutes;
  /status issues fresh ones. The approval itself stays pending in the
  Engine. /allow and /deny still work.
- Engine questions (user_input.required) are sent to WeChat, and the next
  message answers them through POST /v1/user-input. Options can be chosen
  by number, multi-select is supported, and free text is used where
  allowed.
- Chinese command aliases (/状态, /帮助, /对话, /新对话, /停止) and /stop.

Docs: the bridge README and .env.example describe the new defaults and
limits, and dev-up.sh no longer allows every user by default.
docs/CODEWHALE_AGENT.md replaces the August "personal WeChat is not
supported" note: WeChat is supported only through Tencent's official
ClawBot channel. web/data/agent-docs.generated.json must be regenerated
for that doc. It is left to the 0.10.2 release owner, who has it open.

Builds on the bridge contributed by @VincentCorleone (#3206, #6170).

Verified (Node v26.10.0):
- integrations/weixin-bridge: npm run check passed; node --test
  test/*.test.mjs gave 54 passed, 0 failed. Three tests changed with the
  behavior: the fixture's successful sends now return a message_id, the
  approval test approves by code, and the rejection test expects a hold
  until 继续.
- A scratch run against a fake iLink server and a fake Engine (not
  committed) gave 9 passed, 0 failed:
  - stranger refused once;
  - second process refused;
  - -14 pauses polling;
  - 7 parts plus a 继续 notice, then the rest;
  - an answer from outside the 24h window waits, with its notice;
  - question answered by option number;
  - typing started and cancelled;
  - unconfirmed send does not block;
  - rate limit backs off.
- No real WeChat or Tencent call was made. Live behavior (window length,
  send budget, -14 lifetime) still needs qualification with a real account.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-22 07:07:39 -07:00
1 parent aad152549f
commit 1f8d4bea1a
7 files changed
+634 -120

No files matched your search

+5 -3
View File
@@ -51,9 +51,11 @@ quiet hours + digest, automation output routable
to any paired channel, one pairing code bound inside the authenticated
app, unbind revokes instantly.
WeChat note: personal-WeChat automation is **not supported** —
reverse-engineered protocols get users banned. Official 公众号/服务号 APIs
are the only acceptable path and are still under assessment.
WeChat note: personal WeChat is supported only through Tencent's official
ClawBot (iLink) channel, never through unofficial account automation. The
open-source `integrations/weixin-bridge` uses it with a local
`codewhale serve --http`, and it stops when Tencent's risk control objects.
Official Account (公众号/服务号) APIs are not used.
## Pointer
+3 -1
View File
@@ -21,7 +21,8 @@ CODEWHALE_TRUST_MODE=false
CODEWHALE_AUTO_APPROVE=false
# Comma-separated WeChat user IDs (from_user_id) allowed to control the runtime.
# Leave empty only during first pairing, with WEIXIN_ALLOW_UNLISTED=true.
# Leave empty to allow only the WeChat user who scanned the login QR code.
# WEIXIN_ALLOW_UNLISTED=true allows anyone who can reach the bot; use it only to debug.
WEIXIN_CHAT_ALLOWLIST=
WEIXIN_ALLOW_UNLISTED=false
@@ -31,6 +32,7 @@ WEIXIN_THREAD_MAP_PATH=/var/lib/codewhale-weixin-bot-bridge/thread-map.json
# 消息配置
WEIXIN_MAX_REPLY_CHARS=3500
WEIXIN_MAX_SENDS_PER_MESSAGE=8
CODEWHALE_TURN_TIMEOUT_MS=900000
# 长轮询配置
+37 -16
View File
@@ -24,7 +24,6 @@ codewhale serve --http --host 127.0.0.1 --port 7878 --auth-token "$CODEWHALE_RUN
```bash
cd integrations/weixin-bridge
export CODEWHALE_RUNTIME_TOKEN="<与上面相同的 token>"
export WEIXIN_ALLOW_UNLISTED=true
npm start
```
@@ -62,9 +61,13 @@ npm run bridge
- `codewhale serve --http` 绑定于 `127.0.0.1`。
- `/v1/*` runtime 调用使用 `CODEWHALE_RUNTIME_TOKEN`。
- 微信用户必须加入白名单,除非首次配对时设置 `WEIXIN_ALLOW_UNLISTED=true`。
- 默认只有扫码登录的那个微信(`ilink_user_id`)可以使用;其他聊天会收到一次带 `user_id` 的拒绝。
设置 `WEIXIN_CHAT_ALLOWLIST` 后改用显式白名单。
- 仅支持私聊;暂不支持群聊。
- 工具审批通过文本命令:`/allow <approval_id>` 或 `/deny <approval_id>`。
- 工具审批:回复审批消息里的批准码,`1 k7m2fz` 允许、`2 k7m2fz` 拒绝。批准码一次有效、30 分钟过期,
过期后 `/status` 会重新发出;批准本身在 Engine 中保持等待,不会因过期被拒绝。
`/allow <approval_id>`、`/deny <approval_id>` 仍然可用。
- 每个状态目录带一个 `bridge.lock`;同一目录的第二个 bridge 进程会拒绝启动。
- bridge 主动向微信服务器发起长轮询请求,无需公网端口。
## 产品边界与验证
@@ -126,25 +129,42 @@ node src/index.mjs
## 命令
- `/status`
- `/threads`
- `/new`
- `/status`(`/状态`):状态,并为等待中的审批重新发出批准码
- `/threads`(`/对话`)
- `/new`(`/新对话`)
- `/resume <thread_id>`
- `/model <name|default>`
- `/interrupt`
- `/stop`、`/interrupt`(`/停止`)
- `/compact`
- `/allow <approval_id> [remember]`
- `/deny <approval_id>`
- `1 <批准码>` / `2 <批准码>`:允许或拒绝
- `/allow <approval_id> [remember]`、`/deny <approval_id>`
- `继续`(或 `more`):查看剩余的回复
Engine 向你提问时,下一条消息就是回答:回复选项编号或文字;多个问题时每行回答一个。
其他所有内容均作为 Codewhale 提示发送。
## 微信的限制
以下来自腾讯官方客户端和公开 issue,不是完整的服务器合同:
- Bot 只能回复,且只能在你最后一条消息后的 24 小时内回复;bridge 按 23.5 小时计算。
- 每条你发出的消息之后,bridge 最多回复 8 条(`WEIXIN_MAX_SENDS_PER_MESSAGE`)。更长的回答
先发 7 条加一条提示,回复「继续」查看剩余部分。窗口外完成的回答会等你下一条消息再发出,
并提示你刚才的消息还没有运行。
- 回复之间至少间隔 1 秒;遇到 `rate limited` 时按 1 到 15 分钟退避后重试,不会连续重发。
- `getupdates` 或发送返回 `errcode -14`(会话过期)时,与腾讯客户端一样暂停所有 iLink
调用 1 小时再重试;仍然过期时,删除 `account.json` 并重新扫码。
- `ret=0` 但没有 `message_id` 的发送记为 `unconfirmed`:腾讯可能在静默过滤这个账号的消息,
`/status` 会提示。请到手机上确认。
- 每个微信用户只能绑定一个 ClawBot;在别处重新绑定会断开这里。不要反复扫码测试,
有用户反馈因此被限制。
- Engine 生成回答时显示“正在输入”。
## 首次配对
1. 设置 `WEIXIN_ALLOW_UNLISTED=true` 启动 bridge。
2. 扫码登录后,在微信中发送 `/status`。
3. Bridge 返回 runtime 状态和当前发送者的 `user_id`。
4. 将 `user_id` 加入 `WEIXIN_CHAT_ALLOWLIST`。
5. 将 `WEIXIN_ALLOW_UNLISTED` 改回 `false` 并重启 bridge。
扫码登录后,bridge 只接受扫码的那个微信。若需允许其他用户,在微信中发送 `/status`
查看各自的 `user_id`,写入 `WEIXIN_CHAT_ALLOWLIST` 并重启。`WEIXIN_ALLOW_UNLISTED=true`
会允许任何能联系到这个 bot 的人,仅用于排查。
## 环境变量
@@ -155,11 +175,12 @@ node src/index.mjs
| `CODEWHALE_WORKSPACE` | 否 | 工作区路径(默认 cwd) |
| `CODEWHALE_MODEL` | 否 | 模型名称(默认 `auto`) |
| `CODEWHALE_MODE` | 否 | 运行模式(默认 `agent`) |
| `WEIXIN_CHAT_ALLOWLIST` | 否 | 逗号分隔的允许用户 ID |
| `WEIXIN_ALLOW_UNLISTED` | 否 | 首次配对模式(默认 `false`) |
| `WEIXIN_CHAT_ALLOWLIST` | 否 | 逗号分隔的允许用户 ID(默认只允许扫码的微信) |
| `WEIXIN_ALLOW_UNLISTED` | 否 | 允许任何用户(默认 `false`,仅用于排查) |
| `WEIXIN_STATE_DIR` | 否 | 状态持久化目录(默认 `/var/lib/codewhale-weixin-bot-bridge`) |
| `WEIXIN_THREAD_MAP_PATH` | 否 | 线程映射文件路径(默认 `<WEIXIN_STATE_DIR>/thread-map.json`) |
| `WEIXIN_MAX_REPLY_CHARS` | 否 | 单条回复最大字符数(默认 `3500`) |
| `WEIXIN_MAX_SENDS_PER_MESSAGE` | 否 | 每条用户消息后最多回复几条(默认 `8`,最小 `2`) |
| `CODEWHALE_TURN_TIMEOUT_MS` | 否 | 单次事件观察连接的时限(默认 `900000`);断开后恢复观察,不中止 Engine turn |
| `WEIXIN_LONGPOLL_TIMEOUT_MS` | 否 | 长轮询超时(默认 `35000`) |
+3 -3
View File
@@ -8,7 +8,7 @@
# 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_ALLOW_UNLISTED allow any WeChat user (default false: only the scanner)
# WEIXIN_STATE_DIR state directory (default: ./.state)
set -euo pipefail
@@ -26,7 +26,7 @@ fi
export CODEWHALE_RUNTIME_TOKEN
export CODEWHALE_RUNTIME_URL="$runtime_url"
export WEIXIN_ALLOW_UNLISTED="${WEIXIN_ALLOW_UNLISTED:-true}"
export WEIXIN_ALLOW_UNLISTED="${WEIXIN_ALLOW_UNLISTED:-false}"
export WEIXIN_STATE_DIR="${WEIXIN_STATE_DIR:-$bridge_dir/.state}"
runtime_pid=""
@@ -67,7 +67,7 @@ if ! curl -fsS "$runtime_url/health" >/dev/null 2>&1; then
fi
echo "Runtime is healthy. Starting Weixin bridge..."
echo "Allow-unlisted (first pairing): $WEIXIN_ALLOW_UNLISTED"
echo "Allow any WeChat user: $WEIXIN_ALLOW_UNLISTED"
echo "State dir: $WEIXIN_STATE_DIR"
echo ""
+474 -86
View File
@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import fsSync from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
@@ -7,6 +8,7 @@ import {
waitForLogin,
getUpdates,
sendMessage,
sendTyping,
getConfig,
notifyStart,
notifyStop,
@@ -15,7 +17,7 @@ import {
parseBool,
envFirst,
extractText,
parseCommand,
parseWeixinCommand,
commandAction,
preservedChatStateFields,
splitMessage,
@@ -23,6 +25,13 @@ import {
latestRunningTurn,
helpText,
processUpdateBatch,
isContinueReply,
isSessionExpired,
mintCode,
parseApprovalReply,
approvalText,
questionText,
parseQuestionAnswers,
} from "./lib.mjs";
import { renderQrToText } from "./qr.mjs";
import { ThreadStore as CoreThreadStore, writeFileDurable, ApprovalOwnershipError, decideApproval as decideRuntimeApproval } from "../../bridge-core/src/lib.mjs";
@@ -160,6 +169,7 @@ const config = {
"thread-map.json"
),
maxReplyChars: Number(weixinEnv("WEIXIN_MAX_REPLY_CHARS") || 3500),
maxSendsPerMessage: Math.max(2, Number(weixinEnv("WEIXIN_MAX_SENDS_PER_MESSAGE") || 8)),
longPollTimeoutMs: Number(
weixinEnv("WEIXIN_LONGPOLL_TIMEOUT_MS") || 35000
),
@@ -230,26 +240,78 @@ async function* readSse(response) {
// 消息发送 — 通过 iLink sendMessage
// ============================================================================
async function sendText(chatId, text, { clientId } = {}) {
if (!botAccount) {
throw new Error("Bot account unavailable");
}
const chunks = splitMessage(text, config.maxReplyChars);
for (const [index, chunk] of chunks.entries()) {
await sendMessage({
const WEIXIN_SYSTEM_PROMPT =
"You are being controlled from WeChat on a phone through iLink Bot. Keep answers short and plain: no wide tables, and long code only when asked. Ask for tool approvals when needed; do not assume mobile messages imply blanket approval.";
const REPLY_WINDOW_MS = 23.5 * 60 * 60 * 1000;
const SEND_GAP_MS = 1000;
const SESSION_PAUSE_MS = 60 * 60 * 1000;
const RATE_LIMIT_BACKOFF_MS = { first: 60_000, max: 15 * 60_000 };
const APPROVAL_CODE_TTL_MS = 30 * 60 * 1000;
const SETTLED_OUTPUTS = new Set(["accepted", "unconfirmed", "skipped"]);
let lastSendAt = 0;
let sessionPausedUntil = 0;
function sessionPaused() {
return Date.now() < sessionPausedUntil;
}
function pauseSession(source) {
sessionPausedUntil = Date.now() + SESSION_PAUSE_MS;
console.error(`iLink reported an expired WeChat session (errcode -14, ${source}). Pausing all iLink calls until ${new Date(sessionPausedUntil).toISOString()}; if it does not recover, delete account.json and scan a new QR code.`);
}
function replyWindow(state) {
const since = Date.parse(state?.replyWindow?.since || "");
return { since: Number.isFinite(since) ? since : null, sends: Number(state?.replyWindow?.sends) || 0 };
}
function sendAllowance(state) {
const { since, sends } = replyWindow(state);
if (since !== null && Date.now() - since > REPLY_WINDOW_MS) return 0;
return Math.max(0, config.maxSendsPerMessage - sends);
}
async function deliverChunk(chatId, text, { clientId, contextToken } = {}) {
if (!botAccount) throw new Error("Bot account unavailable");
if (sessionPaused()) throw Object.assign(new Error("The WeChat session is paused"), { deliveryStatus: "paused" });
const state = await threadStore.getChat(chatId);
if (sendAllowance(state) <= 0) throw Object.assign(new Error("WeChat's reply window or per-message budget is used up"), { deliveryStatus: "held" });
const { since, sends } = replyWindow(state);
await threadStore.patchChat(chatId, { replyWindow: { since: new Date(since ?? Date.now()).toISOString(), sends: sends + 1 } });
const wait = lastSendAt + SEND_GAP_MS - Date.now();
if (wait > 0) await sleep(wait);
lastSendAt = Date.now();
try {
return await sendMessage({
baseUrl: botAccount.baseUrl,
token: botAccount.token,
body: {
msg: {
to_user_id: chatId,
client_id: clientId ? `${clientId}-${index}` : crypto.randomUUID(),
client_id: clientId || crypto.randomUUID(),
message_type: 2, // BOT
message_state: 2, // FINISH
item_list: [{ type: 1, text_item: { text: chunk } }],
context_token: await getContextToken(chatId),
item_list: [{ type: 1, text_item: { text } }],
context_token: contextToken ?? (await getContextToken(chatId)),
},
},
});
} catch (error) {
if (error.sessionExpired) pauseSession("sendmessage");
throw error;
}
}
async function sendText(chatId, text, { clientId, contextToken } = {}) {
const chunks = splitMessage(text, config.maxReplyChars);
try {
for (const [index, chunk] of chunks.entries()) {
await deliverChunk(chatId, chunk, { clientId: clientId ? `${clientId}-${index}` : undefined, contextToken });
}
} catch (error) {
if (!["held", "paused", "rejected"].includes(error.deliveryStatus)) throw error;
console.warn(`WeChat reply not sent (${error.deliveryStatus}): ${error.message}`);
}
}
@@ -353,8 +415,7 @@ async function ensureThread(chatId, { forceNew = false, threadRequest } = {}) {
trust_mode: config.trustMode,
auto_approve: config.autoApprove,
archived: false,
system_prompt:
"You are being controlled from a WeChat phone chat via iLink Bot. Keep status updates concise. Ask for tool approvals when needed; do not assume mobile messages imply blanket approval.",
system_prompt: WEIXIN_SYSTEM_PROMPT,
},
});
@@ -401,7 +462,7 @@ function inboundKey(msg) {
}
function hasPendingWork(state) {
return Boolean(state?.pendingAdmission || state?.activeTurnId || state?.turnDelivery?.outputs?.some((entry) => entry.status !== "accepted"));
return Boolean(state?.pendingAdmission || state?.activeTurnId || state?.turnDelivery?.outputs?.some((entry) => !SETTLED_OUTPUTS.has(entry.status)));
}
function ownsRecovery(chatId, state) {
@@ -434,7 +495,7 @@ async function runPrompt(chatId, prompt, { messageKey } = {}) {
threadId: current?.threadId || null, submitted: false, sinceSeq: null,
threadRequest: { model: request.model, workspace: config.workspace, mode: request.mode, allow_shell: request.allow_shell,
trust_mode: request.trust_mode, auto_approve: request.auto_approve, archived: false,
system_prompt: "You are being controlled from a WeChat phone chat via iLink Bot. Keep status updates concise. Ask for tool approvals when needed; do not assume mobile messages imply blanket approval." },
system_prompt: WEIXIN_SYSTEM_PROMPT },
} });
startChatRecovery(chatId);
}
@@ -495,10 +556,54 @@ function queueOutput(delivery, key, text) {
}
}
function hasOutput(delivery, key) {
return delivery.outputs.some((entry) => entry.key.startsWith(`${key}:`));
}
function mintApprovalCode(delivery, approvalId) {
const codes = { ...(delivery.approvalCodes || {}) };
let code;
do code = mintCode(); while (codes[code]);
codes[code] = { approvalId, expiresAt: new Date(Date.now() + APPROVAL_CODE_TTL_MS).toISOString() };
delivery.approvalCodes = Object.fromEntries(Object.entries(codes).slice(-100));
return code;
}
function queueApproval(delivery, approval, fallbackKey) {
const approvalId = approval.approval_id || approval.id;
if (!approvalId) {
queueOutput(delivery, `approval:${fallbackKey}`, "需要你的批准,请在 Codewhale 中处理。/ An approval is waiting; decide it in Codewhale.");
return;
}
const key = `approval:${approvalId}`;
if (hasOutput(delivery, key)) return;
queueOutput(delivery, key, approvalText(approval, mintApprovalCode(delivery, approvalId)));
}
function queueQuestion(delivery, input) {
const inputId = input?.input_id || input?.id;
if (!inputId || !input.request) return;
delivery.pendingQuestion = { inputId, request: input.request };
queueOutput(delivery, `question:${inputId}`, questionText(input.request));
}
function applyTurnEvent(delivery, record, seq) {
const payload = record.payload || {};
if (record.event === "item.delta" && payload.kind === "agent_message") delivery.responseText += payload.delta || "";
if (record.event === "approval.required") queueApproval(delivery, payload, seq);
if (record.event === "user_input.required") queueQuestion(delivery, payload);
if ((record.event === "user_input.answered" || record.event === "user_input.canceled")
&& delivery.pendingQuestion?.inputId === (payload.input_id || payload.id)) delivery.pendingQuestion = null;
const status = payload.turn?.status || payload.status;
if (record.event === "turn.completed") finishDelivery(delivery, status || "completed");
if (record.event === "turn.lifecycle" && ["failed", "canceled", "interrupted"].includes(status)) finishDelivery(delivery, status);
}
function finishDelivery(delivery, status) {
if (delivery.terminal) return;
delivery.terminal = true;
delivery.status = status;
delivery.pendingQuestion = null;
const text = delivery.responseText.trim();
queueOutput(delivery, "result", [text, status !== "completed" ? `Turn ${status}.` : (!text ? "Turn completed." : "")].filter(Boolean).join("\n\n"));
}
@@ -507,66 +612,163 @@ async function saveDelivery(chatId, delivery) {
await threadStore.patchChat(chatId, { turnDelivery: delivery, lastSeq: delivery.nextSeq });
}
async function updateDelivery(chatId, change) {
const state = await threadStore.getChat(chatId);
if (!state?.turnDelivery) return null;
const delivery = structuredClone(state.turnDelivery);
if (change(delivery)) await saveDelivery(chatId, delivery);
return delivery;
}
function holdQueued(delivery, fromIndex) {
for (const entry of delivery.outputs.slice(fromIndex)) if (entry.status === "queued") entry.status = "held";
}
function insertContinueNotice(delivery, index) {
const remaining = delivery.outputs.slice(index).filter((entry) => entry.status === "queued").length;
holdQueued(delivery, index);
const key = `continue:${crypto.randomUUID()}`;
delivery.outputs.splice(index, 0, { key: `${key}:0`, status: "queued",
text: `(还有 ${remaining} 条,回复「继续」查看 / Reply 继续 for ${remaining} more)`,
clientId: crypto.createHash("sha256").update(`${delivery.accountId}:${delivery.turnId}:${key}`).digest("hex") });
}
async function flushOutputs(chatId) {
let state = await threadStore.getChat(chatId);
if (!state?.turnDelivery || !ownsRecovery(chatId, state)) return;
let delivery = structuredClone(state.turnDelivery);
if (threadStore.turnOrigin(chatId, delivery.threadId, delivery.turnId)?.actorId !== delivery.actorId) return;
for (const entry of delivery.outputs) {
if (entry.status === "accepted" || entry.status === "uncertain") continue;
if (entry.status === "sending") {
entry.status = "uncertain";
await saveDelivery(chatId, delivery);
continue;
}
entry.status = "sending";
await saveDelivery(chatId, delivery);
try {
await sendText(chatId, entry.text, { clientId: entry.clientId });
entry.status = "accepted";
} catch (error) {
entry.status = error.deliveryStatus === "rejected" ? "queued" : "uncertain";
await saveDelivery(chatId, delivery);
for (;;) {
const state = await threadStore.getChat(chatId);
if (!state?.turnDelivery || !ownsRecovery(chatId, state)) return;
if (threadStore.turnOrigin(chatId, state.turnDelivery.threadId, state.turnDelivery.turnId)?.actorId !== state.turnDelivery.actorId) return;
if (sessionPaused()) return;
const allowance = sendAllowance(state);
let target = null;
const delivery = await updateDelivery(chatId, (draft) => {
let changed = false;
for (const entry of draft.outputs) if (entry.status === "sending") { entry.status = "uncertain"; changed = true; }
if (draft.releaseHeld) {
for (const entry of draft.outputs) if (entry.status === "held") entry.status = "queued";
draft.releaseHeld = false;
changed = true;
}
const index = draft.outputs.findIndex((entry) => entry.status === "queued");
if (index < 0 || Date.parse(draft.outputs[index].retryAt || "") > Date.now()) return changed;
if (allowance <= 0) {
holdQueued(draft, index);
return true;
}
if (allowance === 1 && draft.outputs.slice(index + 1).some((entry) => entry.status === "queued")) insertContinueNotice(draft, index);
target = draft.outputs[index];
target.status = "sending";
return true;
});
if (!target) {
if (delivery?.terminal && delivery.outputs.every((entry) => SETTLED_OUTPUTS.has(entry.status))) {
await threadStore.patchChat(chatId, { activeTurnId: null });
}
return;
}
await saveDelivery(chatId, delivery);
}
if (delivery.terminal && delivery.outputs.every((entry) => entry.status === "accepted")) {
await threadStore.patchChat(chatId, { activeTurnId: null });
let outcome;
try {
const response = await deliverChunk(chatId, target.text, { clientId: `${target.clientId}-0` });
outcome = response?.message_id ? { status: "accepted", serverMessageId: String(response.message_id) } : { status: "unconfirmed" };
if (!response?.message_id) console.warn("WeChat accepted a reply without a message_id; Tencent may be filtering this account's messages.");
} catch (error) {
if (error.deliveryStatus === "paused" || error.sessionExpired) outcome = { status: "queued" };
else if (error.rateLimited) outcome = { status: "queued", rateLimited: true };
else if (error.deliveryStatus === "held" || error.deliveryStatus === "rejected") outcome = { status: "held", holdRest: true };
else outcome = { status: "uncertain" };
}
await updateDelivery(chatId, (draft) => {
const index = draft.outputs.findIndex((entry) => entry.key === target.key);
if (index < 0) return false;
const entry = draft.outputs[index];
entry.status = outcome.status;
if (outcome.serverMessageId) entry.serverMessageId = outcome.serverMessageId;
if (outcome.rateLimited) {
draft.rateLimitBackoffMs = Math.min(draft.rateLimitBackoffMs ? draft.rateLimitBackoffMs * 2 : RATE_LIMIT_BACKOFF_MS.first, RATE_LIMIT_BACKOFF_MS.max);
entry.retryAt = new Date(Date.now() + draft.rateLimitBackoffMs).toISOString();
} else if (outcome.status !== "queued") {
draft.rateLimitBackoffMs = 0;
delete entry.retryAt;
}
if (outcome.holdRest) holdQueued(draft, index);
return true;
});
if (outcome.status !== "accepted" && outcome.status !== "unconfirmed") return;
}
}
function approvalText(approval) {
const id = approval.approval_id || approval.id;
return ["Approval required", `tool=${approval.tool_name || "unknown"}`, id ? `approval_id=${id}` : "",
approval.description || "", id ? `Reply /allow ${id} or /deny ${id}` : "Use the terminal to decide this approval."].filter(Boolean).join("\n");
function queuedWaitMs(delivery) {
const queued = delivery.outputs.filter((entry) => entry.status === "queued");
if (!queued.length) return null;
const retryAt = Math.min(...queued.map((entry) => Date.parse(entry.retryAt || "") || 0));
return Math.max(1000, sessionPausedUntil - Date.now(), retryAt - Date.now());
}
async function reconcileSnapshot(chatId) {
const state = await threadStore.getChat(chatId);
if (!state?.turnDelivery || !ownsRecovery(chatId, state)) return;
const delivery = structuredClone(state.turnDelivery);
const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(delivery.threadId)}`);
const turn = detail.turns?.find((entry) => entry.id === delivery.turnId && entry.thread_id === delivery.threadId);
const { threadId, turnId } = state.turnDelivery;
const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(threadId)}`);
const turn = detail.turns?.find((entry) => entry.id === turnId && entry.thread_id === threadId);
if (!turn) throw new Error("Accepted Engine turn is unavailable");
const items = (detail.items || []).filter((item) => item.turn_id === delivery.turnId && item.kind === "agent_message");
const items = (detail.items || []).filter((item) => item.turn_id === turnId && item.kind === "agent_message");
if (items.some((item) => typeof item.detail !== "string")) throw new Error("Engine snapshot lacks full assistant output");
if (items.length) delivery.responseText = items.map((item) => item.detail).join("\n\n");
delivery.nextSeq = Math.max(delivery.nextSeq, Number(detail.latest_seq || 0));
for (const approval of detail.pending_approvals || []) {
if (approval.turn_id === delivery.turnId) queueOutput(delivery, `approval:${approval.approval_id || approval.id}`, approvalText(approval));
const inputs = (detail.pending_user_inputs || []).filter((input) => input.turn_id === turnId);
await updateDelivery(chatId, (delivery) => {
if (delivery.turnId !== turnId) return false;
if (items.length) delivery.responseText = items.map((item) => item.detail).join("\n\n");
delivery.nextSeq = Math.max(delivery.nextSeq, Number(detail.latest_seq || 0));
for (const approval of detail.pending_approvals || []) {
if (approval.turn_id === turnId) queueApproval(delivery, approval, approval.id);
}
for (const input of inputs) queueQuestion(delivery, input);
if (delivery.pendingQuestion && !inputs.some((input) => input.id === delivery.pendingQuestion.inputId)) delivery.pendingQuestion = null;
if (["completed", "failed", "canceled", "interrupted"].includes(turn.status)) finishDelivery(delivery, turn.status);
return true;
});
}
const typingStates = new Map();
function startTyping(chatId) {
if (typingStates.has(chatId) || !botAccount || sessionPaused()) return;
const typing = { ticket: null, timer: null };
typingStates.set(chatId, typing);
const tick = async () => {
try {
if (sessionPaused()) return;
if (!typing.ticket) {
const accountConfig = await getConfig({ baseUrl: botAccount.baseUrl, token: botAccount.token, ilinkUserId: chatId, contextToken: await getContextToken(chatId) });
typing.ticket = accountConfig?.typing_ticket || "";
}
if (typing.ticket && typingStates.get(chatId) === typing) {
await sendTyping({ baseUrl: botAccount.baseUrl, token: botAccount.token, ilinkUserId: chatId, typingTicket: typing.ticket, status: 1 });
}
} catch {
if (typingStates.get(chatId) === typing) stopTyping(chatId, false);
}
};
typing.timer = setInterval(tick, 5000);
typing.timer.unref?.();
tick();
}
function stopTyping(chatId, cancel = true) {
const typing = typingStates.get(chatId);
if (!typing) return;
clearInterval(typing.timer);
typingStates.delete(chatId);
if (cancel && typing.ticket && botAccount && !sessionPaused()) {
sendTyping({ baseUrl: botAccount.baseUrl, token: botAccount.token, ilinkUserId: chatId, typingTicket: typing.ticket, status: 2 }).catch(() => {});
}
if (["completed", "failed", "canceled", "interrupted"].includes(turn.status)) finishDelivery(delivery, turn.status);
await saveDelivery(chatId, delivery);
}
async function streamTurnEvents(chatId, controller) {
let state = await threadStore.getChat(chatId);
const delivery = structuredClone(state.turnDelivery);
const start = (await threadStore.getChat(chatId)).turnDelivery;
// Bound headers and streaming together; this never interrupts the Engine turn.
const timeout = setTimeout(() => controller.abort(), config.turnTimeoutMs);
try {
const response = await fetch(`${config.runtimeUrl}/v1/threads/${encodeURIComponent(delivery.threadId)}/events?since_seq=${delivery.nextSeq}`,
const response = await fetch(`${config.runtimeUrl}/v1/threads/${encodeURIComponent(start.threadId)}/events?since_seq=${start.nextSeq}`,
{ headers: authHeaders(), signal: controller.signal });
if (!response.ok) throw new Error("Engine event observation unavailable");
for await (const event of readSse(response)) {
@@ -577,23 +779,19 @@ async function streamTurnEvents(chatId, controller) {
break;
}
const seq = Number(record.seq || 0);
if (seq <= delivery.nextSeq) continue;
delivery.nextSeq = seq;
if (record.turn_id === delivery.turnId) {
if (record.event === "item.delta" && record.payload?.kind === "agent_message") delivery.responseText += record.payload.delta || "";
if (record.event === "approval.required") {
const approval = record.payload || {};
queueOutput(delivery, `approval:${approval.approval_id || approval.id || seq}`, approvalText(approval));
}
const status = record.payload?.turn?.status || record.payload?.status;
if (record.event === "turn.completed") finishDelivery(delivery, status || "completed");
if (record.event === "turn.lifecycle" && ["failed", "canceled", "interrupted"].includes(status)) finishDelivery(delivery, status);
}
// Cursor, accumulator and pending output are committed together before send.
await saveDelivery(chatId, delivery);
const delivery = await updateDelivery(chatId, (draft) => {
if (draft.turnId !== start.turnId || seq <= draft.nextSeq) return false;
draft.nextSeq = seq;
if (record.turn_id === draft.turnId) applyTurnEvent(draft, record, seq);
return true;
});
if (!delivery || delivery.turnId !== start.turnId) return true;
if (record.turn_id === start.turnId) {
if (record.event === "item.delta") startTyping(chatId);
if (record.event === "approval.required" || record.event === "user_input.required") stopTyping(chatId);
}
await flushOutputs(chatId);
// flushOutputs mutates output states; retain them for the next journal event.
delivery.outputs = structuredClone((await threadStore.getChat(chatId)).turnDelivery.outputs);
if (delivery.terminal) return true;
if (stopping) break;
}
@@ -601,6 +799,7 @@ async function streamTurnEvents(chatId, controller) {
} finally {
clearTimeout(timeout);
controller.abort();
stopTyping(chatId);
}
}
@@ -621,12 +820,13 @@ function startChatRecovery(chatId) {
}
}
if (!state.turnDelivery) return;
await reconcileSnapshot(chatId);
if (!state.turnDelivery.terminal) await reconcileSnapshot(chatId);
await flushOutputs(chatId);
state = await threadStore.getChat(chatId);
if (state.turnDelivery.terminal) {
if (!state.turnDelivery.outputs.some((entry) => entry.status === "queued")) return;
await sleep(1000);
if (state.turnDelivery?.terminal) {
const wait = queuedWaitMs(state.turnDelivery);
if (wait === null) return;
await sleep(wait);
continue;
}
const controller = new AbortController();
@@ -644,6 +844,65 @@ function startChatRecovery(chatId) {
recoveryTasks.set(chatId, task);
}
async function reissueApprovalCodes(chatId) {
const state = await threadStore.getChat(chatId);
const delivery = state?.turnDelivery;
if (!delivery || delivery.terminal || !ownsRecovery(chatId, state)) return [];
const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(delivery.threadId)}`);
const pending = (detail.pending_approvals || []).filter((approval) => approval.turn_id === delivery.turnId);
const lines = [];
await updateDelivery(chatId, (draft) => {
if (draft.turnId !== delivery.turnId) return false;
for (const approval of pending) lines.push(approvalText(approval, mintApprovalCode(draft, approval.approval_id || approval.id)));
return lines.length > 0;
});
return lines;
}
async function decideByCode(chatId, { decision, code }) {
const state = await threadStore.getChat(chatId);
const entry = ownsRecovery(chatId, state) ? state?.turnDelivery?.approvalCodes?.[code] : null;
if (!entry || entry.usedAt) return sendText(chatId, "没有找到等待中的这个批准码。/ No approval is waiting with that code.");
if (Date.parse(entry.expiresAt) <= Date.now()) return sendText(chatId, "这个批准码已过期,发送 /status 获取新的。/ That code expired; send /status for a new one.");
try {
await decideRuntimeApproval(runtimeJson, { store: threadStore, chatId, actorId: turnActor(chatId), approvalId: entry.approvalId, decision, remember: false });
} catch (error) {
if (error instanceof ApprovalOwnershipError || error.status === 404) return sendText(chatId, "这个批准已不在等待中。/ That approval is no longer waiting.");
return sendText(chatId, `Approval failed: ${error.message}`);
}
await updateDelivery(chatId, (draft) => {
if (!draft.approvalCodes?.[code]) return false;
draft.approvalCodes[code].usedAt = new Date().toISOString();
return true;
});
await sendText(chatId, decision === "allow" ? "已允许 / Allowed" : "已拒绝 / Denied");
}
async function answerQuestion(chatId, text) {
const state = await threadStore.getChat(chatId);
const delivery = state?.turnDelivery;
const question = delivery?.pendingQuestion;
const answers = parseQuestionAnswers(question?.request, text);
if (!answers) return sendText(chatId, "没能对上这个回答,请回复选项编号或文字。/ That did not match; reply with an option number or in words.");
try {
await runtimeJson(`/v1/user-input/${encodeURIComponent(delivery.threadId)}/${encodeURIComponent(question.inputId)}`, { method: "POST", body: { answers } });
} catch (error) {
if (error.status !== 404) return sendText(chatId, `Answer failed: ${error.message}`);
await updateDelivery(chatId, (draft) => {
if (draft.pendingQuestion?.inputId !== question.inputId) return false;
draft.pendingQuestion = null;
return true;
});
return sendText(chatId, "这个问题已不在等待中。/ That question is no longer waiting.");
}
await updateDelivery(chatId, (draft) => {
if (draft.pendingQuestion?.inputId !== question.inputId) return false;
draft.pendingQuestion = null;
return true;
});
await sendText(chatId, "已回答 / Answer sent");
}
async function sendStatus(chatId) {
try {
const state = await threadStore.getChat(chatId);
@@ -652,13 +911,21 @@ async function sendStatus(chatId) {
runtimeJson("/v1/runtime/info"),
runtimeJson("/v1/workspace/status"),
]);
const outputs = ownsRecovery(chatId, state) ? state?.turnDelivery?.outputs || [] : [];
const held = outputs.filter((entry) => entry.status === "held").length;
const unconfirmed = outputs.filter((entry) => entry.status === "unconfirmed").length;
const approvals = await reissueApprovalCodes(chatId);
await sendText(
chatId,
[
`user_id=${chatId}`,
state?.pendingAdmission ? "Turn admission is pending; its original request is retained." : "",
state?.activeTurnId && ownsRecovery(chatId, state) ? `accepted_turn=${state.activeTurnId}` : "",
state?.turnDelivery?.outputs?.some((entry) => entry.status === "uncertain") ? "A reply has unconfirmed API acceptance. It is retained for review and will not be resent automatically." : "",
outputs.some((entry) => entry.status === "uncertain") ? "A reply has unconfirmed API acceptance. It is retained for review and will not be resent automatically." : "",
held ? `还有 ${held} 条回复未发送,回复「继续」查看。/ ${held} replies are waiting; reply 继续.` : "",
unconfirmed ? `微信接受了 ${unconfirmed} 条回复但没有返回消息 ID,可能没有显示。/ WeChat accepted ${unconfirmed} replies without a message ID; they may not have been shown.` : "",
ownsRecovery(chatId, state) && state?.turnDelivery?.pendingQuestion ? "Codewhale 正在等你回答上面的问题。/ Codewhale is waiting for your answer to its question." : "",
...approvals,
`runtime=${health.status || "unknown"}`,
`version=${runtimeInfo.version || "unknown"}`,
`bind=${runtimeInfo.bind_host}:${runtimeInfo.port}`,
@@ -746,11 +1013,11 @@ async function interruptActiveTurn(chatId) {
`/v1/threads/${encodeURIComponent(state.threadId)}`
);
const turnId = state.activeTurnId;
if (state.turnDelivery?.accountId !== accountIdentity() || threadStore.turnOrigin(chatId, state.threadId, turnId)?.actorId !== turnActor(chatId)) throw new ApprovalOwnershipError("Turn is not owned by this account and chat");
if (!turnId) {
await sendText(chatId, "No active turn recorded for this chat.");
return;
}
if (state.turnDelivery?.accountId !== accountIdentity() || threadStore.turnOrigin(chatId, state.threadId, turnId)?.actorId !== turnActor(chatId)) throw new ApprovalOwnershipError("Turn is not owned by this account and chat");
await runtimeJson(
`/v1/threads/${encodeURIComponent(state.threadId)}/turns/${encodeURIComponent(turnId)}/interrupt`,
{ method: "POST" }
@@ -860,11 +1127,69 @@ async function saveSyncBuf(stateDir, buf) {
await writeFileDurable(p, buf, { mode: 0o600 });
}
const refusedSenders = new Set();
async function refuseSender(msg) {
const fromUser = msg.from_user_id;
if (!fromUser || !msg.context_token || refusedSenders.has(fromUser) || refusedSenders.size >= 100 || sessionPaused()) return;
refusedSenders.add(fromUser);
console.warn(`Refused a chat from an unlisted WeChat user (user_id=${fromUser}).`);
try {
await sendMessage({
baseUrl: botAccount.baseUrl,
token: botAccount.token,
body: { msg: { to_user_id: fromUser, client_id: crypto.randomUUID(), message_type: 2, message_state: 2,
item_list: [{ type: 1, text_item: { text: `此微信未获授权使用这个 Codewhale。/ This WeChat is not allowed to use this Codewhale. (user_id=${fromUser})` } }],
context_token: msg.context_token } },
});
} catch (error) {
if (error.sessionExpired) pauseSession("sendmessage");
}
}
async function settleHeldReplies(chatId, text) {
const state = await threadStore.getChat(chatId);
const delivery = state?.turnDelivery;
if (!delivery || !ownsRecovery(chatId, state) || !delivery.outputs.some((entry) => entry.status === "held")) return "none";
if (isContinueReply(text)) {
await updateDelivery(chatId, (draft) => { draft.releaseHeld = true; return true; });
startChatRecovery(chatId);
return "consumed";
}
const command = commandAction(parseWeixinCommand(text));
if (!delivery.terminal || command.kind !== "prompt" || parseApprovalReply(text)) {
await updateDelivery(chatId, (draft) => { draft.releaseHeld = true; return true; });
startChatRecovery(chatId);
return "released";
}
const seen = delivery.outputs.some((entry) => entry.status === "accepted" || entry.status === "unconfirmed");
if (seen) {
const settled = await updateDelivery(chatId, (draft) => {
for (const entry of draft.outputs) if (entry.status === "held") entry.status = "skipped";
return true;
});
if (settled?.outputs.every((entry) => SETTLED_OUTPUTS.has(entry.status))) await threadStore.patchChat(chatId, { activeTurnId: null });
return "skipped";
}
await updateDelivery(chatId, (draft) => {
const index = draft.outputs.findIndex((entry) => entry.status === "held");
const key = `waiting:${crypto.randomUUID()}`;
draft.outputs.splice(index, 0, { key: `${key}:0`, status: "held",
text: "你有一条之前的回复(下一条)。你刚才的消息还没有发给 Codewhale,看完后请重新发送。/ You have an earlier answer (next message). Your new message was not run yet; send it again after reading.",
clientId: crypto.createHash("sha256").update(`${draft.accountId}:${draft.turnId}:${key}`).digest("hex") });
draft.releaseHeld = true;
return true;
});
startChatRecovery(chatId);
return "consumed";
}
/** One durably claimed inbound message; agent observation runs separately. */
async function handleInbound(msg) {
const fromUser = msg.from_user_id || "";
if (msg.message_type !== undefined && msg.message_type !== 1) return;
if (!isAllowed(fromUser)) return;
if (!isAllowed(fromUser)) return refuseSender(msg);
await threadStore.patchChat(fromUser, { replyWindow: { since: new Date().toISOString(), sends: 0 } });
const text = extractText(msg.item_list);
if (msg.context_token) {
const state = await threadStore.getChat(fromUser);
@@ -879,8 +1204,16 @@ async function handleInbound(msg) {
});
}
}
if (!text) return sendText(fromUser, "仅支持文本消息。图片/语音/视频/文件暂不支持。");
await handleCommand(fromUser, parseCommand(text), {
if (!text) return sendText(fromUser, "仅支持文本和语音消息。图片/视频/文件暂不支持。/ Only text and voice messages are supported.");
if ((await settleHeldReplies(fromUser, text)) === "consumed") return;
const approvalReply = parseApprovalReply(text);
if (approvalReply) return decideByCode(fromUser, approvalReply);
const command = parseWeixinCommand(text);
const state = await threadStore.getChat(fromUser);
if (command.name === "prompt" && state?.turnDelivery?.pendingQuestion && !state.turnDelivery.terminal && ownsRecovery(fromUser, state)) {
return answerQuestion(fromUser, text);
}
await handleCommand(fromUser, command, {
messageKey: inboundKey(msg),
contextToken: msg.context_token,
});
@@ -895,6 +1228,10 @@ async function monitorLoop() {
console.log(`Monitor started: baseUrl=${baseUrl} timeoutMs=${nextTimeoutMs}`);
while (!stopping) {
if (sessionPaused()) {
await sleep(Math.min(5000, sessionPausedUntil - Date.now()));
continue;
}
try {
const abortController = new AbortController();
const timer = setTimeout(
@@ -921,6 +1258,12 @@ async function monitorLoop() {
(resp.ret !== undefined && resp.ret !== 0) ||
(resp.errcode !== undefined && resp.errcode !== 0);
if (isApiError && isSessionExpired(resp)) {
pauseSession("getupdates");
consecutiveFailures = 0;
continue;
}
if (isApiError) {
consecutiveFailures += 1;
console.error(
@@ -953,7 +1296,7 @@ async function monitorLoop() {
const state = await threadStore.getChat(msg.from_user_id);
if (state?.pendingAdmission?.messageKey === key || state?.turnDelivery?.messageKey === key) {
startChatRecovery(msg.from_user_id);
} else if (commandAction(parseCommand(extractText(msg.item_list))).kind === "prompt") {
} else if (commandAction(parseWeixinCommand(extractText(msg.item_list))).kind === "prompt" && !parseApprovalReply(extractText(msg.item_list))) {
// Prompt admission always persists before side effects. No receipt
// means this claim crashed before admission, so it can be handled.
await handleInbound(msg);
@@ -993,9 +1336,44 @@ async function monitorLoop() {
}
function isAllowed(fromUser) {
if (!fromUser) return false;
if (config.allowUnlisted) return true;
const allowed = new Set(config.allowlist);
return allowed.has(fromUser);
if (config.allowlist.length) return config.allowlist.includes(fromUser);
return Boolean(botAccount?.userId) && fromUser === botAccount.userId;
}
function processAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error.code === "EPERM";
}
}
async function acquireStateLock(stateDir) {
const lockPath = path.join(stateDir, "bridge.lock");
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const handle = await fs.open(lockPath, "wx", 0o600);
await handle.writeFile(String(process.pid));
await handle.close();
process.once("exit", () => {
try {
if (fsSync.readFileSync(lockPath, "utf8").trim() === String(process.pid)) fsSync.rmSync(lockPath, { force: true });
} catch {}
});
return;
} catch (error) {
if (error.code !== "EEXIST") throw error;
const owner = Number((await fs.readFile(lockPath, "utf8").catch(() => "")).trim());
if (owner && owner !== process.pid && processAlive(owner)) {
throw new Error(`Another Weixin bridge (pid ${owner}) is using ${stateDir}. One state directory serves one bridge; stop it first.`);
}
await fs.rm(lockPath, { force: true });
}
}
throw new Error(`Could not lock the state directory ${stateDir}.`);
}
function sleep(ms) {
@@ -1031,6 +1409,13 @@ async function main() {
);
process.exit(1);
}
try {
await fs.mkdir(config.stateDir, { recursive: true, mode: 0o700 });
await acquireStateLock(config.stateDir);
} catch (error) {
console.error(error.message);
process.exit(1);
}
threadStore = await CoreThreadStore.open(config.threadMapPath, { messageLimit: 500, privateMode: true });
// 尝试加载已有账号
@@ -1087,7 +1472,9 @@ async function main() {
baseUrl: botAccount.baseUrl,
token: botAccount.token,
});
if (startResp.ret && startResp.ret !== 0) {
if (isSessionExpired(startResp)) {
pauseSession("notifystart");
} else if (startResp.ret && startResp.ret !== 0) {
console.warn(`notifyStart: ret=${startResp.ret} errmsg=${startResp.errmsg}`);
} else {
console.log("notifyStart: OK");
@@ -1101,9 +1488,9 @@ async function main() {
process.once("SIGTERM", shutdown);
if (!config.allowlist.length && !config.allowUnlisted) {
console.log(
"No allowlist configured. Incoming chats will receive their user IDs and be refused."
);
console.log(botAccount.userId
? `No allowlist configured; only the WeChat user who scanned the login QR (user_id=${botAccount.userId}) may use this bridge. Other chats are refused once with their user ID.`
: "No allowlist configured and the saved login has no scanner ID. Incoming chats are refused once with their user ID; set WEIXIN_CHAT_ALLOWLIST or scan a new QR code.");
}
// 进入长轮询循环
@@ -1117,6 +1504,7 @@ async function shutdown() {
stopping = true;
console.log("Shutting down...");
for (const controller of recoveryControllers.values()) controller.abort();
for (const chatId of [...typingStates.keys()]) stopTyping(chatId);
if (botAccount?.token) {
try {
+103 -6
View File
@@ -110,21 +110,112 @@ export function extractText(itemList) {
export function helpText() {
return [
"CodeWhale Weixin Bot 命令:",
"/help - 显示此帮助",
"/status - 运行时和工作区状态",
"/threads - 最近运行时线程",
"/new - 为此聊天创建新线程",
"/help (/帮助) - 显示此帮助",
"/status (/状态) - 运行时和工作区状态,并重新发出待批准的批准码",
"/threads (/对话) - 最近运行时线程",
"/new (/新对话) - 为此聊天创建新线程",
"/resume <thread_id> - 绑定到已有线程",
"/model <name|default> - 设置或重置此聊天的模型",
"/interrupt - 中断活跃 turn",
"/stop (/interrupt, /停止) - 中断活跃 turn",
"/compact - 压缩当前线程",
"1 <批准码> / 2 <批准码> - 允许或拒绝工具调用",
"/allow <approval_id> [remember] - 批准工具调用",
"/deny <approval_id> - 拒绝工具调用",
"继续 - 查看剩余的回复",
"",
"其他所有内容均作为 CodeWhale 提示发送。",
].join("\n");
}
const COMMAND_ALIASES = { 帮助: "help", 状态: "status", 对话: "threads", 新对话: "new", 停止: "interrupt", stop: "interrupt" };
export function parseWeixinCommand(text) {
const command = parseCommand(text);
const alias = COMMAND_ALIASES[command.name];
return alias ? { ...command, name: alias } : command;
}
const CONTINUE_REPLIES = new Set(["继续", "more", "continue"]);
export function isContinueReply(text) {
return CONTINUE_REPLIES.has(String(text || "").trim().toLowerCase());
}
const CODE_ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz";
const APPROVAL_REPLY = /^(1|2|y|n|yes|no|允许|同意|拒绝)\s+([23456789a-hjkmnp-z]{6})$/u;
const ALLOW_WORDS = new Set(["1", "y", "yes", "允许", "同意"]);
export function mintCode(length = 6) {
return Array.from(crypto.randomBytes(length), (byte) => CODE_ALPHABET[byte % CODE_ALPHABET.length]).join("");
}
export function parseApprovalReply(text) {
const match = String(text || "").trim().toLowerCase().match(APPROVAL_REPLY);
if (!match) return null;
return { decision: ALLOW_WORDS.has(match[1]) ? "allow" : "deny", code: match[2] };
}
function clip(text, max) {
const value = String(text || "").trim();
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
}
export function approvalText(approval, code) {
const what = [approval.tool_name, approval.intent_summary || approval.summary || approval.description].filter(Boolean).join(": ");
return [
"需要你的批准 / Approval needed",
clip(what, 400),
`回复 1 ${code} 允许,2 ${code} 拒绝`,
`Reply 1 ${code} to allow, 2 ${code} to deny`,
].filter(Boolean).join("\n");
}
export function questionText(request) {
const questions = Array.isArray(request?.questions) ? request.questions : [];
const lines = ["需要你回答 / Codewhale is asking"];
for (const [index, question] of questions.entries()) {
const prefix = questions.length > 1 ? `${index + 1}. ` : "";
lines.push(`${prefix}${question.header ? `${question.header}: ` : ""}${question.question || ""}`);
for (const [optionIndex, option] of (question.options || []).entries()) {
lines.push(` ${optionIndex + 1}) ${option.label}${option.description ? ` - ${clip(option.description, 120)}` : ""}`);
}
}
lines.push(questions.length > 1
? "每行回答一个问题,可回复选项编号或文字。/ Answer one question per line, by option number or in words."
: "回复选项编号或文字。/ Reply with an option number or in words.");
return lines.join("\n");
}
export function parseQuestionAnswers(request, text) {
const questions = Array.isArray(request?.questions) ? request.questions : [];
if (!questions.length) return null;
const replies = questions.length > 1
? String(text || "").split(/\n+/).map((line) => line.trim()).filter(Boolean)
: [String(text || "").trim()];
if (replies.length !== questions.length || replies.some((reply) => !reply)) return null;
const answers = [];
for (const [index, question] of questions.entries()) {
const reply = replies[index];
const options = question.options || [];
const picks = question.multi_select ? reply.split(/[\s,,、]+/u).filter(Boolean) : [reply];
const chosen = picks.every((pick) => /^\d+$/.test(pick) && Number(pick) >= 1 && Number(pick) <= options.length)
? picks.map((pick) => options[Number(pick) - 1])
: options.filter((option) => option.label.toLowerCase() === reply.toLowerCase());
if (chosen.length) {
for (const option of chosen) answers.push({ id: question.id, label: option.label, value: option.label });
} else if (question.allow_free_text || !options.length) {
answers.push({ id: question.id, label: "Other", value: reply });
} else {
return null;
}
}
return answers;
}
export function isSessionExpired(response) {
return response?.ret === -14 || response?.errcode === -14;
}
// ============================================================================
// iLink Bot HTTP API 调用
// ============================================================================
@@ -339,7 +430,11 @@ export async function sendMessage({ baseUrl, token, body, timeoutMs }) {
}
const failure = statusFields.find((field) => response[field] !== 0);
if (failure) {
throw Object.assign(new Error(`iLink sendMessage rejected: ${failure}=${response[failure]}`), { deliveryStatus: "rejected" });
throw Object.assign(new Error(`iLink sendMessage rejected: ${failure}=${response[failure]}`), {
deliveryStatus: "rejected",
sessionExpired: isSessionExpired(response),
rateLimited: typeof response.errmsg === "string" && /rate.?limit/i.test(response.errmsg),
});
}
return response;
}
@@ -358,6 +453,7 @@ export async function sendTyping({ baseUrl, token, ilinkUserId, typingTicket, st
base_info: { bot_agent: "CodeWhale/1.0" },
}),
token,
timeoutMs: 10_000,
});
}
@@ -374,6 +470,7 @@ export async function getConfig({ baseUrl, token, ilinkUserId, contextToken }) {
base_info: { bot_agent: "CodeWhale/1.0" },
}),
token,
timeoutMs: 10_000,
});
return JSON.parse(raw);
}
@@ -34,7 +34,7 @@ async function fixture(t, options = {}) {
if (url.pathname.endsWith("/sendmessage")) {
state.sent.push(body.msg);
if (options.send) return options.send(res, body.msg, state, json);
return json(res, { ret: 0 });
return json(res, { ret: 0, message_id: String(state.sent.length) });
}
if (url.pathname.includes("/msg/notify")) return json(res, { ret: 0 });
if (url.pathname === "/v1/runtime/info") return json(res, { version: "fixture", capabilities: options.capabilities ?? { turn_operation_idempotency: true, turn_operation_lookup: true } });
@@ -109,8 +109,9 @@ test("production process keeps polling and owner approvals live, dedupes real me
const polls = f.polls;
f.approvals = [{ id: "approval-1", turn_id: "turn-1", tool_name: "fixture-tool" }];
f.event("approval.required", f.approvals[0]);
await until(() => f.sent.some((msg) => msg.item_list[0].text_item.text.includes("approval_id=approval-1")), "approval notice missing");
f.batches.push([incoming(0, "private original prompt"), incoming(2, "/allow approval-1", "bob"), incoming(3, "/allow approval-1")]);
await until(() => f.sent.some((msg) => msg.item_list[0].text_item.text.includes("fixture-tool")), "approval notice missing");
const code = f.sent.find((msg) => msg.item_list[0].text_item.text.includes("fixture-tool")).item_list[0].text_item.text.match(/Reply 1 ([a-z2-9]{6}) to allow/)[1];
f.batches.push([incoming(0, "private original prompt"), incoming(2, "/allow approval-1", "bob"), incoming(3, `1 ${code}`)]);
await until(() => f.decisions.length === 1 && f.polls > polls, "initiating owner could not approve or polling stopped during stream");
assert.equal(f.posts.length, 1); assert.ok(f.polls > polls); assert.deepEqual(f.decisions, [{ decision: "allow", remember: false }]);
f.event("item.delta", { kind: "agent_message", delta: "answer" }); f.complete("answer");
@@ -138,12 +139,15 @@ test("ambiguous admission uses stable operation lookup rather than a duplicate P
assert.equal((await f.disk()).chats.alice.activeTurnId, "turn-1");
});
test("proven rejection retries a stable output ID; uncertain send is retained without resending", async (t) => {
test("proven rejection holds the output until the member asks to continue; uncertain send is retained without resending", async (t) => {
const f = await fixture(t, { send: (response, msg, state, json) => {
if (state.sent.length === 1) return json(response, { ret: -14 });
if (state.sent.length === 1) return json(response, { ret: -2 });
return response.destroy();
} }); f.batches.push([incoming(1, "work")]); f.start();
await until(() => f.streams.size, "stream missing"); f.event("item.delta", { kind: "agent_message", delta: "retained answer" }); f.complete("retained answer");
await until(async () => (await f.disk()).chats?.alice?.turnDelivery?.outputs?.[0]?.status === "held", "rejected send was not held");
await delay(1200); assert.equal(f.sent.length, 1);
f.batches.push([incoming(2, "继续")]);
await until(async () => (await f.disk()).chats?.alice?.turnDelivery?.outputs?.[0]?.status === "uncertain", "uncertain send was discarded");
assert.equal(f.sent.length, 2); assert.equal(f.sent[0].client_id, f.sent[1].client_id);
await delay(1200); assert.equal(f.sent.length, 2); assert.equal((await f.disk()).chats.alice.activeTurnId, "turn-1");