返回 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 export function randomUin() {
39 const uint32 = crypto.randomBytes(4).readUInt32BE(0);
40 return Buffer.from(String(uint32), "utf-8").toString("base64");
41 }
42
43 // iLink-App-Id: 从 openclaw-weixin 的 package.json 已知为 "bot"
44 const ILINK_APP_ID = "bot";
45
46 // iLink-App-ClientVersion: 0x00MMNNPP (major<<16 | minor<<8 | patch)
47 function buildClientVersion(version) {
48 const [major = 0, minor = 0, patch = 0] = version.split(".").map(Number);
49 return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff);
50 }
51 const ILINK_APP_CLIENT_VERSION = String(buildClientVersion("2.4.4"));
52
53 // ---------------------------------------------------------------------------
54 // 消息提取
55 // ---------------------------------------------------------------------------
56
57 export const MessageItemType = {
58 NONE: 0,
59 TEXT: 1,
60 IMAGE: 2,
61 VOICE: 3,
62 FILE: 4,
63 VIDEO: 5,
64 TOOL_CALL_START: 11,
65 TOOL_CALL_RESULT: 12,
66 };
67
68 export function extractText(itemList) {
69 if (!Array.isArray(itemList) || !itemList.length) return "";
70 for (const item of itemList) {
71 if (item.type === MessageItemType.TEXT && item.text_item?.text != null) {
72 return String(item.text_item.text);
73 }
74 if (item.type === MessageItemType.VOICE && item.voice_item?.text) {
75 return item.voice_item.text;
76 }
77 }
78 return "";
79 }
80
81 // ---------------------------------------------------------------------------
82 // 帮助文本
83 // ---------------------------------------------------------------------------
84
85 export function helpText() {
86 return [
87 "CodeWhale Weixin Bot 命令:",
88 "/help - 显示此帮助",
89 "/status - 运行时和工作区状态",
90 "/threads - 最近运行时线程",
91 "/new - 为此聊天创建新线程",
92 "/resume <thread_id> - 绑定到已有线程",
93 "/model <name|default> - 设置或重置此聊天的模型",
94 "/interrupt - 中断活跃 turn",
95 "/compact - 压缩当前线程",
96 "/allow <approval_id> [remember] - 批准工具调用",
97 "/deny <approval_id> - 拒绝工具调用",
98 "",
99 "其他所有内容均作为 CodeWhale 提示发送。",
100 ].join("\n");
101 }
102
103 // ============================================================================
104 // iLink Bot HTTP API 调用
105 // ============================================================================
106
107 export const ILinkLoginBase = "https://ilinkai.weixin.qq.com";
108
109 function authHeaders({ token } = {}) {
110 const headers = {
111 "Content-Type": "application/json",
112 AuthorizationType: "ilink_bot_token",
113 "X-WECHAT-UIN": randomUin(),
114 "iLink-App-Id": ILINK_APP_ID,
115 "iLink-App-ClientVersion": ILINK_APP_CLIENT_VERSION,
116 };
117 if (token) {
118 headers["Authorization"] = `Bearer ${token}`;
119 }
120 return headers;
121 }
122
123 /**
124 * 通用 POST 到 iLink API。
125 */
126 export async function apiPost({ baseUrl, endpoint, body, token, timeoutMs, signal }) {
127 const url = `${baseUrl.replace(/\/+$/, "")}/${endpoint}`;
128 const ms = timeoutMs || DEFAULT_API_TIMEOUT_MS;
129 const controller = new AbortController();
130 const timer = setTimeout(() => controller.abort(), ms);
131 signal?.addEventListener("abort", () => controller.abort(), { once: true });
132
133 try {
134 const response = await fetch(url, {
135 method: "POST",
136 headers: authHeaders({ token }),
137 body,
138 signal: controller.signal,
139 });
140 const text = await response.text();
141 if (!response.ok) {
142 throw new Error(
143 `iLink API ${endpoint} failed: HTTP ${response.status} — ${text.slice(0, 200)}`
144 );
145 }
146 return text;
147 } finally {
148 clearTimeout(timer);
149 }
150 }
151
152 /**
153 * 通用 GET 到 iLink API(用于轮询扫码状态等)。
154 */
155 export async function apiGet({ baseUrl, endpoint, token, timeoutMs, signal }) {
156 const url = `${baseUrl.replace(/\/+$/, "")}/${endpoint}`;
157 const ms = timeoutMs || DEFAULT_API_TIMEOUT_MS;
158 const controller = new AbortController();
159 const timer = setTimeout(() => controller.abort(), ms);
160 signal?.addEventListener("abort", () => controller.abort(), { once: true });
161
162 try {
163 const response = await fetch(url, {
164 method: "GET",
165 headers: authHeaders({ token }),
166 signal: controller.signal,
167 });
168 const text = await response.text();
169 if (!response.ok) {
170 // 长轮询超时是正常的,不抛错
171 if (response.status === 524 || text.includes("timeout")) {
172 return text;
173 }
174 throw new Error(
175 `iLink API ${endpoint} failed: HTTP ${response.status} — ${text.slice(0, 200)}`
176 );
177 }
178 return text;
179 } finally {
180 clearTimeout(timer);
181 }
182 }
183
184 // ============================================================================
185 // 扫码登录
186 // ============================================================================
187
188 /**
189 * 获取登录二维码。
190 * 参考 login-qr.ts:79-90 — 端点 get_bot_qrcode,bot_type 是 query 参数。
191 * @returns {Promise<{qrcode: string, qrcodeUrl: string, sessionKey: string}>}
192 */
193 export async function getLoginQR({ botType = "3" } = {}) {
194 const raw = await apiPost({
195 baseUrl: ILinkLoginBase,
196 endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`,
197 body: JSON.stringify({ local_token_list: [] }),
198 });
199 const data = JSON.parse(raw);
200 const qrcodeUrl = data.qrcode_img_content || "";
201 const sessionKey = data.qrcode || crypto.randomUUID();
202 return { qrcode: data.qrcode, qrcodeUrl, sessionKey };
203 }
204
205 /**
206 * 轮询扫码状态直到确认或超时。
207 * 参考 login-qr.ts:112-136 — 端点 get_qrcode_status,GET 方法。
208 * @returns {Promise<{connected: boolean, botToken?: string, accountId?: string, baseUrl?: string, userId?: string, message: string}>}
209 */
210 export async function waitForLogin({ sessionKey, timeoutMs = 300_000 } = {}) {
211 const deadline = Date.now() + timeoutMs;
212
213 while (Date.now() < deadline) {
214 let raw;
215 try {
216 raw = await apiGet({
217 baseUrl: ILinkLoginBase,
218 endpoint: `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(sessionKey)}`,
219 timeoutMs: 35_000, // 长轮询超时
220 });
221 } catch {
222 // 超时等网络错误 → 视为 wait,继续轮询
223 await new Promise((r) => setTimeout(r, 1000));
224 continue;
225 }
226
227 const data = JSON.parse(raw);
228 const status = data.status;
229
230 if (status === "confirmed") {
231 return {
232 connected: true,
233 botToken: data.bot_token,
234 accountId: data.ilink_bot_id,
235 baseUrl: data.baseurl || ILinkLoginBase,
236 userId: data.ilink_user_id,
237 message: "已连接微信。",
238 };
239 }
240
241 if (status === "expired" || status === "binded_redirect") {
242 return { connected: false, message: status === "expired" ? "二维码已过期,请重试。" : "已连接过此桥接,无需重复连接。" };
243 }
244
245 // 等 1 秒再轮询
246 await new Promise((r) => setTimeout(r, 1000));
247 }
248
249 return { connected: false, message: "登录超时,请重试。" };
250 }
251
252 // ============================================================================
253 // 消息 API
254 // ============================================================================
255
256 /**
257 * 长轮询获取新消息。
258 */
259 export async function getUpdates({ baseUrl, token, get_updates_buf = "", timeoutMs = LONGPOLL_DEFAULT_TIMEOUT_MS, signal }) {
260 const raw = await apiPost({
261 baseUrl,
262 endpoint: "ilink/bot/getupdates",
263 body: JSON.stringify({
264 get_updates_buf,
265 base_info: { bot_agent: "CodeWhale/1.0" },
266 }),
267 token,
268 timeoutMs,
269 signal,
270 });
271 return JSON.parse(raw);
272 }
273
274 /**
275 * 发送消息。
276 */
277 export async function sendMessage({ baseUrl, token, body, timeoutMs }) {
278 await apiPost({
279 baseUrl,
280 endpoint: "ilink/bot/sendmessage",
281 body: JSON.stringify({
282 ...body,
283 base_info: { bot_agent: "CodeWhale/1.0" },
284 }),
285 token,
286 timeoutMs,
287 });
288 }
289
290 /**
291 * 发送/取消输入状态。
292 */
293 export async function sendTyping({ baseUrl, token, ilinkUserId, typingTicket, status = 1 }) {
294 await apiPost({
295 baseUrl,
296 endpoint: "ilink/bot/sendtyping",
297 body: JSON.stringify({
298 ilink_user_id: ilinkUserId,
299 typing_ticket: typingTicket,
300 status,
301 base_info: { bot_agent: "CodeWhale/1.0" },
302 }),
303 token,
304 });
305 }
306
307 /**
308 * 获取账号配置(含 typing_ticket)。
309 */
310 export async function getConfig({ baseUrl, token, ilinkUserId, contextToken }) {
311 const raw = await apiPost({
312 baseUrl,
313 endpoint: "ilink/bot/getconfig",
314 body: JSON.stringify({
315 ilink_user_id: ilinkUserId,
316 context_token: contextToken,
317 base_info: { bot_agent: "CodeWhale/1.0" },
318 }),
319 token,
320 });
321 return JSON.parse(raw);
322 }
323
324 /**
325 * 通知上线。
326 */
327 export async function notifyStart({ baseUrl, token }) {
328 const raw = await apiPost({
329 baseUrl,
330 endpoint: "ilink/bot/msg/notifystart",
331 body: JSON.stringify({ base_info: { bot_agent: "CodeWhale/1.0" } }),
332 token,
333 });
334 return JSON.parse(raw);
335 }
336
337 /**
338 * 通知下线。
339 */
340 export async function notifyStop({ baseUrl, token }) {
341 const raw = await apiPost({
342 baseUrl,
343 endpoint: "ilink/bot/msg/notifystop",
344 body: JSON.stringify({ base_info: { bot_agent: "CodeWhale/1.0" } }),
345 token,
346 });
347 return JSON.parse(raw);
348 }
349
349 lines Plain Text