@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
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import cron from 'node-cron';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import { cronDb } from '../../database/db.js';
|
|
4
|
+
|
|
5
|
+
const MAX_PER_USER = 10;
|
|
6
|
+
|
|
7
|
+
class CronScheduler extends EventEmitter {
|
|
8
|
+
#jobs = new Map(); // cronId → node-cron task
|
|
9
|
+
|
|
10
|
+
// 服务启动时从 DB 恢复所有持久化任务(跳过已暂停的)
|
|
11
|
+
loadFromDb() {
|
|
12
|
+
const tasks = cronDb.getAll();
|
|
13
|
+
let loaded = 0;
|
|
14
|
+
for (const task of tasks) {
|
|
15
|
+
if (task.isPaused) continue;
|
|
16
|
+
this.#scheduleJob(task);
|
|
17
|
+
loaded++;
|
|
18
|
+
}
|
|
19
|
+
if (tasks.length > 0) {
|
|
20
|
+
console.log(`[CronScheduler] Loaded ${loaded}/${tasks.length} cron task(s) from DB (${tasks.length - loaded} paused)`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
create({ id, cronExpr, prompt, recurring, userUuid, sessionId, projectPath,
|
|
25
|
+
sessionMode, channel, feishuOpenId, chatId, webhookUrl }) {
|
|
26
|
+
if (!cron.validate(cronExpr)) {
|
|
27
|
+
throw new Error(`无效的 cron 表达式: ${cronExpr}`);
|
|
28
|
+
}
|
|
29
|
+
const existing = cronDb.listByUser(userUuid);
|
|
30
|
+
if (existing.length >= MAX_PER_USER) {
|
|
31
|
+
throw new Error(`每用户最多 ${MAX_PER_USER} 个定时任务,当前已有 ${existing.length} 个`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const task = {
|
|
35
|
+
id, cronExpr, prompt, recurring,
|
|
36
|
+
userUuid, sessionId, projectPath,
|
|
37
|
+
sessionMode: sessionMode || 'new_session',
|
|
38
|
+
channel: channel ?? '',
|
|
39
|
+
feishuOpenId: feishuOpenId || null,
|
|
40
|
+
chatId: chatId || null,
|
|
41
|
+
webhookUrl: webhookUrl || null,
|
|
42
|
+
createdAt: Date.now(),
|
|
43
|
+
lastFiredAt: null,
|
|
44
|
+
isPaused: false,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
cronDb.insert(task);
|
|
48
|
+
this.#scheduleJob(task);
|
|
49
|
+
console.log(`[CronScheduler] Created task ${id} (${cronExpr}) for user ${userUuid}, sessionMode=${sessionMode || 'new_session'}, channel=${channel || 'none'}`);
|
|
50
|
+
return task;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
delete(id, userUuid) {
|
|
54
|
+
const job = this.#jobs.get(id);
|
|
55
|
+
if (job) {
|
|
56
|
+
job.stop();
|
|
57
|
+
this.#jobs.delete(id);
|
|
58
|
+
}
|
|
59
|
+
const ok = cronDb.delete(id, userUuid);
|
|
60
|
+
if (ok) {
|
|
61
|
+
console.log(`[CronScheduler] Deleted task ${id}`);
|
|
62
|
+
} else {
|
|
63
|
+
// 任务不存在或 user_uuid 不匹配
|
|
64
|
+
const allTasks = cronDb.getAll();
|
|
65
|
+
const task = allTasks.find(t => t.id === id);
|
|
66
|
+
if (task) {
|
|
67
|
+
console.warn(`[CronScheduler] Delete denied: task ${id} belongs to user ${task.userUuid}, caller is ${userUuid}`);
|
|
68
|
+
} else {
|
|
69
|
+
console.warn(`[CronScheduler] Delete failed: task ${id} not found in DB`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return ok;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
pause(id, userUuid) {
|
|
76
|
+
const ok = cronDb.setPaused(id, userUuid, true);
|
|
77
|
+
if (ok) {
|
|
78
|
+
const job = this.#jobs.get(id);
|
|
79
|
+
if (job) {
|
|
80
|
+
job.stop();
|
|
81
|
+
this.#jobs.delete(id);
|
|
82
|
+
}
|
|
83
|
+
console.log(`[CronScheduler] Paused task ${id}`);
|
|
84
|
+
}
|
|
85
|
+
return ok;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
resume(id, userUuid) {
|
|
89
|
+
const ok = cronDb.setPaused(id, userUuid, false);
|
|
90
|
+
if (ok) {
|
|
91
|
+
const allTasks = cronDb.getAll();
|
|
92
|
+
const task = allTasks.find(t => t.id === id);
|
|
93
|
+
if (task) {
|
|
94
|
+
this.#scheduleJob(task);
|
|
95
|
+
console.log(`[CronScheduler] Resumed task ${id}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return ok;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
update(id, userUuid, patch) {
|
|
102
|
+
// 停止旧 job
|
|
103
|
+
const job = this.#jobs.get(id);
|
|
104
|
+
if (job) {
|
|
105
|
+
job.stop();
|
|
106
|
+
this.#jobs.delete(id);
|
|
107
|
+
}
|
|
108
|
+
const ok = cronDb.update(id, userUuid, patch);
|
|
109
|
+
if (!ok) return null;
|
|
110
|
+
// 重新读取并调度(若未暂停)
|
|
111
|
+
const allTasks = cronDb.getAll();
|
|
112
|
+
const task = allTasks.find(t => t.id === id);
|
|
113
|
+
if (task && !task.isPaused) {
|
|
114
|
+
this.#scheduleJob(task);
|
|
115
|
+
console.log(`[CronScheduler] Updated task ${id} (${task.cronExpr})`);
|
|
116
|
+
}
|
|
117
|
+
return task || null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
list(userUuid) {
|
|
121
|
+
return cronDb.listByUser(userUuid);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#scheduleJob(task) {
|
|
125
|
+
if (this.#jobs.has(task.id)) {
|
|
126
|
+
// 避免重复注册(loadFromDb 时防御)
|
|
127
|
+
const old = this.#jobs.get(task.id);
|
|
128
|
+
old.stop();
|
|
129
|
+
}
|
|
130
|
+
const job = cron.schedule(task.cronExpr, () => this.#onFire(task.id));
|
|
131
|
+
this.#jobs.set(task.id, job);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#onFire(id) {
|
|
135
|
+
// 从 DB 重新读取,以获取最新的 lastFiredAt 等字段
|
|
136
|
+
const allTasks = cronDb.getAll();
|
|
137
|
+
const task = allTasks.find(t => t.id === id);
|
|
138
|
+
if (!task) {
|
|
139
|
+
// 任务已被删除,停止 job
|
|
140
|
+
const job = this.#jobs.get(id);
|
|
141
|
+
if (job) { job.stop(); this.#jobs.delete(id); }
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 已暂停则跳过(防御:理论上已从 #jobs 移除,不会触发)
|
|
146
|
+
if (task.isPaused) return;
|
|
147
|
+
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
cronDb.updateLastFired(id, now);
|
|
150
|
+
this.emit('cron-fire', { ...task, firedAt: now });
|
|
151
|
+
console.log(`[CronScheduler] Task ${id} fired (sessionMode=${task.sessionMode || 'new_session'}, channel=${task.channel || 'none'})`);
|
|
152
|
+
|
|
153
|
+
if (!task.recurring) {
|
|
154
|
+
// 单次任务:触发后删除
|
|
155
|
+
this.delete(id, task.userUuid);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const cronScheduler = new CronScheduler();
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import cron from 'node-cron';
|
|
3
|
+
import { cronScheduler } from './cron-scheduler.js';
|
|
4
|
+
import { feishuDb } from '../../database/db.js';
|
|
5
|
+
|
|
6
|
+
const MAX_PER_USER = 10;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 剥离参数字符串两端的引号(支持直引号和 curly quotes)并解析 JSON
|
|
10
|
+
*/
|
|
11
|
+
function parseJsonArg(raw) {
|
|
12
|
+
let s = raw.trim();
|
|
13
|
+
// 直引号 ' " 和 curly quotes \u2018\u2019\u201C\u201D
|
|
14
|
+
s = s.replace(/^[\s\u2018\u201C'"]|[\s\u2019\u201D'"]+$/g, '').trim();
|
|
15
|
+
return JSON.parse(s);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 解析 prompt 开头的 [session:...] 和 [channel:...] 前缀(双前缀,任意顺序,均可选)
|
|
20
|
+
* 返回 { sessionMode, channel, webhookUrl, prompt }
|
|
21
|
+
*/
|
|
22
|
+
function parsePrefixes(rawPrompt, defaults) {
|
|
23
|
+
let s = rawPrompt;
|
|
24
|
+
let sessionMode = defaults.sessionMode;
|
|
25
|
+
let channel = defaults.channel;
|
|
26
|
+
let webhookUrl = null;
|
|
27
|
+
|
|
28
|
+
let changed = true;
|
|
29
|
+
while (changed) {
|
|
30
|
+
changed = false;
|
|
31
|
+
|
|
32
|
+
const sm = s.match(/^\[session:(new_session|resume_session)\]/);
|
|
33
|
+
if (sm) {
|
|
34
|
+
sessionMode = sm[1];
|
|
35
|
+
s = s.slice(sm[0].length).trimStart();
|
|
36
|
+
changed = true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ch = s.match(/^\[channel:([^\]]*)\]/);
|
|
40
|
+
if (ch) {
|
|
41
|
+
const spec = ch[1];
|
|
42
|
+
if (spec.startsWith('webhook:')) {
|
|
43
|
+
channel = 'webhook';
|
|
44
|
+
webhookUrl = spec.slice(8);
|
|
45
|
+
} else {
|
|
46
|
+
channel = spec;
|
|
47
|
+
}
|
|
48
|
+
s = s.slice(ch[0].length).trimStart();
|
|
49
|
+
changed = true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { sessionMode, channel, webhookUrl, prompt: s };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 等待 mutableWriter 中的 sessionId 被赋值(最多 5s)
|
|
58
|
+
*/
|
|
59
|
+
async function waitForSessionId(mutableWriter) {
|
|
60
|
+
let sessionId = mutableWriter?.current?.getSessionId?.();
|
|
61
|
+
if (sessionId) return sessionId;
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const interval = setInterval(() => {
|
|
64
|
+
const sid = mutableWriter?.current?.getSessionId?.();
|
|
65
|
+
if (sid) { clearInterval(interval); resolve(sid); }
|
|
66
|
+
}, 200);
|
|
67
|
+
setTimeout(() => { clearInterval(interval); resolve(null); }, 5000);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── __cron_create__ ──────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
export const cronCreateTool = {
|
|
74
|
+
name: '__cron_create__',
|
|
75
|
+
|
|
76
|
+
systemPrompt: `
|
|
77
|
+
## 定时任务(Cron)
|
|
78
|
+
使用以下 Bash 命令操作定时任务(任务持久化到数据库,服务重启自动恢复,循环任务永久运行直到用户手动删除):
|
|
79
|
+
|
|
80
|
+
### 创建定时任务
|
|
81
|
+
\`\`\`
|
|
82
|
+
Bash(__cron_create__ '{"cron":"0 9 * * 1-5","prompt":"分析昨日 git 提交","recurring":true}')
|
|
83
|
+
\`\`\`
|
|
84
|
+
|
|
85
|
+
参数说明:
|
|
86
|
+
- cron: 标准 5 字段 cron 表达式(如 "*/5 * * * *")
|
|
87
|
+
- prompt: 触发时执行的内容,开头可加前缀指定会话方式和投递渠道
|
|
88
|
+
- recurring: true=循环(永久),false=单次(触发后删除)
|
|
89
|
+
|
|
90
|
+
### 前缀说明(**创建前务必询问用户**,在 prompt 开头使用,顺序任意,均可选):
|
|
91
|
+
|
|
92
|
+
**会话方式**(决定使用哪个 Claude 会话上下文):
|
|
93
|
+
- [session:new_session](默认):每次触发创建全新会话(隔离,适合独立任务)
|
|
94
|
+
- [session:resume_session]:每次触发 resume 创建任务时的会话(连续性,适合长期跟踪)
|
|
95
|
+
|
|
96
|
+
**投递渠道**(决定结果发往哪里):
|
|
97
|
+
- (无前缀,网页端默认):服务端执行,结果存入会话历史,用户登录后可查看
|
|
98
|
+
- [channel:feishu](飞书端默认):直接向绑定的飞书会话发送结果
|
|
99
|
+
- [channel:webhook:https://...]:将 Claude 回复 POST 到指定 URL
|
|
100
|
+
|
|
101
|
+
两种前缀可自由组合,例如:
|
|
102
|
+
- [session:resume_session]每日代码审查
|
|
103
|
+
- [channel:webhook:https://hooks.example.com/][session:resume_session]汇报项目进展
|
|
104
|
+
|
|
105
|
+
所有执行均在服务端完成,不依赖用户是否在线。每用户最多 ${MAX_PER_USER} 个并发任务。
|
|
106
|
+
|
|
107
|
+
### 删除定时任务
|
|
108
|
+
\`\`\`
|
|
109
|
+
Bash(__cron_delete__ '{"id":"任务ID"}')
|
|
110
|
+
\`\`\`
|
|
111
|
+
|
|
112
|
+
### 查看定时任务列表
|
|
113
|
+
\`\`\`
|
|
114
|
+
Bash(__cron_list__)
|
|
115
|
+
\`\`\`
|
|
116
|
+
`,
|
|
117
|
+
|
|
118
|
+
match(hookInput) {
|
|
119
|
+
return hookInput.tool_name === 'Bash' &&
|
|
120
|
+
hookInput.tool_input?.command?.trimStart().startsWith('__cron_create__');
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
async execute(hookInput, ctx) {
|
|
124
|
+
const rawArgs = hookInput.tool_input.command.replace(/^\s*__cron_create__\s*/, '');
|
|
125
|
+
let params;
|
|
126
|
+
try {
|
|
127
|
+
params = parseJsonArg(rawArgs);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
return { decision: 'deny', reason: `参数解析失败: ${e.message}。请使用 JSON 格式:__cron_create__ '{"cron":"...","prompt":"...","recurring":true}'` };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { cron: cronExpr, prompt: rawPrompt, recurring = true } = params;
|
|
133
|
+
const { userUuid, mutableWriter, cwd } = ctx;
|
|
134
|
+
|
|
135
|
+
if (!cronExpr || !rawPrompt) {
|
|
136
|
+
return { decision: 'deny', reason: '❌ 缺少必要参数 cron 或 prompt' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!cron.validate(cronExpr)) {
|
|
140
|
+
return { decision: 'deny', reason: `❌ 无效的 cron 表达式: ${cronExpr}(需要标准 5 字段格式,如 "*/5 * * * *")` };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 检测渠道(飞书 vs 网页),确定默认值
|
|
144
|
+
const feishuOpenId = mutableWriter?.current?.feishuOpenId || null;
|
|
145
|
+
const chatId = mutableWriter?.current?.chatId || null;
|
|
146
|
+
const defaults = feishuOpenId
|
|
147
|
+
? { sessionMode: 'new_session', channel: 'feishu' }
|
|
148
|
+
: { sessionMode: 'new_session', channel: '' };
|
|
149
|
+
|
|
150
|
+
// 解析投递前缀
|
|
151
|
+
const { sessionMode, channel, webhookUrl, prompt } = parsePrefixes(rawPrompt, defaults);
|
|
152
|
+
|
|
153
|
+
// webhook 模式需要有效 URL
|
|
154
|
+
if (channel === 'webhook' && !webhookUrl) {
|
|
155
|
+
return { decision: 'deny', reason: '❌ webhook 渠道需要指定 URL,格式: [channel:webhook:https://...]' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// feishu 模式需要用户已绑定飞书账号
|
|
159
|
+
if (channel === 'feishu') {
|
|
160
|
+
const binding = feishuDb.getBindingByUserUuid(userUuid);
|
|
161
|
+
if (!binding) {
|
|
162
|
+
return { decision: 'deny', reason: '❌ 当前账号未绑定飞书,无法使用飞书投递渠道。请先在设置 > 账户中绑定飞书账号,或选择其他投递渠道。' };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 等待 sessionId(最多 5s,resume_session 模式下需要)
|
|
167
|
+
const sessionId = await waitForSessionId(mutableWriter);
|
|
168
|
+
|
|
169
|
+
const id = crypto.randomUUID().slice(0, 8);
|
|
170
|
+
try {
|
|
171
|
+
cronScheduler.create({
|
|
172
|
+
id, cronExpr, prompt, recurring,
|
|
173
|
+
userUuid, sessionId,
|
|
174
|
+
projectPath: cwd || null,
|
|
175
|
+
sessionMode, channel,
|
|
176
|
+
feishuOpenId, chatId,
|
|
177
|
+
webhookUrl: webhookUrl || null,
|
|
178
|
+
});
|
|
179
|
+
} catch (err) {
|
|
180
|
+
return { decision: 'deny', reason: `❌ 创建失败:${err.message}` };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const channelDesc = {
|
|
184
|
+
'': '服务端执行(存入会话历史)',
|
|
185
|
+
'feishu': '飞书直投',
|
|
186
|
+
'webhook': `Webhook POST → ${webhookUrl}`,
|
|
187
|
+
}[channel] ?? channel;
|
|
188
|
+
|
|
189
|
+
const sessionDesc = {
|
|
190
|
+
'new_session': '新建会话',
|
|
191
|
+
'resume_session': 'Resume 原会话',
|
|
192
|
+
}[sessionMode] ?? sessionMode;
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
decision: 'deny',
|
|
196
|
+
reason: [
|
|
197
|
+
`✅ 定时任务已创建并持久化`,
|
|
198
|
+
`任务 ID: ${id}`,
|
|
199
|
+
`频率: ${cronExpr}`,
|
|
200
|
+
`类型: ${recurring ? '循环(永久)' : '单次'}`,
|
|
201
|
+
`会话方式: ${sessionDesc}`,
|
|
202
|
+
`投递渠道: ${channelDesc}`,
|
|
203
|
+
`内容: ${prompt}`,
|
|
204
|
+
].join('\n'),
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// ─── __cron_delete__ ──────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
export const cronDeleteTool = {
|
|
212
|
+
name: '__cron_delete__',
|
|
213
|
+
|
|
214
|
+
systemPrompt: '',
|
|
215
|
+
|
|
216
|
+
match(hookInput) {
|
|
217
|
+
return hookInput.tool_name === 'Bash' &&
|
|
218
|
+
hookInput.tool_input?.command?.trimStart().startsWith('__cron_delete__');
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
async execute(hookInput, ctx) {
|
|
222
|
+
const rawArgs = hookInput.tool_input.command.replace(/^\s*__cron_delete__\s*/, '');
|
|
223
|
+
let params;
|
|
224
|
+
try {
|
|
225
|
+
params = parseJsonArg(rawArgs);
|
|
226
|
+
} catch (e) {
|
|
227
|
+
return { decision: 'deny', reason: `参数解析失败: ${e.message}。请使用 JSON 格式:__cron_delete__ '{"id":"任务ID"}'` };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const { id } = params;
|
|
231
|
+
if (!id) return { decision: 'deny', reason: '❌ 缺少任务 ID' };
|
|
232
|
+
const ok = cronScheduler.delete(id, ctx.userUuid);
|
|
233
|
+
return {
|
|
234
|
+
decision: 'deny',
|
|
235
|
+
reason: ok
|
|
236
|
+
? `✅ 定时任务 ${id} 已取消并从数据库删除`
|
|
237
|
+
: `❌ 任务 ${id} 不存在或无权限删除(当前用户 UUID: ${ctx.userUuid})`,
|
|
238
|
+
};
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// ─── __cron_list__ ────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
export const cronListTool = {
|
|
245
|
+
name: '__cron_list__',
|
|
246
|
+
|
|
247
|
+
systemPrompt: '',
|
|
248
|
+
|
|
249
|
+
match(hookInput) {
|
|
250
|
+
return hookInput.tool_name === 'Bash' &&
|
|
251
|
+
hookInput.tool_input?.command?.trimStart().startsWith('__cron_list__');
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
async execute(_hookInput, ctx) {
|
|
255
|
+
const tasks = cronScheduler.list(ctx.userUuid);
|
|
256
|
+
if (!tasks.length) {
|
|
257
|
+
return { decision: 'deny', reason: `No scheduled jobs for user ${ctx.userUuid}.` };
|
|
258
|
+
}
|
|
259
|
+
const lines = tasks.map(t => {
|
|
260
|
+
const channelInfo = t.channel === 'webhook' ? ` webhook=${t.webhookUrl}` :
|
|
261
|
+
t.channel ? ` channel=${t.channel}` : '';
|
|
262
|
+
const fired = t.lastFiredAt ? ` last_fired=${new Date(t.lastFiredAt).toISOString()}` : '';
|
|
263
|
+
return `ID: ${t.id} cron: ${t.cronExpr} ${t.recurring ? 'recurring' : 'once'} session: ${t.sessionMode || 'new_session'} channel: ${t.channel || 'none'}${channelInfo}${fired}`;
|
|
264
|
+
});
|
|
265
|
+
return { decision: 'deny', reason: lines.join('\n') };
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// ─── SDK 原生工具重定向(拦截并引导 AI 使用 Bash 版本)─────────────────────────
|
|
270
|
+
|
|
271
|
+
const SDK_CRON_REDIRECT = `❌ 请勿使用 SDK 内置的 CronCreate/CronDelete/CronList 工具,它们在本系统中无法持久化。
|
|
272
|
+
请改用以下 Bash 命令:
|
|
273
|
+
- 创建:Bash(__cron_create__ '{"cron":"...","prompt":"...","recurring":true}')
|
|
274
|
+
- 删除:Bash(__cron_delete__ '{"id":"任务ID"}')
|
|
275
|
+
- 列表:Bash(__cron_list__)`;
|
|
276
|
+
|
|
277
|
+
export const sdkCronCreateRedirect = {
|
|
278
|
+
name: 'sdk_CronCreate_redirect',
|
|
279
|
+
systemPrompt: '',
|
|
280
|
+
match(hookInput) { return hookInput.tool_name === 'CronCreate'; },
|
|
281
|
+
async execute() { return { decision: 'deny', reason: SDK_CRON_REDIRECT }; },
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
export const sdkCronDeleteRedirect = {
|
|
285
|
+
name: 'sdk_CronDelete_redirect',
|
|
286
|
+
systemPrompt: '',
|
|
287
|
+
match(hookInput) { return hookInput.tool_name === 'CronDelete'; },
|
|
288
|
+
async execute() { return { decision: 'deny', reason: SDK_CRON_REDIRECT }; },
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
export const sdkCronListRedirect = {
|
|
292
|
+
name: 'sdk_CronList_redirect',
|
|
293
|
+
systemPrompt: '',
|
|
294
|
+
match(hookInput) { return hookInput.tool_name === 'CronList'; },
|
|
295
|
+
async execute() { return { decision: 'deny', reason: SDK_CRON_REDIRECT }; },
|
|
296
|
+
};
|
|
@@ -69,7 +69,24 @@ class BuiltinToolRegistry {
|
|
|
69
69
|
|
|
70
70
|
try {
|
|
71
71
|
const result = await tool.execute(hookInput, toolContext);
|
|
72
|
-
|
|
72
|
+
|
|
73
|
+
// Bash 拦截型内置工具:execute() 副作用已完成,将 deny 转为 allow+printf
|
|
74
|
+
// 让 AI 看到正常的 Bash 执行结果,避免因 is_error:true 误判为失败
|
|
75
|
+
if (result.decision === 'deny' && hookInput.tool_name === 'Bash') {
|
|
76
|
+
const msg = (result.reason || '')
|
|
77
|
+
.replace(/\\/g, '\\\\')
|
|
78
|
+
.replace(/'/g, "\\'")
|
|
79
|
+
.replace(/\n/g, '\\n');
|
|
80
|
+
return {
|
|
81
|
+
hookSpecificOutput: {
|
|
82
|
+
hookEventName: 'PreToolUse',
|
|
83
|
+
permissionDecision: 'allow',
|
|
84
|
+
updatedInput: { command: `printf '%s\n' $'${msg}'` },
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 非 Bash 工具(如 SDK CronCreate/CronDelete/CronList 重定向)保持 deny
|
|
73
90
|
if (result.decision === 'deny' && hookInput.tool_use_id) {
|
|
74
91
|
this.#builtinHandledIds.add(hookInput.tool_use_id);
|
|
75
92
|
}
|
|
@@ -152,6 +169,15 @@ import backgroundTask, { bgStatusTool } from './background-task.js';
|
|
|
152
169
|
registry.register(backgroundTask);
|
|
153
170
|
registry.register(bgStatusTool);
|
|
154
171
|
|
|
172
|
+
import { cronCreateTool, cronDeleteTool, cronListTool, sdkCronCreateRedirect, sdkCronDeleteRedirect, sdkCronListRedirect } from './cron-tool.js';
|
|
173
|
+
registry.register(cronCreateTool);
|
|
174
|
+
registry.register(cronDeleteTool);
|
|
175
|
+
registry.register(cronListTool);
|
|
176
|
+
// 拦截 SDK 原生 CronCreate/CronDelete/CronList,引导 AI 使用 Bash 版本
|
|
177
|
+
registry.register(sdkCronCreateRedirect);
|
|
178
|
+
registry.register(sdkCronDeleteRedirect);
|
|
179
|
+
registry.register(sdkCronListRedirect);
|
|
180
|
+
|
|
155
181
|
// 重新导出后台任务相关模块,供 server/index.js 使用
|
|
156
182
|
export {
|
|
157
183
|
backgroundTaskPool,
|
|
@@ -161,4 +187,7 @@ export {
|
|
|
161
187
|
getAllPendingForUser,
|
|
162
188
|
} from './background-task-pool.js';
|
|
163
189
|
|
|
190
|
+
// 重新导出 cron 调度器,供 server/index.js 使用
|
|
191
|
+
export { cronScheduler } from './cron-scheduler.js';
|
|
192
|
+
|
|
164
193
|
export default registry;
|
|
@@ -62,15 +62,6 @@ async function startFeishuService() {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
* 停止飞书服务(用于优雅关闭)
|
|
67
|
-
*/
|
|
68
|
-
function stopFeishuService() {
|
|
69
|
-
if (larkClientInstance) {
|
|
70
|
-
larkClientInstance.stop();
|
|
71
|
-
larkClientInstance = null;
|
|
72
|
-
console.log('[Feishu] Service stopped');
|
|
73
|
-
}
|
|
74
|
-
}
|
|
65
|
+
function getLarkClient() { return larkClientInstance; }
|
|
75
66
|
|
|
76
|
-
export { startFeishuService,
|
|
67
|
+
export { startFeishuService, getLarkClient };
|
|
@@ -221,6 +221,29 @@ export class LarkClient {
|
|
|
221
221
|
});
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
/**
|
|
225
|
+
* 根据 ID 前缀自动判断 receive_id_type:
|
|
226
|
+
* ou_ → open_id(用户 ID)
|
|
227
|
+
* 其他 → chat_id
|
|
228
|
+
*/
|
|
229
|
+
_resolveReceiveIdType(receiveId) {
|
|
230
|
+
return receiveId?.startsWith('ou_') ? 'open_id' : 'chat_id';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 发送消息到 chatId 或 open_id(自动识别类型) */
|
|
234
|
+
async sendToTarget(receiveId, content, msgType) {
|
|
235
|
+
await this.client.im.message.create({
|
|
236
|
+
params: { receive_id_type: this._resolveReceiveIdType(receiveId) },
|
|
237
|
+
data: { receive_id: receiveId, msg_type: msgType, content },
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 发送文本到 chatId 或 open_id(自动识别类型) */
|
|
242
|
+
async sendTextToTarget(receiveId, text) {
|
|
243
|
+
const { msgType, content } = buildTextOrMarkdownMessage(text);
|
|
244
|
+
return this.sendToTarget(receiveId, content, msgType);
|
|
245
|
+
}
|
|
246
|
+
|
|
224
247
|
/** 回复某条消息(线程化) */
|
|
225
248
|
async replyMessage(messageId, content, msgType) {
|
|
226
249
|
await this.client.im.message.reply({
|
|
@@ -270,10 +293,10 @@ export class LarkClient {
|
|
|
270
293
|
}
|
|
271
294
|
|
|
272
295
|
/** 发送直接 JSON 交互卡片并返回消息 ID(用于 AskUserQuestion 等动态卡片) */
|
|
273
|
-
async sendInteractiveAndGetMsgId(
|
|
296
|
+
async sendInteractiveAndGetMsgId(target, cardContent) {
|
|
274
297
|
const resp = await this.client.im.message.create({
|
|
275
|
-
params: { receive_id_type:
|
|
276
|
-
data: { receive_id:
|
|
298
|
+
params: { receive_id_type: this._resolveReceiveIdType(target) },
|
|
299
|
+
data: { receive_id: target, msg_type: 'interactive', content: cardContent },
|
|
277
300
|
});
|
|
278
301
|
return resp?.data?.message_id || null;
|
|
279
302
|
}
|
|
@@ -347,20 +370,20 @@ export class LarkClient {
|
|
|
347
370
|
* @param {string} chatId
|
|
348
371
|
* @param {Buffer} imageBuffer
|
|
349
372
|
*/
|
|
350
|
-
async sendImageBuffer(
|
|
373
|
+
async sendImageBuffer(target, imageBuffer) {
|
|
351
374
|
const imageKey = await this.uploadImage(imageBuffer);
|
|
352
|
-
await this.
|
|
375
|
+
await this.sendToTarget(target, JSON.stringify({ image_key: imageKey }), 'image');
|
|
353
376
|
}
|
|
354
377
|
|
|
355
378
|
/**
|
|
356
379
|
* 发送文件消息(Buffer → 上传 → 发送)
|
|
357
|
-
* @param {string}
|
|
380
|
+
* @param {string} target chat_id 或 open_id(自动识别类型)
|
|
358
381
|
* @param {Buffer} fileBuffer
|
|
359
382
|
* @param {string} filename
|
|
360
383
|
*/
|
|
361
|
-
async sendFileBuffer(
|
|
384
|
+
async sendFileBuffer(target, fileBuffer, filename) {
|
|
362
385
|
const fileKey = await this.uploadFile(fileBuffer, filename);
|
|
363
|
-
await this.
|
|
386
|
+
await this.sendToTarget(target, JSON.stringify({ file_key: fileKey }), 'file');
|
|
364
387
|
}
|
|
365
388
|
|
|
366
389
|
/**
|