cc2im 0.2.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/LICENSE +21 -0
- package/README.en.md +120 -0
- package/README.md +120 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +314 -0
- package/dist/hub/agent-manager.d.ts +63 -0
- package/dist/hub/agent-manager.js +311 -0
- package/dist/hub/hub-context.d.ts +27 -0
- package/dist/hub/hub-context.js +57 -0
- package/dist/hub/index.d.ts +6 -0
- package/dist/hub/index.js +234 -0
- package/dist/hub/launchd.d.ts +7 -0
- package/dist/hub/launchd.js +151 -0
- package/dist/hub/plugin-manager.d.ts +7 -0
- package/dist/hub/plugin-manager.js +29 -0
- package/dist/hub/router.d.ts +21 -0
- package/dist/hub/router.js +35 -0
- package/dist/hub/socket-server.d.ts +23 -0
- package/dist/hub/socket-server.js +191 -0
- package/dist/plugins/channel-manager/index.d.ts +10 -0
- package/dist/plugins/channel-manager/index.js +387 -0
- package/dist/plugins/cron-scheduler/db.d.ts +12 -0
- package/dist/plugins/cron-scheduler/db.js +160 -0
- package/dist/plugins/cron-scheduler/index.d.ts +4 -0
- package/dist/plugins/cron-scheduler/index.js +22 -0
- package/dist/plugins/cron-scheduler/scheduler.d.ts +20 -0
- package/dist/plugins/cron-scheduler/scheduler.js +129 -0
- package/dist/plugins/persistence/db.d.ts +24 -0
- package/dist/plugins/persistence/db.js +121 -0
- package/dist/plugins/persistence/index.d.ts +2 -0
- package/dist/plugins/persistence/index.js +93 -0
- package/dist/plugins/web-monitor/api-routes.d.ts +33 -0
- package/dist/plugins/web-monitor/api-routes.js +474 -0
- package/dist/plugins/web-monitor/index.d.ts +2 -0
- package/dist/plugins/web-monitor/index.js +21 -0
- package/dist/plugins/web-monitor/log-tailer.d.ts +13 -0
- package/dist/plugins/web-monitor/log-tailer.js +74 -0
- package/dist/plugins/web-monitor/monitor-client.d.ts +17 -0
- package/dist/plugins/web-monitor/monitor-client.js +68 -0
- package/dist/plugins/web-monitor/server.d.ts +14 -0
- package/dist/plugins/web-monitor/server.js +205 -0
- package/dist/plugins/web-monitor/stats-reader.d.ts +22 -0
- package/dist/plugins/web-monitor/stats-reader.js +17 -0
- package/dist/plugins/web-monitor/token-stats.d.ts +19 -0
- package/dist/plugins/web-monitor/token-stats.js +86 -0
- package/dist/plugins/web-monitor/usage-stats.d.ts +13 -0
- package/dist/plugins/web-monitor/usage-stats.js +56 -0
- package/dist/plugins/weixin/chunker.d.ts +16 -0
- package/dist/plugins/weixin/chunker.js +142 -0
- package/dist/plugins/weixin/connection.d.ts +46 -0
- package/dist/plugins/weixin/connection.js +270 -0
- package/dist/plugins/weixin/index.d.ts +10 -0
- package/dist/plugins/weixin/index.js +198 -0
- package/dist/plugins/weixin/media-upload.d.ts +22 -0
- package/dist/plugins/weixin/media-upload.js +134 -0
- package/dist/plugins/weixin/media.d.ts +6 -0
- package/dist/plugins/weixin/media.js +83 -0
- package/dist/plugins/weixin/permission.d.ts +35 -0
- package/dist/plugins/weixin/permission.js +96 -0
- package/dist/plugins/weixin/qr-login.d.ts +23 -0
- package/dist/plugins/weixin/qr-login.js +77 -0
- package/dist/plugins/weixin/weixin-channel.d.ts +33 -0
- package/dist/plugins/weixin/weixin-channel.js +123 -0
- package/dist/shared/channel-config.d.ts +8 -0
- package/dist/shared/channel-config.js +14 -0
- package/dist/shared/channel.d.ts +37 -0
- package/dist/shared/channel.js +8 -0
- package/dist/shared/mcp-config.d.ts +5 -0
- package/dist/shared/mcp-config.js +44 -0
- package/dist/shared/plugin.d.ts +32 -0
- package/dist/shared/plugin.js +1 -0
- package/dist/shared/socket.d.ts +5 -0
- package/dist/shared/socket.js +31 -0
- package/dist/shared/types.d.ts +136 -0
- package/dist/shared/types.js +1 -0
- package/dist/spoke/channel-server.d.ts +48 -0
- package/dist/spoke/channel-server.js +383 -0
- package/dist/spoke/index.d.ts +13 -0
- package/dist/spoke/index.js +115 -0
- package/dist/spoke/permission.d.ts +28 -0
- package/dist/spoke/permission.js +142 -0
- package/dist/spoke/socket-client.d.ts +22 -0
- package/dist/spoke/socket-client.js +83 -0
- package/dist/web-frontend/assets/index-CU9vxw8F.js +9 -0
- package/dist/web-frontend/index.html +82 -0
- package/package.json +54 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cc2im Web — REST API route handler
|
|
3
|
+
*
|
|
4
|
+
* All /api/* routes for the dashboard. Extracted from server.ts
|
|
5
|
+
* so each concern lives in its own file.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
8
|
+
import { join, resolve, sep } from 'node:path';
|
|
9
|
+
import { getTokenStats } from './token-stats.js';
|
|
10
|
+
import { getUsageStats } from './usage-stats.js';
|
|
11
|
+
import { readStats } from './stats-reader.js';
|
|
12
|
+
import { getNicknames, setNickname } from '../persistence/db.js';
|
|
13
|
+
import { listJobs, createJob, deleteJob, updateJob, getRecentRuns } from '../cron-scheduler/db.js';
|
|
14
|
+
import { CronScheduler } from '../cron-scheduler/scheduler.js';
|
|
15
|
+
import { Cron } from 'croner';
|
|
16
|
+
import { fetchQrCode, checkQrStatus, saveCredentials, POLL_INTERVAL } from '../weixin/qr-login.js';
|
|
17
|
+
/**
|
|
18
|
+
* Create the HTTP request handler used by the web dashboard.
|
|
19
|
+
* Extracted so integration tests can exercise the real routing logic
|
|
20
|
+
* with mock dependencies (no SQLite, no hub socket, no filesystem).
|
|
21
|
+
*/
|
|
22
|
+
export function createApiHandler(deps) {
|
|
23
|
+
const { agentsJsonPath, mediaDir, messageHistory, monitor, wsClients, ctx, activeQrPolls, broadcastWs, frontendDir, } = deps;
|
|
24
|
+
return (req, res) => {
|
|
25
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
26
|
+
// REST API
|
|
27
|
+
if (url.pathname === '/api/agents') {
|
|
28
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
29
|
+
try {
|
|
30
|
+
const config = existsSync(agentsJsonPath)
|
|
31
|
+
? JSON.parse(readFileSync(agentsJsonPath, 'utf8'))
|
|
32
|
+
: { defaultAgent: '', agents: {} };
|
|
33
|
+
res.end(JSON.stringify(config));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
res.end('{}');
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (url.pathname === '/api/stats') {
|
|
41
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
42
|
+
res.end(JSON.stringify(readStats() || {}));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (url.pathname === '/api/tokens') {
|
|
46
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
47
|
+
res.end(JSON.stringify(getTokenStats()));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (url.pathname === '/api/usage') {
|
|
51
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
52
|
+
res.end(JSON.stringify(getUsageStats()));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (url.pathname === '/api/health') {
|
|
56
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
57
|
+
res.end(JSON.stringify({
|
|
58
|
+
hubConnected: monitor.isConnected(),
|
|
59
|
+
uptime: process.uptime(),
|
|
60
|
+
wsClients: wsClients.size,
|
|
61
|
+
}));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Serve media files from mediaDir
|
|
65
|
+
if (url.pathname.startsWith('/media/')) {
|
|
66
|
+
const filename = url.pathname.slice('/media/'.length);
|
|
67
|
+
if (!filename) {
|
|
68
|
+
res.writeHead(400);
|
|
69
|
+
res.end('Bad request');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const filePath = resolve(mediaDir, filename);
|
|
73
|
+
// Security: canonical path must be inside mediaDir
|
|
74
|
+
if (!filePath.startsWith(mediaDir + sep)) {
|
|
75
|
+
res.writeHead(400);
|
|
76
|
+
res.end('Bad request');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!existsSync(filePath)) {
|
|
80
|
+
res.writeHead(404);
|
|
81
|
+
res.end('Not found');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const ext = filename.split('.').pop() || '';
|
|
85
|
+
const mediaMime = {
|
|
86
|
+
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
|
|
87
|
+
webp: 'image/webp', mp4: 'video/mp4', pdf: 'application/pdf', bin: 'application/octet-stream',
|
|
88
|
+
};
|
|
89
|
+
res.writeHead(200, {
|
|
90
|
+
'Content-Type': mediaMime[ext] || 'application/octet-stream',
|
|
91
|
+
'Cache-Control': 'public, max-age=86400',
|
|
92
|
+
});
|
|
93
|
+
res.end(readFileSync(filePath));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (url.pathname === '/api/messages') {
|
|
97
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
98
|
+
const agentId = url.searchParams.get('agent');
|
|
99
|
+
const filtered = agentId
|
|
100
|
+
? messageHistory.filter(m => m.event.agentId === agentId)
|
|
101
|
+
: messageHistory;
|
|
102
|
+
res.end(JSON.stringify(filtered));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// --- Channel & Nickname API ---
|
|
106
|
+
if (url.pathname === '/api/channels' && req.method === 'GET') {
|
|
107
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
108
|
+
if (ctx) {
|
|
109
|
+
const channels = ctx.getChannels().map(ch => ({
|
|
110
|
+
id: ch.id,
|
|
111
|
+
type: ch.type,
|
|
112
|
+
label: ch.label,
|
|
113
|
+
status: ch.getStatus(),
|
|
114
|
+
}));
|
|
115
|
+
res.end(JSON.stringify(channels));
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
res.end('[]');
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (url.pathname === '/api/channels' && req.method === 'POST') {
|
|
123
|
+
if (!ctx) {
|
|
124
|
+
res.writeHead(503);
|
|
125
|
+
res.end('{"error":"no hub context"}');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
let body = '';
|
|
129
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
130
|
+
req.on('end', async () => {
|
|
131
|
+
try {
|
|
132
|
+
const { type, accountName } = JSON.parse(body);
|
|
133
|
+
if (!type || !accountName) {
|
|
134
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
135
|
+
res.end('{"error":"type and accountName required"}');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const channelId = `${type}-${accountName}`;
|
|
139
|
+
if (ctx.getChannel(channelId)) {
|
|
140
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
141
|
+
res.end(JSON.stringify({ error: `Channel "${channelId}" already exists` }));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
await ctx.addChannel(type, channelId, accountName);
|
|
145
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
146
|
+
res.end(JSON.stringify({ id: channelId, type, label: accountName, status: 'disconnected' }));
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
150
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (url.pathname.match(/^\/api\/channels\/[^/]+\/disconnect$/) && req.method === 'POST') {
|
|
156
|
+
const channelId = decodeURIComponent(url.pathname.split('/')[3]);
|
|
157
|
+
if (!ctx) {
|
|
158
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
159
|
+
res.end('{"error":"no hub context"}');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const ch = ctx.getChannel(channelId);
|
|
163
|
+
if (!ch) {
|
|
164
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
165
|
+
res.end('{"error":"channel not found"}');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
;
|
|
169
|
+
(async () => {
|
|
170
|
+
try {
|
|
171
|
+
await ch.disconnect();
|
|
172
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
173
|
+
res.end(JSON.stringify({ id: channelId, status: ch.getStatus() }));
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
177
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
178
|
+
}
|
|
179
|
+
})();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (url.pathname.match(/^\/api\/channels\/[^/]+\/probe$/) && req.method === 'POST') {
|
|
183
|
+
const channelId = decodeURIComponent(url.pathname.split('/')[3]);
|
|
184
|
+
if (!ctx) {
|
|
185
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
186
|
+
res.end('{"error":"no hub context"}');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const ch = ctx.getChannel(channelId);
|
|
190
|
+
if (!ch) {
|
|
191
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
192
|
+
res.end('{"error":"channel not found"}');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
196
|
+
res.end(JSON.stringify({ id: channelId, status: ch.getStatus() }));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
// --- QR Login ---
|
|
200
|
+
if (url.pathname.match(/^\/api\/channels\/[^/]+\/login$/) && req.method === 'POST') {
|
|
201
|
+
const channelId = decodeURIComponent(url.pathname.split('/')[3]);
|
|
202
|
+
if (!ctx) {
|
|
203
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
204
|
+
res.end('{"error":"no hub context"}');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
// Note: we intentionally don't check ctx.getChannel(channelId) here.
|
|
208
|
+
// The channel:add listener is async (dynamic import) and may not have
|
|
209
|
+
// registered the channel yet. QR login only needs to fetch a QR code
|
|
210
|
+
// and poll; reconnectChannel (called on confirmed) will find it by then.
|
|
211
|
+
;
|
|
212
|
+
(async () => {
|
|
213
|
+
try {
|
|
214
|
+
// Cancel any existing poll for this channel
|
|
215
|
+
if (activeQrPolls.has(channelId)) {
|
|
216
|
+
clearInterval(activeQrPolls.get(channelId));
|
|
217
|
+
activeQrPolls.delete(channelId);
|
|
218
|
+
}
|
|
219
|
+
const qr = await fetchQrCode();
|
|
220
|
+
// Respond with QR data URL immediately
|
|
221
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
222
|
+
res.end(JSON.stringify({ qrUrl: qr.qrDataUrl }));
|
|
223
|
+
// Broadcast initial QR status to browser (use data URL for rendering)
|
|
224
|
+
broadcastWs({ type: 'qr_status', channelId, status: 'pending', qrUrl: qr.qrDataUrl });
|
|
225
|
+
// Start background polling (auto-expire after 5 minutes as safety net)
|
|
226
|
+
const pollStart = Date.now();
|
|
227
|
+
const MAX_POLL_MS = 5 * 60 * 1000;
|
|
228
|
+
const poll = setInterval(async () => {
|
|
229
|
+
if (Date.now() - pollStart > MAX_POLL_MS) {
|
|
230
|
+
clearInterval(poll);
|
|
231
|
+
activeQrPolls.delete(channelId);
|
|
232
|
+
broadcastWs({ type: 'qr_status', channelId, status: 'expired', qrUrl: qr.qrDataUrl });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
const result = await checkQrStatus(qr.qrToken);
|
|
237
|
+
broadcastWs({ type: 'qr_status', channelId, status: result.status, qrUrl: qr.qrDataUrl });
|
|
238
|
+
if (result.status === 'confirmed' && result.credentials) {
|
|
239
|
+
clearInterval(poll);
|
|
240
|
+
activeQrPolls.delete(channelId);
|
|
241
|
+
saveCredentials(result.credentials, channelId);
|
|
242
|
+
// Reconnect channel with new credentials
|
|
243
|
+
try {
|
|
244
|
+
await ctx.reconnectChannel(channelId);
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
console.error(`[web] QR login reconnect failed: ${err.message}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
else if (result.status === 'expired') {
|
|
251
|
+
clearInterval(poll);
|
|
252
|
+
activeQrPolls.delete(channelId);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
console.error(`[web] QR poll error: ${err.message}`);
|
|
257
|
+
// Don't stop polling on transient errors
|
|
258
|
+
}
|
|
259
|
+
}, POLL_INTERVAL);
|
|
260
|
+
activeQrPolls.set(channelId, poll);
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
if (!res.headersSent) {
|
|
264
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
265
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
})();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// Cancel QR polling (called when user dismisses QR overlay)
|
|
272
|
+
if (url.pathname.match(/^\/api\/channels\/[^/]+\/login$/) && req.method === 'DELETE') {
|
|
273
|
+
const channelId = decodeURIComponent(url.pathname.split('/')[3]);
|
|
274
|
+
if (activeQrPolls.has(channelId)) {
|
|
275
|
+
clearInterval(activeQrPolls.get(channelId));
|
|
276
|
+
activeQrPolls.delete(channelId);
|
|
277
|
+
}
|
|
278
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
279
|
+
res.end('{"ok":true}');
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (url.pathname.startsWith('/api/channels/') && !url.pathname.includes('/probe') && !url.pathname.includes('/disconnect') && !url.pathname.includes('/login') && req.method === 'DELETE') {
|
|
283
|
+
if (!ctx) {
|
|
284
|
+
res.writeHead(503);
|
|
285
|
+
res.end('{"error":"no hub context"}');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const channelId = decodeURIComponent(url.pathname.slice('/api/channels/'.length));
|
|
289
|
+
if (!ctx.getChannel(channelId)) {
|
|
290
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
291
|
+
res.end('{"error":"channel not found"}');
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (ctx.getChannels().length <= 1) {
|
|
295
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
296
|
+
res.end('{"error":"cannot delete the last channel"}');
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
;
|
|
300
|
+
(async () => {
|
|
301
|
+
try {
|
|
302
|
+
await ctx.removeChannel(channelId);
|
|
303
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
304
|
+
res.end('{"ok":true}');
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
308
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
309
|
+
}
|
|
310
|
+
})();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (url.pathname === '/api/nicknames' && req.method === 'GET') {
|
|
314
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
315
|
+
try {
|
|
316
|
+
res.end(JSON.stringify(getNicknames()));
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
res.end('[]');
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (url.pathname.startsWith('/api/nicknames/') && req.method === 'PATCH') {
|
|
324
|
+
const parts = url.pathname.slice('/api/nicknames/'.length).split('/');
|
|
325
|
+
if (parts.length !== 2) {
|
|
326
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
327
|
+
res.end('{"error":"expected /api/nicknames/:channelId/:userId"}');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const [channelId, userId] = parts.map(decodeURIComponent);
|
|
331
|
+
let body = '';
|
|
332
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
333
|
+
req.on('end', () => {
|
|
334
|
+
try {
|
|
335
|
+
const { nickname } = JSON.parse(body);
|
|
336
|
+
if (!nickname || typeof nickname !== 'string') {
|
|
337
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
338
|
+
res.end('{"error":"nickname required"}');
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
setNickname(channelId, userId, nickname.trim());
|
|
342
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
343
|
+
res.end('{"ok":true}');
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
347
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
// --- Cron Jobs API ---
|
|
353
|
+
if (url.pathname === '/api/cron-jobs' && req.method === 'POST') {
|
|
354
|
+
let body = '';
|
|
355
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
356
|
+
req.on('end', () => {
|
|
357
|
+
try {
|
|
358
|
+
const { name, agentId, scheduleType, scheduleValue, timezone, message } = JSON.parse(body);
|
|
359
|
+
if (!name || !scheduleType || !scheduleValue || !message) {
|
|
360
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
361
|
+
res.end(JSON.stringify({ error: 'Missing required fields: name, scheduleType, scheduleValue, message' }));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// Calculate nextRun
|
|
365
|
+
const sched = new CronScheduler({});
|
|
366
|
+
const tz = timezone || 'Asia/Shanghai';
|
|
367
|
+
const nextRun = sched.calcNextRun(scheduleType, scheduleValue, tz);
|
|
368
|
+
if (!nextRun) {
|
|
369
|
+
const errMsg = scheduleType === 'once' ? 'Once schedule is in the past' : `Invalid schedule: ${scheduleType} "${scheduleValue}"`;
|
|
370
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
371
|
+
res.end(JSON.stringify({ error: errMsg }));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const job = createJob({
|
|
375
|
+
name, agentId: agentId || 'brain', scheduleType, scheduleValue,
|
|
376
|
+
timezone: tz, message, enabled: true, nextRun, createdBy: 'dashboard',
|
|
377
|
+
});
|
|
378
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
379
|
+
res.end(JSON.stringify(job));
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
383
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (url.pathname.startsWith('/api/cron-jobs/') && req.method === 'DELETE') {
|
|
389
|
+
const jobId = decodeURIComponent(url.pathname.slice('/api/cron-jobs/'.length));
|
|
390
|
+
const ok = deleteJob(jobId);
|
|
391
|
+
res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' });
|
|
392
|
+
res.end(JSON.stringify(ok ? { success: true } : { error: 'Job not found' }));
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (url.pathname.startsWith('/api/cron-jobs/') && req.method === 'PATCH') {
|
|
396
|
+
const jobId = decodeURIComponent(url.pathname.slice('/api/cron-jobs/'.length));
|
|
397
|
+
let body = '';
|
|
398
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
399
|
+
req.on('end', () => {
|
|
400
|
+
try {
|
|
401
|
+
const updates = JSON.parse(body);
|
|
402
|
+
// If re-enabling a job, recalculate nextRun so it doesn't fire immediately
|
|
403
|
+
if (updates.enabled === true) {
|
|
404
|
+
const jobs = listJobs();
|
|
405
|
+
const job = jobs.find(j => j.id === jobId);
|
|
406
|
+
if (job) {
|
|
407
|
+
if (job.scheduleType === 'cron') {
|
|
408
|
+
try {
|
|
409
|
+
const c = new Cron(job.scheduleValue, { timezone: job.timezone });
|
|
410
|
+
const next = c.nextRun();
|
|
411
|
+
if (next)
|
|
412
|
+
updates.nextRun = next.toISOString();
|
|
413
|
+
}
|
|
414
|
+
catch { /* invalid cron — let it be */ }
|
|
415
|
+
}
|
|
416
|
+
else if (job.scheduleType === 'interval') {
|
|
417
|
+
const ms = parseInt(job.scheduleValue, 10);
|
|
418
|
+
if (!isNaN(ms) && ms > 0) {
|
|
419
|
+
updates.nextRun = new Date(Date.now() + ms).toISOString();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// 'once' type: don't recalculate — if it already fired, it stays disabled
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const ok = updateJob(jobId, updates);
|
|
426
|
+
res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' });
|
|
427
|
+
res.end(JSON.stringify(ok ? { success: true } : { error: 'Job not found' }));
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
431
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (url.pathname === '/api/cron-jobs') {
|
|
437
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
438
|
+
try {
|
|
439
|
+
const jobs = listJobs();
|
|
440
|
+
const data = jobs.map(j => ({ ...j, recentRuns: getRecentRuns(j.id, 5) }));
|
|
441
|
+
res.end(JSON.stringify(data));
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
445
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
446
|
+
}
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
// Serve frontend static files
|
|
450
|
+
if (frontendDir) {
|
|
451
|
+
let filePath = join(frontendDir, url.pathname === '/' ? 'index.html' : url.pathname);
|
|
452
|
+
if (!existsSync(filePath)) {
|
|
453
|
+
// SPA fallback: serve index.html for all routes
|
|
454
|
+
filePath = join(frontendDir, 'index.html');
|
|
455
|
+
}
|
|
456
|
+
if (!existsSync(filePath)) {
|
|
457
|
+
res.writeHead(404);
|
|
458
|
+
res.end('Not found. Run `npx vite build` first.');
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const ext = filePath.split('.').pop() || '';
|
|
462
|
+
const mimeTypes = {
|
|
463
|
+
html: 'text/html', js: 'application/javascript', css: 'text/css',
|
|
464
|
+
json: 'application/json', svg: 'image/svg+xml', png: 'image/png',
|
|
465
|
+
jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
|
|
466
|
+
};
|
|
467
|
+
res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' });
|
|
468
|
+
res.end(readFileSync(filePath));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
res.writeHead(404);
|
|
472
|
+
res.end('Not found');
|
|
473
|
+
};
|
|
474
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const DEFAULT_PORT = 3721;
|
|
2
|
+
export function createWebMonitorPlugin(port = DEFAULT_PORT) {
|
|
3
|
+
let shutdownHandle = null;
|
|
4
|
+
return {
|
|
5
|
+
name: 'web-monitor',
|
|
6
|
+
async init(ctx) {
|
|
7
|
+
try {
|
|
8
|
+
const { startWeb } = await import('./server.js');
|
|
9
|
+
shutdownHandle = await startWeb({ port, ctx });
|
|
10
|
+
console.log(`[web-monitor] Dashboard at http://127.0.0.1:${port}`);
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
// Don't crash the hub if web server fails to start (e.g., port in use)
|
|
14
|
+
console.error(`[web-monitor] Failed to start: ${err.message}`);
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
async destroy() {
|
|
18
|
+
shutdownHandle?.shutdown();
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log Tailer — watch log files and emit new lines
|
|
3
|
+
*/
|
|
4
|
+
export declare class LogTailer {
|
|
5
|
+
private watchers;
|
|
6
|
+
private offsets;
|
|
7
|
+
private onLine;
|
|
8
|
+
constructor(onLine: (source: string, line: string) => void);
|
|
9
|
+
/** Start tailing a file. `source` is the label (e.g. "hub", "brain"). */
|
|
10
|
+
tail(source: string, filePath: string): void;
|
|
11
|
+
private readNewLines;
|
|
12
|
+
stop(): void;
|
|
13
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log Tailer — watch log files and emit new lines
|
|
3
|
+
*/
|
|
4
|
+
import { watch, existsSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
5
|
+
export class LogTailer {
|
|
6
|
+
watchers = new Map();
|
|
7
|
+
offsets = new Map();
|
|
8
|
+
onLine;
|
|
9
|
+
constructor(onLine) {
|
|
10
|
+
this.onLine = onLine;
|
|
11
|
+
}
|
|
12
|
+
/** Start tailing a file. `source` is the label (e.g. "hub", "brain"). */
|
|
13
|
+
tail(source, filePath) {
|
|
14
|
+
if (!existsSync(filePath))
|
|
15
|
+
return;
|
|
16
|
+
if (this.watchers.has(source))
|
|
17
|
+
return;
|
|
18
|
+
// Load last 50 lines as initial context, then tail new lines
|
|
19
|
+
const stat = statSync(filePath);
|
|
20
|
+
const INITIAL_BYTES = Math.min(stat.size, 8192);
|
|
21
|
+
const startOffset = Math.max(0, stat.size - INITIAL_BYTES);
|
|
22
|
+
try {
|
|
23
|
+
const buf = Buffer.alloc(INITIAL_BYTES);
|
|
24
|
+
const fd = openSync(filePath, 'r');
|
|
25
|
+
readSync(fd, buf, 0, INITIAL_BYTES, startOffset);
|
|
26
|
+
closeSync(fd);
|
|
27
|
+
const lines = buf.toString('utf8').split('\n').filter(l => l.trim());
|
|
28
|
+
for (const line of lines.slice(-50)) {
|
|
29
|
+
this.onLine(source, line);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
console.error(`[log-tailer] Failed to read initial lines from ${filePath}:`, e);
|
|
34
|
+
}
|
|
35
|
+
this.offsets.set(source, stat.size);
|
|
36
|
+
const watcher = watch(filePath, () => {
|
|
37
|
+
this.readNewLines(source, filePath);
|
|
38
|
+
});
|
|
39
|
+
// Also poll every 2s as fallback (fs.watch can miss events)
|
|
40
|
+
const interval = setInterval(() => this.readNewLines(source, filePath), 2000);
|
|
41
|
+
watcher.on('close', () => clearInterval(interval));
|
|
42
|
+
this.watchers.set(source, watcher);
|
|
43
|
+
}
|
|
44
|
+
readNewLines(source, filePath) {
|
|
45
|
+
try {
|
|
46
|
+
const stat = statSync(filePath);
|
|
47
|
+
const offset = this.offsets.get(source) || 0;
|
|
48
|
+
if (stat.size <= offset) {
|
|
49
|
+
if (stat.size < offset)
|
|
50
|
+
this.offsets.set(source, 0);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const buf = Buffer.alloc(stat.size - offset);
|
|
54
|
+
const fd = openSync(filePath, 'r');
|
|
55
|
+
readSync(fd, buf, 0, buf.length, offset);
|
|
56
|
+
closeSync(fd);
|
|
57
|
+
this.offsets.set(source, stat.size);
|
|
58
|
+
for (const line of buf.toString('utf8').split('\n')) {
|
|
59
|
+
if (line.trim()) {
|
|
60
|
+
this.onLine(source, line);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// File might be rotated or deleted
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
stop() {
|
|
69
|
+
for (const watcher of this.watchers.values()) {
|
|
70
|
+
watcher.close();
|
|
71
|
+
}
|
|
72
|
+
this.watchers.clear();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub Monitor Client — connects to hub.sock as read-only observer
|
|
3
|
+
*/
|
|
4
|
+
import type { HubEvent } from '../../shared/types.js';
|
|
5
|
+
export declare class MonitorClient {
|
|
6
|
+
private socket;
|
|
7
|
+
private connected;
|
|
8
|
+
private reconnectTimer;
|
|
9
|
+
private reconnectDelay;
|
|
10
|
+
private onEvent;
|
|
11
|
+
constructor(onEvent: (event: HubEvent) => void);
|
|
12
|
+
connect(): void;
|
|
13
|
+
private doConnect;
|
|
14
|
+
private scheduleReconnect;
|
|
15
|
+
isConnected(): boolean;
|
|
16
|
+
disconnect(): void;
|
|
17
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub Monitor Client — connects to hub.sock as read-only observer
|
|
3
|
+
*/
|
|
4
|
+
import { createConnection } from 'node:net';
|
|
5
|
+
import { HUB_SOCKET_PATH, encodeFrame, createFrameParser } from '../../shared/socket.js';
|
|
6
|
+
const RECONNECT_INTERVAL = 3000;
|
|
7
|
+
const MAX_RECONNECT_INTERVAL = 30000;
|
|
8
|
+
export class MonitorClient {
|
|
9
|
+
socket = null;
|
|
10
|
+
connected = false;
|
|
11
|
+
reconnectTimer = null;
|
|
12
|
+
reconnectDelay = RECONNECT_INTERVAL;
|
|
13
|
+
onEvent;
|
|
14
|
+
constructor(onEvent) {
|
|
15
|
+
this.onEvent = onEvent;
|
|
16
|
+
}
|
|
17
|
+
connect() {
|
|
18
|
+
this.doConnect();
|
|
19
|
+
}
|
|
20
|
+
doConnect() {
|
|
21
|
+
const socket = createConnection(HUB_SOCKET_PATH, () => {
|
|
22
|
+
this.socket = socket;
|
|
23
|
+
this.connected = true;
|
|
24
|
+
this.reconnectDelay = RECONNECT_INTERVAL;
|
|
25
|
+
socket.write(encodeFrame({ type: 'register_monitor' }));
|
|
26
|
+
console.log('[web] Connected to hub as monitor');
|
|
27
|
+
});
|
|
28
|
+
const parser = createFrameParser((frame) => {
|
|
29
|
+
if (frame.type === 'hub_event') {
|
|
30
|
+
this.onEvent(frame);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
socket.on('data', parser);
|
|
34
|
+
socket.on('error', (err) => {
|
|
35
|
+
if (err.code !== 'ECONNREFUSED') {
|
|
36
|
+
console.error(`[web] Monitor socket error: ${err.message}`);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
socket.on('close', () => {
|
|
40
|
+
const wasConnected = this.connected;
|
|
41
|
+
this.connected = false;
|
|
42
|
+
this.socket = null;
|
|
43
|
+
if (wasConnected) {
|
|
44
|
+
console.log('[web] Disconnected from hub');
|
|
45
|
+
}
|
|
46
|
+
this.scheduleReconnect();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
scheduleReconnect() {
|
|
50
|
+
if (this.reconnectTimer)
|
|
51
|
+
return;
|
|
52
|
+
this.reconnectTimer = setTimeout(() => {
|
|
53
|
+
this.reconnectTimer = null;
|
|
54
|
+
this.doConnect();
|
|
55
|
+
}, this.reconnectDelay);
|
|
56
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_INTERVAL);
|
|
57
|
+
}
|
|
58
|
+
isConnected() {
|
|
59
|
+
return this.connected;
|
|
60
|
+
}
|
|
61
|
+
disconnect() {
|
|
62
|
+
if (this.reconnectTimer) {
|
|
63
|
+
clearTimeout(this.reconnectTimer);
|
|
64
|
+
this.reconnectTimer = null;
|
|
65
|
+
}
|
|
66
|
+
this.socket?.end();
|
|
67
|
+
}
|
|
68
|
+
}
|