返回 CodeWhale
lib.mjs
1 import crypto from "node:crypto";
2
3 import {
4 activeTurnBlock,
5 commandAction,
6 compactRuntimeError,
7 envFirst,
8 latestRunningTurn,
9 parseApprovalDecisionArgs,
10 parseBool,
11 parseCommand,
12 parseList,
13 preservedChatStateFields,
14 splitMessage,
15 } from "../../bridge-core/src/lib.mjs";
16
17 // ============================================================================
18 // iLink Bot API 协议层 — 参考 @tencent-weixin/openclaw-weixin
19 // ============================================================================
20
21 const DEFAULT_API_TIMEOUT_MS = 30_000;
22 const LONGPOLL_DEFAULT_TIMEOUT_MS = 35_000;
23
24 export {
25 activeTurnBlock,
26 commandAction,
27 compactRuntimeError,
28 envFirst,
29 latestRunningTurn,
30 parseApprovalDecisionArgs,
31 parseBool,
32 parseCommand,
33 parseList,
34 preservedChatStateFields,
35 splitMessage,
36 };
37
38 /**
39 * Handle one getUpdates batch, then advance the poll cursor. The cursor moves
40 * only after every message in the batch was handled, so a crash replays the
41 * batch from iLink instead of dropping a prompt. Each message is claimed
42 * durably first: on replay, finished messages are skipped, and one a crashed
43 * process was still handling is reported through `interrupted` rather than
44 * run twice (its effect is unknown).
45 */
46 export async function processUpdateBatch({ messages, nextCursor, store, keyOf, handle, interrupted, commitCursor }) {
47 for (const msg of messages || []) {
48 const key = keyOf(msg);
49 if (!key) continue;
50 const claim = await store.claimMessage(key);
51 if (claim === "done") continue;
52 if (claim === "interrupted") {
53 await interrupted(msg);
54 continue;
55 }
56 try {
57 await handle(msg);
58 } finally {
59 await store.completeMessage(key);
60 }
61 }
62 if (nextCursor) await commitCursor(nextCursor);
63 }
64
65 export function randomUin() {
66 const uint32 = crypto.randomBytes(4).readUInt32BE(0);
67 return Buffer.from(String(uint32), "utf-8").toString("base64");
68 }
69
70 // iLink-App-Id: 从 openclaw-weixin 的 package.json 已知为 "bot"
71 const ILINK_APP_ID = "bot";
72
73 // iLink-App-ClientVersion: 0x00MMNNPP (major<<16 | minor<<8 | patch)
74 function buildClientVersion(version) {
75 const [major = 0, minor = 0, patch = 0] = version.split(".").map(Number);
76 return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff);
77 }
78 const ILINK_APP_CLIENT_VERSION = String(buildClientVersion("2.4.4"));
79
80 // ---------------------------------------------------------------------------
81 // 消息提取
82 // ---------------------------------------------------------------------------
83
84 export const MessageItemType = {
85 NONE: 0,
86 TEXT: 1,
87 IMAGE: 2,
88 VOICE: 3,
89 FILE: 4,
90 VIDEO: 5,
91 TOOL_CALL_START: 11,
92 TOOL_CALL_RESULT: 12,
93 };
94
95 export function extractText(itemList) {
96 if (!Array.isArray(itemList) || !itemList.length) return "";
97 for (const item of itemList) {
98 if (item.type === MessageItemType.TEXT && item.text_item?.text != null) {
99 return String(item.text_item.text);
100 }
101 if (item.type === MessageItemType.VOICE && item.voice_item?.text) {
102 return item.voice_item.text;
103 }
104 }
105 return "";
106 }
107
108 // ---------------------------------------------------------------------------
109 // 帮助文本
110 // ---------------------------------------------------------------------------
111
112 export function helpText() {
113 return [
114 "CodeWhale Weixin Bot 命令:",
115 "/help - 显示此帮助",
116 "/status - 运行时和工作区状态",
117 "/threads - 最近运行时线程",
118 "/new - 为此聊天创建新线程",
119 "/resume <thread_id> - 绑定到已有线程",
120 "/model <name|default> - 设置或重置此聊天的模型",
121 "/interrupt - 中断活跃 turn",
122 "/compact - 压缩当前线程",
123 "/allow <approval_id> [remember] - 批准工具调用",
124 "/deny <approval_id> - 拒绝工具调用",
125 "",
126 "其他所有内容均作为 CodeWhale 提示发送。",
127 ].join("\n");
128 }
129
130 // ============================================================================
131 // iLink Bot HTTP API 调用
132 // ============================================================================
133
134 export const ILinkLoginBase = "https://ilinkai.weixin.qq.com";
135
136 function authHeaders({ token } = {}) {
137 const headers = {
138 "Content-Type": "application/json",
139 AuthorizationType: "ilink_bot_token",
140 "X-WECHAT-UIN": randomUin(),
141 "iLink-App-Id": ILINK_APP_ID,
142 "iLink-App-ClientVersion": ILINK_APP_CLIENT_VERSION,
143 };
144 if (token) {
145 headers["Authorization"] = `Bearer ${token}`;
146 }
147 return headers;
148 }
149
150 /**
151 * 通用 POST 到 iLink API。
152 */
153 export async function apiPost({ baseUrl, endpoint, body, token, timeoutMs, signal }) {
154 signal?.throwIfAborted();
155 const url = `${baseUrl.replace(/\/+$/, "")}/${endpoint}`;
156 const ms = timeoutMs || DEFAULT_API_TIMEOUT_MS;
157 const controller = new AbortController();
158 const timer = setTimeout(() => controller.abort(), ms);
159 const onAbort = () => controller.abort(signal.reason);
160 signal?.addEventListener("abort", onAbort, { once: true });
161
162 try {
163 const response = await fetch(url, {
164 method: "POST",
165 headers: authHeaders({ token }),
166 body,
167 signal: controller.signal,
168 });
169 const text = await response.text();
170 if (!response.ok) {
171 throw new Error(
172 `iLink API ${endpoint} failed: HTTP ${response.status}`
173 );
174 }
175 return text;
176 } finally {
177 clearTimeout(timer);
178 signal?.removeEventListener("abort", onAbort);
179 }
180 }
181
182 /**
183 * 通用 GET 到 iLink API(用于轮询扫码状态等)。
184 */
185 export async function apiGet({ baseUrl, endpoint, token, timeoutMs, signal }) {
186 const url = `${baseUrl.replace(/\/+$/, "")}/${endpoint}`;
187 const ms = timeoutMs || DEFAULT_API_TIMEOUT_MS;
188 const controller = new AbortController();
189 const timer = setTimeout(() => controller.abort(), ms);
190 signal?.addEventListener("abort", () => controller.abort(), { once: true });
191
192 try {
193 const response = await fetch(url, {
194 method: "GET",
195 headers: authHeaders({ token }),
196 signal: controller.signal,
197 });
198 const text = await response.text();
199 if (!response.ok) {
200 // 长轮询超时是正常的,不抛错
201 if (response.status === 524 || text.includes("timeout")) {
202 return text;
203 }
204 throw new Error(
205 `iLink API ${endpoint} failed: HTTP ${response.status} — ${text.slice(0, 200)}`
206 );
207 }
208 return text;
209 } finally {
210 clearTimeout(timer);
211 }
212 }
213
214 // ============================================================================
215 // 扫码登录
216 // ============================================================================
217
218 /**
219 * 获取登录二维码。
220 * 参考 login-qr.ts:79-90 — 端点 get_bot_qrcode,bot_type 是 query 参数。
221 * @returns {Promise<{qrcode: string, qrcodeUrl: string, sessionKey: string}>}
222 */
223 export async function getLoginQR({ botType = "3" } = {}) {
224 const raw = await apiPost({
225 baseUrl: ILinkLoginBase,
226 endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`,
227 body: JSON.stringify({ local_token_list: [] }),
228 });
229 const data = JSON.parse(raw);
230 const qrcodeUrl = data.qrcode_img_content || "";
231 const sessionKey = data.qrcode || crypto.randomUUID();
232 return { qrcode: data.qrcode, qrcodeUrl, sessionKey };
233 }
234
235 /**
236 * 轮询扫码状态直到确认或超时。
237 * 参考 login-qr.ts:112-136 — 端点 get_qrcode_status,GET 方法。
238 * @returns {Promise<{connected: boolean, botToken?: string, accountId?: string, baseUrl?: string, userId?: string, message: string}>}
239 */
240 export async function waitForLogin({ sessionKey, timeoutMs = 300_000 } = {}) {
241 const deadline = Date.now() + timeoutMs;
242
243 while (Date.now() < deadline) {
244 let raw;
245 try {
246 raw = await apiGet({
247 baseUrl: ILinkLoginBase,
248 endpoint: `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(sessionKey)}`,
249 timeoutMs: 35_000, // 长轮询超时
250 });
251 } catch {
252 // 超时等网络错误 → 视为 wait,继续轮询
253 await new Promise((r) => setTimeout(r, 1000));
254 continue;
255 }
256
257 const data = JSON.parse(raw);
258 const status = data.status;
259
260 if (status === "confirmed") {
261 return {
262 connected: true,
263 botToken: data.bot_token,
264 accountId: data.ilink_bot_id,
265 baseUrl: data.baseurl || ILinkLoginBase,
266 userId: data.ilink_user_id,
267 message: "已连接微信。",
268 };
269 }
270
271 if (status === "expired" || status === "binded_redirect") {
272 return { connected: false, message: status === "expired" ? "二维码已过期,请重试。" : "已连接过此桥接,无需重复连接。" };
273 }
274
275 // 等 1 秒再轮询
276 await new Promise((r) => setTimeout(r, 1000));
277 }
278
279 return { connected: false, message: "登录超时,请重试。" };
280 }
281
282 // ============================================================================
283 // 消息 API
284 // ============================================================================
285
286 /**
287 * 长轮询获取新消息。
288 */
289 export async function getUpdates({ baseUrl, token, get_updates_buf = "", timeoutMs = LONGPOLL_DEFAULT_TIMEOUT_MS, signal }) {
290 const raw = await apiPost({
291 baseUrl,
292 endpoint: "ilink/bot/getupdates",
293 body: JSON.stringify({
294 get_updates_buf,
295 base_info: { bot_agent: "CodeWhale/1.0" },
296 }),
297 token,
298 timeoutMs,
299 signal,
300 });
301 return JSON.parse(raw);
302 }
303
304 /**
305 * Send a message and return the parsed API acceptance response, not a recipient
306 * delivery receipt. Tencent's reference allows omitted status fields; explicit
307 * statuses must be numeric zero. Ambiguous failures must not be retried as if
308 * the provider had rejected the message.
309 */
310 export async function sendMessage({ baseUrl, token, body, timeoutMs }) {
311 let raw;
312 try {
313 raw = await apiPost({
314 baseUrl,
315 endpoint: "ilink/bot/sendmessage",
316 body: JSON.stringify({
317 ...body,
318 base_info: { bot_agent: "CodeWhale/1.0" },
319 }),
320 token,
321 timeoutMs,
322 });
323 } catch (error) {
324 const failure = error instanceof Error ? error : new Error("iLink sendMessage transport failed");
325 failure.deliveryStatus = "uncertain";
326 throw failure;
327 }
328
329 let response;
330 try {
331 response = JSON.parse(raw);
332 } catch {
333 throw Object.assign(new Error("iLink sendMessage returned invalid JSON"), { deliveryStatus: "uncertain" });
334 }
335 if (!response || typeof response !== "object" || Array.isArray(response)) {
336 throw Object.assign(new Error("iLink sendMessage returned a non-object response"), { deliveryStatus: "uncertain" });
337 }
338 const statusFields = ["ret", "errcode"].filter((field) => Object.hasOwn(response, field));
339 if (statusFields.some((field) => !Number.isFinite(response[field]))) {
340 throw Object.assign(new Error("iLink sendMessage returned an invalid status"), { deliveryStatus: "uncertain" });
341 }
342 const failure = statusFields.find((field) => response[field] !== 0);
343 if (failure) {
344 throw Object.assign(new Error(`iLink sendMessage rejected: ${failure}=${response[failure]}`), { deliveryStatus: "rejected" });
345 }
346 return response;
347 }
348
349 /**
350 * 发送/取消输入状态。
351 */
352 export async function sendTyping({ baseUrl, token, ilinkUserId, typingTicket, status = 1 }) {
353 await apiPost({
354 baseUrl,
355 endpoint: "ilink/bot/sendtyping",
356 body: JSON.stringify({
357 ilink_user_id: ilinkUserId,
358 typing_ticket: typingTicket,
359 status,
360 base_info: { bot_agent: "CodeWhale/1.0" },
361 }),
362 token,
363 });
364 }
365
366 /**
367 * 获取账号配置(含 typing_ticket)。
368 */
369 export async function getConfig({ baseUrl, token, ilinkUserId, contextToken }) {
370 const raw = await apiPost({
371 baseUrl,
372 endpoint: "ilink/bot/getconfig",
373 body: JSON.stringify({
374 ilink_user_id: ilinkUserId,
375 context_token: contextToken,
376 base_info: { bot_agent: "CodeWhale/1.0" },
377 }),
378 token,
379 });
380 return JSON.parse(raw);
381 }
382
383 /**
384 * 通知上线。
385 */
386 export async function notifyStart({ baseUrl, token }) {
387 const raw = await apiPost({
388 baseUrl,
389 endpoint: "ilink/bot/msg/notifystart",
390 body: JSON.stringify({ base_info: { bot_agent: "CodeWhale/1.0" } }),
391 token,
392 });
393 return JSON.parse(raw);
394 }
395
396 /**
397 * 通知下线。
398 */
399 export async function notifyStop({ baseUrl, token }) {
400 const raw = await apiPost({
401 baseUrl,
402 endpoint: "ilink/bot/msg/notifystop",
403 body: JSON.stringify({ base_info: { bot_agent: "CodeWhale/1.0" } }),
404 token,
405 });
406 return JSON.parse(raw);
407 }
408
408 lines Plain Text