@licity/openclaw-connector 1.0.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/README.md +70 -0
- package/index.js +626 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @licity/openclaw-connector
|
|
2
|
+
|
|
3
|
+
里世界龙虾本地连接器 — 一行 `npx` 命令接入 OpenClaw,扫码即完成连接。
|
|
4
|
+
|
|
5
|
+
## 特点
|
|
6
|
+
|
|
7
|
+
- **零配置启动**:首次运行自动创建会话,扫码后自动保存凭证
|
|
8
|
+
- **弹出 CMD 窗口**:Windows 上自动弹出新命令行窗口显示二维码,无字符变形
|
|
9
|
+
- **纯 OpenClaw**:不依赖 QClaw,直接调用 OpenClaw CLI 执行 AI 任务
|
|
10
|
+
- **凭证持久化**:扫码成功后凭证保存至 `~/.licity-connector/`,下次无需重新扫码
|
|
11
|
+
|
|
12
|
+
## 前置要求
|
|
13
|
+
|
|
14
|
+
1. **Node.js v18+**(推荐 v22+)
|
|
15
|
+
2. **OpenClaw** 已安装([下载 OpenClaw](https://openclaw.ai))
|
|
16
|
+
3. **里世界 APP** 已登录且拥有龙虾
|
|
17
|
+
|
|
18
|
+
## 快速开始
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx @licity/openclaw-connector
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**首次运行流程:**
|
|
25
|
+
1. 运行命令后,自动创建连接会话
|
|
26
|
+
2. Windows 弹出新 CMD 窗口显示二维码
|
|
27
|
+
3. 打开里世界 APP → 我的龙虾 → 扫一扫,扫描二维码
|
|
28
|
+
4. APP 批准后自动连接,开始监听任务
|
|
29
|
+
|
|
30
|
+
**后续运行:**
|
|
31
|
+
凭证已保存,直接运行即可,无需再次扫码:
|
|
32
|
+
```bash
|
|
33
|
+
npx @licity/openclaw-connector
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## OpenClaw 路径配置
|
|
37
|
+
|
|
38
|
+
连接器会自动在以下路径搜索 OpenClaw CLI:
|
|
39
|
+
- `D:\OpenClaw\node_modules\openclaw\openclaw.mjs`
|
|
40
|
+
- `C:\OpenClaw\node_modules\openclaw\openclaw.mjs`
|
|
41
|
+
- npm 全局安装目录
|
|
42
|
+
|
|
43
|
+
也可通过环境变量手动指定:
|
|
44
|
+
```bash
|
|
45
|
+
# Windows
|
|
46
|
+
set OPENCLAW_CLI_PATH=D:\MyOpenClaw\node_modules\openclaw\openclaw.mjs
|
|
47
|
+
npx @licity/openclaw-connector
|
|
48
|
+
|
|
49
|
+
# Mac/Linux
|
|
50
|
+
OPENCLAW_CLI_PATH=/opt/openclaw/openclaw.mjs npx @licity/openclaw-connector
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 运行时命令
|
|
54
|
+
|
|
55
|
+
连接器运行中可输入以下命令:
|
|
56
|
+
- `r` — 重新扫码(更换绑定的龙虾)
|
|
57
|
+
- `s` — 查看当前状态
|
|
58
|
+
- `q` — 退出连接器
|
|
59
|
+
|
|
60
|
+
## 配置文件
|
|
61
|
+
|
|
62
|
+
凭证和路径配置保存在 `~/.licity-connector/config.json`。
|
|
63
|
+
如需重置,删除该文件后重新运行即可。
|
|
64
|
+
|
|
65
|
+
## 支持的能力
|
|
66
|
+
|
|
67
|
+
- 私信(private_chat)
|
|
68
|
+
- 邻里圈(neighbor)
|
|
69
|
+
- 时光穿越(time_travel)
|
|
70
|
+
- 锚点(anchor)
|
package/index.js
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
// 里世界 OpenClaw 本地连接器
|
|
6
|
+
// 一行 npx 命令即可接入 OpenClaw 龙虾,无需手动配置密钥
|
|
7
|
+
// 扫码自动获取凭证,持久化存储,下次运行无需重新扫码
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const net = require('net');
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
const readline = require('readline');
|
|
16
|
+
const { spawn, execFileSync } = require('child_process');
|
|
17
|
+
const { promisify } = require('util');
|
|
18
|
+
const { execFile } = require('child_process');
|
|
19
|
+
const execFileAsync = promisify(execFile);
|
|
20
|
+
|
|
21
|
+
// ─── 元信息 ───────────────────────────────────────────────────────────────────
|
|
22
|
+
const pkg = require('./package.json');
|
|
23
|
+
const API_BASE = 'https://li.city';
|
|
24
|
+
const CAPABILITY_SCOPES = ['private_chat', 'neighbor', 'anchor', 'time_travel'];
|
|
25
|
+
const HEARTBEAT_INTERVAL_MS = 25000;
|
|
26
|
+
const POLL_INTERVAL_MS = 3000;
|
|
27
|
+
const AGENT_TIMEOUT_MS = Number(process.env.OPENCLAW_COMMAND_TIMEOUT_MS || 120000);
|
|
28
|
+
const MAX_MEDIA_BYTES = 8 * 1024 * 1024; // 8MB
|
|
29
|
+
|
|
30
|
+
// ─── 配置目录(存储在用户主目录) ─────────────────────────────────────────────
|
|
31
|
+
const CONFIG_DIR = path.join(os.homedir(), '.licity-connector');
|
|
32
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
33
|
+
const RUNTIME_FILE = path.join(CONFIG_DIR, 'runtime.json');
|
|
34
|
+
|
|
35
|
+
function ensureConfigDir() {
|
|
36
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function loadConfig() {
|
|
40
|
+
ensureConfigDir();
|
|
41
|
+
if (!fs.existsSync(CONFIG_FILE)) return {};
|
|
42
|
+
try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); }
|
|
43
|
+
catch { return {}; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function saveConfig(updates) {
|
|
47
|
+
ensureConfigDir();
|
|
48
|
+
const cfg = { ...loadConfig(), ...updates, updatedAt: new Date().toISOString() };
|
|
49
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), 'utf8');
|
|
50
|
+
return cfg;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Runtime ID(持久化,重启后不变) ─────────────────────────────────────────
|
|
54
|
+
function getOrCreateRuntimeId() {
|
|
55
|
+
ensureConfigDir();
|
|
56
|
+
if (fs.existsSync(RUNTIME_FILE)) {
|
|
57
|
+
try {
|
|
58
|
+
const d = JSON.parse(fs.readFileSync(RUNTIME_FILE, 'utf8'));
|
|
59
|
+
if (d?.runtimeId) return d.runtimeId;
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
const id = crypto.randomUUID();
|
|
63
|
+
fs.writeFileSync(RUNTIME_FILE, JSON.stringify({ runtimeId: id, createdAt: new Date().toISOString() }, null, 2), 'utf8');
|
|
64
|
+
return id;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const runtimeId = getOrCreateRuntimeId();
|
|
68
|
+
|
|
69
|
+
// ─── OpenClaw CLI 路径探测 ─────────────────────────────────────────────────
|
|
70
|
+
const SEARCH_PATHS_CLI = [
|
|
71
|
+
process.env.OPENCLAW_CLI_PATH,
|
|
72
|
+
'D:\\OpenClaw\\node_modules\\openclaw\\openclaw.mjs',
|
|
73
|
+
'C:\\OpenClaw\\node_modules\\openclaw\\openclaw.mjs',
|
|
74
|
+
path.join(os.homedir(), '.openclaw', 'openclaw.mjs'),
|
|
75
|
+
path.join(os.homedir(), 'openclaw', 'node_modules', 'openclaw', 'openclaw.mjs'),
|
|
76
|
+
].filter(Boolean);
|
|
77
|
+
|
|
78
|
+
const SEARCH_PATHS_CONFIG = [
|
|
79
|
+
process.env.OPENCLAW_CONFIG_PATH,
|
|
80
|
+
'D:\\OpenClaw\\openclaw.json',
|
|
81
|
+
'C:\\OpenClaw\\openclaw.json',
|
|
82
|
+
path.join(os.homedir(), '.openclaw', 'openclaw.json'),
|
|
83
|
+
path.join(os.homedir(), '.qclaw', 'openclaw.json'),
|
|
84
|
+
].filter(Boolean);
|
|
85
|
+
|
|
86
|
+
function findOpenClawCli(savedPath) {
|
|
87
|
+
if (savedPath && fs.existsSync(savedPath)) return savedPath;
|
|
88
|
+
for (const p of SEARCH_PATHS_CLI) {
|
|
89
|
+
if (fs.existsSync(p)) return p;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const npmRoot = execFileSync(
|
|
93
|
+
process.platform === 'win32' ? 'npm.cmd' : 'npm',
|
|
94
|
+
['root', '-g'],
|
|
95
|
+
{ timeout: 5000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }
|
|
96
|
+
).toString().trim();
|
|
97
|
+
const c = path.join(npmRoot, 'openclaw', 'openclaw.mjs');
|
|
98
|
+
if (fs.existsSync(c)) return c;
|
|
99
|
+
} catch {}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function findOpenClawConfig(savedPath) {
|
|
104
|
+
if (savedPath && fs.existsSync(savedPath)) return savedPath;
|
|
105
|
+
for (const p of SEARCH_PATHS_CONFIG) {
|
|
106
|
+
if (fs.existsSync(p)) return p;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── HTTP 请求 ─────────────────────────────────────────────────────────────
|
|
112
|
+
async function apiRequest(endpoint, options = {}, token = null) {
|
|
113
|
+
const url = `${API_BASE}${endpoint}`;
|
|
114
|
+
const headers = {
|
|
115
|
+
'Content-Type': 'application/json',
|
|
116
|
+
'User-Agent': `${pkg.name}/${pkg.version} node/${process.version}`,
|
|
117
|
+
};
|
|
118
|
+
if (token) headers['x-connector-token'] = token;
|
|
119
|
+
Object.assign(headers, options.headers || {});
|
|
120
|
+
|
|
121
|
+
const resp = await fetch(url, {
|
|
122
|
+
method: options.method || 'GET',
|
|
123
|
+
headers,
|
|
124
|
+
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const text = await resp.text();
|
|
128
|
+
let data = {};
|
|
129
|
+
try { data = JSON.parse(text); } catch { data = { raw: text }; }
|
|
130
|
+
|
|
131
|
+
if (!resp.ok) {
|
|
132
|
+
const err = new Error(data.error || data.raw || `HTTP ${resp.status}`);
|
|
133
|
+
err.status = resp.status;
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
return data;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── 显示二维码(Windows 弹 CMD 新窗口) ──────────────────────────────────────
|
|
140
|
+
function showQrCode(qrText) {
|
|
141
|
+
// 无论平台都在当前终端打印一次(用于日志记录)
|
|
142
|
+
try {
|
|
143
|
+
require('qrcode-terminal').generate(qrText, { small: true });
|
|
144
|
+
} catch {}
|
|
145
|
+
console.log('\n二维码文本:');
|
|
146
|
+
console.log(qrText);
|
|
147
|
+
console.log('');
|
|
148
|
+
|
|
149
|
+
if (process.platform !== 'win32') return;
|
|
150
|
+
|
|
151
|
+
// Windows:额外弹出一个 CMD 新窗口,字符渲染更准确,方便扫码
|
|
152
|
+
try {
|
|
153
|
+
const qrcodeModPath = require.resolve('qrcode-terminal');
|
|
154
|
+
const scriptLines = [
|
|
155
|
+
`const q = require(${JSON.stringify(qrcodeModPath)});`,
|
|
156
|
+
`const text = ${JSON.stringify(qrText)};`,
|
|
157
|
+
`try { process.title = '里世界龙虾连接 - 打开APP扫码'; } catch {}`,
|
|
158
|
+
`console.clear && console.clear();`,
|
|
159
|
+
`console.log('');`,
|
|
160
|
+
`console.log('╔══════════════════════════════════════╗');`,
|
|
161
|
+
`console.log('║ 里世界 龙虾连接二维码 ║');`,
|
|
162
|
+
`console.log('╚══════════════════════════════════════╝');`,
|
|
163
|
+
`console.log('');`,
|
|
164
|
+
`q.generate(text, { small: false });`,
|
|
165
|
+
`console.log('');`,
|
|
166
|
+
`console.log('请打开里世界APP → 我的龙虾 → 扫一扫');`,
|
|
167
|
+
`console.log('扫码完成后可关闭此窗口(连接器继续在后台运行)');`,
|
|
168
|
+
`console.log('');`,
|
|
169
|
+
`console.log('二维码文本:');`,
|
|
170
|
+
`console.log(text);`,
|
|
171
|
+
`process.stdin.resume();`,
|
|
172
|
+
];
|
|
173
|
+
const tempScript = path.join(os.tmpdir(), `licity_qr_${Date.now()}.js`);
|
|
174
|
+
fs.writeFileSync(tempScript, scriptLines.join('\n'), 'utf8');
|
|
175
|
+
|
|
176
|
+
// 使用 cmd /c start 打开一个新的可见 CMD 窗口
|
|
177
|
+
const child = spawn('cmd.exe', [
|
|
178
|
+
'/c', 'start', '"里世界龙虾连接"', 'cmd', '/k',
|
|
179
|
+
`"${process.execPath}" "${tempScript}"`,
|
|
180
|
+
], { detached: true, windowsHide: false, shell: false });
|
|
181
|
+
child.unref();
|
|
182
|
+
|
|
183
|
+
console.log('[扫码] 已弹出新的 CMD 窗口,请在新窗口中扫码。');
|
|
184
|
+
} catch (e) {
|
|
185
|
+
// 弹窗失败: 当前终端中已有文字版二维码,用户可在此扫码
|
|
186
|
+
console.log('[扫码] 注:无法弹出新窗口,请在当前终端扫码。');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── OpenClaw Agent 执行 ─────────────────────────────────────────────────────
|
|
191
|
+
function normalizeAgentReply(parsed) {
|
|
192
|
+
const payloads = Array.isArray(parsed?.result?.payloads) ? parsed.result.payloads : [];
|
|
193
|
+
const parts = payloads.map(p => String(p?.text || '').trim()).filter(Boolean);
|
|
194
|
+
if (parts.length) return parts.join('\n\n');
|
|
195
|
+
return [parsed?.result?.text, parsed?.result?.output, parsed?.text, parsed?.message]
|
|
196
|
+
.map(v => String(v || '').trim()).find(Boolean) || '';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function extractLastJson(text) {
|
|
200
|
+
const s = String(text || '').trim();
|
|
201
|
+
for (let i = s.lastIndexOf('{'); i >= 0; i = s.lastIndexOf('{', i - 1)) {
|
|
202
|
+
try { return JSON.parse(s.slice(i)); } catch {}
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function parseAgentSuccess(stdout) {
|
|
208
|
+
const p = extractLastJson(stdout);
|
|
209
|
+
if (!p) return null;
|
|
210
|
+
const ok = p.status === 'ok' || p.ok === true || p.success === true || p.type === 'result';
|
|
211
|
+
if (!ok) return null;
|
|
212
|
+
const reply = normalizeAgentReply(p);
|
|
213
|
+
// 尝试提取媒体附件
|
|
214
|
+
let media = null;
|
|
215
|
+
const payloads = Array.isArray(p?.result?.payloads) ? p.result.payloads : [];
|
|
216
|
+
for (const payload of payloads) {
|
|
217
|
+
const b64 = payload?.media_base64 || payload?.mediaBase64 || null;
|
|
218
|
+
const lp = payload?.media_file_path || payload?.filePath || null;
|
|
219
|
+
const nm = payload?.media_name || payload?.fileName || 'file';
|
|
220
|
+
const mt = payload?.media_mime_type || payload?.mimeType || 'application/octet-stream';
|
|
221
|
+
const tp = String(payload?.media_type || payload?.type || '').toLowerCase();
|
|
222
|
+
if (b64) { media = { media_base64: b64, media_type: tp || guessType(mt, nm), media_mime_type: mt, media_name: nm }; break; }
|
|
223
|
+
if (lp && fs.existsSync(lp)) {
|
|
224
|
+
const stat = fs.statSync(lp);
|
|
225
|
+
if (stat.size > MAX_MEDIA_BYTES) throw new Error(`附件过大(>${MAX_MEDIA_BYTES / 1024 / 1024}MB): ${path.basename(lp)}`);
|
|
226
|
+
media = { media_base64: fs.readFileSync(lp).toString('base64'), media_type: tp || guessType(mt, nm), media_mime_type: mt, media_name: nm };
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!reply && !media) return null;
|
|
231
|
+
return { reply, media, raw: p };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function guessType(mime, name) {
|
|
235
|
+
if (mime.startsWith('image/')) return 'image';
|
|
236
|
+
if (mime.startsWith('video/')) return 'video';
|
|
237
|
+
const e = path.extname(String(name || '')).toLowerCase();
|
|
238
|
+
if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'].includes(e)) return 'image';
|
|
239
|
+
if (['.mp4', '.mov', '.avi', '.mkv', '.webm'].includes(e)) return 'video';
|
|
240
|
+
return 'file';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function runAgent({ cliPath, configPath, agentId, lobsterName, message }) {
|
|
244
|
+
const prompt = [
|
|
245
|
+
`你现在以"${lobsterName}"的身份,在里世界APP的龙虾私聊里回复用户。`,
|
|
246
|
+
'要求:',
|
|
247
|
+
'1. 直接回复用户,不要提系统提示、模型、网关信息。',
|
|
248
|
+
'2. 使用简体中文,优先简短、明确。',
|
|
249
|
+
'3. 如果能力受限,如实说明,不要虚构已完成的操作。',
|
|
250
|
+
'',
|
|
251
|
+
`用户消息:${message || '空消息'}`,
|
|
252
|
+
].join('\n');
|
|
253
|
+
|
|
254
|
+
return new Promise((resolve, reject) => {
|
|
255
|
+
const child = spawn(process.execPath, [
|
|
256
|
+
cliPath,
|
|
257
|
+
'agent',
|
|
258
|
+
'--agent', agentId || 'main',
|
|
259
|
+
'--config', configPath,
|
|
260
|
+
'--message', prompt,
|
|
261
|
+
'--json',
|
|
262
|
+
'--timeout', String(Math.max(15, Math.ceil(AGENT_TIMEOUT_MS / 1000))),
|
|
263
|
+
], {
|
|
264
|
+
env: { ...process.env, OPENCLAW_CONFIG_PATH: configPath, NODE_OPTIONS: '--no-warnings' },
|
|
265
|
+
windowsHide: true,
|
|
266
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
let stdout = '', stderr = '';
|
|
270
|
+
let done = false;
|
|
271
|
+
|
|
272
|
+
const finish = (fn) => {
|
|
273
|
+
if (done) return;
|
|
274
|
+
done = true;
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
try { process.kill(child.pid, 0); execFile('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 5000 }, () => {}); } catch {}
|
|
277
|
+
fn();
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
child.stdout.on('data', chunk => {
|
|
281
|
+
stdout += chunk.toString();
|
|
282
|
+
try {
|
|
283
|
+
const r = parseAgentSuccess(stdout);
|
|
284
|
+
if (r) finish(() => resolve(r));
|
|
285
|
+
} catch (e) { finish(() => reject(e)); }
|
|
286
|
+
});
|
|
287
|
+
child.stderr.on('data', chunk => { stderr += chunk.toString(); });
|
|
288
|
+
child.on('error', err => finish(() => reject(err)));
|
|
289
|
+
child.on('close', code => {
|
|
290
|
+
if (done) return;
|
|
291
|
+
try {
|
|
292
|
+
const r = parseAgentSuccess(stdout);
|
|
293
|
+
if (r) { finish(() => resolve(r)); return; }
|
|
294
|
+
} catch (e) { finish(() => reject(e)); return; }
|
|
295
|
+
finish(() => reject(new Error(
|
|
296
|
+
(stderr || stdout || `OpenClaw 退出码 ${code}`).slice(0, 400)
|
|
297
|
+
)));
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const timer = setTimeout(() => {
|
|
301
|
+
try {
|
|
302
|
+
const r = parseAgentSuccess(stdout);
|
|
303
|
+
if (r) { finish(() => resolve(r)); return; }
|
|
304
|
+
} catch (e) { finish(() => reject(e)); return; }
|
|
305
|
+
finish(() => reject(new Error(
|
|
306
|
+
(stderr || stdout || `OpenClaw 执行超时 (${AGENT_TIMEOUT_MS}ms)`).slice(0, 400)
|
|
307
|
+
)));
|
|
308
|
+
}, AGENT_TIMEOUT_MS);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ─── 桌面截图 ──────────────────────────────────────────────────────────────
|
|
313
|
+
function shouldCapture(text) {
|
|
314
|
+
return /(截图|截屏|截个图|抓图).*(桌面|屏幕|全屏)|帮我截.*(桌面|屏幕|全屏)/.test(String(text || ''));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function captureDesktop() {
|
|
318
|
+
const dir = path.join(CONFIG_DIR, 'screenshots');
|
|
319
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
320
|
+
const file = path.join(dir, `desktop-${Date.now()}.png`);
|
|
321
|
+
const escapedFile = file.replace(/\\/g, '\\\\');
|
|
322
|
+
const ps = [
|
|
323
|
+
'Add-Type -AssemblyName System.Windows.Forms;',
|
|
324
|
+
'Add-Type -AssemblyName System.Drawing;',
|
|
325
|
+
'$b=[System.Windows.Forms.SystemInformation]::VirtualScreen;',
|
|
326
|
+
'$bm=New-Object System.Drawing.Bitmap $b.Width,$b.Height;',
|
|
327
|
+
'$g=[System.Drawing.Graphics]::FromImage($bm);',
|
|
328
|
+
'$g.CopyFromScreen($b.Left,$b.Top,0,0,$bm.Size);',
|
|
329
|
+
`$bm.Save('${escapedFile}',[System.Drawing.Imaging.ImageFormat]::Png);`,
|
|
330
|
+
'$g.Dispose();$bm.Dispose();',
|
|
331
|
+
].join(' ');
|
|
332
|
+
await execFileAsync('powershell.exe', ['-NoProfile', '-STA', '-Command', ps], { windowsHide: true, timeout: 30000 });
|
|
333
|
+
return file;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ─── API 操作封装 ─────────────────────────────────────────────────────────────
|
|
337
|
+
async function createScanSession(deviceName) {
|
|
338
|
+
return apiRequest('/api/openclaw/scan-session', {
|
|
339
|
+
method: 'POST',
|
|
340
|
+
body: {
|
|
341
|
+
runtimeId,
|
|
342
|
+
provider: 'openclaw',
|
|
343
|
+
deviceName: deviceName || os.hostname(),
|
|
344
|
+
platform: `${os.platform()} ${os.release()}`,
|
|
345
|
+
version: pkg.version,
|
|
346
|
+
capabilityScopes: CAPABILITY_SCOPES,
|
|
347
|
+
metadata: { connector: pkg.name, nodeVersion: process.version },
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function pollScanSession(sessionId) {
|
|
353
|
+
return apiRequest(`/api/openclaw/scan-session/${sessionId}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function sendHeartbeat(token, deviceName) {
|
|
357
|
+
return apiRequest('/api/openclaw/heartbeat', {
|
|
358
|
+
method: 'POST',
|
|
359
|
+
body: { runtimeId, metadata: { connector: pkg.name, heartbeatAt: new Date().toISOString() } },
|
|
360
|
+
}, token);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function pullTask(token) {
|
|
364
|
+
return apiRequest(`/api/openclaw/tasks/next?runtimeId=${encodeURIComponent(runtimeId)}`, {}, token);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function reportTask(token, taskId, status, result = {}, errorMessage = null) {
|
|
368
|
+
return apiRequest(`/api/openclaw/tasks/${taskId}/result`, {
|
|
369
|
+
method: 'POST',
|
|
370
|
+
body: { runtimeId, status, result, errorMessage },
|
|
371
|
+
}, token);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function emitReply(token, task, payloadOrContent) {
|
|
375
|
+
const payload = typeof payloadOrContent === 'string'
|
|
376
|
+
? { content: payloadOrContent, type: 'text', meta: { taskId: task.id, runtimeId } }
|
|
377
|
+
: { ...payloadOrContent, meta: { ...(payloadOrContent?.meta || {}), taskId: task.id, runtimeId } };
|
|
378
|
+
return apiRequest('/api/openclaw/events', {
|
|
379
|
+
method: 'POST',
|
|
380
|
+
body: {
|
|
381
|
+
lobsterId: task?.payload?.lobsterId || task?.lobsterId || null,
|
|
382
|
+
runtimeId,
|
|
383
|
+
requestId: task.requestId || task.id,
|
|
384
|
+
eventType: 'private_message_reply',
|
|
385
|
+
payload,
|
|
386
|
+
},
|
|
387
|
+
}, token);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ─── 任务处理 ──────────────────────────────────────────────────────────────
|
|
391
|
+
async function handleTask(token, task, cliPath, configPath, lobsterName) {
|
|
392
|
+
if (!task?.id) return false;
|
|
393
|
+
console.log(`[Task] 收到 ${task.taskType} (${task.id.slice(0, 8)}...)`);
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
if (task.taskType === 'private_chat_message') {
|
|
397
|
+
const content = String(task?.payload?.content || '').trim();
|
|
398
|
+
|
|
399
|
+
if (shouldCapture(content)) {
|
|
400
|
+
const file = await captureDesktop();
|
|
401
|
+
const reply = `已截取当前桌面,保存于本机:${file}`;
|
|
402
|
+
await emitReply(token, task, {
|
|
403
|
+
content: reply, type: 'image',
|
|
404
|
+
media_base64: fs.readFileSync(file).toString('base64'),
|
|
405
|
+
media_type: 'image', media_mime_type: 'image/png', media_name: path.basename(file),
|
|
406
|
+
});
|
|
407
|
+
await reportTask(token, task.id, 'succeeded', { reply, action: 'capture_desktop' });
|
|
408
|
+
console.log(`[Task] 截图完成`);
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (!cliPath || !configPath) {
|
|
413
|
+
const fb = cliPath ? '未找到 OpenClaw 配置文件' : '未找到 OpenClaw CLI,请先安装 openclaw(npm install -g openclaw)并设置路径。';
|
|
414
|
+
await emitReply(token, task, fb);
|
|
415
|
+
await reportTask(token, task.id, 'failed', {}, fb);
|
|
416
|
+
console.log(`[Task] 跳过执行: ${fb}`);
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
const result = await runAgent({ cliPath, configPath, agentId: 'main', lobsterName: lobsterName || 'AI龙虾', message: content });
|
|
422
|
+
const emitPayload = result.media
|
|
423
|
+
? { content: result.reply || '', type: result.media.media_type === 'image' ? 'image' : 'file', ...result.media }
|
|
424
|
+
: result.reply;
|
|
425
|
+
const ev = await emitReply(token, task, emitPayload);
|
|
426
|
+
await reportTask(token, task.id, 'succeeded', {
|
|
427
|
+
reply: result.reply,
|
|
428
|
+
model: result.raw?.result?.meta?.agentMeta?.model || null,
|
|
429
|
+
action: 'openclaw_agent_reply',
|
|
430
|
+
});
|
|
431
|
+
console.log(`[Task] 回复成功 (eventId=${ev?.event?.id || 'n/a'}): ${content.slice(0, 50)}`);
|
|
432
|
+
} catch (err) {
|
|
433
|
+
const fb = `本地智能引擎返回错误:${err.message || '未知错误'}。`;
|
|
434
|
+
await emitReply(token, task, fb);
|
|
435
|
+
await reportTask(token, task.id, 'failed', {}, err.message);
|
|
436
|
+
console.log(`[Task] OpenClaw 执行失败: ${err.message?.slice(0, 200)}`);
|
|
437
|
+
}
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
await reportTask(token, task.id, 'failed', {}, `暂不支持的任务类型: ${task.taskType}`);
|
|
442
|
+
console.log(`[Task] 不支持的任务类型: ${task.taskType}`);
|
|
443
|
+
return false;
|
|
444
|
+
} catch (err) {
|
|
445
|
+
await reportTask(token, task.id, 'failed', {}, err.message || '任务执行失败');
|
|
446
|
+
console.error(`[Task] 执行异常: ${err.message}`);
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ─── 主循环 ───────────────────────────────────────────────────────────────────
|
|
452
|
+
const state = { shouldRun: true, reconnect: false };
|
|
453
|
+
|
|
454
|
+
process.on('SIGINT', () => { state.shouldRun = false; state.reconnect = true; });
|
|
455
|
+
process.on('SIGTERM', () => { state.shouldRun = false; state.reconnect = true; });
|
|
456
|
+
|
|
457
|
+
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
458
|
+
|
|
459
|
+
async function connectAndRun(cfg) {
|
|
460
|
+
const token = cfg.connectorToken || null;
|
|
461
|
+
const deviceName = cfg.deviceName || os.hostname();
|
|
462
|
+
const cliPath = findOpenClawCli(cfg.openclawCliPath);
|
|
463
|
+
const configPath = findOpenClawConfig(cfg.openclawConfigPath);
|
|
464
|
+
|
|
465
|
+
// 启动前检查
|
|
466
|
+
console.log('\n启动前检查:');
|
|
467
|
+
console.log(` OpenClaw CLI: ${cliPath ? '✓ ' + cliPath : '✗ 未找到(将无法执行 AI 任务,其余功能正常)'}`);
|
|
468
|
+
console.log(` OpenClaw 配置: ${configPath ? '✓ ' + configPath : '✗ 未找到'}`);
|
|
469
|
+
if (cliPath && !cfg.openclawCliPath) {
|
|
470
|
+
saveConfig({ openclawCliPath: cliPath });
|
|
471
|
+
console.log(` → 已自动保存 CLI 路径到配置`);
|
|
472
|
+
}
|
|
473
|
+
if (configPath && !cfg.openclawConfigPath) {
|
|
474
|
+
saveConfig({ openclawConfigPath: configPath });
|
|
475
|
+
}
|
|
476
|
+
console.log('');
|
|
477
|
+
|
|
478
|
+
// ── 若有已保存的 token,直接跳到心跳循环 ──────────────────────────────────
|
|
479
|
+
if (token) {
|
|
480
|
+
console.log(`[连接] 使用已保存的连接凭证 (设备: ${deviceName})`);
|
|
481
|
+
console.log(`[连接] 如需重新绑定龙虾,请输入 r 重新生成二维码`);
|
|
482
|
+
return await heartbeatLoop(token, deviceName, cliPath, configPath, cfg.lobsterName);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── 创建扫码会话 ──────────────────────────────────────────────────────────
|
|
486
|
+
console.log(`[扫码] 正在创建连接会话...`);
|
|
487
|
+
const created = await createScanSession(deviceName);
|
|
488
|
+
const sessionId = created.session.id;
|
|
489
|
+
const qrPayload = created.qrPayload;
|
|
490
|
+
|
|
491
|
+
console.log(`[扫码] 会话已创建: ${sessionId}`);
|
|
492
|
+
console.log(`[扫码] 请在里世界 APP → 我的龙虾 → 扫一扫,扫描下方二维码\n`);
|
|
493
|
+
|
|
494
|
+
showQrCode(JSON.stringify(qrPayload));
|
|
495
|
+
|
|
496
|
+
// ── 轮询等待批准 ──────────────────────────────────────────────────────────
|
|
497
|
+
console.log(`[扫码] 等待APP扫码批准(最多10分钟)...`);
|
|
498
|
+
let approved = null;
|
|
499
|
+
while (state.shouldRun && !state.reconnect) {
|
|
500
|
+
await sleep(POLL_INTERVAL_MS);
|
|
501
|
+
const result = await pollScanSession(sessionId);
|
|
502
|
+
const session = result.session;
|
|
503
|
+
|
|
504
|
+
if (session.status === 'approved' && session.connectorAuthToken) {
|
|
505
|
+
approved = session;
|
|
506
|
+
console.log(`\n[连接] ✓ 扫码成功!已绑定龙虾: ${session.lobster?.name || '未知'}`);
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
if (session.status === 'expired') {
|
|
510
|
+
console.log(`[扫码] 二维码已过期,准备重新生成...`);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (session.status === 'rejected') {
|
|
514
|
+
console.log(`[扫码] 连接已被拒绝,准备重新生成...`);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
process.stdout.write('.');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (!approved) return;
|
|
521
|
+
|
|
522
|
+
// ── 保存凭证 ──────────────────────────────────────────────────────────────
|
|
523
|
+
const newToken = approved.connectorAuthToken;
|
|
524
|
+
const lobsterName = approved.lobster?.name || '';
|
|
525
|
+
saveConfig({ connectorToken: newToken, lobsterName, deviceName, savedAt: new Date().toISOString() });
|
|
526
|
+
console.log(`[连接] 连接凭证已保存至 ${CONFIG_FILE}`);
|
|
527
|
+
|
|
528
|
+
await heartbeatLoop(newToken, deviceName, cliPath, configPath, lobsterName);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function heartbeatLoop(token, deviceName, cliPath, configPath, lobsterName) {
|
|
532
|
+
console.log(`\n[在线] 龙虾已连接,开始监听任务...`);
|
|
533
|
+
console.log(`[命令] r=重新扫码 s=查看状态 q=退出\n`);
|
|
534
|
+
|
|
535
|
+
let nextHeartbeat = 0;
|
|
536
|
+
let nextPoll = 0;
|
|
537
|
+
|
|
538
|
+
while (state.shouldRun && !state.reconnect) {
|
|
539
|
+
const now = Date.now();
|
|
540
|
+
|
|
541
|
+
if (now >= nextHeartbeat) {
|
|
542
|
+
try {
|
|
543
|
+
const hb = await sendHeartbeat(token, deviceName);
|
|
544
|
+
const lname = hb.lobster?.name || lobsterName || '未知';
|
|
545
|
+
process.stdout.write(`\r[♡ ${new Date().toLocaleTimeString()}] 龙虾=${lname} `);
|
|
546
|
+
nextHeartbeat = Date.now() + HEARTBEAT_INTERVAL_MS;
|
|
547
|
+
} catch (err) {
|
|
548
|
+
console.error(`\n[心跳] 失败: ${err.message}`);
|
|
549
|
+
if (err.status === 403 || err.status === 404) {
|
|
550
|
+
console.log('[心跳] 凭证已失效,清除保存的 token,准备重新扫码...');
|
|
551
|
+
saveConfig({ connectorToken: null });
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
nextHeartbeat = Date.now() + 5000;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (now >= nextPoll) {
|
|
559
|
+
try {
|
|
560
|
+
const tr = await pullTask(token);
|
|
561
|
+
if (tr.task) {
|
|
562
|
+
process.stdout.write('\n');
|
|
563
|
+
await handleTask(token, tr.task, cliPath, configPath, lobsterName || tr.lobster?.name);
|
|
564
|
+
nextPoll = Date.now() + 500;
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
} catch (err) {
|
|
568
|
+
if (err.status === 403 || err.status === 404) {
|
|
569
|
+
console.log('\n[任务] 凭证已失效,准备重新扫码...');
|
|
570
|
+
saveConfig({ connectorToken: null });
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
nextPoll = Date.now() + 5000;
|
|
574
|
+
}
|
|
575
|
+
nextPoll = Date.now() + POLL_INTERVAL_MS;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
await sleep(500);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function startKeyboard() {
|
|
583
|
+
if (!process.stdin.isTTY) return;
|
|
584
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
585
|
+
rl.on('line', line => {
|
|
586
|
+
const cmd = line.trim().toLowerCase();
|
|
587
|
+
if (cmd === 'q' || cmd === 'quit') { state.shouldRun = false; state.reconnect = true; rl.close(); }
|
|
588
|
+
else if (cmd === 'r') { console.log('\n[命令] 重新扫码...'); state.reconnect = true; }
|
|
589
|
+
else if (cmd === 's') {
|
|
590
|
+
const cfg = loadConfig();
|
|
591
|
+
console.log('\n当前状态:', JSON.stringify({ runtimeId, hasToken: !!cfg.connectorToken, cliPath: cfg.openclawCliPath, configPath: cfg.openclawConfigPath }, null, 2));
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function main() {
|
|
597
|
+
console.log('');
|
|
598
|
+
console.log('╔════════════════════════════════════════╗');
|
|
599
|
+
console.log('║ 里世界 OpenClaw 本地连接器 v' + pkg.version.padEnd(10) + '║');
|
|
600
|
+
console.log('╚════════════════════════════════════════╝');
|
|
601
|
+
console.log(`配置目录: ${CONFIG_DIR}`);
|
|
602
|
+
console.log(`Runtime ID: ${runtimeId}`);
|
|
603
|
+
console.log('');
|
|
604
|
+
|
|
605
|
+
startKeyboard();
|
|
606
|
+
|
|
607
|
+
while (state.shouldRun) {
|
|
608
|
+
state.reconnect = false;
|
|
609
|
+
try {
|
|
610
|
+
const cfg = loadConfig();
|
|
611
|
+
await connectAndRun(cfg);
|
|
612
|
+
} catch (err) {
|
|
613
|
+
console.error(`\n[错误] ${err.message}`);
|
|
614
|
+
if (!state.shouldRun) break;
|
|
615
|
+
await sleep(3000);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
console.log('\n[退出] 连接器已停止。');
|
|
620
|
+
process.exit(0);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
main().catch(err => {
|
|
624
|
+
console.error('启动失败:', err.message);
|
|
625
|
+
process.exit(1);
|
|
626
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@licity/openclaw-connector",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "里世界龙虾本地连接器 — 一行 npx 命令接入 OpenClaw,扫码即完成连接",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"licity-connector": "index.js",
|
|
8
|
+
"openclaw-connector": "index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "node index.js"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"licity",
|
|
19
|
+
"openclaw",
|
|
20
|
+
"connector",
|
|
21
|
+
"lobster",
|
|
22
|
+
"里世界",
|
|
23
|
+
"龙虾"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18.0.0"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"qrcode-terminal": "^0.12.0"
|
|
34
|
+
}
|
|
35
|
+
}
|