@ian2018cs/agenthub 0.1.82 → 0.1.83
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/assets/index-B8xBmKbu.css +32 -0
- package/dist/assets/index-BFd2fqw2.js +199 -0
- package/dist/assets/{vendor-icons-DUmFlkZ8.js → vendor-icons-CTLfTCYl.js} +88 -73
- package/dist/index.html +3 -3
- package/package.json +3 -1
- package/server/claude-sdk.js +1 -0
- package/server/database/db.js +98 -1
- package/server/index.js +108 -2
- package/server/routes/cron.js +132 -0
- package/server/services/builtin-tools/FeishuWriter.js +61 -0
- package/server/services/builtin-tools/SessionWriter.js +29 -0
- package/server/services/builtin-tools/WebhookWriter.js +70 -0
- package/server/services/builtin-tools/background-task.js +1 -5
- package/server/services/builtin-tools/cron-scheduler.js +160 -0
- package/server/services/builtin-tools/cron-tool.js +296 -0
- package/server/services/builtin-tools/index.js +30 -1
- package/server/services/feishu/index.js +2 -11
- package/server/services/feishu/lark-client.js +31 -8
- package/server/services/feishu/sdk-bridge.js +20 -16
- package/dist/assets/index-ByqBXYb8.js +0 -197
- package/dist/assets/index-DkNpDSsg.css +0 -32
package/server/database/db.js
CHANGED
|
@@ -331,6 +331,28 @@ const runMigrations = () => {
|
|
|
331
331
|
db.exec('ALTER TABLE agent_submissions ADD COLUMN update_notes TEXT');
|
|
332
332
|
} catch (_) { /* 列已存在 */ }
|
|
333
333
|
|
|
334
|
+
// 定时任务表(Cron 工具服务端接管)
|
|
335
|
+
db.exec(`
|
|
336
|
+
CREATE TABLE IF NOT EXISTS cron_tasks (
|
|
337
|
+
id TEXT PRIMARY KEY,
|
|
338
|
+
cron_expr TEXT NOT NULL,
|
|
339
|
+
prompt TEXT NOT NULL,
|
|
340
|
+
recurring INTEGER NOT NULL DEFAULT 1,
|
|
341
|
+
user_uuid TEXT NOT NULL,
|
|
342
|
+
session_id TEXT,
|
|
343
|
+
project_path TEXT,
|
|
344
|
+
session_mode TEXT NOT NULL DEFAULT 'new_session',
|
|
345
|
+
channel TEXT NOT NULL DEFAULT '',
|
|
346
|
+
feishu_open_id TEXT,
|
|
347
|
+
chat_id TEXT,
|
|
348
|
+
webhook_url TEXT,
|
|
349
|
+
created_at INTEGER NOT NULL,
|
|
350
|
+
last_fired_at INTEGER,
|
|
351
|
+
is_paused INTEGER NOT NULL DEFAULT 0
|
|
352
|
+
)
|
|
353
|
+
`);
|
|
354
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_cron_tasks_user ON cron_tasks(user_uuid)');
|
|
355
|
+
|
|
334
356
|
// 聊天图片持久化关联表
|
|
335
357
|
db.exec(`
|
|
336
358
|
CREATE TABLE IF NOT EXISTS message_images (
|
|
@@ -1476,6 +1498,80 @@ const agentSubmissionDb = {
|
|
|
1476
1498
|
}
|
|
1477
1499
|
};
|
|
1478
1500
|
|
|
1501
|
+
// Cron tasks database operations
|
|
1502
|
+
const cronDb = {
|
|
1503
|
+
insert: (task) => {
|
|
1504
|
+
db.prepare(`
|
|
1505
|
+
INSERT INTO cron_tasks (id, cron_expr, prompt, recurring, user_uuid, session_id,
|
|
1506
|
+
project_path, session_mode, channel, feishu_open_id, chat_id, webhook_url, created_at, last_fired_at)
|
|
1507
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1508
|
+
`).run(
|
|
1509
|
+
task.id, task.cronExpr, task.prompt, task.recurring ? 1 : 0,
|
|
1510
|
+
task.userUuid, task.sessionId || null, task.projectPath || null,
|
|
1511
|
+
task.sessionMode || 'new_session', task.channel ?? '',
|
|
1512
|
+
task.feishuOpenId || null, task.chatId || null, task.webhookUrl || null,
|
|
1513
|
+
task.createdAt, task.lastFiredAt || null
|
|
1514
|
+
);
|
|
1515
|
+
},
|
|
1516
|
+
|
|
1517
|
+
delete: (id, userUuid) => {
|
|
1518
|
+
const result = db.prepare('DELETE FROM cron_tasks WHERE id = ? AND user_uuid = ?').run(id, userUuid);
|
|
1519
|
+
return result.changes > 0;
|
|
1520
|
+
},
|
|
1521
|
+
|
|
1522
|
+
listByUser: (userUuid) => {
|
|
1523
|
+
return db.prepare('SELECT * FROM cron_tasks WHERE user_uuid = ? ORDER BY created_at ASC').all(userUuid)
|
|
1524
|
+
.map(row => ({
|
|
1525
|
+
id: row.id, cronExpr: row.cron_expr, prompt: row.prompt,
|
|
1526
|
+
recurring: row.recurring === 1, userUuid: row.user_uuid,
|
|
1527
|
+
sessionId: row.session_id, projectPath: row.project_path,
|
|
1528
|
+
sessionMode: row.session_mode, channel: row.channel,
|
|
1529
|
+
feishuOpenId: row.feishu_open_id, chatId: row.chat_id,
|
|
1530
|
+
webhookUrl: row.webhook_url, createdAt: row.created_at, lastFiredAt: row.last_fired_at,
|
|
1531
|
+
isPaused: row.is_paused === 1,
|
|
1532
|
+
}));
|
|
1533
|
+
},
|
|
1534
|
+
|
|
1535
|
+
getAll: () => {
|
|
1536
|
+
return db.prepare('SELECT * FROM cron_tasks ORDER BY created_at ASC').all()
|
|
1537
|
+
.map(row => ({
|
|
1538
|
+
id: row.id, cronExpr: row.cron_expr, prompt: row.prompt,
|
|
1539
|
+
recurring: row.recurring === 1, userUuid: row.user_uuid,
|
|
1540
|
+
sessionId: row.session_id, projectPath: row.project_path,
|
|
1541
|
+
sessionMode: row.session_mode, channel: row.channel,
|
|
1542
|
+
feishuOpenId: row.feishu_open_id, chatId: row.chat_id,
|
|
1543
|
+
webhookUrl: row.webhook_url, createdAt: row.created_at, lastFiredAt: row.last_fired_at,
|
|
1544
|
+
isPaused: row.is_paused === 1,
|
|
1545
|
+
}));
|
|
1546
|
+
},
|
|
1547
|
+
|
|
1548
|
+
updateLastFired: (id, ts) => {
|
|
1549
|
+
db.prepare('UPDATE cron_tasks SET last_fired_at = ? WHERE id = ?').run(ts, id);
|
|
1550
|
+
},
|
|
1551
|
+
|
|
1552
|
+
setPaused: (id, userUuid, isPaused) => {
|
|
1553
|
+
const result = db.prepare('UPDATE cron_tasks SET is_paused = ? WHERE id = ? AND user_uuid = ?')
|
|
1554
|
+
.run(isPaused ? 1 : 0, id, userUuid);
|
|
1555
|
+
return result.changes > 0;
|
|
1556
|
+
},
|
|
1557
|
+
|
|
1558
|
+
update: (id, userUuid, patch) => {
|
|
1559
|
+
const result = db.prepare(`
|
|
1560
|
+
UPDATE cron_tasks
|
|
1561
|
+
SET cron_expr = ?, prompt = ?, recurring = ?, channel = ?,
|
|
1562
|
+
webhook_url = ?, feishu_open_id = ?, chat_id = ?, project_path = ?
|
|
1563
|
+
WHERE id = ? AND user_uuid = ?
|
|
1564
|
+
`).run(
|
|
1565
|
+
patch.cronExpr, patch.prompt, patch.recurring ? 1 : 0,
|
|
1566
|
+
patch.channel ?? '', patch.webhookUrl || null,
|
|
1567
|
+
patch.feishuOpenId || null, patch.chatId || null,
|
|
1568
|
+
patch.projectPath || null,
|
|
1569
|
+
id, userUuid
|
|
1570
|
+
);
|
|
1571
|
+
return result.changes > 0;
|
|
1572
|
+
},
|
|
1573
|
+
};
|
|
1574
|
+
|
|
1479
1575
|
export {
|
|
1480
1576
|
db,
|
|
1481
1577
|
initializeDatabase,
|
|
@@ -1486,5 +1582,6 @@ export {
|
|
|
1486
1582
|
settingsDb,
|
|
1487
1583
|
feishuDb,
|
|
1488
1584
|
imageDb,
|
|
1489
|
-
agentSubmissionDb
|
|
1585
|
+
agentSubmissionDb,
|
|
1586
|
+
cronDb
|
|
1490
1587
|
};
|
package/server/index.js
CHANGED
|
@@ -71,6 +71,7 @@ import builtinTools, {
|
|
|
71
71
|
dequeueResult,
|
|
72
72
|
hasResults,
|
|
73
73
|
getAllPendingForUser,
|
|
74
|
+
cronScheduler,
|
|
74
75
|
} from './services/builtin-tools/index.js';
|
|
75
76
|
import authRoutes from './routes/auth.js';
|
|
76
77
|
import mcpRoutes from './routes/mcp.js';
|
|
@@ -83,12 +84,15 @@ import skillsRoutes from './routes/skills.js';
|
|
|
83
84
|
import mcpReposRoutes from './routes/mcp-repos.js';
|
|
84
85
|
import settingsRoutes from './routes/settings.js';
|
|
85
86
|
import agentsRoutes from './routes/agents.js';
|
|
86
|
-
import
|
|
87
|
+
import cronRoutes from './routes/cron.js';
|
|
88
|
+
import { initializeDatabase, userDb, imageDb, feishuDb } from './database/db.js';
|
|
87
89
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
|
88
90
|
import { getUserPaths, initCodexDirectories, initGeminiDirectories } from './services/user-directories.js';
|
|
89
91
|
import { startUsageScanner } from './services/usage-scanner.js';
|
|
90
|
-
import { startFeishuService,
|
|
92
|
+
import { startFeishuService, getLarkClient } from './services/feishu/index.js';
|
|
91
93
|
import { saveImage, getImagePath } from './services/image-storage.js';
|
|
94
|
+
import { WebhookWriter } from './services/builtin-tools/WebhookWriter.js';
|
|
95
|
+
import { SessionWriter } from './services/builtin-tools/SessionWriter.js';
|
|
92
96
|
import { startImageCleanup } from './services/image-cleanup.js';
|
|
93
97
|
|
|
94
98
|
// File system watcher for projects folder - per user
|
|
@@ -171,6 +175,102 @@ backgroundTaskPool.on('task-complete', (task) => {
|
|
|
171
175
|
tryDeliverBgResult(task.userUuid, task.sessionId);
|
|
172
176
|
});
|
|
173
177
|
|
|
178
|
+
cronScheduler.on('cron-fire', async (task) => {
|
|
179
|
+
const { id, sessionMode, channel, userUuid, sessionId, projectPath, prompt, cronExpr } = task;
|
|
180
|
+
console.log(`[CronFire] task=${id} channel=${channel || 'none'} sessionMode=${sessionMode || 'new_session'}`);
|
|
181
|
+
|
|
182
|
+
// session_mode 决定传不传 sessionId
|
|
183
|
+
const execSessionId = sessionMode === 'resume_session' ? (sessionId || undefined) : undefined;
|
|
184
|
+
|
|
185
|
+
// resume_session 冲突检测:若目标 session 正在被用户使用
|
|
186
|
+
if (execSessionId && isClaudeSDKSessionActive(execSessionId)) {
|
|
187
|
+
if (!task.recurring) {
|
|
188
|
+
// 单次任务:原任务由 #onFire 自动删除,用 setTimeout(0) 确保 delete 先执行后再创建重试任务
|
|
189
|
+
const retryDate = new Date(Date.now() + 60_000);
|
|
190
|
+
const retryCron = `${retryDate.getMinutes()} ${retryDate.getHours()} ${retryDate.getDate()} ${retryDate.getMonth() + 1} *`;
|
|
191
|
+
const retryId = Date.now().toString(36).slice(-8);
|
|
192
|
+
setTimeout(() => {
|
|
193
|
+
try {
|
|
194
|
+
cronScheduler.create({
|
|
195
|
+
id: retryId,
|
|
196
|
+
cronExpr: retryCron,
|
|
197
|
+
prompt: task.prompt,
|
|
198
|
+
recurring: false,
|
|
199
|
+
userUuid: task.userUuid,
|
|
200
|
+
sessionId: task.sessionId,
|
|
201
|
+
projectPath: task.projectPath,
|
|
202
|
+
sessionMode: task.sessionMode,
|
|
203
|
+
channel: task.channel,
|
|
204
|
+
feishuOpenId: task.feishuOpenId,
|
|
205
|
+
chatId: task.chatId,
|
|
206
|
+
webhookUrl: task.webhookUrl,
|
|
207
|
+
});
|
|
208
|
+
console.log(`[CronFire] Session busy, rescheduled one-shot task ${id} → ${retryId} (retry at ${retryCron})`);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.error(`[CronFire] Failed to reschedule one-shot task ${id}:`, err.message);
|
|
211
|
+
}
|
|
212
|
+
}, 0);
|
|
213
|
+
} else {
|
|
214
|
+
console.log(`[CronFire] Session ${execSessionId} busy, skipping recurring task ${id} (retry at next occurrence)`);
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
switch (channel) {
|
|
220
|
+
case 'feishu': {
|
|
221
|
+
// 通过 userUuid 从绑定表实时查 feishu_open_id,不依赖 task 中的缓存值
|
|
222
|
+
const feishuOpenId = feishuDb.getBindingByUserUuid(userUuid)?.feishu_open_id;
|
|
223
|
+
if (!feishuOpenId) {
|
|
224
|
+
console.warn(`[CronFire] No feishu binding for user ${userUuid}, skipping task ${id}`);
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
const state = feishuDb.getSessionState(feishuOpenId) || {};
|
|
228
|
+
const { FeishuWriter } = await import('./services/builtin-tools/FeishuWriter.js');
|
|
229
|
+
const writer = new FeishuWriter({
|
|
230
|
+
feishuOpenId,
|
|
231
|
+
chatId: state.chat_id || null,
|
|
232
|
+
larkClient: getLarkClient(),
|
|
233
|
+
cronId: id,
|
|
234
|
+
});
|
|
235
|
+
queryClaudeSDK(`[定时任务 ID: ${id}]\n\n${prompt}`, {
|
|
236
|
+
userUuid,
|
|
237
|
+
cwd: task.projectPath || state.cwd,
|
|
238
|
+
sessionId: execSessionId,
|
|
239
|
+
permissionMode: 'bypassPermissions',
|
|
240
|
+
}, writer).catch(err => console.error(`[CronFire] Feishu error for task ${id}:`, err.message));
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
case 'webhook': {
|
|
244
|
+
const writer = new WebhookWriter({
|
|
245
|
+
webhookUrl: task.webhookUrl,
|
|
246
|
+
cronId: id,
|
|
247
|
+
cronExpr,
|
|
248
|
+
prompt,
|
|
249
|
+
userUuid,
|
|
250
|
+
cwd: projectPath,
|
|
251
|
+
});
|
|
252
|
+
queryClaudeSDK(prompt, {
|
|
253
|
+
userUuid,
|
|
254
|
+
cwd: projectPath,
|
|
255
|
+
sessionId: execSessionId,
|
|
256
|
+
permissionMode: 'bypassPermissions',
|
|
257
|
+
}, writer).catch(err => console.error(`[CronFire] Webhook error for task ${id}:`, err.message));
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
default: {
|
|
261
|
+
// channel='' — 服务端执行,结果存入会话历史,不做外部投递
|
|
262
|
+
const writer = new SessionWriter({ cronId: id });
|
|
263
|
+
queryClaudeSDK(prompt, {
|
|
264
|
+
userUuid,
|
|
265
|
+
cwd: projectPath,
|
|
266
|
+
sessionId: execSessionId,
|
|
267
|
+
permissionMode: 'bypassPermissions',
|
|
268
|
+
}, writer).catch(err => console.error(`[CronFire] Session error for task ${id}:`, err.message));
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
174
274
|
// Setup file system watcher for a specific user's Claude projects folder
|
|
175
275
|
async function setupUserProjectsWatcher(userUuid, ws) {
|
|
176
276
|
if (!userUuid) {
|
|
@@ -427,6 +527,9 @@ app.use('/api/settings', authenticateToken, settingsRoutes);
|
|
|
427
527
|
// Agents API Routes (protected)
|
|
428
528
|
app.use('/api/agents', authenticateToken, agentsRoutes);
|
|
429
529
|
|
|
530
|
+
// Cron Tasks API Routes (protected)
|
|
531
|
+
app.use('/api/cron-tasks', authenticateToken, cronRoutes);
|
|
532
|
+
|
|
430
533
|
// Static files served after API routes
|
|
431
534
|
// Add cache control: HTML files should not be cached, but assets can be cached
|
|
432
535
|
app.use(express.static(path.join(__dirname, '../dist'), {
|
|
@@ -2598,6 +2701,9 @@ async function startServer() {
|
|
|
2598
2701
|
// Start image cleanup service (30-day expiry)
|
|
2599
2702
|
startImageCleanup();
|
|
2600
2703
|
|
|
2704
|
+
// Load persisted cron tasks from DB and start scheduling
|
|
2705
|
+
cronScheduler.loadFromDb();
|
|
2706
|
+
|
|
2601
2707
|
// Start Feishu integration service (if configured)
|
|
2602
2708
|
if (process.env.FEISHU_APP_ID && process.env.FEISHU_APP_SECRET) {
|
|
2603
2709
|
startFeishuService().catch(err =>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import cron from 'node-cron';
|
|
4
|
+
import { cronScheduler } from '../services/builtin-tools/cron-scheduler.js';
|
|
5
|
+
import { feishuDb } from '../database/db.js';
|
|
6
|
+
|
|
7
|
+
const router = express.Router();
|
|
8
|
+
|
|
9
|
+
function resolveChannelFields(channel, webhookUrl, userUuid) {
|
|
10
|
+
if (channel === 'feishu') {
|
|
11
|
+
const binding = feishuDb.getBindingByUserUuid(userUuid);
|
|
12
|
+
if (!binding) return { error: '未绑定飞书账号', status: 400 };
|
|
13
|
+
return { feishuOpenId: binding.feishu_open_id, chatId: null };
|
|
14
|
+
}
|
|
15
|
+
if (channel === 'webhook') {
|
|
16
|
+
if (!webhookUrl) return { error: 'Webhook 渠道需要提供 URL', status: 400 };
|
|
17
|
+
return { webhookUrl };
|
|
18
|
+
}
|
|
19
|
+
return {}; // 网页端
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// GET /api/cron-tasks — 列出当前用户所有定时任务
|
|
23
|
+
router.get('/', (req, res) => {
|
|
24
|
+
const userUuid = req.user.uuid;
|
|
25
|
+
const tasks = cronScheduler.list(userUuid);
|
|
26
|
+
res.json({ tasks });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// POST /api/cron-tasks — 创建定时任务
|
|
30
|
+
router.post('/', (req, res) => {
|
|
31
|
+
const userUuid = req.user.uuid;
|
|
32
|
+
const { cronExpr, prompt, recurring = true, channel = '', webhookUrl, projectPath } = req.body;
|
|
33
|
+
|
|
34
|
+
if (!cronExpr || !prompt) {
|
|
35
|
+
return res.status(400).json({ error: '缺少 cronExpr 或 prompt' });
|
|
36
|
+
}
|
|
37
|
+
if (!cron.validate(cronExpr)) {
|
|
38
|
+
return res.status(400).json({ error: '无效的 cron 表达式' });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const channelFields = resolveChannelFields(channel, webhookUrl, userUuid);
|
|
42
|
+
if (channelFields.error) {
|
|
43
|
+
return res.status(channelFields.status).json({ error: channelFields.error });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const task = cronScheduler.create({
|
|
48
|
+
id: randomUUID().replace(/-/g, '').slice(0, 8),
|
|
49
|
+
cronExpr,
|
|
50
|
+
prompt,
|
|
51
|
+
recurring,
|
|
52
|
+
userUuid,
|
|
53
|
+
sessionId: null,
|
|
54
|
+
projectPath: projectPath || null,
|
|
55
|
+
sessionMode: 'new_session',
|
|
56
|
+
channel,
|
|
57
|
+
feishuOpenId: channelFields.feishuOpenId || null,
|
|
58
|
+
chatId: channelFields.chatId || null,
|
|
59
|
+
webhookUrl: channelFields.webhookUrl || null,
|
|
60
|
+
});
|
|
61
|
+
res.json({ task });
|
|
62
|
+
} catch (e) {
|
|
63
|
+
res.status(400).json({ error: e.message });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// PUT /api/cron-tasks/:id — 编辑定时任务
|
|
68
|
+
router.put('/:id', (req, res) => {
|
|
69
|
+
const userUuid = req.user.uuid;
|
|
70
|
+
const { id } = req.params;
|
|
71
|
+
const { cronExpr, prompt, recurring = true, channel = '', webhookUrl, projectPath } = req.body;
|
|
72
|
+
|
|
73
|
+
if (!cronExpr || !prompt) {
|
|
74
|
+
return res.status(400).json({ error: '缺少 cronExpr 或 prompt' });
|
|
75
|
+
}
|
|
76
|
+
if (!cron.validate(cronExpr)) {
|
|
77
|
+
return res.status(400).json({ error: '无效的 cron 表达式' });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 验证任务存在且属于当前用户
|
|
81
|
+
const tasks = cronScheduler.list(userUuid);
|
|
82
|
+
if (!tasks.find(t => t.id === id)) {
|
|
83
|
+
return res.status(404).json({ error: '任务不存在或无权限' });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const channelFields = resolveChannelFields(channel, webhookUrl, userUuid);
|
|
87
|
+
if (channelFields.error) {
|
|
88
|
+
return res.status(channelFields.status).json({ error: channelFields.error });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const task = cronScheduler.update(id, userUuid, {
|
|
92
|
+
cronExpr, prompt, recurring, channel,
|
|
93
|
+
projectPath: projectPath || null,
|
|
94
|
+
feishuOpenId: channelFields.feishuOpenId || null,
|
|
95
|
+
chatId: channelFields.chatId || null,
|
|
96
|
+
webhookUrl: channelFields.webhookUrl || null,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (!task) return res.status(500).json({ error: '更新失败' });
|
|
100
|
+
res.json({ task });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// DELETE /api/cron-tasks/:id — 删除定时任务
|
|
104
|
+
router.delete('/:id', (req, res) => {
|
|
105
|
+
const userUuid = req.user.uuid;
|
|
106
|
+
const { id } = req.params;
|
|
107
|
+
const ok = cronScheduler.delete(id, userUuid);
|
|
108
|
+
if (!ok) {
|
|
109
|
+
return res.status(404).json({ error: '任务不存在或无权限' });
|
|
110
|
+
}
|
|
111
|
+
res.json({ success: true });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// PATCH /api/cron-tasks/:id/pause — 切换暂停/恢复状态
|
|
115
|
+
router.patch('/:id/pause', (req, res) => {
|
|
116
|
+
const userUuid = req.user.uuid;
|
|
117
|
+
const { id } = req.params;
|
|
118
|
+
const tasks = cronScheduler.list(userUuid);
|
|
119
|
+
const task = tasks.find(t => t.id === id);
|
|
120
|
+
if (!task) {
|
|
121
|
+
return res.status(404).json({ error: '任务不存在或无权限' });
|
|
122
|
+
}
|
|
123
|
+
const ok = task.isPaused
|
|
124
|
+
? cronScheduler.resume(id, userUuid)
|
|
125
|
+
: cronScheduler.pause(id, userUuid);
|
|
126
|
+
if (!ok) {
|
|
127
|
+
return res.status(500).json({ error: '操作失败' });
|
|
128
|
+
}
|
|
129
|
+
res.json({ success: true, isPaused: !task.isPaused });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
export default router;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { splitMessage } from '../feishu/card-builder.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FeishuWriter — 用于 channel='feishu' 的服务端执行模式
|
|
5
|
+
* 实现与 WebhookWriter 相同的接口(.send / .setSessionId / .getSessionId),
|
|
6
|
+
* 捕获 Claude 的文本回复,在 claude-complete 时分块发送到飞书。
|
|
7
|
+
*/
|
|
8
|
+
export class FeishuWriter {
|
|
9
|
+
constructor({ feishuOpenId, chatId, larkClient, cronId }) {
|
|
10
|
+
this.larkClient = larkClient;
|
|
11
|
+
this.cronId = cronId;
|
|
12
|
+
// 优先用 chat_id(群聊/单聊会话 ID),回退 open_id(用户 ID)
|
|
13
|
+
this._target = chatId || feishuOpenId;
|
|
14
|
+
this.textBuffer = '';
|
|
15
|
+
this.sessionId = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
send(data) {
|
|
19
|
+
try {
|
|
20
|
+
const obj = typeof data === 'string' ? JSON.parse(data) : data;
|
|
21
|
+
if (obj.type === 'claude-response') {
|
|
22
|
+
const d = obj.data;
|
|
23
|
+
if (d?.type === 'assistant' && Array.isArray(d.message?.content)) {
|
|
24
|
+
// 非流式:完整 assistant 消息
|
|
25
|
+
for (const block of d.message.content) {
|
|
26
|
+
if (block.type === 'text') this.textBuffer += block.text;
|
|
27
|
+
}
|
|
28
|
+
} else if (d?.type === 'content_block_delta' && d.delta?.text) {
|
|
29
|
+
// 流式:逐块 delta
|
|
30
|
+
this.textBuffer += d.delta.text;
|
|
31
|
+
}
|
|
32
|
+
} else if (obj.type === 'claude-complete') {
|
|
33
|
+
this._sendToFeishu();
|
|
34
|
+
} else if (obj.type === 'claude-error') {
|
|
35
|
+
console.error(`[CronFeishu] Task ${this.cronId} error:`, obj.error);
|
|
36
|
+
}
|
|
37
|
+
} catch (_) {
|
|
38
|
+
// 忽略 JSON 解析错误
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
setSessionId(id) { this.sessionId = id; }
|
|
43
|
+
getSessionId() { return this.sessionId; }
|
|
44
|
+
|
|
45
|
+
async _sendToFeishu() {
|
|
46
|
+
try {
|
|
47
|
+
const text = this.textBuffer.trim();
|
|
48
|
+
if (!text) {
|
|
49
|
+
console.log(`[CronFeishu] Task ${this.cronId} completed with empty response, skipping send`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const chunks = splitMessage(text, 4000);
|
|
53
|
+
for (const chunk of chunks) {
|
|
54
|
+
await this.larkClient.sendTextToTarget(this._target, chunk);
|
|
55
|
+
}
|
|
56
|
+
console.log(`[CronFeishu] Task ${this.cronId} sent ${chunks.length} chunk(s) to ${this._target}`);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error(`[CronFeishu] Send failed for task ${this.cronId}:`, err.message);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionWriter — 用于 channel='' 的服务端执行模式
|
|
3
|
+
*
|
|
4
|
+
* 接口对齐 WebhookWriter:.send(data) / .setSessionId(id) / .getSessionId()
|
|
5
|
+
*
|
|
6
|
+
* 目的:让 queryClaudeSDK 在服务端以新建或 resume 会话方式执行定时任务,
|
|
7
|
+
* Claude SDK 自动将对话存入会话历史,用户登录后可通过正常会话列表查看。
|
|
8
|
+
* 本 Writer 不向任何外部系统投递结果。
|
|
9
|
+
*/
|
|
10
|
+
export class SessionWriter {
|
|
11
|
+
constructor({ cronId }) {
|
|
12
|
+
this._cronId = cronId;
|
|
13
|
+
this._sessionId = null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
send(data) {
|
|
17
|
+
try {
|
|
18
|
+
const obj = typeof data === 'string' ? JSON.parse(data) : data;
|
|
19
|
+
if (obj.type === 'claude-complete') {
|
|
20
|
+
console.log(`[CronSession] Task ${this._cronId} completed, sessionId=${this._sessionId}`);
|
|
21
|
+
} else if (obj.type === 'claude-error') {
|
|
22
|
+
console.error(`[CronSession] Task ${this._cronId} error:`, obj.error);
|
|
23
|
+
}
|
|
24
|
+
} catch (_) {}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
setSessionId(id) { this._sessionId = id; }
|
|
28
|
+
getSessionId() { return this._sessionId; }
|
|
29
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { buildTextOrMarkdownMessage } from '../feishu/card-builder.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WebhookWriter — 用于 delivery_mode='webhook' 的服务端执行模式
|
|
5
|
+
* 实现与 WebSocketWriter 相同的接口(.send / .setSessionId / .getSessionId),
|
|
6
|
+
* 捕获 Claude 的文本回复,在 claude-complete 时 POST 到 webhookUrl。
|
|
7
|
+
*/
|
|
8
|
+
export class WebhookWriter {
|
|
9
|
+
constructor({ webhookUrl, cronId, cronExpr, prompt, userUuid, cwd }) {
|
|
10
|
+
this.webhookUrl = webhookUrl;
|
|
11
|
+
this.meta = { cronId, cronExpr, prompt };
|
|
12
|
+
this.userUuid = userUuid;
|
|
13
|
+
this.cwd = cwd;
|
|
14
|
+
this.textBuffer = '';
|
|
15
|
+
this.sessionId = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
send(data) {
|
|
19
|
+
try {
|
|
20
|
+
const obj = typeof data === 'string' ? JSON.parse(data) : data;
|
|
21
|
+
if (obj.type === 'claude-response') {
|
|
22
|
+
const d = obj.data;
|
|
23
|
+
if (d?.type === 'assistant' && Array.isArray(d.message?.content)) {
|
|
24
|
+
// 非流式:完整 assistant 消息
|
|
25
|
+
for (const block of d.message.content) {
|
|
26
|
+
if (block.type === 'text') this.textBuffer += block.text;
|
|
27
|
+
}
|
|
28
|
+
} else if (d?.type === 'content_block_delta' && d.delta?.text) {
|
|
29
|
+
// 流式:逐块 delta
|
|
30
|
+
this.textBuffer += d.delta.text;
|
|
31
|
+
}
|
|
32
|
+
} else if (obj.type === 'claude-complete') {
|
|
33
|
+
this._postWebhook();
|
|
34
|
+
}
|
|
35
|
+
} catch (_) {
|
|
36
|
+
// 忽略 JSON 解析错误
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
setSessionId(id) { this.sessionId = id; }
|
|
41
|
+
getSessionId() { return this.sessionId; }
|
|
42
|
+
|
|
43
|
+
async _postWebhook() {
|
|
44
|
+
try {
|
|
45
|
+
const feishu = buildTextOrMarkdownMessage(this.textBuffer);
|
|
46
|
+
const parsedContent = JSON.parse(feishu.content);
|
|
47
|
+
// 飞书 webhook: text 用 content 字段,interactive 用 card 字段
|
|
48
|
+
const feishuPayload = feishu.msgType === 'interactive'
|
|
49
|
+
? { msg_type: feishu.msgType, card: parsedContent }
|
|
50
|
+
: { msg_type: feishu.msgType, content: parsedContent };
|
|
51
|
+
const body = {
|
|
52
|
+
cron_id: this.meta.cronId,
|
|
53
|
+
cron_expr: this.meta.cronExpr,
|
|
54
|
+
prompt: this.meta.prompt,
|
|
55
|
+
fired_at: Date.now(),
|
|
56
|
+
response: this.textBuffer,
|
|
57
|
+
...feishuPayload,
|
|
58
|
+
};
|
|
59
|
+
console.log(`[CronWebhook] POST body:`, JSON.stringify(body));
|
|
60
|
+
await fetch(this.webhookUrl, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify(body),
|
|
64
|
+
});
|
|
65
|
+
console.log(`[CronWebhook] POST succeeded for task ${this.meta.cronId}, response length=${this.textBuffer.length}`);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.error(`[CronWebhook] POST failed for task ${this.meta.cronId}:`, err.message);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -73,11 +73,7 @@ __bg_status__ bg_xxxxxxxxxxxx
|
|
|
73
73
|
|
|
74
74
|
// 解析参数
|
|
75
75
|
const rawArgs = hookInput.tool_input.command.replace(/^\s*__bg_exec__\s*/, '');
|
|
76
|
-
let jsonStr = rawArgs.trim();
|
|
77
|
-
if ((jsonStr.startsWith("'") && jsonStr.endsWith("'")) ||
|
|
78
|
-
(jsonStr.startsWith('"') && jsonStr.endsWith('"'))) {
|
|
79
|
-
jsonStr = jsonStr.slice(1, -1);
|
|
80
|
-
}
|
|
76
|
+
let jsonStr = rawArgs.trim().replace(/^[\u2018\u201C'"]|[\u2019\u201D'"]+$/g, '').trim();
|
|
81
77
|
|
|
82
78
|
let params;
|
|
83
79
|
try {
|