dsh-hooks 0.4.0 → 0.6.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/README.md +33 -9
- package/README.zh.md +33 -9
- package/bin/dsh-hooks.mjs +37 -197
- package/examples/notify-feishu.d.mts +31 -0
- package/lib/client.js +1074 -52
- package/lib/feishu-session.d.ts +47 -0
- package/lib/feishu-session.js +94 -0
- package/lib/feishu.d.ts +125 -0
- package/lib/feishu.js +303 -0
- package/lib/history.d.ts +5 -0
- package/lib/history.js +102 -3
- package/lib/index.js +13 -4
- package/lib/notify.d.ts +2 -2
- package/lib/notify.js +3 -2
- package/lib/patch-config.d.ts +47 -0
- package/lib/patch-config.js +141 -0
- package/lib/runner.d.ts +8 -0
- package/lib/runner.js +4 -1
- package/lib/server.d.ts +44 -6
- package/lib/server.js +226 -3
- package/package.json +2 -2
package/lib/server.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
|
-
import { describeHook, evaluateHooks, mockContext } from './dry-run.js';
|
|
2
|
+
import { describeHook, evaluateHooks, mockContext, patchFilePath } from './dry-run.js';
|
|
3
3
|
import { createHookRunner } from './runner.js';
|
|
4
|
-
import { fireNotify } from './notify.js';
|
|
4
|
+
import { fireNotify, summarizeContext } from './notify.js';
|
|
5
|
+
import { FEISHU_SETUP_BUSY } from './feishu-session.js';
|
|
6
|
+
import { deleteFeishuConfig, readFeishuSummary, runFeishuTest, updateFeishuResultMaxChars } from './feishu.js';
|
|
7
|
+
import { removeScriptHooks, writeHooksConfig } from './patch-config.js';
|
|
5
8
|
/** Plugin version, read from package.json (this package ships its own). */
|
|
6
9
|
export function pluginVersion() {
|
|
7
10
|
const require = createRequire(import.meta.url);
|
|
@@ -44,10 +47,35 @@ async function readJsonBody(req) {
|
|
|
44
47
|
return null;
|
|
45
48
|
}
|
|
46
49
|
}
|
|
50
|
+
/** Sanitized per-hook description for the settings panel (regex sources, no RegExp objects). */
|
|
51
|
+
export function describeHooks(hooks) {
|
|
52
|
+
return hooks.map((hook, i) => ({
|
|
53
|
+
index: i + 1,
|
|
54
|
+
on: hook.on,
|
|
55
|
+
when: hook.when,
|
|
56
|
+
match: hook.match === undefined
|
|
57
|
+
? undefined
|
|
58
|
+
: Object.fromEntries(Object.entries(hook.match).map(([field, re]) => [field, re.source])),
|
|
59
|
+
run: hook.run,
|
|
60
|
+
notify: hook.notify === undefined || hook.notify === null
|
|
61
|
+
? undefined
|
|
62
|
+
: { channel: hook.notify.channel, url: hook.notify.url, slack: hook.notify.slack },
|
|
63
|
+
input: hook.input,
|
|
64
|
+
timeoutMs: hook.timeoutMs,
|
|
65
|
+
retries: hook.retries,
|
|
66
|
+
retryDelayMs: hook.retryDelayMs,
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
const FAILED_OUTCOMES = new Set(['exit-nonzero', 'timeout', 'spawn-failed', 'send-failed']);
|
|
47
70
|
/** Create the /dsh-hooks route handler (exported for tests). */
|
|
48
71
|
export function createHookHandler(options) {
|
|
49
72
|
const { hooks, history } = options;
|
|
50
73
|
const version = options.version ?? pluginVersion();
|
|
74
|
+
const feishu = options.feishu;
|
|
75
|
+
const runFeishuTestCard = feishu?.runTest ?? runFeishuTest;
|
|
76
|
+
const feishuConfigPath = feishu?.configPath;
|
|
77
|
+
const runnerStats = options.runner?.stats ?? (() => ({ inFlight: 0, pendingRetries: 0 }));
|
|
78
|
+
const resolvePatch = options.resolvePatchFile ?? patchFilePath;
|
|
51
79
|
return async (req, res) => {
|
|
52
80
|
if (!isLoopbackRequest(req)) {
|
|
53
81
|
json(res, FAIL('forbidden', 'loopback-only'), 403);
|
|
@@ -56,13 +84,26 @@ export function createHookHandler(options) {
|
|
|
56
84
|
const url = new URL(req.url ?? '/', 'http://x');
|
|
57
85
|
const pathname = url.pathname;
|
|
58
86
|
if (req.method === 'GET' && pathname === '/dsh-hooks/status') {
|
|
59
|
-
|
|
87
|
+
// Pull in disk records (pre-restart and other-process appends) so the
|
|
88
|
+
// badge reflects the durable log, not just this process's memory.
|
|
89
|
+
history.sync();
|
|
90
|
+
const records = history.recent();
|
|
91
|
+
const recentFailures = records.filter((record) => FAILED_OUTCOMES.has(record.outcome)).length;
|
|
92
|
+
json(res, OK({
|
|
93
|
+
name: 'dsh-hooks',
|
|
94
|
+
version,
|
|
95
|
+
hookCount: hooks.length,
|
|
96
|
+
historyCount: records.length,
|
|
97
|
+
hooks: describeHooks(hooks),
|
|
98
|
+
stats: { ...runnerStats(), recentFailures },
|
|
99
|
+
}));
|
|
60
100
|
return;
|
|
61
101
|
}
|
|
62
102
|
if (req.method === 'GET' && pathname === '/dsh-hooks/history') {
|
|
63
103
|
const raw = url.searchParams.get('n');
|
|
64
104
|
const parsed = raw === null ? 50 : Number(raw);
|
|
65
105
|
const n = Number.isFinite(parsed) && parsed > 0 ? Math.min(500, Math.floor(parsed)) : 50;
|
|
106
|
+
history.sync();
|
|
66
107
|
const records = history.recent();
|
|
67
108
|
json(res, OK(records.slice(Math.max(0, records.length - n))));
|
|
68
109
|
return;
|
|
@@ -119,6 +160,188 @@ export function createHookHandler(options) {
|
|
|
119
160
|
}));
|
|
120
161
|
return;
|
|
121
162
|
}
|
|
163
|
+
if (feishu !== undefined && req.method === 'GET' && pathname === '/dsh-hooks/feishu/status') {
|
|
164
|
+
const summary = readFeishuSummary(feishuConfigPath);
|
|
165
|
+
json(res, OK({ ...summary, setup: feishu.manager.status() }));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (feishu !== undefined && req.method === 'POST' && pathname === '/dsh-hooks/feishu/setup') {
|
|
169
|
+
const contentType = req.headers['content-type'] ?? '';
|
|
170
|
+
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
171
|
+
json(res, FAIL('bad-request', 'POST 需要 application/json'), 415);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const payload = await readJsonBody(req);
|
|
175
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
176
|
+
json(res, FAIL('bad-request', 'malformed JSON body'), 400);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const body = payload;
|
|
180
|
+
const profile = typeof body.profile === 'string' && body.profile.trim() !== '' ? body.profile.trim() : 'web';
|
|
181
|
+
const resultMaxChars = typeof body.resultMaxChars === 'number' && Number.isFinite(body.resultMaxChars)
|
|
182
|
+
? body.resultMaxChars
|
|
183
|
+
: undefined;
|
|
184
|
+
try {
|
|
185
|
+
const setup = resultMaxChars === undefined
|
|
186
|
+
? await feishu.manager.start(profile)
|
|
187
|
+
: await feishu.manager.start(profile, { resultMaxChars });
|
|
188
|
+
json(res, OK({ setup }));
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
192
|
+
json(res, FAIL('pending', message), message === FEISHU_SETUP_BUSY ? 409 : 500);
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (feishu !== undefined && req.method === 'POST' && pathname === '/dsh-hooks/feishu/config') {
|
|
197
|
+
const contentType = req.headers['content-type'] ?? '';
|
|
198
|
+
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
199
|
+
json(res, FAIL('bad-request', 'POST 需要 application/json'), 415);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const payload = await readJsonBody(req);
|
|
203
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
204
|
+
json(res, FAIL('bad-request', 'malformed JSON body'), 400);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const value = payload.resultMaxChars;
|
|
208
|
+
if (typeof value !== 'number') {
|
|
209
|
+
json(res, FAIL('bad-request', '缺少数字字段 resultMaxChars'), 400);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const resultMaxChars = updateFeishuResultMaxChars(feishuConfigPath, value);
|
|
214
|
+
json(res, OK({ resultMaxChars }));
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
218
|
+
json(res, FAIL('bad-request', message), 400);
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (feishu !== undefined && req.method === 'POST' && pathname === '/dsh-hooks/feishu/cancel') {
|
|
223
|
+
const contentType = req.headers['content-type'] ?? '';
|
|
224
|
+
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
225
|
+
json(res, FAIL('bad-request', 'POST 需要 application/json'), 415);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
json(res, OK({ cancelled: feishu.manager.cancel() }));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (feishu !== undefined && req.method === 'POST' && pathname === '/dsh-hooks/feishu/test') {
|
|
232
|
+
const contentType = req.headers['content-type'] ?? '';
|
|
233
|
+
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
234
|
+
json(res, FAIL('bad-request', 'POST 需要 application/json'), 415);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
const message = await runFeishuTestCard();
|
|
239
|
+
json(res, OK({ message }));
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
243
|
+
json(res, FAIL('send-failed', message), 500);
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (req.method === 'POST' && pathname === '/dsh-hooks/notify/test') {
|
|
248
|
+
const contentType = req.headers['content-type'] ?? '';
|
|
249
|
+
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
250
|
+
json(res, FAIL('bad-request', 'POST 需要 application/json'), 415);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const payload = await readJsonBody(req);
|
|
254
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
255
|
+
json(res, FAIL('bad-request', 'malformed JSON body'), 400);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const body = payload;
|
|
259
|
+
const channel = body.channel;
|
|
260
|
+
if (channel !== 'webhook' && channel !== 'desktop') {
|
|
261
|
+
json(res, FAIL('bad-request', '缺少字段 channel(webhook 或 desktop)'), 400);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const ctx = {
|
|
265
|
+
event: 'user/message',
|
|
266
|
+
sessionId: 'notify-test',
|
|
267
|
+
sessionName: '通知测试',
|
|
268
|
+
source: 'plugin',
|
|
269
|
+
content: '这是一条 dsh-hooks 测试通知:如果收到这条消息,说明该渠道配置正常。',
|
|
270
|
+
timestamp: new Date().toISOString(),
|
|
271
|
+
};
|
|
272
|
+
const result = await fireNotify({
|
|
273
|
+
channel,
|
|
274
|
+
url: typeof body.url === 'string' && body.url !== '' ? body.url : undefined,
|
|
275
|
+
slack: body.slack === true,
|
|
276
|
+
}, ctx, (record) => history.record(record));
|
|
277
|
+
if (!result.ok) {
|
|
278
|
+
json(res, FAIL('send-failed', result.error ?? '发送失败'), 500);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
json(res, OK({ message: '✅ 测试通知已发送', preview: summarizeContext(ctx) }));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (req.method === 'POST' && pathname === '/dsh-hooks/hooks/save') {
|
|
285
|
+
const contentType = req.headers['content-type'] ?? '';
|
|
286
|
+
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
287
|
+
json(res, FAIL('bad-request', 'POST 需要 application/json'), 415);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const payload = await readJsonBody(req);
|
|
291
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
292
|
+
json(res, FAIL('bad-request', 'malformed JSON body'), 400);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const body = payload;
|
|
296
|
+
const profile = typeof body.profile === 'string' && body.profile.trim() !== '' ? body.profile.trim() : 'web';
|
|
297
|
+
const wireHooks = body.hooks;
|
|
298
|
+
if (!Array.isArray(wireHooks)) {
|
|
299
|
+
json(res, FAIL('bad-request', '缺少数组字段 hooks'), 400);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const patchFile = resolvePatch(profile);
|
|
303
|
+
try {
|
|
304
|
+
const result = writeHooksConfig(patchFile, wireHooks);
|
|
305
|
+
json(res, OK({
|
|
306
|
+
profile,
|
|
307
|
+
hookCount: result.hookCount,
|
|
308
|
+
patchFile: result.patchFile,
|
|
309
|
+
backupPath: result.backupPath,
|
|
310
|
+
message: `✅ 已保存 ${result.hookCount} 个 hook 到 ${profile} profile(已备份原文件)。若未立即生效请重启 dsh web。`,
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
315
|
+
json(res, FAIL('save-failed', message), 400);
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (feishu !== undefined && req.method === 'POST' && pathname === '/dsh-hooks/feishu/disconnect') {
|
|
320
|
+
const contentType = req.headers['content-type'] ?? '';
|
|
321
|
+
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
322
|
+
json(res, FAIL('bad-request', 'POST 需要 application/json'), 415);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const payload = await readJsonBody(req);
|
|
326
|
+
const body = (typeof payload === 'object' && payload !== null ? payload : {});
|
|
327
|
+
const profile = typeof body.profile === 'string' && body.profile.trim() !== '' ? body.profile.trim() : 'web';
|
|
328
|
+
const removeHooks = body.removeHooks === true;
|
|
329
|
+
// Abort any in-flight scan session first.
|
|
330
|
+
feishu.manager.cancel();
|
|
331
|
+
const existed = deleteFeishuConfig(feishuConfigPath);
|
|
332
|
+
if (removeHooks) {
|
|
333
|
+
try {
|
|
334
|
+
removeScriptHooks(resolvePatch(profile), 'notify-feishu.mjs');
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
338
|
+
json(res, FAIL('save-failed', message), 400);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
json(res, OK({ disconnected: true, existed, removedHooks: removeHooks, message: '✅ 已断开飞书连接' }));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
122
345
|
json(res, FAIL('not-found', `unknown route ${pathname}`), 404);
|
|
123
346
|
};
|
|
124
347
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"packageManager": "pnpm@11.21.0",
|
|
5
|
-
"description": "Config-driven lifecycle hooks plugin for DeepSeek Harness: declare event -> command hooks in cordis.patch.yml, no plugin code required. Includes a Hooks section in the Web GUI settings (history timeline + manual tester).",
|
|
5
|
+
"description": "Config-driven lifecycle hooks plugin for DeepSeek Harness: declare event -> command hooks in cordis.patch.yml, no plugin code required. Includes a Hooks section in the Web GUI settings (history timeline + manual tester + notify tests + hook editor + Feishu connect).",
|
|
6
6
|
"author": "PeterBon",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"repository": {
|