openclaw-vchat-plugin 0.0.1
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/bin/openclaw-vchat.js +110 -0
- package/dist/commands.d.ts +18 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +509 -0
- package/dist/commands.js.map +1 -0
- package/dist/constants.d.ts +14 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +51 -0
- package/dist/constants.js.map +1 -0
- package/dist/gateway-client.d.ts +43 -0
- package/dist/gateway-client.d.ts.map +1 -0
- package/dist/gateway-client.js +623 -0
- package/dist/gateway-client.js.map +1 -0
- package/dist/group-manager.d.ts +30 -0
- package/dist/group-manager.d.ts.map +1 -0
- package/dist/group-manager.js +107 -0
- package/dist/group-manager.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +382 -0
- package/dist/index.js.map +1 -0
- package/dist/media-handler.d.ts +31 -0
- package/dist/media-handler.d.ts.map +1 -0
- package/dist/media-handler.js +67 -0
- package/dist/media-handler.js.map +1 -0
- package/dist/message-handler.d.ts +52 -0
- package/dist/message-handler.d.ts.map +1 -0
- package/dist/message-handler.js +291 -0
- package/dist/message-handler.js.map +1 -0
- package/dist/relay-server.d.ts +16 -0
- package/dist/relay-server.d.ts.map +1 -0
- package/dist/relay-server.js +877 -0
- package/dist/relay-server.js.map +1 -0
- package/dist/routes/config.routes.d.ts +12 -0
- package/dist/routes/config.routes.d.ts.map +1 -0
- package/dist/routes/config.routes.js +175 -0
- package/dist/routes/config.routes.js.map +1 -0
- package/dist/services/config.service.d.ts +57 -0
- package/dist/services/config.service.d.ts.map +1 -0
- package/dist/services/config.service.js +361 -0
- package/dist/services/config.service.js.map +1 -0
- package/dist/session-key.d.ts +8 -0
- package/dist/session-key.d.ts.map +1 -0
- package/dist/session-key.js +28 -0
- package/dist/session-key.js.map +1 -0
- package/dist/session-manager.d.ts +32 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +303 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/types.d.ts +81 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/nginx-proxy.conf +24 -0
- package/package.json +51 -0
- package/src/commands.ts +499 -0
- package/src/constants.ts +49 -0
- package/src/gateway-client.ts +648 -0
- package/src/group-manager.ts +119 -0
- package/src/index.ts +443 -0
- package/src/media-handler.ts +70 -0
- package/src/message-handler.ts +419 -0
- package/src/relay-server.ts +979 -0
- package/src/routes/config.routes.ts +144 -0
- package/src/services/config.service.ts +398 -0
- package/src/session-key.ts +30 -0
- package/src/session-manager.ts +374 -0
- package/src/types.ts +96 -0
- package/start.sh +5 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 虚拟群数据模型
|
|
7
|
+
*/
|
|
8
|
+
export interface VirtualGroup {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
emoji: string;
|
|
12
|
+
mode: 'broadcast' | 'mention'; // 广播 = 所有 Agent 回复 | 点名 = @指定 Agent
|
|
13
|
+
agentIds: string[];
|
|
14
|
+
createdAt: number;
|
|
15
|
+
updatedAt: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 虚拟群管理器 — 本地 JSON 持久化
|
|
20
|
+
*/
|
|
21
|
+
export class GroupManager {
|
|
22
|
+
private groups: VirtualGroup[] = [];
|
|
23
|
+
private dataFile: string;
|
|
24
|
+
|
|
25
|
+
constructor(dataDir: string) {
|
|
26
|
+
this.dataFile = path.join(dataDir, 'groups.json');
|
|
27
|
+
this.load();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ===== CRUD =====
|
|
31
|
+
|
|
32
|
+
list(): VirtualGroup[] {
|
|
33
|
+
return this.groups;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get(groupId: string): VirtualGroup | undefined {
|
|
37
|
+
return this.groups.find(g => g.id === groupId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
create(name: string, emoji: string = '👥', agentIds: string[] = [], mode: 'broadcast' | 'mention' = 'mention'): VirtualGroup {
|
|
41
|
+
const group: VirtualGroup = {
|
|
42
|
+
id: randomUUID().substring(0, 8),
|
|
43
|
+
name,
|
|
44
|
+
emoji,
|
|
45
|
+
mode,
|
|
46
|
+
agentIds,
|
|
47
|
+
createdAt: Date.now(),
|
|
48
|
+
updatedAt: Date.now(),
|
|
49
|
+
};
|
|
50
|
+
this.groups.push(group);
|
|
51
|
+
this.save();
|
|
52
|
+
return group;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
update(groupId: string, data: Partial<Pick<VirtualGroup, 'name' | 'emoji' | 'mode'>>): VirtualGroup | null {
|
|
56
|
+
const group = this.get(groupId);
|
|
57
|
+
if (!group) return null;
|
|
58
|
+
if (data.name !== undefined) group.name = data.name;
|
|
59
|
+
if (data.emoji !== undefined) group.emoji = data.emoji;
|
|
60
|
+
if (data.mode !== undefined) group.mode = data.mode;
|
|
61
|
+
group.updatedAt = Date.now();
|
|
62
|
+
this.save();
|
|
63
|
+
return group;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
delete(groupId: string): boolean {
|
|
67
|
+
const before = this.groups.length;
|
|
68
|
+
this.groups = this.groups.filter(g => g.id !== groupId);
|
|
69
|
+
if (this.groups.length < before) {
|
|
70
|
+
this.save();
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ===== Agent 绑定 =====
|
|
77
|
+
|
|
78
|
+
addAgent(groupId: string, agentId: string): VirtualGroup | null {
|
|
79
|
+
const group = this.get(groupId);
|
|
80
|
+
if (!group) return null;
|
|
81
|
+
if (!group.agentIds.includes(agentId)) {
|
|
82
|
+
group.agentIds.push(agentId);
|
|
83
|
+
group.updatedAt = Date.now();
|
|
84
|
+
this.save();
|
|
85
|
+
}
|
|
86
|
+
return group;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
removeAgent(groupId: string, agentId: string): VirtualGroup | null {
|
|
90
|
+
const group = this.get(groupId);
|
|
91
|
+
if (!group) return null;
|
|
92
|
+
group.agentIds = group.agentIds.filter(id => id !== agentId);
|
|
93
|
+
group.updatedAt = Date.now();
|
|
94
|
+
this.save();
|
|
95
|
+
return group;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ===== 持久化 =====
|
|
99
|
+
|
|
100
|
+
private load(): void {
|
|
101
|
+
try {
|
|
102
|
+
if (fs.existsSync(this.dataFile)) {
|
|
103
|
+
const raw = fs.readFileSync(this.dataFile, 'utf-8');
|
|
104
|
+
this.groups = JSON.parse(raw);
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error('[GroupManager] 加载失败:', err);
|
|
108
|
+
this.groups = [];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private save(): void {
|
|
113
|
+
try {
|
|
114
|
+
fs.writeFileSync(this.dataFile, JSON.stringify(this.groups, null, 2), 'utf-8');
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error('[GroupManager] 保存失败:', err);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import fetch from 'node-fetch';
|
|
4
|
+
import WebSocket from 'ws';
|
|
5
|
+
import { createRelayServer } from './relay-server';
|
|
6
|
+
import { GatewayClient } from './gateway-client';
|
|
7
|
+
import { setGatewayClient } from './commands';
|
|
8
|
+
import { PluginConfig, MessageType } from './types';
|
|
9
|
+
import { MessageHandler } from './message-handler';
|
|
10
|
+
import { buildWeChatGatewaySessionKey, sanitizeWeChatId } from './session-key';
|
|
11
|
+
|
|
12
|
+
// @ts-ignore
|
|
13
|
+
import qrcode from 'qrcode-terminal';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* OpenClaw VChat Plugin 入口
|
|
17
|
+
*
|
|
18
|
+
* 启动流程:
|
|
19
|
+
* 1. 检查 ~/.openclaw/wechat-device.json 是否有 deviceKey
|
|
20
|
+
* 2. 有 → 用 deviceKey 注册 → 建立反向 WS 隧道
|
|
21
|
+
* 3. 没有 → 调 /api/devices/pair/init → 终端打印 QR 码 → 轮询配对 → 保存密钥
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const DATA_DIR = path.join(__dirname, '..', 'data');
|
|
25
|
+
const UPLOAD_DIR = path.join(DATA_DIR, 'uploads');
|
|
26
|
+
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || '/root', '.openclaw');
|
|
27
|
+
const DEVICE_CONFIG_PATH = path.join(OPENCLAW_HOME, 'wechat-device.json');
|
|
28
|
+
const BACKEND_URL = process.env.WECHAT_BACKEND_URL || 'https://api.deck0.dev';
|
|
29
|
+
|
|
30
|
+
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
|
31
|
+
fs.mkdirSync(OPENCLAW_HOME, { recursive: true });
|
|
32
|
+
|
|
33
|
+
const config: PluginConfig = {
|
|
34
|
+
port: 39217,
|
|
35
|
+
openclawGatewayUrl: process.env.OPENCLAW_GATEWAY_URL || 'http://localhost:18789',
|
|
36
|
+
openclawModel: process.env.OPENCLAW_MODEL || 'nvidia/z-ai/glm5',
|
|
37
|
+
openclawAuthToken: process.env.OPENCLAW_AUTH_TOKEN || '',
|
|
38
|
+
uploadDir: UPLOAD_DIR,
|
|
39
|
+
maxFileSize: 10 * 1024 * 1024,
|
|
40
|
+
maxVoiceDuration: 60,
|
|
41
|
+
internalSecret: process.env.PLUGIN_SECRET || '',
|
|
42
|
+
allowLocalFallback: process.env.WECHAT_PLUGIN_ENABLE_LOCAL_FALLBACK === '1',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ===== 设备配置读写 =====
|
|
46
|
+
|
|
47
|
+
interface DeviceConfig {
|
|
48
|
+
deviceKey: string;
|
|
49
|
+
deviceToken?: string;
|
|
50
|
+
backendUrl: string;
|
|
51
|
+
pairedAt: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readDeviceConfig(): DeviceConfig | null {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(fs.readFileSync(DEVICE_CONFIG_PATH, 'utf-8'));
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function saveDeviceConfig(cfg: DeviceConfig): void {
|
|
63
|
+
fs.writeFileSync(DEVICE_CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n', 'utf-8');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ===== 反向 WS 隧道 =====
|
|
67
|
+
|
|
68
|
+
let tunnelWs: WebSocket | null = null;
|
|
69
|
+
let gateway: GatewayClient;
|
|
70
|
+
let relayServer: ReturnType<typeof createRelayServer> | null = null;
|
|
71
|
+
let tunnelMessageHandler: MessageHandler | null = null;
|
|
72
|
+
const tunnelAbortByUser = new Map<string, () => void>();
|
|
73
|
+
|
|
74
|
+
function sendTunnelFrame(payload: any): void {
|
|
75
|
+
if (tunnelWs && tunnelWs.readyState === WebSocket.OPEN) {
|
|
76
|
+
tunnelWs.send(JSON.stringify(payload));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeSenderId(value: unknown): string {
|
|
81
|
+
const normalized = String(value || '').trim();
|
|
82
|
+
return normalized || 'unknown';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function ensureTunnelSession(userId: string, sessionId: string | undefined, agentId: string, groupId?: string): string {
|
|
86
|
+
if (!relayServer) throw new Error('插件尚未完成初始化');
|
|
87
|
+
const normalizedAgentId = sanitizeWeChatId(agentId, 'main');
|
|
88
|
+
const existingSessionId = String(sessionId || '').trim();
|
|
89
|
+
if (existingSessionId) {
|
|
90
|
+
return relayServer.sessionManager.ensureSessionBinding(
|
|
91
|
+
userId,
|
|
92
|
+
existingSessionId,
|
|
93
|
+
normalizedAgentId,
|
|
94
|
+
buildWeChatGatewaySessionKey(userId, existingSessionId, normalizedAgentId, groupId),
|
|
95
|
+
).id;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const created = relayServer.sessionManager.createSession(userId, undefined, normalizedAgentId);
|
|
99
|
+
return relayServer.sessionManager.ensureSessionBinding(
|
|
100
|
+
userId,
|
|
101
|
+
created.id,
|
|
102
|
+
normalizedAgentId,
|
|
103
|
+
buildWeChatGatewaySessionKey(userId, created.id, normalizedAgentId, groupId),
|
|
104
|
+
).id;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function connectTunnel(deviceToken: string): void {
|
|
108
|
+
const wsUrl = BACKEND_URL.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:')
|
|
109
|
+
+ `/ws/plugin?deviceToken=${encodeURIComponent(deviceToken)}`;
|
|
110
|
+
|
|
111
|
+
console.log('[隧道] 正在连接后端...');
|
|
112
|
+
tunnelWs = new WebSocket(wsUrl);
|
|
113
|
+
|
|
114
|
+
tunnelWs.on('open', () => {
|
|
115
|
+
console.log('[隧道] ✅ 已连接到后端,等待小程序消息');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
tunnelWs.on('message', (data) => {
|
|
119
|
+
// 后端转发的小程序消息 → 处理并回复
|
|
120
|
+
handleClientMessage(data.toString());
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
tunnelWs.on('close', (code, reason) => {
|
|
124
|
+
console.log(`[隧道] 断开 (${code}): ${reason}`);
|
|
125
|
+
for (const abort of tunnelAbortByUser.values()) {
|
|
126
|
+
try { abort(); } catch { /* ignore */ }
|
|
127
|
+
}
|
|
128
|
+
tunnelAbortByUser.clear();
|
|
129
|
+
tunnelWs = null;
|
|
130
|
+
// 自动重连
|
|
131
|
+
setTimeout(() => {
|
|
132
|
+
console.log('[隧道] 尝试重连...');
|
|
133
|
+
connectTunnel(deviceToken);
|
|
134
|
+
}, 5000);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
tunnelWs.on('error', (err) => {
|
|
138
|
+
console.error('[隧道] 连接错误:', err.message);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 处理小程序通过隧道发来的消息
|
|
144
|
+
*/
|
|
145
|
+
function handleClientMessage(raw: string): void {
|
|
146
|
+
try {
|
|
147
|
+
const msg = JSON.parse(raw);
|
|
148
|
+
const userId = normalizeSenderId(msg.userId);
|
|
149
|
+
const requestId = String(msg.requestId || '').trim();
|
|
150
|
+
|
|
151
|
+
if (msg.type === 'ping') {
|
|
152
|
+
sendTunnelFrame({ type: 'pong' });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (msg.type === 'chat') {
|
|
157
|
+
if (!relayServer || !tunnelMessageHandler) {
|
|
158
|
+
sendTunnelFrame({ type: 'error', message: '插件尚未完成初始化', error: '插件尚未完成初始化' });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const content = String(msg.content || '').trim();
|
|
163
|
+
if (!content) {
|
|
164
|
+
sendTunnelFrame({ type: 'error', message: '消息内容不能为空', error: '消息内容不能为空' });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const agentId = String(msg.agentId || 'main');
|
|
169
|
+
const sessionId = String(msg.sessionId || '').trim();
|
|
170
|
+
const groupId = msg.groupId ? String(msg.groupId) : undefined;
|
|
171
|
+
const sid = ensureTunnelSession(userId, sessionId, agentId, groupId);
|
|
172
|
+
console.log(`[BridgeTunnel] requestId=${requestId || '-'} type=chat agent=${agentId} session=${sid} user=${userId}`);
|
|
173
|
+
const mediaUrl = msg.mediaUrl ? String(msg.mediaUrl) : undefined;
|
|
174
|
+
const rawMessageType = String(msg.messageType || 'text');
|
|
175
|
+
const messageType: MessageType =
|
|
176
|
+
rawMessageType === 'voice' || rawMessageType === 'image' || rawMessageType === 'command' || rawMessageType === 'system'
|
|
177
|
+
? rawMessageType
|
|
178
|
+
: 'text';
|
|
179
|
+
const routingHint = {
|
|
180
|
+
commandRoute: typeof msg.commandRoute === 'string' ? msg.commandRoute : undefined,
|
|
181
|
+
routeSource: typeof msg.routeSource === 'string' ? msg.routeSource : undefined,
|
|
182
|
+
requestId: typeof msg.requestId === 'string' ? msg.requestId : undefined,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const previousAbort = tunnelAbortByUser.get(userId);
|
|
186
|
+
if (previousAbort) {
|
|
187
|
+
previousAbort();
|
|
188
|
+
tunnelAbortByUser.delete(userId);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
sendTunnelFrame({ type: 'start', sessionId: sid, agentId, requestId });
|
|
192
|
+
|
|
193
|
+
let currentAbort: (() => void) | null = null;
|
|
194
|
+
let streamFinished = false;
|
|
195
|
+
const abort = tunnelMessageHandler.handleMessageStream(
|
|
196
|
+
userId,
|
|
197
|
+
sid,
|
|
198
|
+
content,
|
|
199
|
+
messageType,
|
|
200
|
+
{
|
|
201
|
+
onThinking: (text) => {
|
|
202
|
+
sendTunnelFrame({ type: 'thinking', text, sessionId: sid, agentId, requestId });
|
|
203
|
+
},
|
|
204
|
+
onChunk: (text) => {
|
|
205
|
+
sendTunnelFrame({
|
|
206
|
+
type: 'chunk',
|
|
207
|
+
text,
|
|
208
|
+
delta: text,
|
|
209
|
+
sessionId: sid,
|
|
210
|
+
agentId,
|
|
211
|
+
requestId,
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
onDone: (userMessage, assistantMessage) => {
|
|
215
|
+
streamFinished = true;
|
|
216
|
+
if (currentAbort && tunnelAbortByUser.get(userId) === currentAbort) tunnelAbortByUser.delete(userId);
|
|
217
|
+
sendTunnelFrame({
|
|
218
|
+
type: 'done',
|
|
219
|
+
sessionId: sid,
|
|
220
|
+
agentId,
|
|
221
|
+
userMessage,
|
|
222
|
+
assistantMessage,
|
|
223
|
+
text: assistantMessage?.content || '',
|
|
224
|
+
requestId,
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
onError: (error) => {
|
|
228
|
+
streamFinished = true;
|
|
229
|
+
if (currentAbort && tunnelAbortByUser.get(userId) === currentAbort) tunnelAbortByUser.delete(userId);
|
|
230
|
+
sendTunnelFrame({
|
|
231
|
+
type: 'error',
|
|
232
|
+
sessionId: sid,
|
|
233
|
+
agentId,
|
|
234
|
+
message: error,
|
|
235
|
+
error,
|
|
236
|
+
requestId,
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
agentId,
|
|
241
|
+
mediaUrl,
|
|
242
|
+
undefined,
|
|
243
|
+
groupId,
|
|
244
|
+
routingHint,
|
|
245
|
+
);
|
|
246
|
+
currentAbort = abort;
|
|
247
|
+
if (streamFinished) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
tunnelAbortByUser.set(userId, abort);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (msg.type === 'stop') {
|
|
255
|
+
const abort = tunnelAbortByUser.get(userId);
|
|
256
|
+
if (abort) {
|
|
257
|
+
abort();
|
|
258
|
+
tunnelAbortByUser.delete(userId);
|
|
259
|
+
}
|
|
260
|
+
sendTunnelFrame({ type: 'stopped', requestId });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
sendTunnelFrame({
|
|
265
|
+
type: 'error',
|
|
266
|
+
message: `未知消息类型: ${msg.type}`,
|
|
267
|
+
error: `未知消息类型: ${msg.type}`,
|
|
268
|
+
});
|
|
269
|
+
} catch (err) {
|
|
270
|
+
console.error('[隧道] 处理消息错误:', err);
|
|
271
|
+
sendTunnelFrame({
|
|
272
|
+
type: 'error',
|
|
273
|
+
message: '隧道消息解析失败',
|
|
274
|
+
error: '隧道消息解析失败',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ===== 配对流程 =====
|
|
280
|
+
|
|
281
|
+
async function startPairing(): Promise<DeviceConfig> {
|
|
282
|
+
console.log('\n📱 首次使用,需要与小程序配对...\n');
|
|
283
|
+
|
|
284
|
+
// 调后端 init
|
|
285
|
+
const initRes = await fetch(`${BACKEND_URL}/api/devices/pair/init`, { method: 'POST' });
|
|
286
|
+
if (!initRes.ok) {
|
|
287
|
+
throw new Error(`配对初始化失败: ${initRes.status}`);
|
|
288
|
+
}
|
|
289
|
+
const { pairingToken, pairingCode } = await initRes.json() as any;
|
|
290
|
+
|
|
291
|
+
// 终端显示二维码
|
|
292
|
+
const qrContent = JSON.stringify({ type: 'openclaw-pair', token: pairingToken });
|
|
293
|
+
console.log('╔══════════════════════════════════════════╗');
|
|
294
|
+
console.log('║ 请用 OpenClaw 小程序扫描下方二维码配对 ║');
|
|
295
|
+
console.log('╚══════════════════════════════════════════╝');
|
|
296
|
+
console.log('');
|
|
297
|
+
qrcode.generate(qrContent, { small: true }, (qr: string) => {
|
|
298
|
+
console.log(qr);
|
|
299
|
+
});
|
|
300
|
+
console.log(` 配对码: ${pairingCode} (5分钟内有效)`);
|
|
301
|
+
console.log('');
|
|
302
|
+
console.log(' 等待小程序扫码...');
|
|
303
|
+
console.log('');
|
|
304
|
+
|
|
305
|
+
// 轮询
|
|
306
|
+
return new Promise((resolve, reject) => {
|
|
307
|
+
const pollInterval = setInterval(async () => {
|
|
308
|
+
try {
|
|
309
|
+
const statusRes = await fetch(
|
|
310
|
+
`${BACKEND_URL}/api/devices/pair/status?token=${encodeURIComponent(pairingToken)}`
|
|
311
|
+
);
|
|
312
|
+
const status = await statusRes.json() as any;
|
|
313
|
+
|
|
314
|
+
if (status.status === 'paired') {
|
|
315
|
+
clearInterval(pollInterval);
|
|
316
|
+
console.log('✅ 配对成功!设备已绑定\n');
|
|
317
|
+
|
|
318
|
+
const cfg: DeviceConfig = {
|
|
319
|
+
deviceKey: status.deviceKey,
|
|
320
|
+
deviceToken: status.deviceToken,
|
|
321
|
+
backendUrl: BACKEND_URL,
|
|
322
|
+
pairedAt: new Date().toISOString(),
|
|
323
|
+
};
|
|
324
|
+
saveDeviceConfig(cfg);
|
|
325
|
+
resolve(cfg);
|
|
326
|
+
} else if (status.status === 'expired' || status.status === 'not_found') {
|
|
327
|
+
clearInterval(pollInterval);
|
|
328
|
+
reject(new Error('配对已过期,请重新启动插件'));
|
|
329
|
+
}
|
|
330
|
+
// pending → 继续轮询
|
|
331
|
+
} catch (err: any) {
|
|
332
|
+
console.error('[配对] 轮询错误:', err.message);
|
|
333
|
+
}
|
|
334
|
+
}, 2000);
|
|
335
|
+
|
|
336
|
+
// 5分钟超时
|
|
337
|
+
setTimeout(() => {
|
|
338
|
+
clearInterval(pollInterval);
|
|
339
|
+
reject(new Error('配对超时(5分钟),请重新启动插件'));
|
|
340
|
+
}, 5 * 60 * 1000);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ===== 注册设备 =====
|
|
345
|
+
|
|
346
|
+
async function registerDevice(deviceKey: string): Promise<string> {
|
|
347
|
+
const res = await fetch(`${BACKEND_URL}/api/devices/register`, {
|
|
348
|
+
method: 'POST',
|
|
349
|
+
headers: { 'Content-Type': 'application/json' },
|
|
350
|
+
body: JSON.stringify({ deviceKey }),
|
|
351
|
+
});
|
|
352
|
+
if (!res.ok) {
|
|
353
|
+
const errBody = await res.text();
|
|
354
|
+
throw new Error(`设备注册失败: ${res.status} ${errBody}`);
|
|
355
|
+
}
|
|
356
|
+
const result = await res.json() as any;
|
|
357
|
+
return result.deviceToken;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ===== 主启动 =====
|
|
361
|
+
|
|
362
|
+
async function start(): Promise<void> {
|
|
363
|
+
gateway = new GatewayClient(config.openclawGatewayUrl, config.openclawAuthToken);
|
|
364
|
+
setGatewayClient(gateway);
|
|
365
|
+
|
|
366
|
+
// 启动本地中继服务器(用于 HTTP API 调用,如 agent 列表等)
|
|
367
|
+
const relay = createRelayServer(config, gateway);
|
|
368
|
+
relayServer = relay;
|
|
369
|
+
tunnelMessageHandler = new MessageHandler(config, relay.sessionManager, gateway);
|
|
370
|
+
relay.server.listen(config.port, '127.0.0.1', () => {
|
|
371
|
+
// 不打印,下面统一打印
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
let deviceCfg = readDeviceConfig();
|
|
375
|
+
|
|
376
|
+
if (!deviceCfg || !deviceCfg.deviceKey) {
|
|
377
|
+
// 首次使用 → 扫码配对
|
|
378
|
+
try {
|
|
379
|
+
deviceCfg = await startPairing();
|
|
380
|
+
} catch (err: any) {
|
|
381
|
+
console.error('❌', err.message);
|
|
382
|
+
process.exit(1);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 注册设备获取 token
|
|
387
|
+
let deviceToken = deviceCfg.deviceToken;
|
|
388
|
+
try {
|
|
389
|
+
deviceToken = await registerDevice(deviceCfg.deviceKey);
|
|
390
|
+
// 更新本地 token
|
|
391
|
+
deviceCfg.deviceToken = deviceToken;
|
|
392
|
+
saveDeviceConfig(deviceCfg);
|
|
393
|
+
} catch (err: any) {
|
|
394
|
+
console.error('⚠️ 设备注册失败:', err.message);
|
|
395
|
+
if (!deviceToken) {
|
|
396
|
+
console.error('❌ 无法连接后端,请检查网络或重新配对');
|
|
397
|
+
process.exit(1);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 建立反向 WS 隧道
|
|
402
|
+
connectTunnel(deviceToken!);
|
|
403
|
+
|
|
404
|
+
console.log('');
|
|
405
|
+
console.log('🔌 =============================================');
|
|
406
|
+
console.log(' OpenClaw VChat Plugin');
|
|
407
|
+
console.log(' =============================================');
|
|
408
|
+
console.log(` 📡 本地端口: http://127.0.0.1:${config.port}`);
|
|
409
|
+
console.log(` 🔗 OpenClaw: ${config.openclawGatewayUrl}`);
|
|
410
|
+
console.log(` 🤖 AI 模型: ${config.openclawModel}`);
|
|
411
|
+
console.log(` 🌐 后端: ${BACKEND_URL}`);
|
|
412
|
+
console.log(` 🔑 设备: 已配对`);
|
|
413
|
+
console.log(' =============================================');
|
|
414
|
+
console.log('');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
start().catch((err) => {
|
|
418
|
+
console.error('❌ 启动失败:', err);
|
|
419
|
+
process.exit(1);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// 优雅关闭
|
|
423
|
+
const shutdown = () => {
|
|
424
|
+
console.log('\n🛑 正在关闭插件...');
|
|
425
|
+
for (const abort of tunnelAbortByUser.values()) {
|
|
426
|
+
try { abort(); } catch { /* ignore */ }
|
|
427
|
+
}
|
|
428
|
+
tunnelAbortByUser.clear();
|
|
429
|
+
if (tunnelWs) tunnelWs.close();
|
|
430
|
+
if (relayServer) {
|
|
431
|
+
relayServer.server.close(() => {
|
|
432
|
+
relayServer!.sessionManager.close();
|
|
433
|
+
console.log('✅ 插件已关闭');
|
|
434
|
+
process.exit(0);
|
|
435
|
+
});
|
|
436
|
+
} else {
|
|
437
|
+
process.exit(0);
|
|
438
|
+
}
|
|
439
|
+
setTimeout(() => { process.exit(1); }, 5000);
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
process.on('SIGINT', shutdown);
|
|
443
|
+
process.on('SIGTERM', shutdown);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
4
|
+
import { PluginConfig } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 媒体文件处理:语音和图片的存储与访问
|
|
8
|
+
*/
|
|
9
|
+
export class MediaHandler {
|
|
10
|
+
private uploadDir: string;
|
|
11
|
+
|
|
12
|
+
constructor(config: PluginConfig) {
|
|
13
|
+
this.uploadDir = config.uploadDir;
|
|
14
|
+
// 确保上传目录存在
|
|
15
|
+
fs.mkdirSync(path.join(this.uploadDir, 'voice'), { recursive: true });
|
|
16
|
+
fs.mkdirSync(path.join(this.uploadDir, 'image'), { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 保存语音文件,返回文件路径和访问 URL
|
|
21
|
+
*/
|
|
22
|
+
saveVoice(buffer: Buffer, originalName: string): { filePath: string; url: string } {
|
|
23
|
+
const ext = path.extname(originalName) || '.mp3';
|
|
24
|
+
const filename = `${uuidv4()}${ext}`;
|
|
25
|
+
const filePath = path.join(this.uploadDir, 'voice', filename);
|
|
26
|
+
|
|
27
|
+
fs.writeFileSync(filePath, buffer);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
filePath,
|
|
31
|
+
url: `/uploads/voice/${filename}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 保存图片文件,返回文件路径和访问 URL
|
|
37
|
+
*/
|
|
38
|
+
saveImage(buffer: Buffer, originalName: string): { filePath: string; url: string } {
|
|
39
|
+
const ext = path.extname(originalName) || '.jpg';
|
|
40
|
+
const filename = `${uuidv4()}${ext}`;
|
|
41
|
+
const filePath = path.join(this.uploadDir, 'image', filename);
|
|
42
|
+
|
|
43
|
+
fs.writeFileSync(filePath, buffer);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
filePath,
|
|
47
|
+
url: `/uploads/image/${filename}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 删除媒体文件
|
|
53
|
+
*/
|
|
54
|
+
deleteFile(filePath: string): void {
|
|
55
|
+
try {
|
|
56
|
+
if (fs.existsSync(filePath)) {
|
|
57
|
+
fs.unlinkSync(filePath);
|
|
58
|
+
}
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error('[MediaHandler] 删除文件失败:', filePath, err);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 获取上传目录的绝对路径(用于 Express 静态文件服务)
|
|
66
|
+
*/
|
|
67
|
+
getUploadDir(): string {
|
|
68
|
+
return this.uploadDir;
|
|
69
|
+
}
|
|
70
|
+
}
|