mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
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:
1 parent
aad152549f
commit
1f8d4bea1a
7 files changed
+634
-120
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
# 长轮询配置
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user