openclaw-vchat-plugin 0.0.8 → 0.0.10
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/dist/group-event-store.d.ts +47 -0
- package/dist/group-event-store.d.ts.map +1 -0
- package/dist/group-event-store.js +145 -0
- package/dist/group-event-store.js.map +1 -0
- package/dist/index.js +32 -9
- package/dist/index.js.map +1 -1
- package/dist/plugin-update-runner.d.ts +2 -0
- package/dist/plugin-update-runner.d.ts.map +1 -0
- package/dist/plugin-update-runner.js +179 -0
- package/dist/plugin-update-runner.js.map +1 -0
- package/dist/relay-server.d.ts +2 -0
- package/dist/relay-server.d.ts.map +1 -1
- package/dist/relay-server.js +111 -4
- package/dist/relay-server.js.map +1 -1
- package/dist/routes/config.routes.d.ts.map +1 -1
- package/dist/routes/config.routes.js +43 -0
- package/dist/routes/config.routes.js.map +1 -1
- package/dist/runtime-data.d.ts +5 -0
- package/dist/runtime-data.d.ts.map +1 -0
- package/dist/runtime-data.js +114 -0
- package/dist/runtime-data.js.map +1 -0
- package/dist/services/plugin-update.service.d.ts +24 -0
- package/dist/services/plugin-update.service.d.ts.map +1 -0
- package/dist/services/plugin-update.service.js +308 -0
- package/dist/services/plugin-update.service.js.map +1 -0
- package/dist/services/skills.service.d.ts +33 -0
- package/dist/services/skills.service.d.ts.map +1 -0
- package/dist/services/skills.service.js +177 -0
- package/dist/services/skills.service.js.map +1 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/group-event-store.ts +204 -0
- package/src/index.ts +30 -6
- package/src/plugin-update-runner.ts +217 -0
- package/src/relay-server.ts +113 -4
- package/src/routes/config.routes.ts +46 -0
- package/src/runtime-data.ts +113 -0
- package/src/services/plugin-update.service.ts +335 -0
- package/src/services/skills.service.ts +175 -0
- package/src/types.ts +1 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
import { MessageType } from './types';
|
|
5
|
+
|
|
6
|
+
export interface GroupEvent {
|
|
7
|
+
id: string;
|
|
8
|
+
groupId: string;
|
|
9
|
+
requestId?: string;
|
|
10
|
+
sessionId?: string;
|
|
11
|
+
role: 'user' | 'assistant' | 'system';
|
|
12
|
+
type: MessageType;
|
|
13
|
+
content: string;
|
|
14
|
+
mediaUrl?: string;
|
|
15
|
+
timestamp: number;
|
|
16
|
+
senderUserId?: string;
|
|
17
|
+
senderName?: string;
|
|
18
|
+
senderAvatarUrl?: string;
|
|
19
|
+
agentId?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeText(value: unknown): string {
|
|
23
|
+
return String(value || '').trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeMessageType(value: unknown): MessageType {
|
|
27
|
+
const normalized = normalizeText(value).toLowerCase();
|
|
28
|
+
if (normalized === 'voice' || normalized === 'image' || normalized === 'system' || normalized === 'command') {
|
|
29
|
+
return normalized;
|
|
30
|
+
}
|
|
31
|
+
return 'text';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class GroupEventStore {
|
|
35
|
+
private db: Database.Database;
|
|
36
|
+
|
|
37
|
+
constructor(dataDir: string) {
|
|
38
|
+
const dbPath = path.join(dataDir, 'openclaw-wechat.db');
|
|
39
|
+
this.db = new Database(dbPath);
|
|
40
|
+
this.db.pragma('journal_mode = WAL');
|
|
41
|
+
this.db.pragma('foreign_keys = ON');
|
|
42
|
+
this.initTables();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private initTables(): void {
|
|
46
|
+
this.db.exec(`
|
|
47
|
+
CREATE TABLE IF NOT EXISTS group_events (
|
|
48
|
+
id TEXT PRIMARY KEY,
|
|
49
|
+
group_id TEXT NOT NULL,
|
|
50
|
+
request_id TEXT,
|
|
51
|
+
session_id TEXT,
|
|
52
|
+
role TEXT NOT NULL,
|
|
53
|
+
type TEXT NOT NULL DEFAULT 'text',
|
|
54
|
+
content TEXT NOT NULL,
|
|
55
|
+
media_url TEXT,
|
|
56
|
+
sender_user_id TEXT,
|
|
57
|
+
sender_name TEXT,
|
|
58
|
+
sender_avatar_url TEXT,
|
|
59
|
+
agent_id TEXT,
|
|
60
|
+
created_at INTEGER NOT NULL
|
|
61
|
+
);
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_group_events_group_id_created_at
|
|
63
|
+
ON group_events(group_id, created_at DESC);
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_group_events_request_id
|
|
65
|
+
ON group_events(request_id);
|
|
66
|
+
`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
appendUserMessage(input: {
|
|
70
|
+
groupId: string;
|
|
71
|
+
requestId?: string;
|
|
72
|
+
sessionId?: string;
|
|
73
|
+
content: string;
|
|
74
|
+
type?: MessageType;
|
|
75
|
+
mediaUrl?: string;
|
|
76
|
+
senderUserId?: string;
|
|
77
|
+
senderName?: string;
|
|
78
|
+
senderAvatarUrl?: string;
|
|
79
|
+
timestamp?: number;
|
|
80
|
+
}): GroupEvent {
|
|
81
|
+
const groupId = normalizeText(input.groupId);
|
|
82
|
+
const content = String(input.content || '');
|
|
83
|
+
const requestId = normalizeText(input.requestId);
|
|
84
|
+
|
|
85
|
+
if (requestId) {
|
|
86
|
+
const existing = this.db.prepare(`
|
|
87
|
+
SELECT * FROM group_events
|
|
88
|
+
WHERE group_id = ? AND request_id = ? AND role = 'user'
|
|
89
|
+
LIMIT 1
|
|
90
|
+
`).get(groupId, requestId) as any;
|
|
91
|
+
if (existing) {
|
|
92
|
+
return this.mapRow(existing);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const event: GroupEvent = {
|
|
97
|
+
id: randomUUID(),
|
|
98
|
+
groupId,
|
|
99
|
+
requestId: requestId || undefined,
|
|
100
|
+
sessionId: normalizeText(input.sessionId) || undefined,
|
|
101
|
+
role: 'user',
|
|
102
|
+
type: normalizeMessageType(input.type),
|
|
103
|
+
content,
|
|
104
|
+
mediaUrl: normalizeText(input.mediaUrl) || undefined,
|
|
105
|
+
timestamp: Number(input.timestamp) || Date.now(),
|
|
106
|
+
senderUserId: normalizeText(input.senderUserId) || undefined,
|
|
107
|
+
senderName: normalizeText(input.senderName) || undefined,
|
|
108
|
+
senderAvatarUrl: normalizeText(input.senderAvatarUrl) || undefined,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
this.insert(event);
|
|
112
|
+
return event;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
appendAssistantMessage(input: {
|
|
116
|
+
groupId: string;
|
|
117
|
+
requestId?: string;
|
|
118
|
+
sessionId?: string;
|
|
119
|
+
agentId?: string;
|
|
120
|
+
content: string;
|
|
121
|
+
type?: MessageType;
|
|
122
|
+
mediaUrl?: string;
|
|
123
|
+
timestamp?: number;
|
|
124
|
+
}): GroupEvent {
|
|
125
|
+
const event: GroupEvent = {
|
|
126
|
+
id: randomUUID(),
|
|
127
|
+
groupId: normalizeText(input.groupId),
|
|
128
|
+
requestId: normalizeText(input.requestId) || undefined,
|
|
129
|
+
sessionId: normalizeText(input.sessionId) || undefined,
|
|
130
|
+
role: 'assistant',
|
|
131
|
+
type: normalizeMessageType(input.type),
|
|
132
|
+
content: String(input.content || ''),
|
|
133
|
+
mediaUrl: normalizeText(input.mediaUrl) || undefined,
|
|
134
|
+
timestamp: Number(input.timestamp) || Date.now(),
|
|
135
|
+
agentId: normalizeText(input.agentId) || undefined,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
this.insert(event);
|
|
139
|
+
return event;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
list(groupId: string, limit = 100, before?: number): GroupEvent[] {
|
|
143
|
+
const safeLimit = Math.max(1, Math.min(Number(limit) || 100, 200));
|
|
144
|
+
const safeBefore = Number(before) || 0;
|
|
145
|
+
const rows = safeBefore > 0
|
|
146
|
+
? this.db.prepare(`
|
|
147
|
+
SELECT * FROM group_events
|
|
148
|
+
WHERE group_id = ? AND created_at < ?
|
|
149
|
+
ORDER BY created_at DESC
|
|
150
|
+
LIMIT ?
|
|
151
|
+
`).all(normalizeText(groupId), safeBefore, safeLimit) as any[]
|
|
152
|
+
: this.db.prepare(`
|
|
153
|
+
SELECT * FROM group_events
|
|
154
|
+
WHERE group_id = ?
|
|
155
|
+
ORDER BY created_at DESC
|
|
156
|
+
LIMIT ?
|
|
157
|
+
`).all(normalizeText(groupId), safeLimit) as any[];
|
|
158
|
+
|
|
159
|
+
return rows
|
|
160
|
+
.map((row) => this.mapRow(row))
|
|
161
|
+
.sort((a, b) => a.timestamp - b.timestamp);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private insert(event: GroupEvent): void {
|
|
165
|
+
this.db.prepare(`
|
|
166
|
+
INSERT INTO group_events (
|
|
167
|
+
id, group_id, request_id, session_id, role, type, content, media_url,
|
|
168
|
+
sender_user_id, sender_name, sender_avatar_url, agent_id, created_at
|
|
169
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
170
|
+
`).run(
|
|
171
|
+
event.id,
|
|
172
|
+
event.groupId,
|
|
173
|
+
event.requestId || null,
|
|
174
|
+
event.sessionId || null,
|
|
175
|
+
event.role,
|
|
176
|
+
event.type,
|
|
177
|
+
event.content,
|
|
178
|
+
event.mediaUrl || null,
|
|
179
|
+
event.senderUserId || null,
|
|
180
|
+
event.senderName || null,
|
|
181
|
+
event.senderAvatarUrl || null,
|
|
182
|
+
event.agentId || null,
|
|
183
|
+
event.timestamp,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private mapRow(row: any): GroupEvent {
|
|
188
|
+
return {
|
|
189
|
+
id: normalizeText(row.id),
|
|
190
|
+
groupId: normalizeText(row.group_id),
|
|
191
|
+
requestId: normalizeText(row.request_id) || undefined,
|
|
192
|
+
sessionId: normalizeText(row.session_id) || undefined,
|
|
193
|
+
role: normalizeText(row.role) === 'assistant' ? 'assistant' : normalizeText(row.role) === 'system' ? 'system' : 'user',
|
|
194
|
+
type: normalizeMessageType(row.type),
|
|
195
|
+
content: String(row.content || ''),
|
|
196
|
+
mediaUrl: normalizeText(row.media_url) || undefined,
|
|
197
|
+
timestamp: Number(row.created_at) || Date.now(),
|
|
198
|
+
senderUserId: normalizeText(row.sender_user_id) || undefined,
|
|
199
|
+
senderName: normalizeText(row.sender_name) || undefined,
|
|
200
|
+
senderAvatarUrl: normalizeText(row.sender_avatar_url) || undefined,
|
|
201
|
+
agentId: normalizeText(row.agent_id) || undefined,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { GatewayClient } from './gateway-client';
|
|
|
8
8
|
import { setGatewayClient } from './commands';
|
|
9
9
|
import { PluginConfig, MessageType } from './types';
|
|
10
10
|
import { MessageHandler } from './message-handler';
|
|
11
|
+
import { ensureRuntimeDataLayout, OPENCLAW_HOME, VCHAT_DATA_DIR, VCHAT_UPLOAD_DIR } from './runtime-data';
|
|
11
12
|
import { buildWeChatGatewaySessionKey, sanitizeWeChatId } from './session-key';
|
|
12
13
|
|
|
13
14
|
// @ts-ignore
|
|
@@ -22,23 +23,20 @@ import qrcode from 'qrcode-terminal';
|
|
|
22
23
|
* 3. 没有 → 调 /api/devices/pair/init → 终端打印 QR 码 → 轮询配对 → 保存密钥
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
|
-
const DATA_DIR = path.join(__dirname, '..', 'data');
|
|
26
|
-
const UPLOAD_DIR = path.join(DATA_DIR, 'uploads');
|
|
27
|
-
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || '/root', '.openclaw');
|
|
28
26
|
const DEVICE_CONFIG_PATH = path.join(OPENCLAW_HOME, 'wechat-device.json');
|
|
29
27
|
const INSTALLATION_CONFIG_PATH = path.join(OPENCLAW_HOME, 'vchat-installation.json');
|
|
30
28
|
const VCHAT_ENV_PATH = path.join(process.env.HOME || '/root', '.openclaw-vchat.env');
|
|
31
29
|
const DEFAULT_BACKEND_URL = 'https://lxkzt.cqljkj.com';
|
|
32
30
|
|
|
33
|
-
|
|
34
|
-
fs.mkdirSync(OPENCLAW_HOME, { recursive: true });
|
|
31
|
+
ensureRuntimeDataLayout();
|
|
35
32
|
|
|
36
33
|
const config: PluginConfig = {
|
|
37
34
|
port: 39217,
|
|
38
35
|
openclawGatewayUrl: process.env.OPENCLAW_GATEWAY_URL || 'http://localhost:18789',
|
|
39
36
|
openclawModel: process.env.OPENCLAW_MODEL || 'nvidia/z-ai/glm5',
|
|
40
37
|
openclawAuthToken: process.env.OPENCLAW_AUTH_TOKEN || '',
|
|
41
|
-
|
|
38
|
+
dataDir: VCHAT_DATA_DIR,
|
|
39
|
+
uploadDir: VCHAT_UPLOAD_DIR,
|
|
42
40
|
maxFileSize: 10 * 1024 * 1024,
|
|
43
41
|
maxVoiceDuration: 60,
|
|
44
42
|
internalSecret: process.env.PLUGIN_SECRET || '',
|
|
@@ -234,6 +232,7 @@ function handleClientMessage(raw: string): void {
|
|
|
234
232
|
sendTunnelFrame({ type: 'error', message: '插件尚未完成初始化', error: '插件尚未完成初始化' });
|
|
235
233
|
return;
|
|
236
234
|
}
|
|
235
|
+
const currentRelayServer = relayServer;
|
|
237
236
|
|
|
238
237
|
const content = String(msg.content || '').trim();
|
|
239
238
|
if (!content) {
|
|
@@ -258,6 +257,20 @@ function handleClientMessage(raw: string): void {
|
|
|
258
257
|
requestId: typeof msg.requestId === 'string' ? msg.requestId : undefined,
|
|
259
258
|
};
|
|
260
259
|
|
|
260
|
+
if (groupId) {
|
|
261
|
+
currentRelayServer.groupEventStore.appendUserMessage({
|
|
262
|
+
groupId,
|
|
263
|
+
requestId: requestId || undefined,
|
|
264
|
+
sessionId: sid,
|
|
265
|
+
content,
|
|
266
|
+
type: messageType,
|
|
267
|
+
mediaUrl,
|
|
268
|
+
senderUserId: userId,
|
|
269
|
+
senderName: typeof msg.senderName === 'string' ? msg.senderName : undefined,
|
|
270
|
+
senderAvatarUrl: typeof msg.senderAvatarUrl === 'string' ? msg.senderAvatarUrl : undefined,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
261
274
|
const previousAbort = tunnelAbortByUser.get(userId);
|
|
262
275
|
if (previousAbort) {
|
|
263
276
|
previousAbort();
|
|
@@ -290,6 +303,17 @@ function handleClientMessage(raw: string): void {
|
|
|
290
303
|
onDone: (userMessage, assistantMessage) => {
|
|
291
304
|
streamFinished = true;
|
|
292
305
|
if (currentAbort && tunnelAbortByUser.get(userId) === currentAbort) tunnelAbortByUser.delete(userId);
|
|
306
|
+
if (groupId && assistantMessage?.content) {
|
|
307
|
+
currentRelayServer.groupEventStore.appendAssistantMessage({
|
|
308
|
+
groupId,
|
|
309
|
+
requestId: requestId || undefined,
|
|
310
|
+
sessionId: sid,
|
|
311
|
+
agentId,
|
|
312
|
+
content: assistantMessage.content,
|
|
313
|
+
type: assistantMessage.type,
|
|
314
|
+
mediaUrl: assistantMessage.mediaUrl,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
293
317
|
sendTunnelFrame({
|
|
294
318
|
type: 'done',
|
|
295
319
|
sessionId: sid,
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import { execFileSync } from 'child_process';
|
|
4
|
+
|
|
5
|
+
type UpdatePhase =
|
|
6
|
+
| 'queued'
|
|
7
|
+
| 'checking'
|
|
8
|
+
| 'installing'
|
|
9
|
+
| 'restarting'
|
|
10
|
+
| 'verifying'
|
|
11
|
+
| 'succeeded'
|
|
12
|
+
| 'failed'
|
|
13
|
+
| 'rolled_back';
|
|
14
|
+
|
|
15
|
+
type UpdateStatus = 'running' | 'succeeded' | 'failed' | 'rolled_back';
|
|
16
|
+
|
|
17
|
+
type UpdateState = {
|
|
18
|
+
status: UpdateStatus;
|
|
19
|
+
phase: UpdatePhase;
|
|
20
|
+
currentVersion: string;
|
|
21
|
+
latestVersion: string;
|
|
22
|
+
targetVersion: string;
|
|
23
|
+
updateAvailable: boolean;
|
|
24
|
+
message: string;
|
|
25
|
+
packageName: string;
|
|
26
|
+
registry: string;
|
|
27
|
+
pm2ProcessName: string;
|
|
28
|
+
pm2Managed: boolean;
|
|
29
|
+
startedAt: number;
|
|
30
|
+
finishedAt: number;
|
|
31
|
+
error: string;
|
|
32
|
+
warning: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function cleanString(value: unknown): string {
|
|
36
|
+
return String(value || '').trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function npmCommand(): string {
|
|
40
|
+
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function pm2Command(): string {
|
|
44
|
+
return process.platform === 'win32' ? 'pm2.cmd' : 'pm2';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sleep(ms: number): Promise<void> {
|
|
48
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const statePath = cleanString(process.env.OPENCLAW_VCHAT_UPDATE_STATE);
|
|
52
|
+
const packageName = cleanString(process.env.OPENCLAW_VCHAT_UPDATE_PACKAGE) || 'openclaw-vchat-plugin';
|
|
53
|
+
const currentVersion = cleanString(process.env.OPENCLAW_VCHAT_UPDATE_CURRENT);
|
|
54
|
+
const targetVersion = cleanString(process.env.OPENCLAW_VCHAT_UPDATE_TARGET);
|
|
55
|
+
const registry = cleanString(process.env.OPENCLAW_VCHAT_UPDATE_REGISTRY);
|
|
56
|
+
const pm2Name = cleanString(process.env.OPENCLAW_VCHAT_UPDATE_PM2_NAME) || 'openclaw-vchat';
|
|
57
|
+
const healthUrl = cleanString(process.env.OPENCLAW_VCHAT_UPDATE_HEALTH_URL) || 'http://127.0.0.1:39217/internal/health';
|
|
58
|
+
|
|
59
|
+
function readState(): UpdateState {
|
|
60
|
+
const raw = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
61
|
+
return raw as UpdateState;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeState(patch: Partial<UpdateState>): UpdateState {
|
|
65
|
+
const current = readState();
|
|
66
|
+
const next = {
|
|
67
|
+
...current,
|
|
68
|
+
...patch,
|
|
69
|
+
};
|
|
70
|
+
fs.writeFileSync(statePath, JSON.stringify(next, null, 2) + '\n', 'utf-8');
|
|
71
|
+
return next;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function runCommand(command: string, args: string[], timeout: number): string {
|
|
75
|
+
const stdout = execFileSync(command, args, {
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
timeout,
|
|
78
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
79
|
+
env: process.env,
|
|
80
|
+
});
|
|
81
|
+
return cleanString(stdout);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildNpmArgs(version: string): string[] {
|
|
85
|
+
const args = ['install', '-g', `${packageName}@${version}`];
|
|
86
|
+
if (registry) args.push('--registry', registry);
|
|
87
|
+
return args;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function restartPm2(): void {
|
|
91
|
+
runCommand(pm2Command(), ['restart', pm2Name, '--update-env'], 30000);
|
|
92
|
+
try {
|
|
93
|
+
runCommand(pm2Command(), ['save'], 10000);
|
|
94
|
+
} catch {
|
|
95
|
+
// ignore save failure
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function waitForHealth(url: string, timeoutMs: number): Promise<void> {
|
|
100
|
+
const deadline = Date.now() + timeoutMs;
|
|
101
|
+
while (Date.now() < deadline) {
|
|
102
|
+
const ok = await new Promise<boolean>((resolve) => {
|
|
103
|
+
const req = http.get(url, (res) => {
|
|
104
|
+
const statusCode = Number(res.statusCode) || 0;
|
|
105
|
+
res.resume();
|
|
106
|
+
resolve(statusCode >= 200 && statusCode < 300);
|
|
107
|
+
});
|
|
108
|
+
req.on('error', () => resolve(false));
|
|
109
|
+
req.setTimeout(3000, () => {
|
|
110
|
+
req.destroy();
|
|
111
|
+
resolve(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
if (ok) return;
|
|
115
|
+
await sleep(1500);
|
|
116
|
+
}
|
|
117
|
+
throw new Error('插件重启后健康检查超时');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function rollback(reason: string): Promise<void> {
|
|
121
|
+
if (!currentVersion) {
|
|
122
|
+
writeState({
|
|
123
|
+
status: 'failed',
|
|
124
|
+
phase: 'failed',
|
|
125
|
+
finishedAt: Date.now(),
|
|
126
|
+
error: reason,
|
|
127
|
+
message: '更新失败,且缺少回滚版本',
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
writeState({
|
|
134
|
+
phase: 'installing',
|
|
135
|
+
message: `更新失败,正在回滚到 v${currentVersion}`,
|
|
136
|
+
error: reason,
|
|
137
|
+
});
|
|
138
|
+
runCommand(npmCommand(), buildNpmArgs(currentVersion), 600000);
|
|
139
|
+
writeState({
|
|
140
|
+
phase: 'restarting',
|
|
141
|
+
message: '回滚安装完成,正在重启插件',
|
|
142
|
+
error: reason,
|
|
143
|
+
});
|
|
144
|
+
restartPm2();
|
|
145
|
+
writeState({
|
|
146
|
+
phase: 'verifying',
|
|
147
|
+
message: '正在验证回滚后的插件状态',
|
|
148
|
+
error: reason,
|
|
149
|
+
});
|
|
150
|
+
await waitForHealth(healthUrl, 60000);
|
|
151
|
+
writeState({
|
|
152
|
+
status: 'rolled_back',
|
|
153
|
+
phase: 'rolled_back',
|
|
154
|
+
finishedAt: Date.now(),
|
|
155
|
+
message: `更新失败,已回滚到 v${currentVersion}`,
|
|
156
|
+
error: reason,
|
|
157
|
+
warning: '请稍后重试,或先检查 Node / npm / 网络环境',
|
|
158
|
+
});
|
|
159
|
+
} catch (rollbackErr: any) {
|
|
160
|
+
writeState({
|
|
161
|
+
status: 'failed',
|
|
162
|
+
phase: 'failed',
|
|
163
|
+
finishedAt: Date.now(),
|
|
164
|
+
message: '更新失败,且自动回滚失败',
|
|
165
|
+
error: `${reason}; rollback=${cleanString(rollbackErr?.message) || 'unknown'}`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function main() {
|
|
171
|
+
try {
|
|
172
|
+
writeState({
|
|
173
|
+
status: 'running',
|
|
174
|
+
phase: 'checking',
|
|
175
|
+
message: `准备更新到 v${targetVersion}`,
|
|
176
|
+
error: '',
|
|
177
|
+
warning: '',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
writeState({
|
|
181
|
+
phase: 'installing',
|
|
182
|
+
message: `正在下载并安装 v${targetVersion}`,
|
|
183
|
+
});
|
|
184
|
+
runCommand(npmCommand(), buildNpmArgs(targetVersion), 600000);
|
|
185
|
+
|
|
186
|
+
writeState({
|
|
187
|
+
phase: 'restarting',
|
|
188
|
+
message: '安装完成,正在重启插件进程',
|
|
189
|
+
});
|
|
190
|
+
restartPm2();
|
|
191
|
+
|
|
192
|
+
writeState({
|
|
193
|
+
phase: 'verifying',
|
|
194
|
+
message: '正在验证更新后的插件状态',
|
|
195
|
+
});
|
|
196
|
+
await waitForHealth(healthUrl, 60000);
|
|
197
|
+
|
|
198
|
+
writeState({
|
|
199
|
+
status: 'succeeded',
|
|
200
|
+
phase: 'succeeded',
|
|
201
|
+
currentVersion: targetVersion,
|
|
202
|
+
latestVersion: targetVersion,
|
|
203
|
+
targetVersion,
|
|
204
|
+
updateAvailable: false,
|
|
205
|
+
finishedAt: Date.now(),
|
|
206
|
+
message: `插件已更新到 v${targetVersion}`,
|
|
207
|
+
error: '',
|
|
208
|
+
warning: '',
|
|
209
|
+
});
|
|
210
|
+
} catch (err: any) {
|
|
211
|
+
await rollback(cleanString(err?.message) || 'plugin-update-failed');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
main()
|
|
216
|
+
.then(() => process.exit(0))
|
|
217
|
+
.catch(() => process.exit(1));
|
package/src/relay-server.ts
CHANGED
|
@@ -12,11 +12,14 @@ import { MessageHandler } from './message-handler';
|
|
|
12
12
|
import { MediaHandler } from './media-handler';
|
|
13
13
|
import { GatewayClient } from './gateway-client';
|
|
14
14
|
import { GroupManager } from './group-manager';
|
|
15
|
+
import { GroupEventStore } from './group-event-store';
|
|
15
16
|
import { BOT_COMMANDS } from './commands';
|
|
16
17
|
import { Message, PluginConfig } from './types';
|
|
17
18
|
import { BRIDGE_CAPABILITIES, BRIDGE_PLUGIN_VERSION, BRIDGE_PROTOCOL_VERSION } from './constants';
|
|
18
19
|
import { buildWeChatGatewaySessionKey, parseWeChatDirectThreadSessionKey, sanitizeWeChatId } from './session-key';
|
|
19
20
|
import configRoutes from './routes/config.routes';
|
|
21
|
+
import { getSkillsStatus, installSkill, setSkillEnabledLocally, updateSkill } from './services/skills.service';
|
|
22
|
+
import { checkPluginUpdateInfo, readPluginUpdateState, startPluginUpdate } from './services/plugin-update.service';
|
|
20
23
|
|
|
21
24
|
interface GatewayWechatSession {
|
|
22
25
|
id: string;
|
|
@@ -494,13 +497,14 @@ function ensureBridgeSession(
|
|
|
494
497
|
*/
|
|
495
498
|
export function createRelayServer(config: PluginConfig, gateway: GatewayClient) {
|
|
496
499
|
const app = express();
|
|
497
|
-
const
|
|
498
|
-
const
|
|
500
|
+
const sessionManager = new SessionManager(config.dataDir);
|
|
501
|
+
const groupEventStore = new GroupEventStore(config.dataDir);
|
|
499
502
|
const messageHandler = new MessageHandler(config, sessionManager, gateway);
|
|
500
503
|
const mediaHandler = new MediaHandler(config);
|
|
501
|
-
const groupManager = new GroupManager(dataDir);
|
|
504
|
+
const groupManager = new GroupManager(config.dataDir);
|
|
502
505
|
const memoSessions = createRequestMemo<any>(1000);
|
|
503
506
|
const memoGroups = createRequestMemo<any>(1000);
|
|
507
|
+
const memoSkills = createRequestMemo<any>(1500);
|
|
504
508
|
const memoHistory = createRequestMemo<any>(800);
|
|
505
509
|
|
|
506
510
|
// ===== 中间件 =====
|
|
@@ -817,6 +821,19 @@ export function createRelayServer(config: PluginConfig, gateway: GatewayClient)
|
|
|
817
821
|
.catch((err: any) => res.status(500).json({ error: err?.message || '群列表获取失败' }));
|
|
818
822
|
});
|
|
819
823
|
|
|
824
|
+
internal.get('/groups/:id/history', (req: Request, res: Response) => {
|
|
825
|
+
try {
|
|
826
|
+
const limit = parseInt(String(req.query.limit || ''), 10) || 100;
|
|
827
|
+
const before = req.query.before ? parseInt(String(req.query.before), 10) : undefined;
|
|
828
|
+
res.json({
|
|
829
|
+
messages: groupEventStore.list(req.params.id, limit, before),
|
|
830
|
+
source: 'local-group-events',
|
|
831
|
+
});
|
|
832
|
+
} catch (err: any) {
|
|
833
|
+
res.status(500).json({ error: err?.message || '群历史获取失败' });
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
|
|
820
837
|
internal.post('/groups', (req: Request, res: Response) => {
|
|
821
838
|
const { name, emoji, agentIds, mode } = req.body;
|
|
822
839
|
if (!name) { res.status(400).json({ error: '缺少群名称' }); return; }
|
|
@@ -847,6 +864,98 @@ export function createRelayServer(config: PluginConfig, gateway: GatewayClient)
|
|
|
847
864
|
group ? res.json(group) : res.status(404).json({ error: '群不存在' });
|
|
848
865
|
});
|
|
849
866
|
|
|
867
|
+
// ===== Skills API =====
|
|
868
|
+
|
|
869
|
+
internal.get('/skills', async (req: Request, res: Response) => {
|
|
870
|
+
try {
|
|
871
|
+
const agentId = typeof req.query.agentId === 'string' ? req.query.agentId : '';
|
|
872
|
+
const memoKey = `skills:${String(agentId || '*').trim() || '*'}`;
|
|
873
|
+
const payload = await memoSkills(memoKey, async () => {
|
|
874
|
+
const status = await getSkillsStatus(gateway, agentId);
|
|
875
|
+
return {
|
|
876
|
+
...status,
|
|
877
|
+
source: 'gateway',
|
|
878
|
+
};
|
|
879
|
+
});
|
|
880
|
+
res.json(payload);
|
|
881
|
+
} catch (err: any) {
|
|
882
|
+
res.status(500).json({ error: err?.message || '获取 Skills 列表失败' });
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
internal.post('/skills/install', async (req: Request, res: Response) => {
|
|
887
|
+
try {
|
|
888
|
+
const result = await installSkill(gateway, {
|
|
889
|
+
name: typeof req.body?.name === 'string' ? req.body.name : '',
|
|
890
|
+
installId: typeof req.body?.installId === 'string' ? req.body.installId : '',
|
|
891
|
+
timeoutMs: req.body?.timeoutMs,
|
|
892
|
+
});
|
|
893
|
+
res.json(result);
|
|
894
|
+
} catch (err: any) {
|
|
895
|
+
res.status(500).json({ error: err?.message || '安装 Skill 失败' });
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
internal.post('/skills/:skillKey/update', async (req: Request, res: Response) => {
|
|
900
|
+
try {
|
|
901
|
+
const skillKey = String(req.params.skillKey || '').trim();
|
|
902
|
+
if (!skillKey) {
|
|
903
|
+
res.status(400).json({ error: '缺少 skillKey' });
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const payload = {
|
|
908
|
+
enabled: typeof req.body?.enabled === 'boolean' ? req.body.enabled : undefined,
|
|
909
|
+
apiKey: typeof req.body?.apiKey === 'string' ? req.body.apiKey : undefined,
|
|
910
|
+
env: req.body?.env && typeof req.body.env === 'object' ? req.body.env : undefined,
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
try {
|
|
914
|
+
const result = await updateSkill(gateway, skillKey, payload);
|
|
915
|
+
res.json(result);
|
|
916
|
+
} catch (err: any) {
|
|
917
|
+
if (typeof payload.enabled !== 'boolean') throw err;
|
|
918
|
+
const fallback = setSkillEnabledLocally(gateway, skillKey, payload.enabled);
|
|
919
|
+
res.json({
|
|
920
|
+
...fallback,
|
|
921
|
+
source: 'local-config',
|
|
922
|
+
warning: err?.message || 'Gateway skills.update 失败,已改为本地配置写入',
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
} catch (err: any) {
|
|
926
|
+
res.status(500).json({ error: err?.message || '更新 Skill 状态失败' });
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
// ===== Plugin update =====
|
|
931
|
+
|
|
932
|
+
internal.get('/plugin/update/info', async (_req: Request, res: Response) => {
|
|
933
|
+
try {
|
|
934
|
+
const info = await checkPluginUpdateInfo();
|
|
935
|
+
res.json(info);
|
|
936
|
+
} catch (err: any) {
|
|
937
|
+
res.status(500).json({ error: err?.message || '检查插件更新失败' });
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
internal.get('/plugin/update/status', (_req: Request, res: Response) => {
|
|
942
|
+
try {
|
|
943
|
+
res.json(readPluginUpdateState());
|
|
944
|
+
} catch (err: any) {
|
|
945
|
+
res.status(500).json({ error: err?.message || '获取插件更新状态失败' });
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
internal.post('/plugin/update/start', async (req: Request, res: Response) => {
|
|
950
|
+
try {
|
|
951
|
+
const targetVersion = typeof req.body?.version === 'string' ? req.body.version : '';
|
|
952
|
+
const result = await startPluginUpdate(targetVersion);
|
|
953
|
+
res.json(result);
|
|
954
|
+
} catch (err: any) {
|
|
955
|
+
res.status(400).json({ error: err?.message || '启动插件更新失败' });
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
|
|
850
959
|
// ===== 聊天 =====
|
|
851
960
|
|
|
852
961
|
internal.get('/chat/history', async (req: Request, res: Response) => {
|
|
@@ -1111,7 +1220,7 @@ export function createRelayServer(config: PluginConfig, gateway: GatewayClient)
|
|
|
1111
1220
|
});
|
|
1112
1221
|
});
|
|
1113
1222
|
|
|
1114
|
-
return { app, server, sessionManager, wss };
|
|
1223
|
+
return { app, server, sessionManager, groupEventStore, wss };
|
|
1115
1224
|
}
|
|
1116
1225
|
|
|
1117
1226
|
// ===== WebSocket 处理函数 =====
|