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.
Files changed (41) hide show
  1. package/dist/group-event-store.d.ts +47 -0
  2. package/dist/group-event-store.d.ts.map +1 -0
  3. package/dist/group-event-store.js +145 -0
  4. package/dist/group-event-store.js.map +1 -0
  5. package/dist/index.js +32 -9
  6. package/dist/index.js.map +1 -1
  7. package/dist/plugin-update-runner.d.ts +2 -0
  8. package/dist/plugin-update-runner.d.ts.map +1 -0
  9. package/dist/plugin-update-runner.js +179 -0
  10. package/dist/plugin-update-runner.js.map +1 -0
  11. package/dist/relay-server.d.ts +2 -0
  12. package/dist/relay-server.d.ts.map +1 -1
  13. package/dist/relay-server.js +111 -4
  14. package/dist/relay-server.js.map +1 -1
  15. package/dist/routes/config.routes.d.ts.map +1 -1
  16. package/dist/routes/config.routes.js +43 -0
  17. package/dist/routes/config.routes.js.map +1 -1
  18. package/dist/runtime-data.d.ts +5 -0
  19. package/dist/runtime-data.d.ts.map +1 -0
  20. package/dist/runtime-data.js +114 -0
  21. package/dist/runtime-data.js.map +1 -0
  22. package/dist/services/plugin-update.service.d.ts +24 -0
  23. package/dist/services/plugin-update.service.d.ts.map +1 -0
  24. package/dist/services/plugin-update.service.js +308 -0
  25. package/dist/services/plugin-update.service.js.map +1 -0
  26. package/dist/services/skills.service.d.ts +33 -0
  27. package/dist/services/skills.service.d.ts.map +1 -0
  28. package/dist/services/skills.service.js +177 -0
  29. package/dist/services/skills.service.js.map +1 -0
  30. package/dist/types.d.ts +1 -0
  31. package/dist/types.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/group-event-store.ts +204 -0
  34. package/src/index.ts +30 -6
  35. package/src/plugin-update-runner.ts +217 -0
  36. package/src/relay-server.ts +113 -4
  37. package/src/routes/config.routes.ts +46 -0
  38. package/src/runtime-data.ts +113 -0
  39. package/src/services/plugin-update.service.ts +335 -0
  40. package/src/services/skills.service.ts +175 -0
  41. 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
- fs.mkdirSync(UPLOAD_DIR, { recursive: true });
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
- uploadDir: UPLOAD_DIR,
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));
@@ -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 dataDir = path.join(config.uploadDir, '..');
498
- const sessionManager = new SessionManager(dataDir);
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 处理函数 =====