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/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
- json(res, OK({ name: 'dsh-hooks', version, hookCount: hooks.length, historyCount: history.recent().length }));
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.4.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": {