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,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChannelManager plugin — unified channel lifecycle, routing, and permissions.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the weixin plugin's "glue" logic with a channel-agnostic implementation.
|
|
5
|
+
* Owns: channel lifecycle, message routing (channel<->agent), typing indicators,
|
|
6
|
+
* pending-ack timers, permission management, and user-tracking per agent.
|
|
7
|
+
*/
|
|
8
|
+
import { basename, join } from 'node:path';
|
|
9
|
+
import { copyFileSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { SOCKET_DIR } from '../../shared/socket.js';
|
|
11
|
+
import { PermissionManager } from '../weixin/permission.js';
|
|
12
|
+
const TYPING_ACK_DELAY_MS = 10_000; // 10s before "processing..." ack
|
|
13
|
+
export function createChannelManagerPlugin(channels) {
|
|
14
|
+
let permissionMgr;
|
|
15
|
+
let cleanupInterval;
|
|
16
|
+
const lastUserByAgent = new Map();
|
|
17
|
+
const lastChannelByUser = new Map(); // userId -> channelId
|
|
18
|
+
let lastGlobalUser = null;
|
|
19
|
+
// Per-agent pending ack timer: agentId -> { ref, timer }
|
|
20
|
+
const pendingAck = new Map();
|
|
21
|
+
// Channel lookup by id
|
|
22
|
+
const channelMap = new Map();
|
|
23
|
+
for (const ch of channels) {
|
|
24
|
+
channelMap.set(ch.id, ch);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
name: 'channel-manager',
|
|
28
|
+
async init(ctx) {
|
|
29
|
+
// Register channels into HubContext so other plugins can access them
|
|
30
|
+
for (const ch of channels) {
|
|
31
|
+
ctx.registerChannel(ch);
|
|
32
|
+
}
|
|
33
|
+
// Helper: find channel by id
|
|
34
|
+
function getChannel(channelId) {
|
|
35
|
+
return channelMap.get(channelId);
|
|
36
|
+
}
|
|
37
|
+
// Helper: send text via channel, with fallback log
|
|
38
|
+
async function channelSendText(ref, text) {
|
|
39
|
+
const ch = getChannel(ref.channelId);
|
|
40
|
+
if (!ch) {
|
|
41
|
+
console.error(`[channel-manager] Channel "${ref.channelId}" not found, cannot send to ${ref.userId}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
await ch.sendText(ref.userId, text);
|
|
45
|
+
}
|
|
46
|
+
// --- Permission manager ---
|
|
47
|
+
// PermissionManager needs a send function for permission prompts.
|
|
48
|
+
// We provide a callback that resolves channel from lastUserByAgent.
|
|
49
|
+
permissionMgr = new PermissionManager();
|
|
50
|
+
// --- Pending ack management ---
|
|
51
|
+
function clearPendingAck(agentId) {
|
|
52
|
+
const pending = pendingAck.get(agentId);
|
|
53
|
+
if (pending) {
|
|
54
|
+
clearTimeout(pending.timer);
|
|
55
|
+
pendingAck.delete(agentId);
|
|
56
|
+
const ch = getChannel(pending.ref.channelId);
|
|
57
|
+
ch?.stopTyping(pending.ref.userId).catch(() => { });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function startPendingAck(agentId, ref) {
|
|
61
|
+
clearPendingAck(agentId); // clear any previous
|
|
62
|
+
const ch = getChannel(ref.channelId);
|
|
63
|
+
ch?.startTyping(ref.userId).catch(() => { });
|
|
64
|
+
const timer = setTimeout(async () => {
|
|
65
|
+
pendingAck.delete(agentId);
|
|
66
|
+
await channelSendText(ref, `\u23F3 \u6536\u5230\uFF0C\u6B63\u5728\u5904\u7406...`).catch(() => { });
|
|
67
|
+
}, TYPING_ACK_DELAY_MS);
|
|
68
|
+
pendingAck.set(agentId, { ref, timer });
|
|
69
|
+
}
|
|
70
|
+
// --- Spoke -> Channel: handle spoke messages ---
|
|
71
|
+
ctx.on('spoke:message', async (agentId, msg) => {
|
|
72
|
+
switch (msg.type) {
|
|
73
|
+
case 'reply': {
|
|
74
|
+
clearPendingAck(agentId);
|
|
75
|
+
// Resolve which channel to reply on
|
|
76
|
+
const ref = resolveUserRef(agentId, msg.userId);
|
|
77
|
+
if (!ref) {
|
|
78
|
+
console.error(`[channel-manager] Cannot resolve channel for reply from ${agentId} to ${msg.userId}`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
console.log(`[hub] Reply from ${agentId} to ${msg.userId}: ${msg.text.slice(0, 100)}`);
|
|
82
|
+
ctx.broadcastMonitor({ kind: 'message_out', agentId, userId: msg.userId, text: msg.text, timestamp: new Date().toISOString(), channelId: ref.channelId });
|
|
83
|
+
await channelSendText(ref, msg.text);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
case 'permission_request': {
|
|
87
|
+
console.log(`[hub] Permission request from ${agentId}: ${msg.toolName}`);
|
|
88
|
+
// sendFn resolves channel from the agent's tracked UserRef
|
|
89
|
+
const sendFn = async (userId, text) => {
|
|
90
|
+
const ref = resolveUserRef(agentId, userId);
|
|
91
|
+
if (ref) {
|
|
92
|
+
await channelSendText(ref, text);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
console.error(`[channel-manager] Cannot resolve channel for permission prompt to ${userId}`);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
await permissionMgr.handleRequest(agentId, msg, ctx, sendFn, lastUserByAgent, lastGlobalUser);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case 'status': {
|
|
102
|
+
console.log(`[hub] Agent ${agentId} status: ${msg.status}`);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case 'permission_timeout': {
|
|
106
|
+
permissionMgr.handleTimeout(msg.requestId);
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case 'send_file': {
|
|
110
|
+
clearPendingAck(agentId);
|
|
111
|
+
const ref = resolveUserRef(agentId, msg.userId);
|
|
112
|
+
if (!ref) {
|
|
113
|
+
console.error(`[channel-manager] Cannot resolve channel for send_file from ${agentId} to ${msg.userId}`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']);
|
|
117
|
+
const VIDEO_EXTS = new Set(['mp4', 'mov', 'avi', 'webm']);
|
|
118
|
+
const ext = msg.filePath.split('.').pop()?.toLowerCase() || '';
|
|
119
|
+
const isImage = IMAGE_EXTS.has(ext);
|
|
120
|
+
const isVideo = VIDEO_EXTS.has(ext);
|
|
121
|
+
const msgType = isImage ? 'image' : isVideo ? 'video' : 'file';
|
|
122
|
+
try {
|
|
123
|
+
const ch = getChannel(ref.channelId);
|
|
124
|
+
if (!ch)
|
|
125
|
+
throw new Error(`Channel "${ref.channelId}" not found`);
|
|
126
|
+
await ch.sendFile(ref.userId, msg.filePath);
|
|
127
|
+
// Copy to media dir so dashboard can preview
|
|
128
|
+
const mediaDir = join(SOCKET_DIR, 'media');
|
|
129
|
+
const mediaName = `${Date.now()}-${basename(msg.filePath)}`;
|
|
130
|
+
try {
|
|
131
|
+
mkdirSync(mediaDir, { recursive: true });
|
|
132
|
+
copyFileSync(msg.filePath, join(mediaDir, mediaName));
|
|
133
|
+
}
|
|
134
|
+
catch { }
|
|
135
|
+
console.log(`[hub] File sent from ${agentId} to ${msg.userId}: ${msg.filePath}`);
|
|
136
|
+
ctx.broadcastMonitor({
|
|
137
|
+
kind: 'message_out', agentId, userId: msg.userId,
|
|
138
|
+
text: isImage ? '[\u56FE\u7247]' : `[${msgType}] ${basename(msg.filePath)}`,
|
|
139
|
+
timestamp: new Date().toISOString(),
|
|
140
|
+
msgType,
|
|
141
|
+
mediaUrl: `/media/${mediaName}`,
|
|
142
|
+
channelId: ref.channelId,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
console.error(`[hub] Failed to send file from ${agentId}: ${err.message}`);
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
// NOTE: 'management' type is handled by hub core, not by this plugin
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
// --- Channel -> Agent: wire up message + status handlers ---
|
|
154
|
+
function wireChannel(ch) {
|
|
155
|
+
ch.onMessage(async (incomingMsg) => {
|
|
156
|
+
const userId = incomingMsg.userId;
|
|
157
|
+
const channelId = incomingMsg.channelId;
|
|
158
|
+
const ref = { userId, channelId };
|
|
159
|
+
lastGlobalUser = ref;
|
|
160
|
+
lastChannelByUser.set(userId, channelId);
|
|
161
|
+
// Permission verdict detection
|
|
162
|
+
if (permissionMgr.tryHandleVerdict({ type: incomingMsg.type, text: incomingMsg.text, userId, channelId }, ctx))
|
|
163
|
+
return;
|
|
164
|
+
// Route message
|
|
165
|
+
const router = ctx.getRouter();
|
|
166
|
+
const routed = router.route(incomingMsg.text || '', channelId);
|
|
167
|
+
lastUserByAgent.set(routed.agentId, ref);
|
|
168
|
+
// Unknown agent
|
|
169
|
+
if (routed.unknownAgent) {
|
|
170
|
+
const available = router.getAgentNames();
|
|
171
|
+
await channelSendText(ref, `\u26A0 Agent "${routed.agentId}" \u4E0D\u5B58\u5728\uFF0C\u53EF\u7528\u7684 agent: ${available.join(', ') || '\u65E0'}`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// Intercepted commands (restart, effort)
|
|
175
|
+
if (routed.intercepted) {
|
|
176
|
+
const agentManager = ctx.getAgentManager();
|
|
177
|
+
switch (routed.intercepted.command) {
|
|
178
|
+
case 'restart': {
|
|
179
|
+
await channelSendText(ref, `\u6B63\u5728\u91CD\u542F ${routed.agentId}...`);
|
|
180
|
+
const result = await agentManager.restart(routed.agentId);
|
|
181
|
+
await channelSendText(ref, result.success ? `\u2713 ${routed.agentId} \u5DF2\u91CD\u542F` : `\u2717 \u91CD\u542F\u5931\u8D25: ${result.error}`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
case 'effort': {
|
|
185
|
+
const effort = routed.intercepted.args[0];
|
|
186
|
+
agentManager.updateEffort(routed.agentId, effort);
|
|
187
|
+
await channelSendText(ref, `\u6B63\u5728\u4EE5 --effort ${effort} \u91CD\u542F ${routed.agentId}...`);
|
|
188
|
+
const result = await agentManager.restart(routed.agentId);
|
|
189
|
+
await channelSendText(ref, result.success ? `\u2713 ${routed.agentId} \u5DF2\u91CD\u542F (effort: ${effort})` : `\u2717 \u91CD\u542F\u5931\u8D25: ${result.error}`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Forward to spoke — persistence plugin will queue if offline
|
|
195
|
+
const text = buildMessageContent(incomingMsg, routed.text);
|
|
196
|
+
console.log(`[hub] Forwarding to ${routed.agentId}: ${text.substring(0, 80)}`);
|
|
197
|
+
const mediaUrl = incomingMsg.mediaPath ? `/media/${basename(incomingMsg.mediaPath)}` : undefined;
|
|
198
|
+
ctx.broadcastMonitor({ kind: 'message_in', agentId: routed.agentId, userId, text: routed.text, timestamp: new Date().toISOString(), msgType: incomingMsg.type, mediaUrl, channelId: incomingMsg.channelId, channelType: incomingMsg.channelType });
|
|
199
|
+
const sent = ctx.deliverToAgent(routed.agentId, {
|
|
200
|
+
type: 'message',
|
|
201
|
+
userId,
|
|
202
|
+
text,
|
|
203
|
+
msgType: incomingMsg.type,
|
|
204
|
+
mediaPath: incomingMsg.mediaPath ?? undefined,
|
|
205
|
+
timestamp: incomingMsg.timestamp?.toISOString() ?? new Date().toISOString(),
|
|
206
|
+
channelId: incomingMsg.channelId,
|
|
207
|
+
});
|
|
208
|
+
if (sent) {
|
|
209
|
+
startPendingAck(routed.agentId, ref);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
console.log(`[hub] Message queued for offline agent "${routed.agentId}"`);
|
|
213
|
+
await channelSendText(ref, `\uD83D\uDCEC ${routed.agentId} \u6682\u65F6\u79BB\u7EBF\uFF0C\u6D88\u606F\u5DF2\u6392\u961F\uFF0C\u4E0A\u7EBF\u540E\u81EA\u52A8\u6295\u9012\u3002`);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
// Channel status change -> monitor broadcast
|
|
217
|
+
ch.onStatusChange((status, detail) => {
|
|
218
|
+
console.log(`[channel-manager] ${ch.label} status: ${status}${detail ? ` (${detail})` : ''}`);
|
|
219
|
+
ctx.broadcastMonitor({
|
|
220
|
+
kind: 'channel_status',
|
|
221
|
+
agentId: ch.id, // reuse agentId field for channelId
|
|
222
|
+
timestamp: new Date().toISOString(),
|
|
223
|
+
text: `${ch.label}: ${status}${detail ? ` — ${detail}` : ''}`,
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
for (const ch of channels) {
|
|
228
|
+
wireChannel(ch);
|
|
229
|
+
}
|
|
230
|
+
// --- Runtime channel add/remove ---
|
|
231
|
+
ctx.on('channel:add', async (type, channelId, accountName) => {
|
|
232
|
+
if (channelMap.has(channelId))
|
|
233
|
+
return; // already exists
|
|
234
|
+
if (type === 'weixin') {
|
|
235
|
+
const { WeixinChannel } = await import('../weixin/weixin-channel.js');
|
|
236
|
+
const ch = new WeixinChannel(channelId, accountName);
|
|
237
|
+
channelMap.set(channelId, ch);
|
|
238
|
+
ctx.registerChannel(ch);
|
|
239
|
+
wireChannel(ch);
|
|
240
|
+
// Persist
|
|
241
|
+
const { loadChannelConfigs, saveChannelConfigs } = await import('../../shared/channel-config.js');
|
|
242
|
+
const configs = loadChannelConfigs();
|
|
243
|
+
if (!configs.find(c => c.id === channelId)) {
|
|
244
|
+
configs.push({ id: channelId, type: 'weixin', accountName });
|
|
245
|
+
saveChannelConfigs(configs);
|
|
246
|
+
}
|
|
247
|
+
// Don't auto-connect — wait for QR login to provide credentials
|
|
248
|
+
// Connection will be triggered by reconnectChannel after QR confirmed
|
|
249
|
+
console.log(`[channel-manager] Channel "${channelId}" created (awaiting login)`);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
console.warn(`[channel-manager] Unknown channel type: ${type}`);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
ctx.on('channel:remove', async (channelId) => {
|
|
256
|
+
const ch = channelMap.get(channelId);
|
|
257
|
+
if (ch) {
|
|
258
|
+
try {
|
|
259
|
+
await ch.disconnect();
|
|
260
|
+
}
|
|
261
|
+
catch { }
|
|
262
|
+
channelMap.delete(channelId);
|
|
263
|
+
}
|
|
264
|
+
// Clean up user refs pointing to deleted channel
|
|
265
|
+
for (const [agentId, ref] of lastUserByAgent) {
|
|
266
|
+
if (ref.channelId === channelId)
|
|
267
|
+
lastUserByAgent.delete(agentId);
|
|
268
|
+
}
|
|
269
|
+
for (const [uid, chId] of lastChannelByUser) {
|
|
270
|
+
if (chId === channelId)
|
|
271
|
+
lastChannelByUser.delete(uid);
|
|
272
|
+
}
|
|
273
|
+
if (lastGlobalUser?.channelId === channelId)
|
|
274
|
+
lastGlobalUser = null;
|
|
275
|
+
// Persist
|
|
276
|
+
const { loadChannelConfigs, saveChannelConfigs } = await import('../../shared/channel-config.js');
|
|
277
|
+
const configs = loadChannelConfigs().filter(c => c.id !== channelId);
|
|
278
|
+
saveChannelConfigs(configs);
|
|
279
|
+
console.log(`[channel-manager] Channel "${channelId}" removed`);
|
|
280
|
+
});
|
|
281
|
+
ctx.on('channel:reconnect', async (channelId) => {
|
|
282
|
+
const ch = channelMap.get(channelId);
|
|
283
|
+
if (!ch) {
|
|
284
|
+
console.warn(`[channel-manager] reconnect: channel "${channelId}" not found`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
console.log(`[channel-manager] Reconnecting "${channelId}"...`);
|
|
288
|
+
try {
|
|
289
|
+
await ch.disconnect();
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
console.warn(`[channel-manager] disconnect before reconnect failed: ${err.message}`);
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
await ch.connect();
|
|
296
|
+
console.log(`[channel-manager] "${channelId}" reconnected`);
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
console.error(`[channel-manager] "${channelId}" reconnect failed: ${err.message}`);
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
// --- Helper: resolve UserRef for outbound messages ---
|
|
303
|
+
function resolveUserRef(agentId, userId) {
|
|
304
|
+
// If we have a tracked ref for this agent, use it
|
|
305
|
+
const tracked = lastUserByAgent.get(agentId);
|
|
306
|
+
if (tracked) {
|
|
307
|
+
if (!userId || tracked.userId === userId)
|
|
308
|
+
return tracked;
|
|
309
|
+
// userId specified but differs — look up channel by userId
|
|
310
|
+
const channelId = lastChannelByUser.get(userId);
|
|
311
|
+
if (channelId)
|
|
312
|
+
return { userId, channelId };
|
|
313
|
+
// Fall back to tracked channel (best effort)
|
|
314
|
+
return { userId, channelId: tracked.channelId };
|
|
315
|
+
}
|
|
316
|
+
// Fall back to global last user
|
|
317
|
+
if (lastGlobalUser) {
|
|
318
|
+
if (!userId || lastGlobalUser.userId === userId)
|
|
319
|
+
return lastGlobalUser;
|
|
320
|
+
const channelId = lastChannelByUser.get(userId);
|
|
321
|
+
if (channelId)
|
|
322
|
+
return { userId, channelId };
|
|
323
|
+
return { userId, channelId: lastGlobalUser.channelId };
|
|
324
|
+
}
|
|
325
|
+
// No ref available
|
|
326
|
+
if (userId) {
|
|
327
|
+
const channelId = lastChannelByUser.get(userId);
|
|
328
|
+
if (channelId)
|
|
329
|
+
return { userId, channelId };
|
|
330
|
+
const firstChannel = channels[0];
|
|
331
|
+
if (firstChannel)
|
|
332
|
+
return { userId, channelId: firstChannel.id };
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
// Alert user when an agent dies after max restart attempts
|
|
337
|
+
ctx.on('agent:dead', (agentId) => {
|
|
338
|
+
const ref = lastGlobalUser;
|
|
339
|
+
if (ref) {
|
|
340
|
+
channelSendText(ref, `⚠ Agent "${agentId}" 多次崩溃已停止自动重启,请检查日志。`).catch(() => { });
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
// Permission cleanup interval
|
|
344
|
+
cleanupInterval = setInterval(() => permissionMgr.cleanup(), 60_000);
|
|
345
|
+
// --- Connect all channels ---
|
|
346
|
+
for (const ch of channels) {
|
|
347
|
+
try {
|
|
348
|
+
await ch.connect();
|
|
349
|
+
console.log(`[channel-manager] ${ch.label} connected`);
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
console.error(`[channel-manager] ${ch.label} connect failed: ${err.message}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
async destroy() {
|
|
357
|
+
if (cleanupInterval)
|
|
358
|
+
clearInterval(cleanupInterval);
|
|
359
|
+
// Disconnect all channels
|
|
360
|
+
for (const ch of channels) {
|
|
361
|
+
try {
|
|
362
|
+
await ch.disconnect();
|
|
363
|
+
console.log(`[channel-manager] ${ch.label} disconnected`);
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
console.error(`[channel-manager] ${ch.label} disconnect failed: ${err.message}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Build the message content string sent to the spoke agent.
|
|
374
|
+
* Includes channel info and handles media/voice messages.
|
|
375
|
+
*/
|
|
376
|
+
function buildMessageContent(msg, routedText) {
|
|
377
|
+
const tag = `[${msg.channelType} ${msg.userId}]`;
|
|
378
|
+
if (msg.type === 'voice' && msg.voiceText)
|
|
379
|
+
return `${tag} (\u8BED\u97F3\u8F6C\u6587\u5B57) ${msg.voiceText}`;
|
|
380
|
+
if (msg.type === 'voice')
|
|
381
|
+
return `${tag} (\u8BED\u97F3\u6D88\u606F\uFF0C\u65E0\u6CD5\u8BC6\u522B)`;
|
|
382
|
+
if (msg.mediaPath)
|
|
383
|
+
return `${tag} (${msg.type} \u5DF2\u4E0B\u8F7D\u5230 ${msg.mediaPath})`;
|
|
384
|
+
if (msg.type !== 'text')
|
|
385
|
+
return `${tag} (${msg.type} \u6D88\u606F\uFF0C\u4E0B\u8F7D\u5931\u8D25)`;
|
|
386
|
+
return `${tag} ${routedText}`;
|
|
387
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import type { CronJob, CronRun } from '../../shared/types.js';
|
|
3
|
+
export declare function openCronDb(): Database.Database;
|
|
4
|
+
export declare function closeCronDb(): void;
|
|
5
|
+
export declare function createJob(job: Omit<CronJob, 'id' | 'createdAt'>): CronJob;
|
|
6
|
+
export declare function deleteJob(id: string): boolean;
|
|
7
|
+
export declare function updateJob(id: string, updates: Partial<Pick<CronJob, 'enabled' | 'nextRun' | 'name' | 'message' | 'scheduleValue' | 'scheduleType' | 'timezone'>>): boolean;
|
|
8
|
+
export declare function listJobs(agentId?: string): CronJob[];
|
|
9
|
+
export declare function getEnabledJobs(): CronJob[];
|
|
10
|
+
export declare function recordRun(jobId: string, status: CronRun['status'], detail?: string): string;
|
|
11
|
+
export declare function getRecentRuns(jobId: string, limit?: number): CronRun[];
|
|
12
|
+
export declare function cleanupRuns(): number;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { SOCKET_DIR } from '../../shared/socket.js';
|
|
5
|
+
const DB_PATH = join(SOCKET_DIR, 'cc2im.db');
|
|
6
|
+
const RUNS_TTL_DAYS = 30;
|
|
7
|
+
let db = null;
|
|
8
|
+
export function openCronDb() {
|
|
9
|
+
if (db)
|
|
10
|
+
return db;
|
|
11
|
+
db = new Database(DB_PATH);
|
|
12
|
+
db.pragma('journal_mode = WAL');
|
|
13
|
+
db.pragma('busy_timeout = 5000');
|
|
14
|
+
db.exec(`
|
|
15
|
+
CREATE TABLE IF NOT EXISTS cron_jobs (
|
|
16
|
+
id TEXT PRIMARY KEY,
|
|
17
|
+
name TEXT NOT NULL,
|
|
18
|
+
agent_id TEXT NOT NULL,
|
|
19
|
+
schedule_type TEXT NOT NULL CHECK(schedule_type IN ('cron', 'once', 'interval')),
|
|
20
|
+
schedule_value TEXT NOT NULL,
|
|
21
|
+
timezone TEXT NOT NULL DEFAULT 'Asia/Shanghai',
|
|
22
|
+
message TEXT NOT NULL,
|
|
23
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
24
|
+
next_run TEXT,
|
|
25
|
+
created_at TEXT NOT NULL,
|
|
26
|
+
created_by TEXT NOT NULL DEFAULT 'dashboard'
|
|
27
|
+
);
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_cron_next ON cron_jobs(next_run) WHERE enabled = 1;
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS cron_runs (
|
|
31
|
+
id TEXT PRIMARY KEY,
|
|
32
|
+
job_id TEXT NOT NULL,
|
|
33
|
+
fired_at TEXT NOT NULL,
|
|
34
|
+
status TEXT NOT NULL CHECK(status IN ('delivered', 'queued', 'failed')),
|
|
35
|
+
detail TEXT
|
|
36
|
+
);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_runs_job ON cron_runs(job_id, fired_at);
|
|
38
|
+
`);
|
|
39
|
+
return db;
|
|
40
|
+
}
|
|
41
|
+
export function closeCronDb() {
|
|
42
|
+
db?.close();
|
|
43
|
+
db = null;
|
|
44
|
+
}
|
|
45
|
+
// --- helpers ---
|
|
46
|
+
function rowToJob(row) {
|
|
47
|
+
return {
|
|
48
|
+
id: row.id,
|
|
49
|
+
name: row.name,
|
|
50
|
+
agentId: row.agent_id,
|
|
51
|
+
scheduleType: row.schedule_type,
|
|
52
|
+
scheduleValue: row.schedule_value,
|
|
53
|
+
timezone: row.timezone,
|
|
54
|
+
message: row.message,
|
|
55
|
+
enabled: row.enabled === 1,
|
|
56
|
+
nextRun: row.next_run ?? null,
|
|
57
|
+
createdAt: row.created_at,
|
|
58
|
+
createdBy: row.created_by,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function rowToRun(row) {
|
|
62
|
+
return {
|
|
63
|
+
id: row.id,
|
|
64
|
+
jobId: row.job_id,
|
|
65
|
+
firedAt: row.fired_at,
|
|
66
|
+
status: row.status,
|
|
67
|
+
detail: row.detail ?? undefined,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// --- CRUD ---
|
|
71
|
+
export function createJob(job) {
|
|
72
|
+
const id = randomUUID();
|
|
73
|
+
const createdAt = new Date().toISOString();
|
|
74
|
+
openCronDb().prepare(`
|
|
75
|
+
INSERT INTO cron_jobs (id, name, agent_id, schedule_type, schedule_value, timezone, message, enabled, next_run, created_at, created_by)
|
|
76
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
77
|
+
`).run(id, job.name, job.agentId, job.scheduleType, job.scheduleValue, job.timezone, job.message, job.enabled ? 1 : 0, job.nextRun ?? null, createdAt, job.createdBy);
|
|
78
|
+
return { ...job, id, createdAt };
|
|
79
|
+
}
|
|
80
|
+
export function deleteJob(id) {
|
|
81
|
+
const d = openCronDb();
|
|
82
|
+
d.prepare('DELETE FROM cron_runs WHERE job_id = ?').run(id);
|
|
83
|
+
const result = d.prepare('DELETE FROM cron_jobs WHERE id = ?').run(id);
|
|
84
|
+
return result.changes > 0;
|
|
85
|
+
}
|
|
86
|
+
export function updateJob(id, updates) {
|
|
87
|
+
const setClauses = [];
|
|
88
|
+
const values = [];
|
|
89
|
+
if (updates.enabled !== undefined) {
|
|
90
|
+
setClauses.push('enabled = ?');
|
|
91
|
+
values.push(updates.enabled ? 1 : 0);
|
|
92
|
+
}
|
|
93
|
+
if (updates.nextRun !== undefined) {
|
|
94
|
+
setClauses.push('next_run = ?');
|
|
95
|
+
values.push(updates.nextRun);
|
|
96
|
+
}
|
|
97
|
+
if (updates.name !== undefined) {
|
|
98
|
+
setClauses.push('name = ?');
|
|
99
|
+
values.push(updates.name);
|
|
100
|
+
}
|
|
101
|
+
if (updates.message !== undefined) {
|
|
102
|
+
setClauses.push('message = ?');
|
|
103
|
+
values.push(updates.message);
|
|
104
|
+
}
|
|
105
|
+
if (updates.scheduleValue !== undefined) {
|
|
106
|
+
setClauses.push('schedule_value = ?');
|
|
107
|
+
values.push(updates.scheduleValue);
|
|
108
|
+
}
|
|
109
|
+
if (updates.scheduleType !== undefined) {
|
|
110
|
+
setClauses.push('schedule_type = ?');
|
|
111
|
+
values.push(updates.scheduleType);
|
|
112
|
+
}
|
|
113
|
+
if (updates.timezone !== undefined) {
|
|
114
|
+
setClauses.push('timezone = ?');
|
|
115
|
+
values.push(updates.timezone);
|
|
116
|
+
}
|
|
117
|
+
if (setClauses.length === 0)
|
|
118
|
+
return false;
|
|
119
|
+
values.push(id);
|
|
120
|
+
const result = openCronDb()
|
|
121
|
+
.prepare(`UPDATE cron_jobs SET ${setClauses.join(', ')} WHERE id = ?`)
|
|
122
|
+
.run(...values);
|
|
123
|
+
return result.changes > 0;
|
|
124
|
+
}
|
|
125
|
+
export function listJobs(agentId) {
|
|
126
|
+
const d = openCronDb();
|
|
127
|
+
const rows = agentId
|
|
128
|
+
? d.prepare('SELECT * FROM cron_jobs WHERE agent_id = ? ORDER BY created_at DESC').all(agentId)
|
|
129
|
+
: d.prepare('SELECT * FROM cron_jobs ORDER BY created_at DESC').all();
|
|
130
|
+
return rows.map(rowToJob);
|
|
131
|
+
}
|
|
132
|
+
export function getEnabledJobs() {
|
|
133
|
+
const rows = openCronDb()
|
|
134
|
+
.prepare('SELECT * FROM cron_jobs WHERE enabled = 1 ORDER BY next_run ASC')
|
|
135
|
+
.all();
|
|
136
|
+
return rows.map(rowToJob);
|
|
137
|
+
}
|
|
138
|
+
export function recordRun(jobId, status, detail) {
|
|
139
|
+
const id = randomUUID();
|
|
140
|
+
const firedAt = new Date().toISOString();
|
|
141
|
+
openCronDb().prepare(`
|
|
142
|
+
INSERT INTO cron_runs (id, job_id, fired_at, status, detail)
|
|
143
|
+
VALUES (?, ?, ?, ?, ?)
|
|
144
|
+
`).run(id, jobId, firedAt, status, detail ?? null);
|
|
145
|
+
return id;
|
|
146
|
+
}
|
|
147
|
+
export function getRecentRuns(jobId, limit = 20) {
|
|
148
|
+
const rows = openCronDb()
|
|
149
|
+
.prepare('SELECT * FROM cron_runs WHERE job_id = ? ORDER BY fired_at DESC LIMIT ?')
|
|
150
|
+
.all(jobId, limit);
|
|
151
|
+
return rows.map(rowToRun);
|
|
152
|
+
}
|
|
153
|
+
export function cleanupRuns() {
|
|
154
|
+
const cutoff = new Date();
|
|
155
|
+
cutoff.setDate(cutoff.getDate() - RUNS_TTL_DAYS);
|
|
156
|
+
const result = openCronDb()
|
|
157
|
+
.prepare('DELETE FROM cron_runs WHERE fired_at < ?')
|
|
158
|
+
.run(cutoff.toISOString());
|
|
159
|
+
return result.changes;
|
|
160
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { openCronDb, closeCronDb } from './db.js';
|
|
2
|
+
import { CronScheduler } from './scheduler.js';
|
|
3
|
+
export function createCronSchedulerPlugin() {
|
|
4
|
+
let scheduler = null;
|
|
5
|
+
return {
|
|
6
|
+
name: 'cron-scheduler',
|
|
7
|
+
init(ctx) {
|
|
8
|
+
openCronDb();
|
|
9
|
+
console.log('[cron-scheduler] SQLite tables ready');
|
|
10
|
+
scheduler = new CronScheduler(ctx);
|
|
11
|
+
scheduler.start();
|
|
12
|
+
},
|
|
13
|
+
destroy() {
|
|
14
|
+
scheduler?.stop();
|
|
15
|
+
closeCronDb();
|
|
16
|
+
console.log('[cron-scheduler] Stopped');
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
// Re-export for hub management handler
|
|
21
|
+
export { createJob, deleteJob, updateJob, listJobs, getRecentRuns } from './db.js';
|
|
22
|
+
export { CronScheduler } from './scheduler.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { HubContext } from '../../shared/plugin.js';
|
|
2
|
+
export declare class CronScheduler {
|
|
3
|
+
private ctx;
|
|
4
|
+
private tickTimer;
|
|
5
|
+
private cleanupTimer;
|
|
6
|
+
constructor(ctx: HubContext);
|
|
7
|
+
start(): void;
|
|
8
|
+
stop(): void;
|
|
9
|
+
/**
|
|
10
|
+
* Calculate next run time for a given schedule.
|
|
11
|
+
* Public so hub management handler can use it when creating/updating jobs.
|
|
12
|
+
*/
|
|
13
|
+
calcNextRun(type: 'cron' | 'once' | 'interval', value: string, timezone: string): string | null;
|
|
14
|
+
/**
|
|
15
|
+
* Recalculate nextRun for all enabled jobs. Called on startup.
|
|
16
|
+
*/
|
|
17
|
+
recalcAll(): void;
|
|
18
|
+
private tick;
|
|
19
|
+
private fire;
|
|
20
|
+
}
|