wezard 2.4.1 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -1
- package/README.md +113 -57
- package/cli/wezard.sh +2 -0
- package/config.example.jsonc +7 -4
- package/dist/cli/wx.js +78 -0
- package/dist/cli/wx.js.map +1 -0
- package/dist/daemon/aibot-media.js +62 -0
- package/dist/daemon/aibot-media.js.map +1 -0
- package/dist/daemon/approval.js +11 -7
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/ask.js +3 -2
- package/dist/daemon/ask.js.map +1 -1
- package/dist/daemon/cc-session.js +27 -4
- package/dist/daemon/cc-session.js.map +1 -1
- package/dist/daemon/chat-log.js +2 -2
- package/dist/daemon/chat-log.js.map +1 -1
- package/dist/daemon/chat-name.js +6 -2
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/config-api.js +5 -6
- package/dist/daemon/config-api.js.map +1 -1
- package/dist/daemon/direction.js +53 -0
- package/dist/daemon/direction.js.map +1 -0
- package/dist/daemon/ilink.js +162 -0
- package/dist/daemon/ilink.js.map +1 -0
- package/dist/daemon/inbound.js +57 -14
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +297 -65
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +4 -3
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/last-response.js +6 -3
- package/dist/daemon/last-response.js.map +1 -1
- package/dist/daemon/media.js +108 -0
- package/dist/daemon/media.js.map +1 -0
- package/dist/daemon/mirror-bridge.js +109 -92
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/model-select.js +159 -54
- package/dist/daemon/model-select.js.map +1 -1
- package/dist/daemon/notices.js +35 -15
- package/dist/daemon/notices.js.map +1 -1
- package/dist/daemon/outbound.js +2 -1
- package/dist/daemon/outbound.js.map +1 -1
- package/dist/daemon/peers.js +29 -23
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/pending-items.js +1 -1
- package/dist/daemon/pending-items.js.map +1 -1
- package/dist/daemon/reach.js +6 -0
- package/dist/daemon/reach.js.map +1 -0
- package/dist/daemon/receipts.js +145 -3
- package/dist/daemon/receipts.js.map +1 -1
- package/dist/daemon/route.js +11 -4
- package/dist/daemon/route.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +157 -60
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/steward-guard.js +48 -7
- package/dist/daemon/steward-guard.js.map +1 -1
- package/dist/daemon/weixin-cmd.js +268 -0
- package/dist/daemon/weixin-cmd.js.map +1 -0
- package/dist/daemon/weixin-port.js +226 -0
- package/dist/daemon/weixin-port.js.map +1 -0
- package/dist/daemon/weixin.js +772 -0
- package/dist/daemon/weixin.js.map +1 -0
- package/dist/daemon/wizard.js +47 -19
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +43 -23
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +59 -28
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +9 -1
- package/dist/shared/chat-render.js.map +1 -1
- package/dist/shared/chat-view.js +16 -2
- package/dist/shared/chat-view.js.map +1 -1
- package/dist/shared/cli-backends.js +3 -0
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/config.js +35 -10
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/json-map-store.js +1 -0
- package/dist/shared/json-map-store.js.map +1 -1
- package/dist/shared/reminder.js +1 -0
- package/dist/shared/reminder.js.map +1 -1
- package/dist/shared/role-render.js +4 -3
- package/dist/shared/role-render.js.map +1 -1
- package/dist/shared/role-view.js +97 -62
- package/dist/shared/role-view.js.map +1 -1
- package/dist/shared/seen-log.js +26 -0
- package/dist/shared/seen-log.js.map +1 -0
- package/dist/shared/session-label.js +84 -31
- package/dist/shared/session-label.js.map +1 -1
- package/dist/shared/turn-state.js +1 -1
- package/dist/shared/turn-state.js.map +1 -1
- package/dist/shared/world.js.map +1 -1
- package/dist/shared/wx-text.js +101 -0
- package/dist/shared/wx-text.js.map +1 -0
- package/dist/svr/index.js +5 -2
- package/dist/svr/index.js.map +1 -1
- package/package.json +6 -2
- package/web/chat.css +26 -5
- package/web/chat.js +128 -63
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
// 微信 ClawBot 通道: 账号表 + 每账号一路长轮询 + 出站账本 + 扫码绑定。
|
|
2
|
+
//
|
|
3
|
+
// 每个扫码绑定的微信用户 = 一个群聊 `chat:wx_…` (chatIdOfUser), 与企微群聊同构 ——
|
|
4
|
+
// 这里只管「微信那头」: 收到的消息交给 onInbound (weixin-port 把它翻成 aibot 帧), 发出去
|
|
5
|
+
// 的文本进账本。账本存在是因为送达约束很硬 (社区实测, 互相矛盾): 没有新鲜的 context_token
|
|
6
|
+
// 发不出去, 它只随人说话刷新; 一份 token 之后能发的条数有限; ~8 分钟 4 条就被频控半小时。
|
|
7
|
+
// 所以出站一律先进 outbox, 由 pump 按「额度 / 节流 / 暂停」决定何时合并发出; 发不出的压着,
|
|
8
|
+
// 人下一次开口时先取回 —— 宁可晚到, 不丢。
|
|
9
|
+
//
|
|
10
|
+
// 账号间互不牵连: 各自一条轮询链、各自的 outbox / 暂停; 一个号 -14 掉线只歇它自己 (一小时后再试)。
|
|
11
|
+
// 出站一段发成功了才从 outbox 摘掉: reload / 掉线打断在半途, 没发的还在盘上。
|
|
12
|
+
import { chmodSync, readFileSync } from "node:fs";
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
import { loadJsonMap } from "../shared/json-map-store.js";
|
|
15
|
+
import { expandHome } from "../shared/paths.js";
|
|
16
|
+
import { chatIdOfUser, chunkText, nickOf } from "../shared/wx-text.js";
|
|
17
|
+
import { reheadFor } from "../shared/session-label.js";
|
|
18
|
+
import { ILINK_BASE, LONG_POLL_MS, STALE_TOKEN, ITEM, getBotQrcode, getQrcodeStatus, getUpdates, sendText, sendItems, getTypingTicket, sendTyping, notifyLifecycle, mintClientId, cdnDownloadUrl, downloadCdn, parseAesKey, MEDIA_TYPE, getUploadUrl, uploadCdn, cdnUploadUrl, encryptEcb, md5, } from "./ilink.js";
|
|
19
|
+
/** 微信侧出站上限: 服务端未公开, 取官方插件单媒体缓存上限。 */
|
|
20
|
+
export const WX_MEDIA_MAX = 25 * 1024 * 1024;
|
|
21
|
+
const SEP = "\n\n— — —\n\n";
|
|
22
|
+
const TYPING_TICKET_MS = 24 * 3600_000;
|
|
23
|
+
const TYPING_EVERY_MS = 5_000;
|
|
24
|
+
const LOGIN_DEADLINE_MS = 8 * 60_000;
|
|
25
|
+
const MAX_QR_REFRESH = 3;
|
|
26
|
+
const SEEN_MAX = 500;
|
|
27
|
+
/** -14 后整号歇多久再试 (官方插件同款)。 */
|
|
28
|
+
const STALE_PAUSE_MS = 3600_000;
|
|
29
|
+
/** outbox 上限: 掉线 / 久没说话时出站一直往里堆, 满了先丢最老的正文 (卡片留着)。 */
|
|
30
|
+
const OUTBOX_MAX = 50;
|
|
31
|
+
/** 第几次被拒收 (换了新 token 之后仍拒) 就丢掉队头那一段。 */
|
|
32
|
+
const REJECT_DROP = 2;
|
|
33
|
+
const LAST_SLOT = "\n\n📭 这一轮额度用完, 之后的消息回任意一句取回";
|
|
34
|
+
let outSeq = 0;
|
|
35
|
+
const outId = () => `o${Date.now().toString(36)}${(outSeq++).toString(36)}`;
|
|
36
|
+
export const outItem = (text, extra = {}) => ({ id: outId(), text, at: Date.now(), ...extra });
|
|
37
|
+
const sleep = (ms, signal) => new Promise((res) => {
|
|
38
|
+
const t = setTimeout(res, ms);
|
|
39
|
+
signal?.addEventListener("abort", () => { clearTimeout(t); res(); }, { once: true });
|
|
40
|
+
});
|
|
41
|
+
const redact = (t) => (t.length > 8 ? `${t.slice(0, 4)}…${t.slice(-2)}` : "***");
|
|
42
|
+
/** 这一刻能发什么: 无 token / 过期 → 压着; 暂停中 / 未到间隔 → 等; 额度内合并发, 用到最后一格就在那条挂提示, 超了留尾巴。 */
|
|
43
|
+
export const planFlush = (a, now, w) => {
|
|
44
|
+
if (a.outbox.length === 0)
|
|
45
|
+
return { kind: "hold", why: "empty" };
|
|
46
|
+
if (a.pausedUntil && a.pausedUntil > now)
|
|
47
|
+
return { kind: "wait", ms: a.pausedUntil - now };
|
|
48
|
+
if (!a.ctx || now - a.ctx.at > w.ctxTtlSec * 1000)
|
|
49
|
+
return { kind: "hold", why: "stale" };
|
|
50
|
+
const budget = w.sendCap - a.ctx.used;
|
|
51
|
+
if (budget <= 0)
|
|
52
|
+
return { kind: "hold", why: "cap" };
|
|
53
|
+
const gap = (a.lastOutAt ?? 0) + w.minGapSec * 1000 - now;
|
|
54
|
+
if (gap > 0)
|
|
55
|
+
return { kind: "wait", ms: gap };
|
|
56
|
+
const head = a.outbox[0];
|
|
57
|
+
if (head.cid)
|
|
58
|
+
return { kind: "send", chunks: [{ text: head.text, cid: head.cid }], take: [head.id], rest: [] };
|
|
59
|
+
// 卡片插队: 等人点的东西不能排在一长段正文后面被额度挡住。
|
|
60
|
+
const ordered = [...a.outbox.filter((o) => o.card), ...a.outbox.filter((o) => !o.card)];
|
|
61
|
+
const texts = chunkText(ordered.map((o) => o.text).join(SEP));
|
|
62
|
+
const take = a.outbox.map((o) => o.id);
|
|
63
|
+
const hint = (xs, tail) => [...xs.slice(0, -1), `${xs[xs.length - 1]}${tail}`].map((text) => ({ text }));
|
|
64
|
+
if (texts.length < budget)
|
|
65
|
+
return { kind: "send", chunks: texts.map((text) => ({ text })), take, rest: [] };
|
|
66
|
+
if (texts.length === budget)
|
|
67
|
+
return { kind: "send", chunks: hint(texts, LAST_SLOT), take, rest: [] };
|
|
68
|
+
const left = texts.slice(budget);
|
|
69
|
+
return {
|
|
70
|
+
kind: "send",
|
|
71
|
+
chunks: hint(texts.slice(0, budget), `\n\n📬 还有 ${left.length} 段没发出 (微信限额), 回任意一句取回`),
|
|
72
|
+
take,
|
|
73
|
+
rest: [{ id: `${take[0]}r`, text: left.join("\n\n"), at: now }],
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
/** sendmessage 失败怎么办: 掉线 / 频控 / 网络 (重试) / 拒收 (这一条本身不对, 或 context_token 失效)。 */
|
|
77
|
+
export const classifySendFail = (r) => r.ret === STALE_TOKEN || r.errcode === STALE_TOKEN ? "dead"
|
|
78
|
+
: r.status === 429 || /rate/i.test(r.errmsg ?? "") ? "rate"
|
|
79
|
+
: r.net ? "net"
|
|
80
|
+
: "bad";
|
|
81
|
+
/** outbox 超上限: 先丢最老的正文, 卡片 (等人点的) 留到最后。 */
|
|
82
|
+
export const trimOutbox = (xs, max = OUTBOX_MAX) => {
|
|
83
|
+
if (xs.length <= max)
|
|
84
|
+
return xs;
|
|
85
|
+
const i = Math.max(0, xs.findIndex((o) => !o.card));
|
|
86
|
+
return trimOutbox([...xs.slice(0, i), ...xs.slice(i + 1)], max);
|
|
87
|
+
};
|
|
88
|
+
// ── 入站翻译 (纯) ────────────────────────────────────────────────────
|
|
89
|
+
const mediaRef = (m, hexKey, name) => m && (m.full_url || m.encrypt_query_param)
|
|
90
|
+
? `wxcdn:${Buffer.from(JSON.stringify({ m, k: hexKey ?? "", n: name ?? "" })).toString("base64url")}`
|
|
91
|
+
: undefined;
|
|
92
|
+
const itemText = (it) => !it ? ""
|
|
93
|
+
: it.type === ITEM.text ? it.text_item?.text ?? ""
|
|
94
|
+
: it.type === ITEM.voice ? it.voice_item?.text ? `[语音] ${it.voice_item.text}` : "[语音]"
|
|
95
|
+
: it.type === ITEM.image ? "[图片]"
|
|
96
|
+
: it.type === ITEM.file ? `[文件] ${it.file_item?.file_name ?? ""}`.trim()
|
|
97
|
+
: it.type === ITEM.video ? "[视频]"
|
|
98
|
+
: "";
|
|
99
|
+
export const normalizeInbound = (chatId, m, now) => {
|
|
100
|
+
const items = m.item_list ?? [];
|
|
101
|
+
const texts = items.flatMap((it) => it.type === ITEM.text ? [it.text_item?.text ?? ""]
|
|
102
|
+
: it.type === ITEM.voice ? [it.voice_item?.text ? it.voice_item.text : "[语音, 未转写]"]
|
|
103
|
+
: []).filter((t) => t.trim() !== "");
|
|
104
|
+
const images = items.flatMap((it) => (it.type === ITEM.image ? [mediaRef(it.image_item?.media, it.image_item?.aeskey)] : [])).filter((x) => !!x);
|
|
105
|
+
const files = items.flatMap((it) => {
|
|
106
|
+
const ref = it.type === ITEM.file ? mediaRef(it.file_item?.media, undefined, it.file_item?.file_name) : it.type === ITEM.video ? mediaRef(it.video_item?.media, undefined, "video.mp4") : undefined;
|
|
107
|
+
return ref ? [{ ref, name: it.type === ITEM.file ? it.file_item?.file_name ?? "file" : "video.mp4", kind: it.type === ITEM.file ? "file" : "video" }] : [];
|
|
108
|
+
});
|
|
109
|
+
const ref = items.find((it) => it.ref_msg)?.ref_msg;
|
|
110
|
+
const quote = ref ? (ref.title || itemText(ref.message_item)).trim() : "";
|
|
111
|
+
return {
|
|
112
|
+
chatId,
|
|
113
|
+
msgId: `wx${m.message_id ?? m.seq ?? now}`,
|
|
114
|
+
at: m.create_time_ms ?? now,
|
|
115
|
+
text: texts.join("\n"),
|
|
116
|
+
images,
|
|
117
|
+
files,
|
|
118
|
+
...(quote ? { quote } : {}),
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
// ── 服务 ─────────────────────────────────────────────────────────────
|
|
122
|
+
export const startWeixin = (deps) => {
|
|
123
|
+
const { cfg, log, onBound, notifyWecom } = deps;
|
|
124
|
+
const w = () => cfg.weixin;
|
|
125
|
+
const file = expandHome(w().stateFile);
|
|
126
|
+
// 凭据在里面: 每次写完收紧权限 (json-map-store 自己不管 mode)。
|
|
127
|
+
const db = loadJsonMap(w().stateFile);
|
|
128
|
+
const tighten = () => { try {
|
|
129
|
+
chmodSync(file, 0o600);
|
|
130
|
+
}
|
|
131
|
+
catch { /* 文件还没有 */ } };
|
|
132
|
+
tighten();
|
|
133
|
+
const save = (a) => { db.set(a.botId, a); tighten(); return a; };
|
|
134
|
+
const all = () => Object.values(db.all());
|
|
135
|
+
const byChat = (chatId) => all().find((a) => a.chatId === chatId);
|
|
136
|
+
const patch = (botId, f) => {
|
|
137
|
+
const a = db.get(botId);
|
|
138
|
+
return a ? save({ ...a, ...f(a) }) : undefined;
|
|
139
|
+
};
|
|
140
|
+
let inbound = () => undefined;
|
|
141
|
+
const polls = new Map();
|
|
142
|
+
const seen = new Map();
|
|
143
|
+
const pumps = new Map();
|
|
144
|
+
// 上个进程压下的 outbox 条目 id (init 末尾填, 见那里); 填之前 flush 不重写。
|
|
145
|
+
let legacy;
|
|
146
|
+
const reheadLegacy = (botId) => {
|
|
147
|
+
const a = db.get(botId);
|
|
148
|
+
const old = legacy;
|
|
149
|
+
if (!old || !a?.outbox.some((o) => old.has(o.id)))
|
|
150
|
+
return;
|
|
151
|
+
const dest = `chat:${a.chatId}`;
|
|
152
|
+
patch(botId, (x) => ({ outbox: x.outbox.map((o) => (old.delete(o.id) && !o.card ? { ...o, text: reheadFor(o.text, dest) } : o)) }));
|
|
153
|
+
};
|
|
154
|
+
const timers = new Map();
|
|
155
|
+
const typingState = new Map();
|
|
156
|
+
let stopped = false;
|
|
157
|
+
let started = false;
|
|
158
|
+
const fallbackOf = (a) => w().fallbackChat || (a.boundBy.includes(":") ? a.boundBy : "");
|
|
159
|
+
const tell = (a, md) => {
|
|
160
|
+
const to = fallbackOf(a);
|
|
161
|
+
if (to)
|
|
162
|
+
notifyWecom(to, md);
|
|
163
|
+
};
|
|
164
|
+
const isLive = (a) => !!a && a.state === "live" && w().enabled;
|
|
165
|
+
// 一个号停用 (掉线 / 解绑 / 被重绑替下) 时: 它排着的定时 pump 和「正在输入」状态一并作废 ——
|
|
166
|
+
// 留着的话, 同 botId 重绑后旧的 10 分钟频控定时器会挡住新号的 15 秒间隔。
|
|
167
|
+
const clearBot = (botId) => {
|
|
168
|
+
const t = timers.get(botId);
|
|
169
|
+
if (t)
|
|
170
|
+
clearTimeout(t.t);
|
|
171
|
+
timers.delete(botId);
|
|
172
|
+
typingState.delete(botId);
|
|
173
|
+
};
|
|
174
|
+
// ── 出站 ──
|
|
175
|
+
// -14: token 被拒 (多半是被别处重绑顶替)。不停轮询: 整号歇一小时再试, 成功即复活 (官方插件同款);
|
|
176
|
+
// 只在 live → expired 那一下报企微。
|
|
177
|
+
const expire = (a, why) => {
|
|
178
|
+
const was = db.get(a.botId)?.state;
|
|
179
|
+
clearBot(a.botId);
|
|
180
|
+
const cur = patch(a.botId, () => ({ state: "expired", ctx: undefined, retryAt: Date.now() + STALE_PAUSE_MS }));
|
|
181
|
+
if (!cur || was !== "live")
|
|
182
|
+
return;
|
|
183
|
+
log.warn({ botId: a.botId, chatId: a.chatId, why }, "weixin account expired");
|
|
184
|
+
tell(cur, `📵 微信通道 \`${a.chatId}\` 掉线 (${why})${cur.outbox.length ? `, 压着 ${cur.outbox.length} 条` : ""} —— 每小时自动重试一次; 若是被别处重新绑定顶替了, 发 \`/wx bind\` 重新扫码即可, 群聊与 wizard 都保留`);
|
|
185
|
+
};
|
|
186
|
+
/** 掉线的号重新可用: 轮询醒来接着收, 压着的接着发。`wake=false` = 调用者就是那条轮询本身。 */
|
|
187
|
+
const revive = (botId, why, wake = true) => {
|
|
188
|
+
const cur = patch(botId, () => ({ state: "live", retryAt: undefined }));
|
|
189
|
+
if (!cur)
|
|
190
|
+
return;
|
|
191
|
+
log.info({ botId, chatId: cur.chatId, why }, "weixin account revived");
|
|
192
|
+
if (wake && w().enabled) {
|
|
193
|
+
const c = polls.get(botId);
|
|
194
|
+
if (c)
|
|
195
|
+
c.abort();
|
|
196
|
+
else
|
|
197
|
+
startPoll(cur);
|
|
198
|
+
}
|
|
199
|
+
pump(botId);
|
|
200
|
+
};
|
|
201
|
+
// 同一账号只留一个定时器, 取较早的那一刻: 晚的会在早的那次 pump 里重新算出来。
|
|
202
|
+
const schedule = (botId, ms) => {
|
|
203
|
+
const at = Date.now() + Math.max(50, ms);
|
|
204
|
+
const cur = timers.get(botId);
|
|
205
|
+
if (cur && cur.at <= at)
|
|
206
|
+
return;
|
|
207
|
+
if (cur)
|
|
208
|
+
clearTimeout(cur.t);
|
|
209
|
+
timers.set(botId, { at, t: setTimeout(() => { timers.delete(botId); pump(botId); }, at - Date.now()) });
|
|
210
|
+
};
|
|
211
|
+
// 同一账号的出站串行: 两次 pump 交叠会把同一份 outbox 发两遍。
|
|
212
|
+
const chain = (botId, f) => {
|
|
213
|
+
const next = (pumps.get(botId) ?? Promise.resolve()).then(f).catch((e) => log.error({ botId, err: e.message }, "weixin pump failed"));
|
|
214
|
+
pumps.set(botId, next);
|
|
215
|
+
return next;
|
|
216
|
+
};
|
|
217
|
+
const pump = (botId) => void chain(botId, () => flushOnce(botId));
|
|
218
|
+
const cardTitle = (a) => (a.outbox.find((o) => o.card)?.text.split("\n")[0] ?? "").slice(0, 40);
|
|
219
|
+
const noteHold = (a, why) => {
|
|
220
|
+
if (why === "stale" && !a.staleNoted) {
|
|
221
|
+
patch(a.botId, () => ({ staleNoted: true }));
|
|
222
|
+
tell(a, `📭 微信 \`${a.chatId}\` 那头久没说话, ${a.outbox.length} 条消息发不出去, 压着 —— 在微信里随便说一句就会取回`);
|
|
223
|
+
}
|
|
224
|
+
if (why === "cap") {
|
|
225
|
+
log.info({ botId: a.botId, held: a.outbox.length }, "weixin outbox held (cap)");
|
|
226
|
+
// 微信里那条「额度用完」提示人未必在意; 压着的是等人点的卡, 审批就挂着 —— 企微那头得知道。
|
|
227
|
+
if (a.outbox.some((o) => o.card) && !a.capNoted) {
|
|
228
|
+
patch(a.botId, () => ({ capNoted: true }));
|
|
229
|
+
tell(a, `🃏 微信 \`${a.chatId}\` 这一轮出站额度用完, 压着 ${a.outbox.length} 条, 含待答卡「${cardTitle(a)}」—— 对方在微信里随便说一句就会发出`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
const flushOnce = async (botId) => {
|
|
234
|
+
reheadLegacy(botId);
|
|
235
|
+
const a = db.get(botId);
|
|
236
|
+
if (!isLive(a) || stopped)
|
|
237
|
+
return;
|
|
238
|
+
const plan = planFlush(a, Date.now(), w());
|
|
239
|
+
if (plan.kind === "wait")
|
|
240
|
+
return schedule(botId, plan.ms);
|
|
241
|
+
if (plan.kind === "hold") {
|
|
242
|
+
stopTyping(botId);
|
|
243
|
+
return noteHold(a, plan.why);
|
|
244
|
+
}
|
|
245
|
+
// 发出一段摘一段: 这次拿走的那几条 (mine) 换成「还没发的段 + 超额尾巴」, 发送期间新进来的原样留在后面。
|
|
246
|
+
let mine = new Set(plan.take);
|
|
247
|
+
const keep = (x, left) => [...left, ...x.outbox.filter((o) => !mine.has(o.id))];
|
|
248
|
+
for (const [i, c] of plan.chunks.entries()) {
|
|
249
|
+
const cur = db.get(botId);
|
|
250
|
+
if (stopped || !isLive(cur) || !cur.ctx)
|
|
251
|
+
return;
|
|
252
|
+
const cid = c.cid ?? mintClientId();
|
|
253
|
+
const r = await sendText(cur.baseUrl, cur.botToken, cur.userId, cur.ctx.token, c.text, cid);
|
|
254
|
+
const later = plan.chunks.slice(i + 1).map((x) => outItem(x.text));
|
|
255
|
+
if (r.ok) {
|
|
256
|
+
const left = [...later, ...plan.rest];
|
|
257
|
+
patch(botId, (x) => ({ outbox: keep(x, left), ctx: x.ctx && { ...x.ctx, used: x.ctx.used + 1 }, lastOutAt: Date.now(), sent: (x.sent ?? 0) + 1, rejects: 0 }));
|
|
258
|
+
mine = new Set(left.map((o) => o.id));
|
|
259
|
+
log.info({ botId, len: c.text.length, msgId: r.data?.message_id }, "weixin tx");
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const kind = classifySendFail(r);
|
|
263
|
+
const rejects = kind === "bad" ? (cur.rejects ?? 0) + 1 : 0;
|
|
264
|
+
const drop = rejects >= REJECT_DROP;
|
|
265
|
+
log.warn({ botId, status: r.status, ret: r.ret, errcode: r.errcode, errmsg: r.errmsg, kind, rejects }, "weixin tx failed");
|
|
266
|
+
// 写回: 一段都没发、也不用重发同一个 client_id → outbox 原样不动 (卡片标记留着); 否则换成没发的那几段。
|
|
267
|
+
const untouched = i === 0 && kind !== "net" && !drop;
|
|
268
|
+
const back = [...(drop ? [] : [outItem(c.text, kind === "net" ? { cid } : {})]), ...later, ...plan.rest];
|
|
269
|
+
patch(botId, (x) => ({
|
|
270
|
+
...(untouched ? {} : { outbox: keep(x, back) }),
|
|
271
|
+
failed: (x.failed ?? 0) + 1,
|
|
272
|
+
rejects: drop ? 0 : rejects,
|
|
273
|
+
...(kind === "rate" ? { pausedUntil: Date.now() + w().ratePauseMin * 60_000 } : {}),
|
|
274
|
+
...(kind === "bad" && !drop ? { ctx: undefined, staleNoted: true } : {}),
|
|
275
|
+
}));
|
|
276
|
+
const after = db.get(botId);
|
|
277
|
+
if (kind === "dead")
|
|
278
|
+
return expire(after, `token 失效 ${r.ret ?? r.errcode}`);
|
|
279
|
+
if (kind === "rate") {
|
|
280
|
+
tell(after, `⏸️ 微信 \`${after.chatId}\` 被频控, 暂停出站 ${w().ratePauseMin} 分钟, ${after.outbox.length} 条排队`);
|
|
281
|
+
return schedule(botId, w().ratePauseMin * 60_000);
|
|
282
|
+
}
|
|
283
|
+
if (kind === "net")
|
|
284
|
+
return schedule(botId, 30_000);
|
|
285
|
+
const why = `${r.status && r.status >= 400 ? `HTTP ${r.status} ` : ""}${r.errmsg ?? r.ret ?? r.errcode ?? ""}`.trim();
|
|
286
|
+
if (drop) {
|
|
287
|
+
tell(after, `🗑️ 微信 \`${after.chatId}\` 换了新 token 仍拒收这一段 (${why}), 已丢弃, 后面的照发:\n> ${c.text.slice(0, 120).replace(/\n/g, " ")}`);
|
|
288
|
+
return pump(botId);
|
|
289
|
+
}
|
|
290
|
+
// 第一次拒收: 多半是 context_token 失效, 压着等人再开口; 换了新 token 还拒才丢 (见上)。
|
|
291
|
+
tell(after, `⚠️ 微信 \`${after.chatId}\` 拒收 (${why}), 先当 token 过期压着 ${after.outbox.length} 条 —— 对方再说一句会重试, 再被拒就丢弃那一段`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
stopTyping(botId);
|
|
295
|
+
if (db.get(botId)?.outbox.length)
|
|
296
|
+
pump(botId); // 超额的尾巴 (走 hold 报一次) / 发送期间又进来的
|
|
297
|
+
};
|
|
298
|
+
const send = (chatId, text, opts = {}) => {
|
|
299
|
+
const a = byChat(chatId);
|
|
300
|
+
if (!a || !text.trim())
|
|
301
|
+
return;
|
|
302
|
+
const cur = patch(a.botId, (x) => ({ outbox: trimOutbox([...x.outbox, outItem(text, opts.card ? { card: true } : {})]) }));
|
|
303
|
+
if (cur && cur.outbox.length >= OUTBOX_MAX)
|
|
304
|
+
log.warn({ botId: a.botId, live: isLive(cur) }, "weixin outbox full — oldest text dropped");
|
|
305
|
+
pump(a.botId);
|
|
306
|
+
};
|
|
307
|
+
// ── 正在输入 ──
|
|
308
|
+
// 开 / 关走同一条链: 开的请求还在取 ticket 时来了关, 关必须排在它后面, 否则一直亮着。
|
|
309
|
+
const typingOf = (botId) => typingState.get(botId) ?? (typingState.set(botId, { chain: Promise.resolve() }), typingState.get(botId));
|
|
310
|
+
const typingStep = (botId, f) => {
|
|
311
|
+
const t = typingOf(botId);
|
|
312
|
+
t.chain = t.chain.then(() => {
|
|
313
|
+
const a = db.get(botId);
|
|
314
|
+
return isLive(a) ? f(t, a) : undefined;
|
|
315
|
+
}).catch(() => undefined);
|
|
316
|
+
};
|
|
317
|
+
// 关了就把节流复位: 一段话发出去 (flush 末尾会关) 之后, 下一帧进度要当场把「正在输入」点回来, 不该再等满 5 秒。
|
|
318
|
+
const stopTyping = (botId) => typingStep(botId, async (t, a) => {
|
|
319
|
+
t.lastAt = undefined;
|
|
320
|
+
if (!t.on || !t.ticket)
|
|
321
|
+
return;
|
|
322
|
+
t.on = false;
|
|
323
|
+
await sendTyping(a.baseUrl, a.botToken, a.userId, t.ticket, false);
|
|
324
|
+
});
|
|
325
|
+
const typing = (chatId, on) => {
|
|
326
|
+
const a = byChat(chatId);
|
|
327
|
+
if (!a || !w().typing || !isLive(a))
|
|
328
|
+
return;
|
|
329
|
+
if (!on)
|
|
330
|
+
return stopTyping(a.botId);
|
|
331
|
+
const t = typingOf(a.botId);
|
|
332
|
+
const now = Date.now();
|
|
333
|
+
if (t.lastAt && now - t.lastAt < TYPING_EVERY_MS)
|
|
334
|
+
return;
|
|
335
|
+
t.lastAt = now;
|
|
336
|
+
typingStep(a.botId, async (t, a) => {
|
|
337
|
+
if (!t.ticket || now - (t.ticketAt ?? 0) > TYPING_TICKET_MS) {
|
|
338
|
+
const r = await getTypingTicket(a.baseUrl, a.botToken, a.userId, a.ctx?.token);
|
|
339
|
+
if (!r.ok || !r.data?.typing_ticket)
|
|
340
|
+
return;
|
|
341
|
+
t.ticket = r.data.typing_ticket;
|
|
342
|
+
t.ticketAt = now;
|
|
343
|
+
}
|
|
344
|
+
const r = await sendTyping(a.baseUrl, a.botToken, a.userId, t.ticket, true);
|
|
345
|
+
t.on = r.ok;
|
|
346
|
+
});
|
|
347
|
+
};
|
|
348
|
+
// ── 入站 ──
|
|
349
|
+
const accept = (a, m) => {
|
|
350
|
+
if (m.message_type === 2)
|
|
351
|
+
return; // bot 自己发的回声
|
|
352
|
+
if (m.from_user_id && m.from_user_id !== a.userId) {
|
|
353
|
+
log.warn({ botId: a.botId, from: m.from_user_id }, "weixin rx from a stranger — dropped");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const id = String(m.message_id ?? m.seq ?? "");
|
|
357
|
+
const ids = seen.get(a.botId) ?? new Set();
|
|
358
|
+
if (id && ids.has(id))
|
|
359
|
+
return;
|
|
360
|
+
if (id) {
|
|
361
|
+
ids.add(id);
|
|
362
|
+
if (ids.size > SEEN_MAX)
|
|
363
|
+
ids.delete(ids.values().next().value);
|
|
364
|
+
seen.set(a.botId, ids);
|
|
365
|
+
}
|
|
366
|
+
const now = Date.now();
|
|
367
|
+
// 人一开口: 新 token、额度归零、静默提示复位 —— 然后先把压着的发出去。
|
|
368
|
+
patch(a.botId, () => ({ lastInAt: now, staleNoted: false, capNoted: false, ...(m.context_token ? { ctx: { token: m.context_token, at: now, used: 0 } } : {}) }));
|
|
369
|
+
pump(a.botId);
|
|
370
|
+
const msg = normalizeInbound(a.chatId, m, now);
|
|
371
|
+
// lagMs = 人在手机上发出 → 长轮询交到我们手上 (服务端投递 + 网络), 量「人觉得慢」先看它。
|
|
372
|
+
log.info({ botId: a.botId, chatId: a.chatId, len: msg.text.length, images: msg.images.length, files: msg.files.length, ...(m.create_time_ms ? { lagMs: now - m.create_time_ms } : {}) }, "weixin rx");
|
|
373
|
+
try {
|
|
374
|
+
inbound(msg);
|
|
375
|
+
}
|
|
376
|
+
catch (e) {
|
|
377
|
+
log.error({ err: e.message }, "weixin inbound handler threw");
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
const poll = async (botId) => {
|
|
381
|
+
let fails = 0;
|
|
382
|
+
let timeoutMs = LONG_POLL_MS;
|
|
383
|
+
while (!stopped) {
|
|
384
|
+
const a = db.get(botId);
|
|
385
|
+
if (!a)
|
|
386
|
+
return;
|
|
387
|
+
const ctl = new AbortController();
|
|
388
|
+
polls.set(botId, ctl);
|
|
389
|
+
// 掉线的号歇到 retryAt 再试; 歇着时被 kick (切网) 就再算一遍剩多久, 被 revive 就醒来接着收。
|
|
390
|
+
const rest = a.state === "expired" ? (a.retryAt ?? 0) - Date.now() : 0;
|
|
391
|
+
if (rest > 0) {
|
|
392
|
+
await sleep(rest, ctl.signal);
|
|
393
|
+
if (stopped || polls.get(botId) !== ctl)
|
|
394
|
+
return;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
const r = await getUpdates(a.baseUrl, a.botToken, a.cursor, timeoutMs, ctl.signal);
|
|
398
|
+
if (stopped || polls.get(botId) !== ctl)
|
|
399
|
+
return; // 被 unbind / 重绑替下
|
|
400
|
+
if (ctl.signal.aborted)
|
|
401
|
+
continue; // kick: 立即重连
|
|
402
|
+
if (r.ok) {
|
|
403
|
+
fails = 0;
|
|
404
|
+
timeoutMs = r.data?.longpolling_timeout_ms || LONG_POLL_MS;
|
|
405
|
+
// 歇够了还收得到 = token 又能用了。retryAt 没到 (发送那头刚报 -14) 先不信这一轮。
|
|
406
|
+
const cur = db.get(botId);
|
|
407
|
+
if (cur?.state === "expired" && (cur.retryAt ?? 0) <= Date.now())
|
|
408
|
+
revive(botId, "getupdates ok", false);
|
|
409
|
+
const buf = r.data?.get_updates_buf;
|
|
410
|
+
if (buf)
|
|
411
|
+
patch(botId, () => ({ cursor: buf }));
|
|
412
|
+
for (const m of r.data?.msgs ?? [])
|
|
413
|
+
accept(db.get(botId), m);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (r.ret === STALE_TOKEN || r.errcode === STALE_TOKEN) {
|
|
417
|
+
expire(a, `getupdates ${r.ret ?? r.errcode} ${r.errmsg ?? ""}`.trim());
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
fails += 1;
|
|
421
|
+
// 合盖过夜 / 切网: 永不放弃, 封顶 30s (与 ws.ts 的 MAX_RECONNECT=-1 同一个理由)。
|
|
422
|
+
const backoff = Math.min(30_000, 1000 * 2 ** Math.min(fails, 5));
|
|
423
|
+
log.warn({ botId, fails, backoff, net: r.net, status: r.status, ret: r.ret, errmsg: r.errmsg }, "weixin getupdates failed");
|
|
424
|
+
await sleep(backoff, ctl.signal);
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
const startPoll = (a) => {
|
|
428
|
+
polls.get(a.botId)?.abort();
|
|
429
|
+
polls.delete(a.botId);
|
|
430
|
+
if (a.state === "live")
|
|
431
|
+
void notifyLifecycle(a.baseUrl, a.botToken, true);
|
|
432
|
+
void poll(a.botId).catch((e) => log.error({ botId: a.botId, err: e.message }, "weixin poll crashed"));
|
|
433
|
+
pump(a.botId);
|
|
434
|
+
};
|
|
435
|
+
const logins = new Map();
|
|
436
|
+
const loginDb = loadJsonMap(w().stateFile.replace(/\.json$/, "") + "-logins.json");
|
|
437
|
+
const keep = (l) => {
|
|
438
|
+
if (!l.qrcode)
|
|
439
|
+
return;
|
|
440
|
+
const { id, by, name, to, qrcode, qrUrl, base, refresh, startedAt, status, message } = l;
|
|
441
|
+
loginDb.set(by, { id, by, name, to, qrcode, qrUrl, base, refresh, startedAt, status, message });
|
|
442
|
+
};
|
|
443
|
+
const view = (l) => ({ id: l.id, by: l.by, name: l.name, status: l.status, qrUrl: l.qrUrl, refresh: l.refresh, message: l.message, startedAt: l.startedAt, ...(l.chatId ? { chatId: l.chatId } : {}) });
|
|
444
|
+
const set = (l, p) => {
|
|
445
|
+
if (p.status && p.status !== l.status)
|
|
446
|
+
log.info({ id: l.id, by: l.by, from: l.status, to: p.status, message: p.message }, "weixin bind status");
|
|
447
|
+
Object.assign(l, p);
|
|
448
|
+
if (!l.ctl.signal.aborted)
|
|
449
|
+
keep(l);
|
|
450
|
+
try {
|
|
451
|
+
l.onChange(view(l));
|
|
452
|
+
}
|
|
453
|
+
catch { /* 观察者自己的事 */ }
|
|
454
|
+
};
|
|
455
|
+
const finish = (l, p) => {
|
|
456
|
+
set(l, p);
|
|
457
|
+
l.ctl.abort();
|
|
458
|
+
if (loginDb.get(l.by)?.id === l.id)
|
|
459
|
+
loginDb.drop(l.by);
|
|
460
|
+
// 留 10 分钟给 CLI / rolepage 读终态。
|
|
461
|
+
setTimeout(() => { if (logins.get(l.by) === l)
|
|
462
|
+
logins.delete(l.by); }, 10 * 60_000).unref();
|
|
463
|
+
};
|
|
464
|
+
const confirm = async (l, body) => {
|
|
465
|
+
const userId = body.ilink_user_id ?? "";
|
|
466
|
+
if (!body.bot_token || !body.ilink_bot_id || !userId)
|
|
467
|
+
return finish(l, { status: "failed", message: "确认响应缺字段 (bot_token / ilink_bot_id / ilink_user_id)" });
|
|
468
|
+
const chatId = chatIdOfUser(userId);
|
|
469
|
+
const olds = all().filter((a) => a.userId === userId || a.chatId === chatId);
|
|
470
|
+
if (olds.length === 0 && all().length >= w().maxAccounts) {
|
|
471
|
+
return finish(l, { status: "failed", message: `已绑满 ${w().maxAccounts} 个微信号 (weixin.maxAccounts), 先 /wx unbind 一个` });
|
|
472
|
+
}
|
|
473
|
+
// 同一人重扫: 旧号停掉、删行, 新 bot 接管同一个群 —— 压着的消息带过去。
|
|
474
|
+
const carried = olds.flatMap((o) => o.outbox);
|
|
475
|
+
for (const o of olds) {
|
|
476
|
+
polls.get(o.botId)?.abort();
|
|
477
|
+
polls.delete(o.botId);
|
|
478
|
+
clearBot(o.botId);
|
|
479
|
+
db.drop(o.botId);
|
|
480
|
+
}
|
|
481
|
+
const acct = save({
|
|
482
|
+
botId: body.ilink_bot_id,
|
|
483
|
+
botToken: body.bot_token,
|
|
484
|
+
baseUrl: body.baseurl || w().baseUrl || ILINK_BASE,
|
|
485
|
+
userId,
|
|
486
|
+
chatId,
|
|
487
|
+
boundBy: l.by,
|
|
488
|
+
boundAt: Date.now(),
|
|
489
|
+
cursor: "",
|
|
490
|
+
state: "live",
|
|
491
|
+
outbox: carried,
|
|
492
|
+
});
|
|
493
|
+
log.info({ botId: acct.botId, chatId, user: userId, token: redact(acct.botToken), rebind: olds.length > 0 }, "weixin bound");
|
|
494
|
+
startPoll(acct);
|
|
495
|
+
let name = l.name || nickOf(body);
|
|
496
|
+
try {
|
|
497
|
+
name = await onBound(acct, name, olds.length > 0);
|
|
498
|
+
}
|
|
499
|
+
catch (e) {
|
|
500
|
+
log.error({ err: e.message }, "weixin onBound failed");
|
|
501
|
+
}
|
|
502
|
+
finish(l, { status: "done", chatId, name, message: olds.length ? "已重新绑定 (原群聊与 wizard 保留)" : "已绑定" });
|
|
503
|
+
};
|
|
504
|
+
const runLogin = async (l) => {
|
|
505
|
+
const deadline = l.startedAt + LOGIN_DEADLINE_MS;
|
|
506
|
+
while (!l.ctl.signal.aborted && Date.now() < deadline) {
|
|
507
|
+
const code = l.code;
|
|
508
|
+
// 只清这一轮带出去的那个码: 请求在途时人可能刚交了新码, 别把新码一起清掉。
|
|
509
|
+
const spent = () => { if (code && l.code === code)
|
|
510
|
+
l.code = undefined; };
|
|
511
|
+
const r = await getQrcodeStatus(l.base, l.qrcode, code, l.ctl.signal);
|
|
512
|
+
if (l.ctl.signal.aborted)
|
|
513
|
+
return;
|
|
514
|
+
if (!r.ok) {
|
|
515
|
+
log.warn({ id: l.id, errmsg: r.errmsg, ret: r.ret }, "weixin qr status failed");
|
|
516
|
+
await sleep(2_000, l.ctl.signal);
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
const body = r.data ?? {};
|
|
520
|
+
switch (body.status) {
|
|
521
|
+
case "wait":
|
|
522
|
+
break;
|
|
523
|
+
case "scaned":
|
|
524
|
+
spent(); // 码已被接受, 之后别再带着它问
|
|
525
|
+
if (l.status !== "scaned")
|
|
526
|
+
set(l, { status: "scaned", message: "已扫码, 请在微信里点确认" });
|
|
527
|
+
break;
|
|
528
|
+
case "need_verifycode":
|
|
529
|
+
// 带着码还回 need_verifycode = 码不对; 清掉等人重输。
|
|
530
|
+
if (code) {
|
|
531
|
+
spent();
|
|
532
|
+
set(l, { status: "need_code", message: "配对码不对, 请重新输入" });
|
|
533
|
+
}
|
|
534
|
+
else if (l.status !== "need_code")
|
|
535
|
+
set(l, { status: "need_code", message: "微信上显示了一串配对码, 请把它发回来" });
|
|
536
|
+
break;
|
|
537
|
+
case "verify_code_blocked":
|
|
538
|
+
return finish(l, { status: "failed", message: "配对码错误次数过多, 请稍后重新发起绑定" });
|
|
539
|
+
case "scaned_but_redirect":
|
|
540
|
+
if (typeof body.redirect_host === "string" && body.redirect_host)
|
|
541
|
+
l.base = `https://${body.redirect_host}`;
|
|
542
|
+
break;
|
|
543
|
+
case "binded_redirect": {
|
|
544
|
+
// 服务端认出了本机某个 token: 掉线 (-14) 的号可能其实还能用 —— 叫醒它们重试, 不行会再歇回去。
|
|
545
|
+
const dead = all().filter((a) => a.state === "expired");
|
|
546
|
+
for (const a of dead)
|
|
547
|
+
revive(a.botId, "binded_redirect");
|
|
548
|
+
return finish(l, { status: "already", message: `这个微信已经绑在本机, 无需重复绑定 —— 直接在微信里说话${dead.length ? ` (掉线的 ${dead.length} 个号已唤醒重连)` : ""}` });
|
|
549
|
+
}
|
|
550
|
+
case "expired": {
|
|
551
|
+
if (l.refresh >= MAX_QR_REFRESH)
|
|
552
|
+
return finish(l, { status: "failed", message: "二维码多次过期, 请重新发起绑定" });
|
|
553
|
+
const q = await getBotQrcode(w().baseUrl || ILINK_BASE, all().map((a) => a.botToken));
|
|
554
|
+
if (!q.ok || !q.data?.qrcode)
|
|
555
|
+
return finish(l, { status: "failed", message: `刷新二维码失败: ${q.errmsg ?? q.ret ?? "unknown"}` });
|
|
556
|
+
l.qrcode = q.data.qrcode;
|
|
557
|
+
l.code = undefined; // 旧码配的是旧二维码
|
|
558
|
+
set(l, { status: "qr", qrUrl: q.data.qrcode_img_content, refresh: l.refresh + 1, message: `二维码已刷新 (${l.refresh + 1}/${MAX_QR_REFRESH})` });
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
case "confirmed":
|
|
562
|
+
return confirm(l, body);
|
|
563
|
+
default:
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
// 每轮固定歇 1s (官方插件同款): 服务端对 scaned 之类不 hold 时, 不歇就是 8 分钟的热循环。
|
|
567
|
+
await sleep(1_000, l.ctl.signal);
|
|
568
|
+
}
|
|
569
|
+
if (!l.ctl.signal.aborted)
|
|
570
|
+
finish(l, { status: "failed", message: "等扫码超时 (8 分钟), 请重新发起绑定" });
|
|
571
|
+
};
|
|
572
|
+
const startBind = (by, name, onChange, to = by) => {
|
|
573
|
+
if (!w().enabled)
|
|
574
|
+
return { ok: false, reason: "微信通道未开启 (weixin.enabled=false)" };
|
|
575
|
+
logins.get(by)?.ctl.abort();
|
|
576
|
+
const l = {
|
|
577
|
+
id: `wb${Date.now().toString(36)}`, by, name, status: "qr", qrUrl: "", refresh: 0, message: "正在取二维码…",
|
|
578
|
+
startedAt: Date.now(), qrcode: "", base: w().baseUrl || ILINK_BASE, to, ctl: new AbortController(), onChange,
|
|
579
|
+
};
|
|
580
|
+
log.info({ id: l.id, by, to, name }, "weixin bind start");
|
|
581
|
+
logins.set(by, l);
|
|
582
|
+
void (async () => {
|
|
583
|
+
const q = await getBotQrcode(w().baseUrl || ILINK_BASE, all().map((a) => a.botToken));
|
|
584
|
+
if (!q.ok || !q.data?.qrcode)
|
|
585
|
+
return finish(l, { status: "failed", message: `取二维码失败: ${q.errmsg ?? q.ret ?? "unknown"}` });
|
|
586
|
+
l.qrcode = q.data.qrcode;
|
|
587
|
+
set(l, { status: "qr", qrUrl: q.data.qrcode_img_content, message: "请用微信扫码 (约 2 分钟有效, 过期自动换新码)" });
|
|
588
|
+
await runLogin(l);
|
|
589
|
+
})().catch((e) => finish(l, { status: "failed", message: e.message }));
|
|
590
|
+
return { ok: true, view: view(l) };
|
|
591
|
+
};
|
|
592
|
+
const submitCode = (by, code) => {
|
|
593
|
+
const l = logins.get(by);
|
|
594
|
+
if (!l || l.ctl.signal.aborted)
|
|
595
|
+
return { ok: false, reason: "没有进行中的绑定" };
|
|
596
|
+
if (!/^\w{2,12}$/.test(code.trim()))
|
|
597
|
+
return { ok: false, reason: "配对码格式不对" };
|
|
598
|
+
l.code = code.trim();
|
|
599
|
+
set(l, { message: "已提交配对码, 校验中…" });
|
|
600
|
+
return { ok: true };
|
|
601
|
+
};
|
|
602
|
+
const bindOf = (k) => {
|
|
603
|
+
const l = logins.get(k) ?? [...logins.values()].find((x) => x.id === k);
|
|
604
|
+
return l && view(l);
|
|
605
|
+
};
|
|
606
|
+
const unbind = (chatId) => {
|
|
607
|
+
const a = byChat(chatId);
|
|
608
|
+
if (!a)
|
|
609
|
+
return { ok: false, reason: `没有绑定 ${chatId}` };
|
|
610
|
+
polls.get(a.botId)?.abort();
|
|
611
|
+
polls.delete(a.botId);
|
|
612
|
+
clearBot(a.botId);
|
|
613
|
+
void notifyLifecycle(a.baseUrl, a.botToken, false);
|
|
614
|
+
db.drop(a.botId);
|
|
615
|
+
log.info({ botId: a.botId, chatId }, "weixin unbound");
|
|
616
|
+
return { ok: true };
|
|
617
|
+
};
|
|
618
|
+
const download = async (ref) => {
|
|
619
|
+
const { m, k, n } = JSON.parse(Buffer.from(ref.replace(/^wxcdn:/, ""), "base64url").toString("utf8"));
|
|
620
|
+
const url = cdnDownloadUrl(m, w().cdnBaseUrl);
|
|
621
|
+
if (!url)
|
|
622
|
+
throw new Error("wxcdn: no download url");
|
|
623
|
+
// 图片优先 image_item.aeskey (hex), 其次 media.aes_key; 都没有 = 明文。
|
|
624
|
+
const key = k ? Buffer.from(k, "hex") : m.aes_key ? parseAesKey(m.aes_key) : undefined;
|
|
625
|
+
const r = await downloadCdn(url, key);
|
|
626
|
+
if (!r.ok)
|
|
627
|
+
throw new Error(`wxcdn download failed: ${r.reason}`);
|
|
628
|
+
return { buffer: r.data, ...(n ? { filename: n } : {}) };
|
|
629
|
+
};
|
|
630
|
+
const uploadAndSend = async (botId, f) => {
|
|
631
|
+
const a0 = db.get(botId);
|
|
632
|
+
if (!isLive(a0))
|
|
633
|
+
return { ok: false, reason: db.get(botId)?.state === "expired" ? "微信通道已掉线, 先 /wx bind 重新绑定" : "微信通道未开启" };
|
|
634
|
+
// 媒体也守最小间隔: 紧跟在 caption 后面直发, 正是频控盯的那种连发。
|
|
635
|
+
const gap = (a0.lastOutAt ?? 0) + w().minGapSec * 1000 - Date.now();
|
|
636
|
+
if (gap > 0)
|
|
637
|
+
await sleep(gap);
|
|
638
|
+
const a = db.get(botId);
|
|
639
|
+
if (!isLive(a))
|
|
640
|
+
return { ok: false, reason: "微信通道已掉线, 先 /wx bind 重新绑定" };
|
|
641
|
+
const now = Date.now();
|
|
642
|
+
if (a.pausedUntil && a.pausedUntil > now)
|
|
643
|
+
return { ok: false, reason: `微信被频控, ${Math.ceil((a.pausedUntil - now) / 60_000)} 分钟后再试` };
|
|
644
|
+
if (!a.ctx || now - a.ctx.at > w().ctxTtlSec * 1000)
|
|
645
|
+
return { ok: false, reason: `微信那头 ${Math.round((now - (a.ctx?.at ?? 0)) / 1000)}s 没说话, 超过 ${w().ctxTtlSec}s 的消息微信会静默丢弃 —— 等对方在微信里说一句再发` };
|
|
646
|
+
if (a.ctx.used >= w().sendCap)
|
|
647
|
+
return { ok: false, reason: `这一轮已发满 ${w().sendCap} 条 (weixin.sendCap), 等对方再说话再发` };
|
|
648
|
+
const raw = readFileSync(f.path);
|
|
649
|
+
const key = randomBytes(16);
|
|
650
|
+
const cipher = encryptEcb(raw, key);
|
|
651
|
+
const filekey = randomBytes(16).toString("hex");
|
|
652
|
+
const u = await getUploadUrl(a.baseUrl, a.botToken, { filekey, media_type: MEDIA_TYPE[f.kind], to_user_id: a.userId, rawsize: raw.length, rawfilemd5: md5(raw), filesize: cipher.length, aeskey: key.toString("hex") });
|
|
653
|
+
const url = u.ok && u.data ? cdnUploadUrl(u.data, filekey, w().cdnBaseUrl) : undefined;
|
|
654
|
+
if (!url)
|
|
655
|
+
return { ok: false, reason: `微信拒发上传参数: ${u.errmsg ?? u.ret ?? "no upload url"}` };
|
|
656
|
+
const up = await uploadCdn(url, cipher);
|
|
657
|
+
if (!up.ok)
|
|
658
|
+
return { ok: false, reason: up.reason };
|
|
659
|
+
// aes_key: hex 串再 base64 (官方插件的发送形态)。
|
|
660
|
+
const media = { encrypt_query_param: up.param, aes_key: Buffer.from(key.toString("hex")).toString("base64"), encrypt_type: 1 };
|
|
661
|
+
const item = f.kind === "image" ? { type: ITEM.image, image_item: { media, mid_size: cipher.length } }
|
|
662
|
+
: f.kind === "video" ? { type: ITEM.video, video_item: { media, video_size: cipher.length } }
|
|
663
|
+
: { type: ITEM.file, file_item: { media, file_name: f.name, len: String(raw.length) } };
|
|
664
|
+
const r = await sendItems(a.baseUrl, a.botToken, a.userId, a.ctx.token, [item]);
|
|
665
|
+
if (!r.ok) {
|
|
666
|
+
// 拒收只回 reason 不清 token: 媒体不进账本, 调用方当场知道, 文字出站不该跟着被压。
|
|
667
|
+
const kind = classifySendFail(r);
|
|
668
|
+
patch(botId, (x) => ({ failed: (x.failed ?? 0) + 1, ...(kind === "rate" ? { pausedUntil: Date.now() + w().ratePauseMin * 60_000 } : {}) }));
|
|
669
|
+
if (kind === "dead")
|
|
670
|
+
expire(db.get(botId), `token 失效 ${r.ret ?? r.errcode}`);
|
|
671
|
+
return { ok: false, reason: `微信拒收 (${kind}): ${r.errmsg ?? r.ret ?? ""}` };
|
|
672
|
+
}
|
|
673
|
+
patch(botId, (x) => ({ ctx: x.ctx && { ...x.ctx, used: x.ctx.used + 1 }, lastOutAt: Date.now(), sent: (x.sent ?? 0) + 1 }));
|
|
674
|
+
log.info({ botId, kind: f.kind, name: f.name, bytes: raw.length }, "weixin tx media");
|
|
675
|
+
return { ok: true };
|
|
676
|
+
};
|
|
677
|
+
// 排着的文字先发完 (按间隔等, 顺序不乱: caption 在前、文件在后); 被压着 (无 token / 额度) 的不等。
|
|
678
|
+
// 频控暂停这种长等待不在这里干等 —— uploadAndSend 会如实回 reason。
|
|
679
|
+
const drainText = async (botId) => {
|
|
680
|
+
for (let n = 0; n < 8; n++) {
|
|
681
|
+
const a = db.get(botId);
|
|
682
|
+
if (!isLive(a) || stopped)
|
|
683
|
+
return;
|
|
684
|
+
const plan = planFlush(a, Date.now(), w());
|
|
685
|
+
if (plan.kind === "hold")
|
|
686
|
+
return;
|
|
687
|
+
if (plan.kind === "wait") {
|
|
688
|
+
if (plan.ms > w().minGapSec * 1000 + 1_000)
|
|
689
|
+
return;
|
|
690
|
+
await sleep(plan.ms);
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
await flushOnce(botId);
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
const sendMedia = (chatId, f) => {
|
|
697
|
+
const a = byChat(chatId);
|
|
698
|
+
if (!a)
|
|
699
|
+
return Promise.resolve({ ok: false, reason: `没有绑定 ${chatId}` });
|
|
700
|
+
let out = { ok: false, reason: "未执行" };
|
|
701
|
+
const run = chain(a.botId, async () => {
|
|
702
|
+
await drainText(a.botId);
|
|
703
|
+
out = await uploadAndSend(a.botId, f).catch((e) => ({ ok: false, reason: e.message }));
|
|
704
|
+
});
|
|
705
|
+
return run.then(() => out);
|
|
706
|
+
};
|
|
707
|
+
const kick = (reason) => {
|
|
708
|
+
log.info({ reason, n: polls.size }, "weixin kick long-polls");
|
|
709
|
+
for (const c of polls.values())
|
|
710
|
+
c.abort();
|
|
711
|
+
};
|
|
712
|
+
const start = () => {
|
|
713
|
+
if (started)
|
|
714
|
+
return;
|
|
715
|
+
started = true;
|
|
716
|
+
// 开机: 只在开关开着时收发; 关着的时候账号原样留着, 再开即续。掉线的号也起轮询 —— 它歇到 retryAt 再试。
|
|
717
|
+
if (w().enabled)
|
|
718
|
+
for (const a of all())
|
|
719
|
+
startPoll(a);
|
|
720
|
+
for (const sv of Object.values(loginDb.all())) {
|
|
721
|
+
if (!w().enabled || Date.now() - sv.startedAt > LOGIN_DEADLINE_MS || logins.has(sv.by)) {
|
|
722
|
+
loginDb.drop(sv.by);
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
const l = { ...sv, ctl: new AbortController(), onChange: deps.sinkFor && sv.to !== "cli" ? deps.sinkFor(sv.to) : () => undefined };
|
|
726
|
+
logins.set(sv.by, l);
|
|
727
|
+
log.info({ id: l.id, by: l.by, status: l.status }, "weixin bind resumed after reload");
|
|
728
|
+
void runLogin(l).catch((e) => finish(l, { status: "failed", message: e.message }));
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
const stop = async () => {
|
|
732
|
+
stopped = true;
|
|
733
|
+
for (const c of polls.values())
|
|
734
|
+
c.abort();
|
|
735
|
+
for (const t of timers.values())
|
|
736
|
+
clearTimeout(t.t);
|
|
737
|
+
for (const l of logins.values())
|
|
738
|
+
l.ctl.abort();
|
|
739
|
+
// 等在途的那一段发完再走 (sendmessage 自带 15s 超时, 这里再封个顶): 发出一段才摘一段,
|
|
740
|
+
// 被打断的也还在 outbox 里, 等这一下只是少一次重发。
|
|
741
|
+
await Promise.race([Promise.allSettled([...pumps.values()]), sleep(5_000)]);
|
|
742
|
+
await Promise.all(all().filter((a) => a.state === "live").map((a) => notifyLifecycle(a.baseUrl, a.botToken, false)));
|
|
743
|
+
};
|
|
744
|
+
// 老版本落盘的 outbox 条目没有 id (发出一段摘一段要靠它)。
|
|
745
|
+
for (const a of all().filter((x) => x.outbox.some((o) => !o.id))) {
|
|
746
|
+
patch(a.botId, (x) => ({ outbox: x.outbox.map((o) => (o.id ? o : outItem(o.text, { ...(o.card ? { card: true } : {}), at: o.at }))) }));
|
|
747
|
+
}
|
|
748
|
+
// 上个进程压下的条目, 头是按当时的规矩写的 (带 emoji 头像、默认 wizard 也报名): 第一次发之前按现行规矩
|
|
749
|
+
// 重写一遍 (reheadFor)。拖到 flush 才做 —— 认名字靠 tag linker, 它在 init 之后才绑; 本进程新压的已是新头, 不碰。
|
|
750
|
+
legacy = new Set(all().flatMap((x) => x.outbox.map((o) => o.id)));
|
|
751
|
+
log.info({ enabled: w().enabled, accounts: all().length }, "weixin init");
|
|
752
|
+
return {
|
|
753
|
+
owns: (chatId) => !!byChat(chatId),
|
|
754
|
+
fresh: (chatId) => ((a) => !!a && a.state === "live" && !!a.ctx && Date.now() - a.ctx.at <= w().ctxTtlSec * 1000)(byChat(chatId)),
|
|
755
|
+
live: (chatId) => isLive(byChat(chatId)),
|
|
756
|
+
accountOf: byChat,
|
|
757
|
+
list: all,
|
|
758
|
+
send,
|
|
759
|
+
typing,
|
|
760
|
+
download,
|
|
761
|
+
sendMedia,
|
|
762
|
+
onInbound: (fn) => { inbound = fn; },
|
|
763
|
+
startBind,
|
|
764
|
+
submitCode,
|
|
765
|
+
bindOf,
|
|
766
|
+
unbind,
|
|
767
|
+
kick,
|
|
768
|
+
start,
|
|
769
|
+
stop,
|
|
770
|
+
};
|
|
771
|
+
};
|
|
772
|
+
//# sourceMappingURL=weixin.js.map
|