@licity/qclaw-local-connector 1.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/index.js ADDED
@@ -0,0 +1,986 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('fs');
3
+ const net = require('net');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+ const { execFile, spawn } = require('child_process');
8
+ const readline = require('readline');
9
+ const { promisify } = require('util');
10
+ const qrcode = require('qrcode-terminal');
11
+
12
+ // 优先从运行目录加载 .env(全局安装时从 CWD),否则从安装包目录加载
13
+ const envFromCwd = path.join(process.cwd(), '.env');
14
+ const envFromPkg = path.join(__dirname, '.env');
15
+ require('dotenv').config({ path: fs.existsSync(envFromCwd) ? envFromCwd : envFromPkg });
16
+
17
+ const pkg = require('./package.json');
18
+
19
+ const apiBaseUrl = String(process.env.LICITY_API_BASE_URL || 'https://li.city').replace(/\/$/, '');
20
+ const connectorKey = String(process.env.OPENCLAW_CONNECTOR_SECRET || '').trim();
21
+ const provider = String(process.env.CONNECTOR_PROVIDER || 'qclaw-local').trim();
22
+ const deviceName = String(process.env.DEVICE_NAME || os.hostname()).trim();
23
+ const qclawPath = String(process.env.QCLAW_PATH || 'D:\\QClaw\\QClaw.exe').trim();
24
+ const qclawInstallDir = path.dirname(qclawPath);
25
+ const qclawCliWrapperPath = path.join(qclawInstallDir, 'resources', 'openclaw', 'config', 'skills', 'qclaw-openclaw', 'scripts', 'openclaw-win.cmd');
26
+ const qclawStateDir = String(process.env.QCLAW_STATE_DIR || path.join(os.homedir(), '.qclaw')).trim();
27
+ const qclawConfigPath = String(process.env.QCLAW_CONFIG_PATH || path.join(qclawStateDir, 'qclaw.json')).trim();
28
+ const openclawConfigPath = String(process.env.OPENCLAW_CONFIG_PATH || path.join(qclawStateDir, 'openclaw.json')).trim();
29
+ const openclawCliPath = String(process.env.OPENCLAW_CLI_PATH || path.join(qclawInstallDir, 'resources', 'openclaw', 'node_modules', 'openclaw', 'openclaw.mjs')).trim();
30
+ const openclawAgentId = String(process.env.OPENCLAW_AGENT_ID || 'main').trim();
31
+ const openclawCommandTimeoutMs = Number(process.env.OPENCLAW_COMMAND_TIMEOUT_MS || 60000);
32
+ const heartbeatIntervalMs = Number(process.env.HEARTBEAT_INTERVAL_MS || 25000);
33
+ const pollIntervalMs = Number(process.env.POLL_INTERVAL_MS || 3000);
34
+ const maxEmbeddedMediaBytes = Number(process.env.MAX_EMBEDDED_MEDIA_BYTES || 8 * 1024 * 1024);
35
+ const capabilityScopes = String(process.env.CAPABILITY_SCOPES || 'private_chat,neighbor,anchor,time_travel')
36
+ .split(',')
37
+ .map((item) => item.trim())
38
+ .filter(Boolean);
39
+ const execFileAsync = promisify(execFile);
40
+
41
+ if (!connectorKey) {
42
+ console.error('缺少 OPENCLAW_CONNECTOR_SECRET,无法启动本地 Connector。');
43
+ process.exit(1);
44
+ }
45
+
46
+ const state = {
47
+ shouldRun: true,
48
+ reconnectRequested: false,
49
+ mode: 'idle',
50
+ currentSessionId: null,
51
+ currentLobster: null,
52
+ heartbeatCount: 0,
53
+ };
54
+
55
+ // 全局安装时数据目录在运行目录下,本地开发时在包目录的 data/
56
+ const isGlobalInstall = path.resolve(process.cwd()) !== path.resolve(__dirname);
57
+ const dataDir = isGlobalInstall
58
+ ? path.join(process.cwd(), '.licity-connector')
59
+ : path.join(__dirname, 'data');
60
+ const runtimeFile = path.join(dataDir, 'runtime.json');
61
+
62
+ function ensureRuntimeId() {
63
+ fs.mkdirSync(dataDir, { recursive: true });
64
+
65
+ if (fs.existsSync(runtimeFile)) {
66
+ try {
67
+ const saved = JSON.parse(fs.readFileSync(runtimeFile, 'utf8'));
68
+ if (saved && typeof saved.runtimeId === 'string' && saved.runtimeId.trim()) {
69
+ return saved.runtimeId.trim();
70
+ }
71
+ } catch (error) {
72
+ console.warn('读取本地 runtime.json 失败,将重新生成 runtimeId。');
73
+ }
74
+ }
75
+
76
+ const runtimeId = crypto.randomUUID();
77
+ fs.writeFileSync(runtimeFile, JSON.stringify({ runtimeId, createdAt: new Date().toISOString() }, null, 2));
78
+ return runtimeId;
79
+ }
80
+
81
+ const runtimeId = ensureRuntimeId();
82
+ const screenshotDir = path.join(dataDir, 'screenshots');
83
+
84
+ function sleep(ms) {
85
+ return new Promise((resolve) => setTimeout(resolve, ms));
86
+ }
87
+
88
+ function ensureDir(dirPath) {
89
+ fs.mkdirSync(dirPath, { recursive: true });
90
+ }
91
+
92
+ function readJsonFileIfExists(filePath) {
93
+ if (!filePath || !fs.existsSync(filePath)) {
94
+ return null;
95
+ }
96
+
97
+ try {
98
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
99
+ } catch (error) {
100
+ console.warn(`读取 JSON 配置失败: ${filePath}`);
101
+ return null;
102
+ }
103
+ }
104
+
105
+ function toWebSocketUrl(rawUrl) {
106
+ const source = String(rawUrl || '').trim();
107
+ if (!source) return '';
108
+ if (source.startsWith('https://')) {
109
+ return `wss://${source.slice('https://'.length)}`;
110
+ }
111
+ if (source.startsWith('http://')) {
112
+ return `ws://${source.slice('http://'.length)}`;
113
+ }
114
+ return source;
115
+ }
116
+
117
+ function buildOpenClawChildEnv() {
118
+ const qclawConfig = readJsonFileIfExists(qclawConfigPath) || {};
119
+ const openclawConfig = readJsonFileIfExists(openclawConfigPath) || {};
120
+ const qclawGatewayBaseUrl = String(qclawConfig.authGatewayBaseUrl || '').trim();
121
+ const gatewayToken = String(openclawConfig?.gateway?.auth?.token || '').trim();
122
+ const modelBaseUrl = String(process.env.QCLAW_LLM_BASE_URL || qclawGatewayBaseUrl).trim();
123
+ const modelApiKey = String(process.env.QCLAW_LLM_API_KEY || gatewayToken).trim();
124
+ const wechatWsUrl = String(process.env.QCLAW_WECHAT_WS_URL || toWebSocketUrl(qclawGatewayBaseUrl)).trim();
125
+
126
+ return {
127
+ ...process.env,
128
+ OPENCLAW_CONFIG_PATH: openclawConfigPath,
129
+ OPENCLAW_STATE_DIR: qclawStateDir,
130
+ OPENCLAW_NIX_MODE: '1',
131
+ NODE_OPTIONS: '--no-warnings',
132
+ ...(modelBaseUrl ? { QCLAW_LLM_BASE_URL: modelBaseUrl } : {}),
133
+ ...(modelApiKey ? { QCLAW_LLM_API_KEY: modelApiKey } : {}),
134
+ ...(wechatWsUrl ? { QCLAW_WECHAT_WS_URL: wechatWsUrl } : {}),
135
+ };
136
+ }
137
+
138
+ function parseLoopbackPort(rawUrl) {
139
+ try {
140
+ const parsed = new URL(String(rawUrl || '').trim());
141
+ const host = String(parsed.hostname || '').toLowerCase();
142
+ if (!['127.0.0.1', 'localhost'].includes(host)) return null;
143
+ return Number(parsed.port || (parsed.protocol === 'https:' || parsed.protocol === 'wss:' ? 443 : 80));
144
+ } catch (error) {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function probeTcpPort(port, timeoutMs = 1500) {
150
+ return new Promise((resolve) => {
151
+ if (!port) {
152
+ resolve(false);
153
+ return;
154
+ }
155
+
156
+ const socket = new net.Socket();
157
+ let settled = false;
158
+
159
+ const finish = (result) => {
160
+ if (settled) return;
161
+ settled = true;
162
+ socket.destroy();
163
+ resolve(result);
164
+ };
165
+
166
+ socket.setTimeout(timeoutMs);
167
+ socket.once('connect', () => finish(true));
168
+ socket.once('timeout', () => finish(false));
169
+ socket.once('error', () => finish(false));
170
+ socket.connect(port, '127.0.0.1');
171
+ });
172
+ }
173
+
174
+ async function probeGatewayAccess(baseUrl, apiKey) {
175
+ const source = String(baseUrl || '').replace(/\/$/, '').trim();
176
+ if (!source) {
177
+ return { ok: false, status: 0, message: '未配置模型网关地址' };
178
+ }
179
+
180
+ try {
181
+ const response = await fetch(`${source}/models`, {
182
+ method: 'GET',
183
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
184
+ });
185
+ const rawText = await response.text();
186
+ let parsed = null;
187
+ try {
188
+ parsed = rawText ? JSON.parse(rawText) : null;
189
+ } catch (error) {
190
+ parsed = null;
191
+ }
192
+
193
+ if (response.ok) {
194
+ return {
195
+ ok: true,
196
+ status: response.status,
197
+ message: parsed?.data?.length ? `已返回 ${parsed.data.length} 个模型` : '模型网关可访问',
198
+ };
199
+ }
200
+
201
+ return {
202
+ ok: false,
203
+ status: response.status,
204
+ message: parsed?.error?.message || parsed?.message || rawText || `HTTP ${response.status}`,
205
+ };
206
+ } catch (error) {
207
+ return {
208
+ ok: false,
209
+ status: 0,
210
+ message: error.message || '探测模型网关失败',
211
+ };
212
+ }
213
+ }
214
+
215
+ async function runPreflightChecks() {
216
+ const childEnv = buildOpenClawChildEnv();
217
+ const modelBaseUrl = String(childEnv.QCLAW_LLM_BASE_URL || '').trim();
218
+ const modelApiKey = String(childEnv.QCLAW_LLM_API_KEY || '').trim();
219
+ const wsUrl = String(childEnv.QCLAW_WECHAT_WS_URL || '').trim();
220
+ const modelPort = parseLoopbackPort(modelBaseUrl);
221
+ const wsPort = parseLoopbackPort(wsUrl);
222
+ const gatewayProbe = modelPort ? await probeGatewayAccess(modelBaseUrl, modelApiKey) : { ok: false, status: 0, message: '未执行探测' };
223
+
224
+ return {
225
+ openclawConfigExists: fs.existsSync(openclawConfigPath),
226
+ openclawCliExists: fs.existsSync(openclawCliPath),
227
+ modelBaseUrl,
228
+ modelApiKey,
229
+ wsUrl,
230
+ modelPort,
231
+ wsPort,
232
+ modelPortReady: modelPort ? await probeTcpPort(modelPort) : null,
233
+ wsPortReady: wsPort ? await probeTcpPort(wsPort) : null,
234
+ gatewayProbe,
235
+ };
236
+ }
237
+
238
+ async function requestJson(endpoint, options = {}) {
239
+ const url = `${apiBaseUrl}${endpoint}`;
240
+ const response = await fetch(url, {
241
+ method: options.method || 'GET',
242
+ headers: {
243
+ 'Content-Type': 'application/json',
244
+ 'x-connector-key': connectorKey,
245
+ ...(options.headers || {}),
246
+ },
247
+ body: options.body ? JSON.stringify(options.body) : undefined,
248
+ });
249
+
250
+ const rawText = await response.text();
251
+ let data = {};
252
+
253
+ if (rawText) {
254
+ try {
255
+ data = JSON.parse(rawText);
256
+ } catch (error) {
257
+ data = { raw: rawText };
258
+ }
259
+ }
260
+
261
+ if (!response.ok) {
262
+ const error = new Error(data.error || data.raw || `请求失败: ${response.status}`);
263
+ error.status = response.status;
264
+ throw error;
265
+ }
266
+
267
+ return data;
268
+ }
269
+
270
+ function printBanner() {
271
+ console.log('========================================');
272
+ console.log(' 里世界 QClaw 本地龙虾 Connector');
273
+ console.log('========================================');
274
+ console.log(`API: ${apiBaseUrl}`);
275
+ console.log(`Provider: ${provider}`);
276
+ console.log(`Runtime ID: ${runtimeId}`);
277
+ console.log(`设备名: ${deviceName}`);
278
+ console.log(`QClaw 路径: ${qclawPath}`);
279
+ console.log(`OpenClaw 配置: ${openclawConfigPath}`);
280
+ console.log(`能力域: ${capabilityScopes.join(', ') || '无'}`);
281
+ console.log('命令: 输入 r 重新生成二维码,输入 s 查看状态,输入 q 退出');
282
+ console.log('');
283
+ }
284
+
285
+ function printPreflightResult(preflight) {
286
+ console.log('启动前检查:');
287
+ console.log(`- OpenClaw 配置文件: ${preflight.openclawConfigExists ? '已找到' : '未找到'}`);
288
+ console.log(`- OpenClaw CLI: ${preflight.openclawCliExists ? '已找到' : '未找到'}`);
289
+ if (preflight.modelBaseUrl) {
290
+ console.log(`- 模型网关: ${preflight.modelBaseUrl}`);
291
+ if (preflight.modelPort) {
292
+ console.log(` 本地端口 ${preflight.modelPort}: ${preflight.modelPortReady ? '已监听' : '未监听'}`);
293
+ }
294
+ console.log(` 网关访问: ${preflight.gatewayProbe.ok ? '成功' : '失败'}${preflight.gatewayProbe.message ? ` (${preflight.gatewayProbe.message})` : ''}`);
295
+ }
296
+ if (preflight.wsUrl) {
297
+ console.log(`- 微信通道: ${preflight.wsUrl}`);
298
+ if (preflight.wsPort) {
299
+ console.log(` 本地端口 ${preflight.wsPort}: ${preflight.wsPortReady ? '已监听' : '未监听'}`);
300
+ }
301
+ }
302
+ if (preflight.modelPort && !preflight.modelPortReady) {
303
+ console.log('! 检测到本地模型端口未启动。仅启动连接器不够,还需要先启动 QClaw 主程序并保持其本地网关可用。');
304
+ }
305
+ if (preflight.gatewayProbe?.message && String(preflight.gatewayProbe.message).includes('Access denied (PID)')) {
306
+ console.log('! 当前连接器进程访问 QClaw 模型代理时被本机 PID 策略拒绝。');
307
+ console.log('! 这说明端口虽然已监听,但普通问答任务仍无法由外部 node 进程真正执行。');
308
+ }
309
+ console.log('');
310
+ }
311
+
312
+ function extractLastJsonObject(rawText) {
313
+ const source = String(rawText || '').trim();
314
+ if (!source) return null;
315
+
316
+ for (let index = source.lastIndexOf('{'); index >= 0; index = source.lastIndexOf('{', index - 1)) {
317
+ const candidate = source.slice(index).trim();
318
+ try {
319
+ return JSON.parse(candidate);
320
+ } catch (error) {
321
+ continue;
322
+ }
323
+ }
324
+
325
+ return null;
326
+ }
327
+
328
+ function normalizeAgentReply(result) {
329
+ const payloads = Array.isArray(result?.result?.payloads) ? result.result.payloads : [];
330
+ const textParts = payloads
331
+ .map((payload) => String(payload?.text || '').trim())
332
+ .filter(Boolean);
333
+
334
+ if (textParts.length > 0) {
335
+ return textParts.join('\n\n');
336
+ }
337
+
338
+ const directReply = [
339
+ result?.result?.text,
340
+ result?.result?.output,
341
+ result?.text,
342
+ result?.message,
343
+ ].map((item) => String(item || '').trim()).find(Boolean);
344
+
345
+ if (directReply) {
346
+ return directReply;
347
+ }
348
+
349
+ return '';
350
+ }
351
+
352
+ function guessMimeTypeFromPath(filePath) {
353
+ const ext = path.extname(String(filePath || '')).toLowerCase();
354
+ if (['.jpg', '.jpeg'].includes(ext)) return 'image/jpeg';
355
+ if (ext === '.png') return 'image/png';
356
+ if (ext === '.gif') return 'image/gif';
357
+ if (ext === '.webp') return 'image/webp';
358
+ if (ext === '.pdf') return 'application/pdf';
359
+ if (ext === '.zip') return 'application/zip';
360
+ if (ext === '.json') return 'application/json';
361
+ if (ext === '.csv') return 'text/csv';
362
+ if (ext === '.txt') return 'text/plain';
363
+ if (ext === '.docx') return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
364
+ if (ext === '.xlsx') return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
365
+ return 'application/octet-stream';
366
+ }
367
+
368
+ function normalizeMediaType(mediaType, mimeType, fileName) {
369
+ const explicit = String(mediaType || '').toLowerCase().trim();
370
+ if (['image', 'video', 'file'].includes(explicit)) {
371
+ return explicit;
372
+ }
373
+ const mime = String(mimeType || '').toLowerCase().trim();
374
+ if (mime.startsWith('image/')) return 'image';
375
+ if (mime.startsWith('video/')) return 'video';
376
+ const ext = path.extname(String(fileName || '')).toLowerCase();
377
+ if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'].includes(ext)) return 'image';
378
+ if (['.mp4', '.mov', '.avi', '.mkv', '.webm'].includes(ext)) return 'video';
379
+ return 'file';
380
+ }
381
+
382
+ function extractAgentMedia(result) {
383
+ const payloads = Array.isArray(result?.result?.payloads) ? result.result.payloads : [];
384
+ for (const payload of payloads) {
385
+ if (!payload || typeof payload !== 'object') continue;
386
+
387
+ const mediaBase64 = payload.media_base64 || payload.mediaBase64 || payload.file_base64 || payload.fileBase64 || null;
388
+ const localPath = payload.media_file_path || payload.mediaFilePath || payload.file_path || payload.filePath || null;
389
+ const declaredName = payload.media_name || payload.mediaName || payload.file_name || payload.fileName || null;
390
+ const declaredMime = payload.media_mime_type || payload.mediaMimeType || payload.mime_type || payload.mimeType || null;
391
+ const derivedName = declaredName || (localPath ? path.basename(localPath) : 'connector-file');
392
+ const derivedMime = declaredMime || (localPath ? guessMimeTypeFromPath(localPath) : 'application/octet-stream');
393
+ const derivedType = normalizeMediaType(payload.media_type || payload.type, derivedMime, derivedName);
394
+
395
+ if (mediaBase64) {
396
+ return {
397
+ media_base64: mediaBase64,
398
+ media_type: derivedType,
399
+ media_mime_type: derivedMime,
400
+ media_name: derivedName,
401
+ };
402
+ }
403
+
404
+ if (localPath && fs.existsSync(localPath)) {
405
+ const stat = fs.statSync(localPath);
406
+ if (stat.size > maxEmbeddedMediaBytes) {
407
+ throw new Error(`附件过大,当前仅支持回传 ${Math.floor(maxEmbeddedMediaBytes / 1024 / 1024)}MB 以内文件:${path.basename(localPath)}`);
408
+ }
409
+
410
+ return {
411
+ media_base64: fs.readFileSync(localPath).toString('base64'),
412
+ media_type: derivedType,
413
+ media_mime_type: derivedMime,
414
+ media_name: derivedName,
415
+ };
416
+ }
417
+ }
418
+
419
+ return null;
420
+ }
421
+
422
+ function parseAgentSuccess(rawStdout) {
423
+ const parsed = extractLastJsonObject(rawStdout);
424
+ if (!parsed) {
425
+ return null;
426
+ }
427
+
428
+ const isSuccess = parsed.status === 'ok' || parsed.ok === true || parsed.success === true || parsed.type === 'result';
429
+ if (!isSuccess) {
430
+ return null;
431
+ }
432
+
433
+ const reply = normalizeAgentReply(parsed);
434
+ let media = null;
435
+ try {
436
+ media = extractAgentMedia(parsed);
437
+ } catch (error) {
438
+ return {
439
+ reply: `我已生成附件,但当前附件不能直接回传到里世界。原因:${error.message || '附件处理失败'}。`,
440
+ media: null,
441
+ raw: parsed,
442
+ };
443
+ }
444
+ if (!reply && !media) {
445
+ return null;
446
+ }
447
+
448
+ return {
449
+ reply,
450
+ media,
451
+ raw: parsed,
452
+ };
453
+ }
454
+
455
+ async function killProcessTree(pid) {
456
+ if (!pid) return;
457
+
458
+ try {
459
+ await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F'], {
460
+ windowsHide: true,
461
+ timeout: 15000,
462
+ });
463
+ } catch (error) {
464
+ // 进程可能已经正常退出,此时无需额外处理。
465
+ }
466
+ }
467
+
468
+ async function runOpenClawAgent(task) {
469
+ if (!fs.existsSync(openclawConfigPath)) {
470
+ throw new Error(`未找到 OpenClaw 配置文件:${openclawConfigPath}`);
471
+ }
472
+
473
+ if (!fs.existsSync(openclawCliPath)) {
474
+ throw new Error(`未找到 OpenClaw CLI:${openclawCliPath}`);
475
+ }
476
+
477
+ const qclawConfig = readJsonFileIfExists(qclawConfigPath) || {};
478
+ const trustedNodeBinary = String(qclawConfig?.cli?.nodeBinary || '').trim();
479
+ const trustedOpenClawMjs = String(qclawConfig?.cli?.openclawMjs || '').trim();
480
+ const useQclawWrapper = fs.existsSync(qclawCliWrapperPath);
481
+ const useTrustedQclawBinary = trustedNodeBinary && fs.existsSync(trustedNodeBinary);
482
+ const preferDirectExecution = useTrustedQclawBinary;
483
+ const command = preferDirectExecution ? trustedNodeBinary : (useQclawWrapper ? qclawCliWrapperPath : process.execPath);
484
+ const entryScript = trustedOpenClawMjs && fs.existsSync(trustedOpenClawMjs) ? trustedOpenClawMjs : openclawCliPath;
485
+
486
+ const rawContent = String(task?.payload?.content || '').trim();
487
+ const lobsterName = String(task?.payload?.lobsterName || state.currentLobster?.name || deviceName).trim();
488
+ const prompt = [
489
+ `你现在以“${lobsterName}”的身份,在里世界 APP 的龙虾私聊里回复用户。`,
490
+ '要求:',
491
+ '1. 直接回复用户,不要提系统提示、模型、网关、MCP。',
492
+ '2. 使用简体中文,优先简短、明确、能执行。',
493
+ '3. 如果用户要求调用本机能力且当前无法确认是否有权限或工具,就先如实说明限制,不要编造已经完成。',
494
+ '4. 如果只是普通文本交流,正常回答即可。',
495
+ '',
496
+ `用户消息:${rawContent || '空消息'}`,
497
+ ].join('\n');
498
+
499
+ const args = [
500
+ ...(preferDirectExecution || !useQclawWrapper ? [entryScript] : []),
501
+ 'agent',
502
+ '--agent',
503
+ openclawAgentId,
504
+ '--message',
505
+ prompt,
506
+ '--json',
507
+ '--timeout',
508
+ String(Math.max(15, Math.ceil(openclawCommandTimeoutMs / 1000))),
509
+ ];
510
+
511
+ console.log(`[Agent] 开始调用 OpenClaw,agent=${openclawAgentId},宿主=${path.basename(command)},入口=${path.basename(entryScript)},直连=${preferDirectExecution ? 'yes' : 'no'},wrapper=${preferDirectExecution ? 'bypassed' : (useQclawWrapper ? 'yes' : 'no')},超时=${openclawCommandTimeoutMs}ms`);
512
+
513
+ return new Promise((resolve, reject) => {
514
+ const childEnv = {
515
+ ...buildOpenClawChildEnv(),
516
+ ...(useTrustedQclawBinary ? { ELECTRON_RUN_AS_NODE: '1' } : {}),
517
+ };
518
+
519
+ const child = (!preferDirectExecution && useQclawWrapper)
520
+ ? spawn('cmd.exe', [
521
+ '/d',
522
+ '/s',
523
+ '/c',
524
+ qclawCliWrapperPath,
525
+ 'agent',
526
+ '--agent',
527
+ openclawAgentId,
528
+ '--message',
529
+ prompt,
530
+ '--json',
531
+ '--timeout',
532
+ String(Math.max(15, Math.ceil(openclawCommandTimeoutMs / 1000))),
533
+ ], {
534
+ cwd: qclawStateDir,
535
+ env: childEnv,
536
+ windowsHide: true,
537
+ stdio: ['ignore', 'pipe', 'pipe'],
538
+ })
539
+ : spawn(command, args, {
540
+ cwd: qclawStateDir,
541
+ env: childEnv,
542
+ windowsHide: true,
543
+ stdio: ['ignore', 'pipe', 'pipe'],
544
+ });
545
+
546
+ let stdout = '';
547
+ let stderr = '';
548
+ let settled = false;
549
+
550
+ const finish = async (handler) => {
551
+ if (settled) return;
552
+ settled = true;
553
+ clearTimeout(timer);
554
+ await killProcessTree(child.pid);
555
+ handler();
556
+ };
557
+
558
+ child.stdout.on('data', (chunk) => {
559
+ stdout += chunk.toString();
560
+ const success = parseAgentSuccess(stdout);
561
+ if (success) {
562
+ finish(() => resolve(success));
563
+ }
564
+ });
565
+
566
+ child.stderr.on('data', (chunk) => {
567
+ stderr += chunk.toString();
568
+ });
569
+
570
+ child.on('error', (error) => {
571
+ finish(() => reject(error));
572
+ });
573
+
574
+ child.on('close', (code) => {
575
+ if (settled) return;
576
+
577
+ const success = parseAgentSuccess(stdout);
578
+ if (success) {
579
+ finish(() => resolve(success));
580
+ return;
581
+ }
582
+
583
+ const stderrText = String(stderr || '').trim();
584
+ const stdoutText = String(stdout || '').trim();
585
+ const fallbackMessage = code === null ? 'OpenClaw 进程异常结束' : `OpenClaw 退出码: ${code}`;
586
+ if (stderrText) {
587
+ console.log(`[Agent] stderr: ${stderrText.slice(0, 500)}`);
588
+ }
589
+ if (stdoutText) {
590
+ console.log(`[Agent] stdout: ${stdoutText.slice(0, 500)}`);
591
+ }
592
+ finish(() => reject(new Error(stderrText || stdoutText || fallbackMessage || 'OpenClaw 未返回可解析结果')));
593
+ });
594
+
595
+ const timer = setTimeout(() => {
596
+ const success = parseAgentSuccess(stdout);
597
+ if (success) {
598
+ finish(() => resolve(success));
599
+ return;
600
+ }
601
+
602
+ const stderrText = String(stderr || '').trim();
603
+ const stdoutText = String(stdout || '').trim();
604
+ if (stderrText) {
605
+ console.log(`[Agent] timeout 前 stderr: ${stderrText.slice(0, 500)}`);
606
+ }
607
+ if (stdoutText) {
608
+ console.log(`[Agent] timeout 前 stdout: ${stdoutText.slice(0, 500)}`);
609
+ }
610
+ finish(() => reject(new Error(stderrText || stdoutText || `OpenClaw 执行超时(${openclawCommandTimeoutMs}ms)`)));
611
+ }, openclawCommandTimeoutMs);
612
+ });
613
+ }
614
+
615
+ function shouldCaptureDesktop(rawText) {
616
+ const text = String(rawText || '').trim();
617
+ if (!text) return false;
618
+ return /(截图|截屏|截个图|抓图).*(桌面|屏幕|全屏)|帮我截.*(桌面|屏幕|全屏)/.test(text);
619
+ }
620
+
621
+ async function captureDesktopScreenshot() {
622
+ ensureDir(screenshotDir);
623
+ const filePath = path.join(screenshotDir, `desktop-${Date.now()}.png`);
624
+ const psScript = [
625
+ 'Add-Type -AssemblyName System.Windows.Forms;',
626
+ 'Add-Type -AssemblyName System.Drawing;',
627
+ '$bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen;',
628
+ '$bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height;',
629
+ '$graphics = [System.Drawing.Graphics]::FromImage($bitmap);',
630
+ '$graphics.CopyFromScreen($bounds.Left, $bounds.Top, 0, 0, $bitmap.Size);',
631
+ `$bitmap.Save('${filePath.replace(/\\/g, '\\\\')}', [System.Drawing.Imaging.ImageFormat]::Png);`,
632
+ '$graphics.Dispose();',
633
+ '$bitmap.Dispose();',
634
+ ].join(' ');
635
+
636
+ await execFileAsync('powershell.exe', ['-NoProfile', '-STA', '-Command', psScript], {
637
+ windowsHide: true,
638
+ timeout: 30000,
639
+ });
640
+
641
+ return filePath;
642
+ }
643
+
644
+ function printQrPayload(payload) {
645
+ const qrText = JSON.stringify(payload);
646
+ console.log('请在“里世界”APP里打开扫一扫,扫描下方二维码完成连接。');
647
+ qrcode.generate(qrText, { small: true });
648
+ console.log('二维码原文:');
649
+ console.log(qrText);
650
+ console.log('');
651
+ }
652
+
653
+ async function createScanSession() {
654
+ state.mode = 'creating-session';
655
+ state.currentSessionId = null;
656
+ state.currentLobster = null;
657
+ state.heartbeatCount = 0;
658
+
659
+ return requestJson('/api/openclaw/scan-session', {
660
+ method: 'POST',
661
+ body: {
662
+ runtimeId,
663
+ provider,
664
+ deviceName,
665
+ platform: `${os.platform()} ${os.release()}`,
666
+ version: pkg.version,
667
+ capabilityScopes,
668
+ metadata: {
669
+ connector: 'licity-qclaw-local-connector',
670
+ nodeVersion: process.version,
671
+ qclawPath,
672
+ },
673
+ },
674
+ });
675
+ }
676
+
677
+ async function waitForApproval(sessionId) {
678
+ state.mode = 'waiting-approval';
679
+ state.currentSessionId = sessionId;
680
+
681
+ while (state.shouldRun && !state.reconnectRequested) {
682
+ const result = await requestJson(`/api/openclaw/scan-session/${sessionId}`);
683
+ const session = result.session;
684
+
685
+ if (session.status === 'approved') {
686
+ state.mode = 'connected';
687
+ state.currentLobster = session.lobster || null;
688
+ return session;
689
+ }
690
+
691
+ if (session.status === 'expired') {
692
+ throw new Error('二维码已过期,准备重新生成。');
693
+ }
694
+
695
+ if (session.status === 'rejected') {
696
+ throw new Error('连接请求已被拒绝,准备重新生成二维码。');
697
+ }
698
+
699
+ await sleep(pollIntervalMs);
700
+ }
701
+
702
+ return null;
703
+ }
704
+
705
+ async function sendHeartbeat() {
706
+ const result = await requestJson('/api/openclaw/heartbeat', {
707
+ method: 'POST',
708
+ body: {
709
+ runtimeId,
710
+ metadata: {
711
+ connector: 'licity-qclaw-local-connector',
712
+ qclawPath,
713
+ heartbeatAt: new Date().toISOString(),
714
+ },
715
+ },
716
+ });
717
+
718
+ state.heartbeatCount += 1;
719
+ state.currentLobster = result.lobster || state.currentLobster;
720
+ return result;
721
+ }
722
+
723
+ async function pullNextTask() {
724
+ return requestJson(`/api/openclaw/tasks/next?runtimeId=${encodeURIComponent(runtimeId)}`);
725
+ }
726
+
727
+ async function reportTaskResult(taskId, status, result = {}, errorMessage = null) {
728
+ return requestJson(`/api/openclaw/tasks/${taskId}/result`, {
729
+ method: 'POST',
730
+ body: {
731
+ runtimeId,
732
+ status,
733
+ result,
734
+ errorMessage,
735
+ },
736
+ });
737
+ }
738
+
739
+ async function emitTaskReply(task, payloadOrContent) {
740
+ const payload = typeof payloadOrContent === 'string'
741
+ ? {
742
+ content: payloadOrContent,
743
+ type: 'text',
744
+ meta: {
745
+ taskId: task.id,
746
+ runtimeId,
747
+ qclawPath,
748
+ },
749
+ }
750
+ : {
751
+ ...payloadOrContent,
752
+ meta: {
753
+ ...(payloadOrContent?.meta || {}),
754
+ taskId: task.id,
755
+ runtimeId,
756
+ qclawPath,
757
+ },
758
+ };
759
+
760
+ return requestJson('/api/openclaw/events', {
761
+ method: 'POST',
762
+ body: {
763
+ lobsterId: task?.payload?.lobsterId || task?.lobsterId || null,
764
+ runtimeId,
765
+ requestId: task.requestId || task.id,
766
+ eventType: 'private_message_reply',
767
+ payload,
768
+ },
769
+ });
770
+ }
771
+
772
+ async function handleTask(task) {
773
+ if (!task || !task.id) return false;
774
+
775
+ console.log(`[Task] 收到任务 ${task.taskType} (${task.id})`);
776
+
777
+ try {
778
+ if (task.taskType === 'private_chat_message') {
779
+ const rawContent = String(task.payload?.content || '').trim();
780
+ const brief = rawContent ? rawContent.slice(0, 80) : '空消息';
781
+ let reply = '';
782
+ let taskResult = {
783
+ qclawPath,
784
+ openclawAgentId,
785
+ };
786
+
787
+ if (shouldCaptureDesktop(rawContent)) {
788
+ const screenshotPath = await captureDesktopScreenshot();
789
+ reply = `已为你截取当前桌面,图片已保存在本机:${screenshotPath}`;
790
+ const mediaBase64 = fs.readFileSync(screenshotPath).toString('base64');
791
+ taskResult = {
792
+ ...taskResult,
793
+ action: 'capture_desktop_screenshot',
794
+ screenshotPath,
795
+ };
796
+ await emitTaskReply(task, {
797
+ content: reply,
798
+ type: 'image',
799
+ media_base64: mediaBase64,
800
+ media_type: 'image',
801
+ media_mime_type: 'image/png',
802
+ media_name: path.basename(screenshotPath),
803
+ });
804
+ } else {
805
+ try {
806
+ console.log('[Task] 进入 OpenClaw 普通任务执行分支');
807
+ const agentResult = await runOpenClawAgent(task);
808
+ reply = agentResult.reply;
809
+ taskResult = {
810
+ ...taskResult,
811
+ openclawSessionId: agentResult.raw?.result?.meta?.agentMeta?.sessionId || null,
812
+ provider: agentResult.raw?.result?.meta?.agentMeta?.provider || null,
813
+ model: agentResult.raw?.result?.meta?.agentMeta?.model || null,
814
+ action: 'openclaw_agent_reply',
815
+ };
816
+ if (agentResult.media) {
817
+ taskResult.mediaType = agentResult.media.media_type;
818
+ taskResult.mediaName = agentResult.media.media_name;
819
+ }
820
+
821
+ const emitPayload = agentResult.media
822
+ ? {
823
+ content: reply || '',
824
+ type: agentResult.media.media_type === 'image' ? 'image' : 'file',
825
+ ...agentResult.media,
826
+ }
827
+ : reply;
828
+
829
+ const emitResult = await emitTaskReply(task, emitPayload);
830
+ console.log(`[Task] 私聊回写成功: lobsterId=${task?.payload?.lobsterId || task?.lobsterId || 'unknown'}, requestId=${task.requestId || task.id}, eventId=${emitResult?.event?.id || 'n/a'}`);
831
+ } catch (agentError) {
832
+ console.log(`[Task] OpenClaw 执行失败,转降级回复: ${agentError.message || '未知错误'}`);
833
+ reply = `我已收到你的消息,但本地智能引擎当前没有正常返回结果。原因为:${agentError.message || '未知错误'}。如果你要我执行桌面类操作,请直接描述具体动作,例如“帮我截图桌面”。`;
834
+ taskResult = {
835
+ ...taskResult,
836
+ action: 'fallback_text_reply',
837
+ openclawError: agentError.message || '未知错误',
838
+ };
839
+ const emitResult = await emitTaskReply(task, reply);
840
+ console.log(`[Task] 私聊回写成功: lobsterId=${task?.payload?.lobsterId || task?.lobsterId || 'unknown'}, requestId=${task.requestId || task.id}, eventId=${emitResult?.event?.id || 'n/a'}`);
841
+ }
842
+ }
843
+
844
+ await reportTaskResult(task.id, 'succeeded', {
845
+ reply,
846
+ ...taskResult,
847
+ });
848
+ console.log(`[Task] 私聊任务已执行并回写: ${brief}`);
849
+ return true;
850
+ }
851
+
852
+ await reportTaskResult(task.id, 'failed', {}, `暂不支持的任务类型: ${task.taskType}`);
853
+ console.warn(`[Task] 暂不支持的任务类型: ${task.taskType}`);
854
+ return false;
855
+ } catch (error) {
856
+ await reportTaskResult(task.id, 'failed', {}, error.message || '任务执行失败');
857
+ console.error(`[Task] 执行失败: ${error.message}`);
858
+ return false;
859
+ }
860
+ }
861
+
862
+ async function heartbeatLoop() {
863
+ state.mode = 'heartbeating';
864
+ console.log(`已连接龙虾: ${state.currentLobster?.name || '未知'} (${state.currentLobster?.id || 'n/a'})`);
865
+
866
+ let nextHeartbeatAt = 0;
867
+ let nextPollAt = 0;
868
+
869
+ while (state.shouldRun && !state.reconnectRequested) {
870
+ const now = Date.now();
871
+
872
+ if (now >= nextHeartbeatAt) {
873
+ try {
874
+ const result = await sendHeartbeat();
875
+ console.log(`[Heartbeat ${state.heartbeatCount}] 龙虾=${result.lobster?.name || '未知'} 权限快照已同步`);
876
+ } catch (error) {
877
+ console.error(`心跳失败: ${error.message}`);
878
+ if (error.status === 403 || error.status === 404) {
879
+ console.log('当前绑定已失效,准备重新进入扫码连接。');
880
+ return;
881
+ }
882
+ }
883
+ nextHeartbeatAt = Date.now() + heartbeatIntervalMs;
884
+ }
885
+
886
+ if (now >= nextPollAt) {
887
+ try {
888
+ const taskResult = await pullNextTask();
889
+ if (taskResult.task) {
890
+ await handleTask(taskResult.task);
891
+ nextPollAt = Date.now() + 500;
892
+ continue;
893
+ }
894
+ } catch (error) {
895
+ console.error(`拉取任务失败: ${error.message}`);
896
+ if (error.status === 403 || error.status === 404) {
897
+ console.log('当前绑定已失效,准备重新进入扫码连接。');
898
+ return;
899
+ }
900
+ }
901
+ nextPollAt = Date.now() + pollIntervalMs;
902
+ }
903
+
904
+ await sleep(1000);
905
+ }
906
+ }
907
+
908
+ function startConsoleCommands() {
909
+ const rl = readline.createInterface({
910
+ input: process.stdin,
911
+ output: process.stdout,
912
+ });
913
+
914
+ rl.on('line', (line) => {
915
+ const command = String(line || '').trim().toLowerCase();
916
+ if (command === 'q' || command === 'quit' || command === 'exit') {
917
+ state.shouldRun = false;
918
+ state.reconnectRequested = true;
919
+ rl.close();
920
+ return;
921
+ }
922
+
923
+ if (command === 'r' || command === 'refresh') {
924
+ console.log('收到重连命令,准备重新生成二维码。');
925
+ state.reconnectRequested = true;
926
+ return;
927
+ }
928
+
929
+ if (command === 's' || command === 'status') {
930
+ console.log(JSON.stringify({
931
+ mode: state.mode,
932
+ runtimeId,
933
+ currentSessionId: state.currentSessionId,
934
+ currentLobster: state.currentLobster,
935
+ heartbeatCount: state.heartbeatCount,
936
+ }, null, 2));
937
+ }
938
+ });
939
+ }
940
+
941
+ async function mainLoop() {
942
+ printBanner();
943
+ const preflight = await runPreflightChecks();
944
+ printPreflightResult(preflight);
945
+ startConsoleCommands();
946
+
947
+ while (state.shouldRun) {
948
+ state.reconnectRequested = false;
949
+ try {
950
+ const created = await createScanSession();
951
+ console.log(`新的连接会话已创建: ${created.session.id}`);
952
+ printQrPayload(created.qrPayload);
953
+
954
+ const approvedSession = await waitForApproval(created.session.id);
955
+ if (!approvedSession || !state.shouldRun) {
956
+ continue;
957
+ }
958
+
959
+ await heartbeatLoop();
960
+ } catch (error) {
961
+ console.error(`连接循环失败: ${error.message}`);
962
+ if (!state.shouldRun) {
963
+ break;
964
+ }
965
+ await sleep(3000);
966
+ }
967
+ }
968
+
969
+ console.log('本地 Connector 已退出。');
970
+ process.exit(0);
971
+ }
972
+
973
+ process.on('SIGINT', () => {
974
+ state.shouldRun = false;
975
+ state.reconnectRequested = true;
976
+ });
977
+
978
+ process.on('SIGTERM', () => {
979
+ state.shouldRun = false;
980
+ state.reconnectRequested = true;
981
+ });
982
+
983
+ mainLoop().catch((error) => {
984
+ console.error('Connector 启动失败:', error);
985
+ process.exit(1);
986
+ });