v-clawbot 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LZH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # v-clawbot
2
+
3
+ 在微信 ClawBot 中与 AI 对话的插件。
4
+ > 注:开发请阅读 [开发者指南](DEVELOPMENT.md)
5
+
6
+ ## 安装
7
+
8
+ ```bash
9
+ npm install -g v-clawbot
10
+ ```
11
+
12
+ ## 使用
13
+
14
+ ### 1. 初始化项目
15
+
16
+ ```bash
17
+ v-clawbot init
18
+ ```
19
+
20
+ 在当前目录创建 `.opencode/` 目录,包含插件文件。
21
+
22
+ ### 2. 扫码登录
23
+
24
+ ```bash
25
+ v-clawbot login
26
+ ```
27
+
28
+ 1. 终端显示 QR 码
29
+ 2. 使用微信扫码确认登录
30
+ 3. 凭据自动保存
31
+
32
+ ### 3. 启动 OpenCode
33
+
34
+ ```bash
35
+ opencode web
36
+ ```
37
+
38
+ 插件会自动启动,开始接收微信消息。
39
+
40
+ ## 命令参数
41
+
42
+ ```bash
43
+ v-clawbot init [--platform opencode|cursor] [-y]
44
+ v-clawbot login [-y]
45
+ ```
46
+
47
+ - `-y, --yes`:跳过确认提示
48
+ - `--platform`:指定平台(opencode 或 cursor)
49
+
50
+ ## 许可证
51
+
52
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,790 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
+
5
+ // src/cli/index.ts
6
+ import { readFileSync as readFileSync4 } from "node:fs";
7
+ import { dirname as dirname2, resolve as resolve2 } from "node:path";
8
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
9
+ import { Command as Command6 } from "commander";
10
+
11
+ // src/cli/commands/chat.ts
12
+ import { Command } from "commander";
13
+ var DEFAULT_MODEL_ID = "big-pickle";
14
+ var DEFAULT_PROVIDER_ID = "opencode";
15
+ var DEFAULT_AGENT = "general";
16
+ var REQUEST_TIMEOUT_MS = 60000;
17
+ async function sendToOpencode(baseUrl, sessionId, text) {
18
+ const res = await fetch(`${baseUrl}/session/${sessionId}/message`, {
19
+ method: "POST",
20
+ headers: {
21
+ "Content-Type": "application/json",
22
+ Accept: "application/json"
23
+ },
24
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
25
+ body: JSON.stringify({
26
+ parts: [{ type: "text", text }],
27
+ model: { modelID: DEFAULT_MODEL_ID, providerID: DEFAULT_PROVIDER_ID },
28
+ agent: DEFAULT_AGENT
29
+ })
30
+ });
31
+ if (!res.ok) {
32
+ const errBody = await res.text().catch(() => "");
33
+ throw new Error(`opencode API error: HTTP ${res.status} ${errBody.slice(0, 200)}`);
34
+ }
35
+ const result = await res.json();
36
+ const responseText = (result.parts ?? []).filter((p) => p.type === "text" && p.text).map((p) => p.text).join(`
37
+ `);
38
+ return responseText;
39
+ }
40
+ async function resolveActiveSession(baseUrl) {
41
+ try {
42
+ const res = await fetch(`${baseUrl}/api/session`, {
43
+ headers: { Accept: "application/json" },
44
+ signal: AbortSignal.timeout(1e4)
45
+ });
46
+ if (!res.ok)
47
+ return null;
48
+ const body = await res.json();
49
+ const sessions = body.data ?? [];
50
+ const filtered = sessions.filter((s) => {
51
+ if (s.parentID != null && s.parentID !== "")
52
+ return false;
53
+ if (s.agent == null || s.agent === "")
54
+ return false;
55
+ return true;
56
+ });
57
+ filtered.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0));
58
+ return filtered[0]?.id ?? null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+ var chatCommand = new Command("chat").description("Send message to opencode and return AI response").argument("<text>", "Message text to send").option("--session <id>", "Session ID (auto-detect if not provided)").option("--server <url>", "opencode server URL (default: http://localhost:4096)").action(async (text, options) => {
64
+ const baseUrl = options.server ?? "http://localhost:4096";
65
+ let sessionId = options.session;
66
+ if (!sessionId) {
67
+ sessionId = await resolveActiveSession(baseUrl);
68
+ if (!sessionId) {
69
+ console.error("No active session found. Please specify --session or start opencode first.");
70
+ process.exit(1);
71
+ }
72
+ }
73
+ try {
74
+ const response = await sendToOpencode(baseUrl, sessionId, text);
75
+ if (response) {
76
+ process.stdout.write(response);
77
+ }
78
+ } catch (err) {
79
+ console.error("Failed to send message to opencode:", err.message);
80
+ process.exit(1);
81
+ }
82
+ });
83
+
84
+ // src/cli/commands/init.ts
85
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
86
+ import { dirname, resolve } from "node:path";
87
+ import {
88
+ cwd,
89
+ stdin as processStdin,
90
+ stdout as processStdout
91
+ } from "node:process";
92
+ import { createInterface } from "node:readline/promises";
93
+ import { fileURLToPath } from "node:url";
94
+ import { Command as Command2 } from "commander";
95
+ var DIRNAME = dirname(fileURLToPath(import.meta.url));
96
+ var PLATFORMS = {
97
+ opencode: {
98
+ pluginSource: resolve(DIRNAME, "../plugins/opencode/v-clawbot.ts"),
99
+ targetDir: ".opencode",
100
+ targetFile: ".opencode/plugins/v-clawbot.ts",
101
+ config: {
102
+ $schema: "https://opencode.ai/config.json",
103
+ server: { port: 4096 }
104
+ },
105
+ packageJson: {
106
+ dependencies: {
107
+ "@opencode-ai/plugin": "1.15.6"
108
+ }
109
+ }
110
+ },
111
+ cursor: {
112
+ pluginSource: resolve(DIRNAME, "../plugins/cursor/v-clawbot.ts"),
113
+ targetDir: ".cursor",
114
+ targetFile: ".cursor/extensions/v-clawbot.ts",
115
+ config: null,
116
+ packageJson: null
117
+ }
118
+ };
119
+ var AVAILABLE_PLATFORMS = Object.keys(PLATFORMS).join(", ");
120
+ async function promptOverwrite() {
121
+ const rl = createInterface({ input: processStdin, output: processStdout });
122
+ const answer = await rl.question("Target directory already exists. Overwrite? (y/N) ");
123
+ rl.close();
124
+ return answer.trim().toLowerCase() === "y";
125
+ }
126
+ function loadPluginTemplate(platform) {
127
+ const config = PLATFORMS[platform];
128
+ if (!config) {
129
+ throw new Error(`Unknown platform: ${platform}. Available: ${AVAILABLE_PLATFORMS}`);
130
+ }
131
+ if (!existsSync(config.pluginSource)) {
132
+ throw new Error(`Plugin file not found: ${config.pluginSource}`);
133
+ }
134
+ return readFileSync(config.pluginSource, "utf-8");
135
+ }
136
+ var initCommand = new Command2("init").description("Initialize project scaffolding for WeChat integration").option("-p, --platform <name>", `Target platform (${AVAILABLE_PLATFORMS})`, "opencode").option("-y, --yes", "Skip confirmation prompt", false).action(async (options) => {
137
+ const platform = options.platform.toLowerCase();
138
+ if (!PLATFORMS[platform]) {
139
+ console.error(`Invalid platform: ${platform}`);
140
+ console.error(`Available platforms: ${AVAILABLE_PLATFORMS}`);
141
+ process.exit(1);
142
+ }
143
+ const config = PLATFORMS[platform];
144
+ const root = cwd();
145
+ if (existsSync(resolve(root, config.targetDir))) {
146
+ if (!options.yes) {
147
+ const ok = await promptOverwrite();
148
+ if (!ok) {
149
+ console.log("Aborted.");
150
+ return;
151
+ }
152
+ }
153
+ }
154
+ mkdirSync(resolve(root, config.targetDir, "plugins"), { recursive: true });
155
+ if (config.config) {
156
+ writeFileSync(resolve(root, config.targetDir, "opencode.json"), JSON.stringify(config.config, null, 2), "utf-8");
157
+ }
158
+ if (config.packageJson) {
159
+ writeFileSync(resolve(root, config.targetDir, "package.json"), JSON.stringify(config.packageJson, null, 2), "utf-8");
160
+ }
161
+ const pluginContent = loadPluginTemplate(platform);
162
+ writeFileSync(resolve(root, config.targetFile), pluginContent, "utf-8");
163
+ console.log(`✅ ${config.targetDir}/ scaffolding created.`);
164
+ console.log(" Next: run 'v-clawbot login' to authenticate.");
165
+ });
166
+
167
+ // src/cli/commands/login.ts
168
+ import { stdin as processStdin2, stdout as processStdout2 } from "node:process";
169
+ import { createInterface as createInterface2 } from "node:readline/promises";
170
+ import { Command as Command3 } from "commander";
171
+
172
+ // src/cli/utils/credentials.ts
173
+ import {
174
+ chmodSync,
175
+ existsSync as existsSync2,
176
+ mkdirSync as mkdirSync2,
177
+ readFileSync as readFileSync2,
178
+ writeFileSync as writeFileSync2
179
+ } from "node:fs";
180
+ import { join } from "node:path";
181
+ var CREDENTIALS_DIR = join(process.cwd(), ".opencode");
182
+ var CREDENTIALS_PATH = join(CREDENTIALS_DIR, "wechat-credentials.json");
183
+ function credentialsExist() {
184
+ return existsSync2(CREDENTIALS_PATH);
185
+ }
186
+ function loadCredentials() {
187
+ try {
188
+ if (!existsSync2(CREDENTIALS_PATH)) {
189
+ return null;
190
+ }
191
+ const raw = readFileSync2(CREDENTIALS_PATH, "utf-8");
192
+ const data = JSON.parse(raw);
193
+ if (!data.bot_token || !data.ilink_bot_id || !data.ilink_user_id || !data.baseurl) {
194
+ return null;
195
+ }
196
+ return data;
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+ function saveCredentials(creds) {
202
+ if (process.platform === "win32") {
203
+ mkdirSync2(CREDENTIALS_DIR, { recursive: true });
204
+ } else {
205
+ mkdirSync2(CREDENTIALS_DIR, { recursive: true, mode: 448 });
206
+ }
207
+ writeFileSync2(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), "utf-8");
208
+ if (process.platform !== "win32") {
209
+ chmodSync(CREDENTIALS_PATH, 384);
210
+ }
211
+ }
212
+
213
+ // src/cli/commands/login.ts
214
+ var FIXED_BASE_URL = "https://ilinkai.weixin.qq.com";
215
+ var BOT_TYPE = 3;
216
+ var APP_ID = "bot";
217
+ var CLIENT_VERSION = 1;
218
+ var QR_REFRESH_MAX = 3;
219
+ async function fetchQrCode() {
220
+ const url = `${FIXED_BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=${BOT_TYPE}`;
221
+ const res = await fetch(url, {
222
+ method: "POST",
223
+ headers: {
224
+ "iLink-App-Id": APP_ID,
225
+ "iLink-App-ClientVersion": String(CLIENT_VERSION)
226
+ },
227
+ body: JSON.stringify({ local_token_list: [] })
228
+ });
229
+ if (!res.ok) {
230
+ throw new Error(`Failed to get QR code: HTTP ${res.status} ${res.statusText}`);
231
+ }
232
+ const data = await res.json();
233
+ if (data.ret !== 0 || !data.qrcode) {
234
+ throw new Error(`Failed to get QR code: ${JSON.stringify(data)}`);
235
+ }
236
+ return {
237
+ qrcode: data.qrcode,
238
+ qrcode_img_content: data.qrcode_img_content ?? ""
239
+ };
240
+ }
241
+ async function pollQrStatus(qrcode, verifyCode) {
242
+ let url = `${FIXED_BASE_URL}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
243
+ if (verifyCode) {
244
+ url += `&verify_code=${encodeURIComponent(verifyCode)}`;
245
+ }
246
+ const res = await fetch(url, {
247
+ method: "GET",
248
+ headers: {
249
+ "iLink-App-Id": APP_ID,
250
+ "iLink-App-ClientVersion": String(CLIENT_VERSION)
251
+ }
252
+ });
253
+ if (!res.ok) {
254
+ throw new Error(`QR status polling failed: HTTP ${res.status} ${res.statusText}`);
255
+ }
256
+ const data = await res.json();
257
+ if (data.data) {
258
+ return data.data;
259
+ }
260
+ return {
261
+ status: data.status ?? "wait",
262
+ bot_token: data.bot_token,
263
+ ilink_bot_id: data.ilink_bot_id,
264
+ ilink_user_id: data.ilink_user_id,
265
+ baseurl: data.baseurl
266
+ };
267
+ }
268
+ async function promptVerifyCode() {
269
+ const rl = createInterface2({ input: processStdin2, output: processStdout2 });
270
+ const code = await rl.question("Enter the verification code shown on your phone: ");
271
+ rl.close();
272
+ return code.trim();
273
+ }
274
+ async function promptRelogin() {
275
+ const rl = createInterface2({ input: processStdin2, output: processStdout2 });
276
+ const answer = await rl.question("Credentials already exist. Re-login? (y/N) ");
277
+ rl.close();
278
+ return answer.trim().toLowerCase() === "y";
279
+ }
280
+ var loginCommand = new Command3("login").description("QR code login to WeChat iLink API").option("-y, --yes", "Skip re-login confirmation", false).action(async (options) => {
281
+ if (credentialsExist()) {
282
+ if (!options.yes) {
283
+ const ok = await promptRelogin();
284
+ if (!ok) {
285
+ console.log("Aborted. Existing credentials kept.");
286
+ return;
287
+ }
288
+ }
289
+ }
290
+ console.log("Fetching QR code...");
291
+ let qrData;
292
+ let qrRefreshCount = 0;
293
+ try {
294
+ qrData = await fetchQrCode();
295
+ } catch (err) {
296
+ console.error("Failed to fetch QR code:", err.message);
297
+ return;
298
+ }
299
+ console.log(`
300
+ Scan the QR code with WeChat to log in:`);
301
+ console.log(qrData.qrcode_img_content);
302
+ console.log();
303
+ try {
304
+ const qrcodeTerminal = await import("qrcode-terminal");
305
+ qrcodeTerminal.default.generate(qrData.qrcode_img_content, {
306
+ small: true
307
+ });
308
+ } catch {
309
+ console.log("QR code URL (open in browser if terminal rendering fails):");
310
+ console.log(qrData.qrcode_img_content);
311
+ }
312
+ let pendingVerifyCode;
313
+ let confirmed = false;
314
+ let pollCount = 0;
315
+ while (!confirmed) {
316
+ pollCount++;
317
+ let statusRes;
318
+ try {
319
+ statusRes = await pollQrStatus(qrData.qrcode, pendingVerifyCode);
320
+ } catch (err) {
321
+ console.error(`
322
+ Polling error, retrying in 3s:`, err.message);
323
+ await new Promise((r) => setTimeout(r, 3000));
324
+ continue;
325
+ }
326
+ switch (statusRes.status) {
327
+ case "wait":
328
+ process.stdout.write(".");
329
+ break;
330
+ case "scaned":
331
+ console.log(`
332
+ Scan detected, verifying...`);
333
+ pendingVerifyCode = undefined;
334
+ break;
335
+ case "need_verifycode":
336
+ pendingVerifyCode = await promptVerifyCode();
337
+ break;
338
+ case "confirmed": {
339
+ confirmed = true;
340
+ const botToken = statusRes.bot_token;
341
+ const botId = statusRes.ilink_bot_id;
342
+ const userId = statusRes.ilink_user_id;
343
+ const baseurl = statusRes.baseurl ?? FIXED_BASE_URL;
344
+ if (!botToken || !botId || !userId) {
345
+ console.error("Login confirmed but credentials incomplete:", JSON.stringify(statusRes));
346
+ return;
347
+ }
348
+ saveCredentials({
349
+ bot_token: botToken,
350
+ ilink_bot_id: botId,
351
+ ilink_user_id: userId,
352
+ baseurl
353
+ });
354
+ console.log(`
355
+ ✅ Login successful!`);
356
+ console.log(" Credentials saved to .opencode/wechat-credentials.json");
357
+ break;
358
+ }
359
+ case "expired": {
360
+ qrRefreshCount++;
361
+ if (qrRefreshCount > QR_REFRESH_MAX) {
362
+ console.error(`
363
+ QR code expired. Max refresh attempts reached. Please try again.`);
364
+ return;
365
+ }
366
+ console.log(`
367
+ QR code expired, refreshing...`);
368
+ try {
369
+ qrData = await fetchQrCode();
370
+ console.log("New QR code:");
371
+ console.log(qrData.qrcode_img_content);
372
+ try {
373
+ const qrcodeTerminal = await import("qrcode-terminal");
374
+ qrcodeTerminal.default.generate(qrData.qrcode_img_content, {
375
+ small: true
376
+ });
377
+ } catch {}
378
+ } catch (err) {
379
+ console.error("Failed to refresh QR code:", err.message);
380
+ return;
381
+ }
382
+ break;
383
+ }
384
+ case "verify_code_blocked":
385
+ console.error(`
386
+ Too many incorrect verification codes. Refreshing QR...`);
387
+ qrRefreshCount++;
388
+ if (qrRefreshCount > QR_REFRESH_MAX) {
389
+ console.error("Max refresh attempts reached. Please try again.");
390
+ return;
391
+ }
392
+ try {
393
+ qrData = await fetchQrCode();
394
+ } catch (err) {
395
+ console.error("Failed to refresh QR code:", err.message);
396
+ return;
397
+ }
398
+ break;
399
+ case "binded_redirect":
400
+ console.log(`
401
+ Already connected to this bot. Login skipped.`);
402
+ confirmed = true;
403
+ break;
404
+ default:
405
+ if (pollCount % 10 === 0) {
406
+ process.stdout.write(".");
407
+ }
408
+ break;
409
+ }
410
+ }
411
+ });
412
+
413
+ // src/cli/commands/poll.ts
414
+ import { Command as Command4 } from "commander";
415
+
416
+ // src/core/ilink-client.ts
417
+ var APP_ID2 = "bot";
418
+ var CLIENT_VERSION2 = 1;
419
+ var CHANNEL_VERSION = "1.0.0";
420
+ var BOT_AGENT = "VClawbot/1.0.0";
421
+ function buildHeaders(creds) {
422
+ const uinBytes = new Uint8Array(4);
423
+ crypto.getRandomValues(uinBytes);
424
+ const uin = Array.from(uinBytes).map((b) => b.toString(16).padStart(2, "0")).join("");
425
+ return {
426
+ "Content-Type": "application/json",
427
+ AuthorizationType: "ilink_bot_token",
428
+ Authorization: `Bearer ${creds.bot_token}`,
429
+ "X-WECHAT-UIN": btoa(String(Number.parseInt(uin, 16))),
430
+ "iLink-App-Id": APP_ID2,
431
+ "iLink-App-ClientVersion": String(CLIENT_VERSION2)
432
+ };
433
+ }
434
+ async function getUpdates(creds, buf, signal) {
435
+ const res = await fetch(`${creds.baseurl}/ilink/bot/getupdates`, {
436
+ method: "POST",
437
+ headers: buildHeaders(creds),
438
+ signal,
439
+ body: JSON.stringify({
440
+ get_updates_buf: buf,
441
+ base_info: { channel_version: CHANNEL_VERSION, bot_agent: BOT_AGENT }
442
+ })
443
+ });
444
+ if (!res.ok) {
445
+ throw new Error(`getUpdates failed: HTTP ${res.status} ${res.statusText}`);
446
+ }
447
+ return res.json();
448
+ }
449
+ function generateClientId() {
450
+ const bytes = new Uint8Array(4);
451
+ crypto.getRandomValues(bytes);
452
+ const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
453
+ return `v-clawbot:${Date.now()}-${hex}`;
454
+ }
455
+ async function sendMessage(creds, toUserId, contextToken, text) {
456
+ const body = {
457
+ base_info: { channel_version: CHANNEL_VERSION, bot_agent: BOT_AGENT },
458
+ msg: {
459
+ from_user_id: "",
460
+ to_user_id: toUserId,
461
+ client_id: generateClientId(),
462
+ message_type: 2,
463
+ message_state: 2,
464
+ context_token: contextToken,
465
+ item_list: [{ type: 1, text_item: { text } }]
466
+ }
467
+ };
468
+ const url = `${creds.baseurl}/ilink/bot/sendmessage`;
469
+ console.log(`[ilink] sendMessage to=${toUserId}, clientId=${body.msg.client_id}`);
470
+ const res = await fetch(url, {
471
+ method: "POST",
472
+ headers: buildHeaders(creds),
473
+ body: JSON.stringify(body)
474
+ });
475
+ const respBody = await res.text();
476
+ console.log(`[ilink] sendMessage response: HTTP ${res.status}, body=${respBody.slice(0, 300)}`);
477
+ if (!res.ok) {
478
+ throw new Error(`sendMessage failed: HTTP ${res.status} ${res.statusText}`);
479
+ }
480
+ let resp = {};
481
+ try {
482
+ resp = JSON.parse(respBody);
483
+ } catch {}
484
+ if (resp.ret !== undefined && resp.ret !== 0) {
485
+ throw new Error(`sendMessage failed: ret=${resp.ret}`);
486
+ }
487
+ }
488
+ async function notifyStart(creds) {
489
+ const res = await fetch(`${creds.baseurl}/ilink/bot/msg/notifystart`, {
490
+ method: "POST",
491
+ headers: buildHeaders(creds),
492
+ body: JSON.stringify({
493
+ base_info: { channel_version: CHANNEL_VERSION, bot_agent: BOT_AGENT }
494
+ })
495
+ });
496
+ if (!res.ok) {
497
+ throw new Error(`notifyStart failed: HTTP ${res.status} ${res.statusText}`);
498
+ }
499
+ }
500
+ async function notifyStop(creds) {
501
+ const res = await fetch(`${creds.baseurl}/ilink/bot/msg/notifystop`, {
502
+ method: "POST",
503
+ headers: buildHeaders(creds),
504
+ body: JSON.stringify({
505
+ base_info: { channel_version: CHANNEL_VERSION, bot_agent: BOT_AGENT }
506
+ })
507
+ });
508
+ if (!res.ok) {
509
+ throw new Error(`notifyStop failed: HTTP ${res.status} ${res.statusText}`);
510
+ }
511
+ }
512
+
513
+ // src/core/state.ts
514
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
515
+ import { homedir } from "node:os";
516
+ import { join as join2 } from "node:path";
517
+ var STATE_DIR = join2(homedir(), ".v-clawbot");
518
+ var STATE_FILE = join2(STATE_DIR, "state.json");
519
+ function ensureStateDir() {
520
+ try {
521
+ if (process.platform === "win32") {
522
+ mkdirSync3(STATE_DIR, { recursive: true });
523
+ } else {
524
+ mkdirSync3(STATE_DIR, { recursive: true, mode: 448 });
525
+ }
526
+ } catch {}
527
+ }
528
+ function loadState() {
529
+ try {
530
+ if (!existsSync3(STATE_FILE)) {
531
+ return { lastActiveUserId: null, users: {} };
532
+ }
533
+ const raw = readFileSync3(STATE_FILE, "utf-8");
534
+ const data = JSON.parse(raw);
535
+ return {
536
+ lastActiveUserId: data.lastActiveUserId ?? null,
537
+ users: data.users ?? {}
538
+ };
539
+ } catch {
540
+ return { lastActiveUserId: null, users: {} };
541
+ }
542
+ }
543
+ function saveState(state) {
544
+ ensureStateDir();
545
+ try {
546
+ writeFileSync3(STATE_FILE, JSON.stringify(state, null, 2) + `
547
+ `, "utf-8");
548
+ } catch {}
549
+ }
550
+ function updateUserContext(state, userId, contextToken) {
551
+ const now = Date.now();
552
+ const user = state.users[userId] ?? { contextToken, lastActiveAt: 0 };
553
+ user.contextToken = contextToken;
554
+ user.lastActiveAt = now;
555
+ return {
556
+ ...state,
557
+ lastActiveUserId: userId,
558
+ users: { ...state.users, [userId]: user }
559
+ };
560
+ }
561
+
562
+ // src/core/types.ts
563
+ function classifyPollError(err) {
564
+ if (err instanceof DOMException && err.name === "AbortError") {
565
+ return { category: "abort", retryable: false };
566
+ }
567
+ const error = err;
568
+ const msg = error.message ?? "";
569
+ const code = error.code ?? "";
570
+ if (code === "UNKNOWN_CERTIFICATE_VERIFICATION_ERROR" || msg.includes("certificate") && code !== "CERT_HAS_EXPIRED") {
571
+ return { category: "tls_mislabel", retryable: true };
572
+ }
573
+ if (code === "CERT_HAS_EXPIRED") {
574
+ return { category: "tls_expired", retryable: false };
575
+ }
576
+ if ([
577
+ "ECONNRESET",
578
+ "ECONNREFUSED",
579
+ "ETIMEDOUT",
580
+ "UND_ERR_CONNECT_TIMEOUT",
581
+ "timeout"
582
+ ].includes(code)) {
583
+ return { category: "network", retryable: true };
584
+ }
585
+ if (error instanceof TypeError) {
586
+ return { category: "network", retryable: true };
587
+ }
588
+ return { category: "unknown", retryable: true };
589
+ }
590
+ function formatPollError(err, category) {
591
+ const error = err;
592
+ const name = error.name ?? "unknown";
593
+ const msg = error.message ?? "";
594
+ const code = error.code ?? "";
595
+ let detail = `[${name}] ${msg}`;
596
+ if (code)
597
+ detail += ` (code: ${code})`;
598
+ return `[wechat-bridge] Polling error [${category.category}]: ${detail}`;
599
+ }
600
+ function getBackoffMs(failureCount) {
601
+ return Math.min(1000 * 2 ** (failureCount - 1), 60000);
602
+ }
603
+
604
+ // src/cli/commands/poll.ts
605
+ var MESSAGE_CACHE_SIZE = 1000;
606
+ function createDedup(capacity = MESSAGE_CACHE_SIZE) {
607
+ const set = new Set;
608
+ return {
609
+ add: (id) => {
610
+ if (set.has(id))
611
+ return false;
612
+ set.add(id);
613
+ if (set.size > capacity) {
614
+ const first = set.values().next();
615
+ if (first.value !== undefined)
616
+ set.delete(first.value);
617
+ }
618
+ return true;
619
+ }
620
+ };
621
+ }
622
+ function formatMessage(msg, text) {
623
+ return JSON.stringify({
624
+ type: "message",
625
+ from: msg.from_user_id,
626
+ text,
627
+ timestamp: msg.create_time_ms
628
+ });
629
+ }
630
+ function formatError(message, retryable) {
631
+ return JSON.stringify({
632
+ type: "error",
633
+ message,
634
+ retryable
635
+ });
636
+ }
637
+ function formatStatus(state) {
638
+ return JSON.stringify({
639
+ type: "status",
640
+ state
641
+ });
642
+ }
643
+ async function pollLoop() {
644
+ const creds = loadCredentials();
645
+ if (!creds) {
646
+ console.error("No credentials found. Run 'v-clawbot login' first.");
647
+ process.exit(1);
648
+ }
649
+ try {
650
+ await notifyStart(creds);
651
+ } catch {}
652
+ const dedup = createDedup();
653
+ let updatesBuf = "";
654
+ let consecutiveFailures = 0;
655
+ let silenced = false;
656
+ console.log(formatStatus("connected"));
657
+ const controller = new AbortController;
658
+ process.on("SIGINT", async () => {
659
+ controller.abort();
660
+ try {
661
+ await notifyStop(creds);
662
+ } catch {}
663
+ process.exit(0);
664
+ });
665
+ process.on("SIGTERM", async () => {
666
+ controller.abort();
667
+ try {
668
+ await notifyStop(creds);
669
+ } catch {}
670
+ process.exit(0);
671
+ });
672
+ while (!controller.signal.aborted) {
673
+ try {
674
+ const resp = await getUpdates(creds, updatesBuf, controller.signal);
675
+ if (resp.ret !== undefined && resp.ret !== 0) {
676
+ const errMsg = `getUpdates error: ret=${resp.ret}`;
677
+ console.error(errMsg);
678
+ console.log(formatError(errMsg, resp.ret === -14));
679
+ await sleep(5000);
680
+ continue;
681
+ }
682
+ updatesBuf = resp.get_updates_buf ?? updatesBuf;
683
+ if (resp.msgs && resp.msgs.length > 0) {
684
+ for (const msg of resp.msgs) {
685
+ if (msg.message_type !== 1)
686
+ continue;
687
+ if (!dedup.add(msg.message_id))
688
+ continue;
689
+ const text = msg.item_list.filter((item) => item.type === 1 && item.text_item).map((item) => item.text_item.text).join(`
690
+ `);
691
+ if (!text)
692
+ continue;
693
+ let state = loadState();
694
+ state = updateUserContext(state, msg.from_user_id, msg.context_token);
695
+ saveState(state);
696
+ console.log(formatMessage(msg, text));
697
+ }
698
+ }
699
+ if (consecutiveFailures > 0) {
700
+ if (silenced) {
701
+ console.error(`Polling recovered after ${consecutiveFailures} failures`);
702
+ }
703
+ consecutiveFailures = 0;
704
+ silenced = false;
705
+ }
706
+ } catch (err) {
707
+ if (controller.signal.aborted)
708
+ break;
709
+ const cat = classifyPollError(err);
710
+ if (cat.category === "abort")
711
+ break;
712
+ consecutiveFailures++;
713
+ if (consecutiveFailures >= 10) {
714
+ if (consecutiveFailures === 10) {
715
+ const lastErr = formatPollError(err, cat);
716
+ console.error(`Polling failed ${consecutiveFailures} times, silencing further errors`);
717
+ console.error(`Last error: ${lastErr}`);
718
+ }
719
+ silenced = true;
720
+ } else {
721
+ const line = formatPollError(err, cat);
722
+ console.error(line);
723
+ console.log(formatError(line, cat.retryable));
724
+ }
725
+ const delay = getBackoffMs(consecutiveFailures);
726
+ await sleep(delay);
727
+ }
728
+ }
729
+ }
730
+ function sleep(ms) {
731
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
732
+ }
733
+ var pollCommand = new Command4("poll").description("Long poll iLink API and output messages as JSON lines").action(async () => {
734
+ try {
735
+ await pollLoop();
736
+ } catch (err) {
737
+ console.error("Poll loop crashed:", err.message);
738
+ process.exit(1);
739
+ }
740
+ });
741
+
742
+ // src/cli/commands/send.ts
743
+ import { Command as Command5 } from "commander";
744
+ var sendCommand = new Command5("send").description("Send message to WeChat user").argument("<text>", "Message text to send").option("--to <userId>", "Target user ID (uses last active user if not provided)").action(async (text, options) => {
745
+ const creds = loadCredentials();
746
+ if (!creds) {
747
+ console.error("No credentials found. Run 'v-clawbot login' first.");
748
+ process.exit(1);
749
+ }
750
+ const state = loadState();
751
+ let userId = options.to;
752
+ if (!userId) {
753
+ userId = state.lastActiveUserId;
754
+ if (!userId) {
755
+ console.error("No target user specified and no active user found. Use --to <userId>.");
756
+ process.exit(1);
757
+ }
758
+ }
759
+ const userCtx = state.users[userId];
760
+ if (!userCtx) {
761
+ console.error(`No context found for user ${userId}. Message must be received first.`);
762
+ process.exit(1);
763
+ }
764
+ try {
765
+ await sendMessage(creds, userId, userCtx.contextToken, text);
766
+ } catch (err) {
767
+ console.error("Failed to send message:", err.message);
768
+ process.exit(1);
769
+ }
770
+ });
771
+
772
+ // src/cli/index.ts
773
+ var DIRNAME2 = dirname2(fileURLToPath2(import.meta.url));
774
+ function loadVersion() {
775
+ try {
776
+ const pkgPath = resolve2(DIRNAME2, "../../package.json");
777
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf-8"));
778
+ return pkg.version ?? "0.0.0";
779
+ } catch {
780
+ return "0.0.0";
781
+ }
782
+ }
783
+ var program = new Command6;
784
+ program.name("v-clawbot").description("CLI tool for WeChat integration with OpenCode and Cursor").version(loadVersion());
785
+ program.addCommand(initCommand);
786
+ program.addCommand(loginCommand);
787
+ program.addCommand(pollCommand);
788
+ program.addCommand(chatCommand);
789
+ program.addCommand(sendCommand);
790
+ program.parse(process.argv);
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "v-clawbot",
3
+ "version": "0.1.0",
4
+ "description": "在微信 ClawBot 中与 AI 对话的插件",
5
+ "keywords": [
6
+ "wechat",
7
+ "clawbot",
8
+ "opencode",
9
+ "plugin",
10
+ "v-clawbot"
11
+ ],
12
+ "author": "LZH <liu.zhenghui@outlook.com>",
13
+ "type": "module",
14
+ "bin": {
15
+ "v-clawbot": "./dist/index.js"
16
+ },
17
+ "exports": {
18
+ ".": "./dist/index.js"
19
+ },
20
+ "main": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "files": [
23
+ "dist",
24
+ "plugins"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://gitee.com/liuzhenghui/v-clawbot"
29
+ },
30
+ "bugs": {
31
+ "url": "https://gitee.com/liuzhenghui/v-clawbot/issues"
32
+ },
33
+ "scripts": {
34
+ "build": "bun run build.ts",
35
+ "test": "bun test",
36
+ "test:cov": "bun test --coverage",
37
+ "test:init": "bun run build && cd travel-example && node ../dist/index.js init -y",
38
+ "test:login": "bun run build && cd travel-example && node ../dist/index.js login -y",
39
+ "test:start": "cd travel-example && opencode web",
40
+ "test:poll": "bun run build && cd travel-example && node ../dist/index.js poll",
41
+ "test:send": "bun run build && cd travel-example && node ../dist/index.js send",
42
+ "test:status": "bun run scripts/check-status.ts",
43
+ "logs": "bun run scripts/tail-log.ts",
44
+ "logs:poll": "opencode logs --follow",
45
+ "clean": "bun run scripts/clean-dist.ts",
46
+ "lint": "biome check .",
47
+ "lint:fix": "biome check --write .",
48
+ "typecheck": "tsc --noEmit"
49
+ },
50
+ "dependencies": {
51
+ "@opencode-ai/plugin": "1.15.6"
52
+ },
53
+ "devDependencies": {
54
+ "@biomejs/biome": "^2.4.16",
55
+ "@types/bun": "^1.3.14",
56
+ "@types/node": "^22.0.0",
57
+ "commander": "^12.0.0",
58
+ "qrcode-terminal": "^0.12.0",
59
+ "typescript": "^5.7.0"
60
+ },
61
+ "engines": {
62
+ "node": ">=18",
63
+ "bun": ">=1.3"
64
+ },
65
+ "license": "MIT",
66
+ "publishConfig": {
67
+ "registry": "https://registry.npmjs.org/"
68
+ }
69
+ }
@@ -0,0 +1,16 @@
1
+ // Cursor platform plugin placeholder
2
+ // TODO: Implement Cursor extension integration
3
+
4
+ export default {
5
+ id: "v-clawbot-cursor",
6
+ name: "V-ClawBot for Cursor",
7
+ version: "0.1.0",
8
+ description: "WeChat integration plugin for Cursor",
9
+ activate: async () => {
10
+ console.log("[v-clawbot-cursor] Plugin activated");
11
+ // TODO: Implement Cursor extension activation
12
+ },
13
+ deactivate: async () => {
14
+ console.log("[v-clawbot-cursor] Plugin deactivated");
15
+ },
16
+ };
@@ -0,0 +1,125 @@
1
+ import type { PluginModule, PluginInput } from "@opencode-ai/plugin";
2
+ import { spawn } from "node:child_process";
3
+ import { createInterface } from "node:readline";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, resolve } from "node:path";
6
+ import { existsSync } from "node:fs";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // CLI 路径解析
10
+ // ---------------------------------------------------------------------------
11
+
12
+ function resolveCliPath(): string {
13
+ const pluginDir = dirname(fileURLToPath(import.meta.url));
14
+ let dir = pluginDir;
15
+ for (let i = 0; i < 5; i++) {
16
+ for (const p of [
17
+ resolve(dir, "dist/index.js"),
18
+ resolve(dir, "node_modules/v-clawbot/dist/index.js"),
19
+ ]) {
20
+ if (existsSync(p)) return p;
21
+ }
22
+ dir = resolve(dir, "..");
23
+ }
24
+ return "v-clawbot";
25
+ }
26
+
27
+ const cliPath = resolveCliPath();
28
+ const isNode = cliPath !== "v-clawbot";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // 辅助函数
32
+ // ---------------------------------------------------------------------------
33
+
34
+ function runCommand(command: string, args: string[]): Promise<string> {
35
+ return new Promise((resolve, reject) => {
36
+ const proc = spawn(command, args, {
37
+ stdio: ["pipe", "pipe", "pipe"],
38
+ shell: true,
39
+ });
40
+
41
+ let stdout = "";
42
+ let stderr = "";
43
+
44
+ proc.stdout.on("data", (data) => {
45
+ stdout += data.toString();
46
+ });
47
+
48
+ proc.stderr.on("data", (data) => {
49
+ stderr += data.toString();
50
+ });
51
+
52
+ proc.on("close", (code) => {
53
+ if (code === 0) {
54
+ resolve(stdout.trim());
55
+ } else {
56
+ reject(new Error(`Command failed with code ${code}: ${stderr}`));
57
+ }
58
+ });
59
+
60
+ proc.on("error", reject);
61
+ });
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // 插件实现
66
+ // ---------------------------------------------------------------------------
67
+
68
+ const wechatPlugin: PluginModule = {
69
+ id: "v-clawbot",
70
+ server: async (input: PluginInput) => {
71
+ const { serverUrl } = input;
72
+ const baseUrl = serverUrl.origin;
73
+
74
+ console.log("[v-clawbot] Plugin started, server URL:", baseUrl);
75
+
76
+ // 启动 poll 进程
77
+ const poll = isNode
78
+ ? spawn("node", [cliPath, "poll"], { stdio: ["pipe", "pipe", "inherit"], shell: true })
79
+ : spawn(cliPath, ["poll"], { stdio: ["pipe", "pipe", "inherit"], shell: true });
80
+
81
+ const rl = createInterface({ input: poll.stdout! });
82
+
83
+ rl.on("line", async (line) => {
84
+ try {
85
+ const msg = JSON.parse(line);
86
+
87
+ if (msg.type === "message") {
88
+ console.log(`[v-clawbot] Received message from ${msg.from}: "${msg.text.slice(0, 50)}..."`);
89
+
90
+ // 调用 chat 命令发送到 opencode
91
+ const aiReply = isNode
92
+ ? await runCommand("node", [cliPath, "chat", msg.text, "--server", baseUrl])
93
+ : await runCommand(cliPath, ["chat", msg.text, "--server", baseUrl]);
94
+
95
+ if (aiReply) {
96
+ console.log(`[v-clawbot] AI response: "${aiReply.slice(0, 50)}..."`);
97
+
98
+ // 调用 send 命令发送回微信
99
+ await (isNode
100
+ ? runCommand("node", [cliPath, "send", aiReply])
101
+ : runCommand(cliPath, ["send", aiReply]));
102
+ console.log("[v-clawbot] Reply sent to WeChat");
103
+ }
104
+ } else if (msg.type === "error") {
105
+ console.error(`[v-clawbot] Poll error: ${msg.message}`);
106
+ }
107
+ } catch (err) {
108
+ console.error("[v-clawbot] Error processing message:", (err as Error).message);
109
+ }
110
+ });
111
+
112
+ poll.on("close", (code) => {
113
+ console.log(`[v-clawbot] Poll process exited with code ${code}`);
114
+ });
115
+
116
+ poll.on("error", (err) => {
117
+ console.error("[v-clawbot] Failed to start poll:", err.message);
118
+ });
119
+
120
+ // 返回空对象,插件不需要额外的 hooks
121
+ return {};
122
+ },
123
+ };
124
+
125
+ export default wechatPlugin;