@goodandready/dsh-plugin-notify 0.3.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/index.js ADDED
@@ -0,0 +1,444 @@
1
+ import { spawn } from 'node:child_process';
2
+ import Schema from '@deepseek-ai/schemastery';
3
+ import { credentialRef } from '@deepseek-ai/dsh-credentials';
4
+
5
+ export const name = '@goodandready/dsh-plugin-notify';
6
+ /** Settings namespace shared with the Web settings card. */
7
+ export const NS = '@goodandready/dsh-plugin-notify';
8
+
9
+ // Session firehose + credentials (webhook URL refs) + settings scope for the card + webServer for SSE events.
10
+ export const inject = ['sessions', 'credentials', 'settings', 'webServer'];
11
+
12
+ const webhookRef = (label) => Schema.string()
13
+ .role('credential-ref')
14
+ .description(`${label}: DSH credential name whose value is the full webhook URL (not the URL itself). Empty disables the channel.`);
15
+
16
+ export const Config = Schema.object({
17
+ webhooks: Schema.object({
18
+ feishu: webhookRef('Feishu custom bot'),
19
+ wecom: webhookRef('WeCom group bot'),
20
+ dingtalk: webhookRef('DingTalk group bot'),
21
+ slack: webhookRef('Slack Incoming Webhook'),
22
+ discord: webhookRef('Discord webhook'),
23
+ custom: webhookRef('Custom generic webhook (POST JSON)'),
24
+ }).description('Per-channel credential refs for webhook URLs; leave empty to disable'),
25
+ events: Schema.array(Schema.string())
26
+ .description('Events that trigger notifications: task_done / error / approval_requested'),
27
+ local: Schema.boolean().default(true).description('Also emit a local system notification (macOS osascript)'),
28
+ enableSound: Schema.boolean().default(false).description('Play synthesized audio chime on completion, error, or approval'),
29
+ enableToasts: Schema.boolean().default(false).description('Show in-app on-screen toast notifications across sessions'),
30
+ enableDesktopNotifications: Schema.boolean().default(false).description('Show native desktop/OS push notifications (Windows, macOS, Linux)'),
31
+ notifyBackgroundOnly: Schema.boolean().default(false).description('Notify only if the event occurred in a background/inactive session'),
32
+ timeoutMs: Schema.number().default(5000).description('Per-webhook request timeout (ms)'),
33
+ dnd: Schema.object({
34
+ start: Schema.string().default('').description('Do-not-disturb start (HH:MM, empty disables)'),
35
+ end: Schema.string().default('').description('Do-not-disturb end (HH:MM, cross-midnight ok)'),
36
+ }).description('DND window: events are logged but no local/webhook emission'),
37
+ includeSession: Schema.boolean().default(true).description('Include the session line in notification text'),
38
+ includeDuration: Schema.boolean().default(true).description('Include the duration line in notification text'),
39
+ excludeSessionPrefixes: Schema.array(Schema.string())
40
+ .default([])
41
+ .description('Skip notifications when session id starts with any prefix (e.g. msgw- for messenger-gateway)'),
42
+ });
43
+
44
+ function isExcludedSession(sessionId, prefixes) {
45
+ const sid = String(sessionId);
46
+ for (const p of prefixes ?? []) {
47
+ if (typeof p === 'string' && p.length > 0 && sid.startsWith(p))
48
+ return true;
49
+ }
50
+ return false;
51
+ }
52
+
53
+ const DEFAULT_EVENTS = ['task_done', 'error', 'approval_requested'];
54
+ /** Per-session last `turn/start` epoch ms, for turn-duration reporting. */
55
+ const turnStarts = new Map();
56
+ const warnedLegacyUrls = new Set();
57
+
58
+ /** Resolve a config value to a webhook URL: credential ref (preferred), env fallback, or legacy raw URL. */
59
+ export async function resolveWebhookValue(ctx, refOrUrl) {
60
+ if (!refOrUrl || typeof refOrUrl !== 'string') return '';
61
+ const v = refOrUrl.trim();
62
+ if (!v) return '';
63
+ if (/^https?:\/\//i.test(v)) {
64
+ if (!warnedLegacyUrls.has(v)) {
65
+ warnedLegacyUrls.add(v);
66
+ ctx?.logger?.warn?.('[plugin-notify] raw webhook URL in Config is deprecated; store the URL in Credentials and put only the credential name in settings');
67
+ }
68
+ return v;
69
+ }
70
+ if (ctx?.credentials && typeof ctx.credentials.resolve === 'function') {
71
+ try {
72
+ const hit = await ctx.credentials.resolve(credentialRef(v));
73
+ if (hit?.value) return String(hit.value);
74
+ } catch (error) {
75
+ ctx?.logger?.warn?.(`[plugin-notify] credential resolve skipped for ${v}: ${String(error)}`);
76
+ }
77
+ }
78
+ return process.env[v] || '';
79
+ }
80
+
81
+ export async function resolveWebhooks(ctx, webhooks = {}) {
82
+ const out = {};
83
+ for (const [channel, ref] of Object.entries(webhooks || {})) {
84
+ const url = await resolveWebhookValue(ctx, ref);
85
+ if (url) out[channel] = url;
86
+ }
87
+ return out;
88
+ }
89
+
90
+ const sseClients = new Set();
91
+
92
+ function isLoopback(address) {
93
+ if (!address) return false;
94
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address === 'localhost';
95
+ }
96
+
97
+ /**
98
+ * Fail-closed origin check for internal SSE events stream.
99
+ * Accepts same-origin sec-fetch-site, matching origin and host, loopback remote address, or DSH auth.
100
+ */
101
+ export function isTrustedRequest(request) {
102
+ if (!request || !request.headers) return false;
103
+
104
+ const authHeader = request.headers['authorization'] || request.headers['x-dsh-auth'];
105
+ if (authHeader && authHeader.length > 5) return true;
106
+
107
+ const remoteAddr = request.socket?.remoteAddress || request.connection?.remoteAddress;
108
+ if (remoteAddr && isLoopback(remoteAddr)) return true;
109
+
110
+ const secFetchSite = request.headers['sec-fetch-site'];
111
+ if (secFetchSite === 'same-origin' || secFetchSite === 'same-site') {
112
+ return true;
113
+ }
114
+
115
+ const origin = request.headers['origin'];
116
+ const host = request.headers['host'];
117
+ if (origin && host) {
118
+ try {
119
+ const originHost = new URL(origin).host;
120
+ if (originHost === host) return true;
121
+ } catch { /* invalid origin URL */ }
122
+ }
123
+
124
+ return false;
125
+ }
126
+
127
+ export function broadcastSse(eventData) {
128
+ if (sseClients.size === 0) return 0;
129
+ const raw = `data: ${JSON.stringify(eventData)}\n\n`;
130
+ let sent = 0;
131
+ for (const res of sseClients) {
132
+ try {
133
+ res.write(raw);
134
+ sent++;
135
+ } catch {
136
+ sseClients.delete(res);
137
+ }
138
+ }
139
+ return sent;
140
+ }
141
+
142
+ export function handleSseConnection(req, res) {
143
+ if (req.method !== 'GET') {
144
+ if (typeof res.writeHead === 'function') res.writeHead(405, { 'Content-Type': 'text/plain' });
145
+ if (typeof res.end === 'function') res.end('Method Not Allowed');
146
+ return;
147
+ }
148
+ if (!isTrustedRequest(req)) {
149
+ if (typeof res.writeHead === 'function') res.writeHead(403, { 'Content-Type': 'text/plain' });
150
+ if (typeof res.end === 'function') res.end('Forbidden');
151
+ return;
152
+ }
153
+ if (typeof res.writeHead === 'function') {
154
+ res.writeHead(200, {
155
+ 'Content-Type': 'text/event-stream',
156
+ 'Cache-Control': 'no-cache, no-transform',
157
+ 'Connection': 'keep-alive',
158
+ });
159
+ }
160
+ if (typeof res.write === 'function') {
161
+ res.write(': connected\n\n');
162
+ }
163
+ sseClients.add(res);
164
+
165
+ const onEnd = () => {
166
+ sseClients.delete(res);
167
+ };
168
+ if (typeof req.on === 'function') req.on('close', onEnd);
169
+ if (typeof res.on === 'function') {
170
+ res.on('close', onEnd);
171
+ res.on('error', onEnd);
172
+ }
173
+ }
174
+
175
+ function mountWebServer(targetCtx) {
176
+ if (!targetCtx?.webServer?.register) return;
177
+ const routeDef = {
178
+ kind: 'exact',
179
+ path: '/dsh-plugin-notify/events',
180
+ handler: (req, res) => handleSseConnection(req, res),
181
+ };
182
+ if (typeof targetCtx.effect === 'function') {
183
+ targetCtx.effect(() => targetCtx.webServer.register(routeDef), 'dsh-plugin-notify: sse events');
184
+ } else {
185
+ targetCtx.webServer.register(routeDef);
186
+ }
187
+ }
188
+
189
+ export function apply(ctx, config = {}) {
190
+ let getConfig = () => config ?? {};
191
+
192
+ let webServerMounted = false;
193
+ const tryMountWebServer = (target) => {
194
+ if (webServerMounted) return;
195
+ if (target?.webServer?.register) {
196
+ webServerMounted = true;
197
+ mountWebServer(target);
198
+ }
199
+ };
200
+
201
+ if (typeof ctx.inject === 'function') {
202
+ ctx.inject(['settings'], (sctx) => {
203
+ const scope = sctx.settings.register(NS, Config, { base: config ?? {} });
204
+ getConfig = () => scope.get() ?? config ?? {};
205
+ });
206
+ ctx.inject(['webServer'], (wctx) => tryMountWebServer(wctx));
207
+ }
208
+ if (ctx.webServer) {
209
+ tryMountWebServer(ctx);
210
+ }
211
+
212
+ const dispatch = (n) => {
213
+ const cfg = getConfig();
214
+ const dnd = cfg.dnd;
215
+ if (inDnd(dnd)) {
216
+ (ctx?.logger?.debug ?? ctx?.logger?.info)?.(`[plugin-notify] ${n.kind} · ${n.title} · session ${n.sessionId} · DND (${dnd?.start}-${dnd?.end}), logged only`);
217
+ return;
218
+ }
219
+ const timeoutMs = cfg.timeoutMs ?? 5000;
220
+ const local = cfg.local ?? true;
221
+ const includeSession = cfg.includeSession ?? true;
222
+ const includeDuration = cfg.includeDuration ?? true;
223
+
224
+ // Broadcast to connected web/desktop clients via SSE
225
+ broadcastSse({
226
+ kind: n.kind,
227
+ title: n.title,
228
+ sessionId: n.sessionId,
229
+ summary: n.summary,
230
+ reason: n.reason,
231
+ durationMs: n.durationMs,
232
+ timestamp: Date.now(),
233
+ });
234
+
235
+ // Resolve credential refs then fire-and-forget posts; never block the agent loop.
236
+ Promise.resolve()
237
+ .then(() => resolveWebhooks(ctx, cfg.webhooks ?? {}))
238
+ .then((urls) => send(ctx, n, urls, timeoutMs, local, includeSession, includeDuration))
239
+ .catch((error) => {
240
+ ctx?.logger?.warn?.(`[plugin-notify] webhook resolve/send failed: ${String(error)}`);
241
+ });
242
+ };
243
+
244
+ ctx.on('session/event', (session, event) => {
245
+ const cfg = getConfig();
246
+ const events = new Set(normalizeEvents(cfg.events));
247
+ const excludeSessionPrefixes = cfg.excludeSessionPrefixes ?? [];
248
+
249
+ if (event.type === 'turn/start') {
250
+ turnStarts.set(String(session.id), Date.now());
251
+ return;
252
+ }
253
+ if (event.type === 'turn/end') {
254
+ if (isExcludedSession(session.id, excludeSessionPrefixes))
255
+ return;
256
+ const reason = event.data.reason;
257
+ const kind = reason.kind === 'completed' ? 'task_done' : 'error';
258
+ if (!events.has(kind))
259
+ return;
260
+ const started = turnStarts.get(String(session.id));
261
+ turnStarts.delete(String(session.id));
262
+ dispatch({
263
+ kind,
264
+ title: sessionTitle(session),
265
+ sessionId: String(session.id),
266
+ summary: summarizeTurn(session, event.data.turn),
267
+ reason: reasonLabel(reason),
268
+ durationMs: started === undefined ? undefined : Date.now() - started,
269
+ });
270
+ return;
271
+ }
272
+ if (event.type === 'approval/asked') {
273
+ if (isExcludedSession(session.id, excludeSessionPrefixes))
274
+ return;
275
+ if (!events.has('approval_requested'))
276
+ return;
277
+ const data = event.data;
278
+ dispatch({
279
+ kind: 'approval_requested',
280
+ title: sessionTitle(session),
281
+ sessionId: String(session.id),
282
+ summary: `Waiting for approval: tool ${data.toolName}${data.reason ? ` (${data.reason})` : ''}`,
283
+ });
284
+ }
285
+ });
286
+ }
287
+
288
+ function normalizeEvents(configured) {
289
+ if (!configured || configured.length === 0)
290
+ return [...DEFAULT_EVENTS];
291
+ const known = ['task_done', 'error', 'approval_requested'];
292
+ return known.filter(kind => configured.includes(kind));
293
+ }
294
+
295
+ function send(ctx, n, webhooks, timeoutMs, local, includeSession, includeDuration) {
296
+ const text = renderText(n, includeSession, includeDuration);
297
+ const signal = AbortSignal.timeout(timeoutMs);
298
+ const channels = Object.keys(webhooks);
299
+ (ctx?.logger?.debug ?? ctx?.logger?.info)?.(`[plugin-notify] ${n.kind} · ${n.title} · session ${n.sessionId} · channels ${channels.join(',') || 'none'} · local ${local}`);
300
+ const post = (url, body) => {
301
+ fetch(url, {
302
+ method: 'POST',
303
+ headers: { 'content-type': 'application/json' },
304
+ body: JSON.stringify(body),
305
+ signal,
306
+ }).catch(error => {
307
+ ctx?.logger?.warn?.(`[plugin-notify] webhook POST failed (${String(url).slice(0, 64)}…): ${String(error)}`);
308
+ });
309
+ };
310
+ if (webhooks.feishu)
311
+ post(webhooks.feishu, { msg_type: 'text', content: { text } });
312
+ if (webhooks.wecom)
313
+ post(webhooks.wecom, { msgtype: 'text', text: { content: text } });
314
+ if (webhooks.dingtalk)
315
+ post(webhooks.dingtalk, { msgtype: 'text', text: { content: text } });
316
+ if (webhooks.slack)
317
+ post(webhooks.slack, { text });
318
+ if (webhooks.discord)
319
+ post(webhooks.discord, { content: text });
320
+ if (webhooks.custom) {
321
+ post(webhooks.custom, {
322
+ text,
323
+ kind: n.kind,
324
+ title: n.title,
325
+ sessionId: n.sessionId,
326
+ durationMs: n.durationMs,
327
+ time: new Date().toISOString(),
328
+ });
329
+ }
330
+ if (local)
331
+ notifyLocal(n.kind === 'task_done' ? '✅ Task done' : n.kind === 'error' ? '⚠️ Error' : '⏸️ Approval needed', text);
332
+ }
333
+
334
+ function renderText(n, includeSession, includeDuration) {
335
+ const kindLabel = n.kind === 'task_done' ? 'Task done' : n.kind === 'error' ? 'Error' : 'Approval needed';
336
+ const lines = [`【${kindLabel}】${n.title}`];
337
+ if (n.summary)
338
+ lines.push(`Summary: ${n.summary}`);
339
+ if (n.reason)
340
+ lines.push(`Reason: ${n.reason}`);
341
+ if (includeDuration && n.durationMs !== undefined)
342
+ lines.push(`Duration: ${formatDuration(n.durationMs)}`);
343
+ if (includeSession)
344
+ lines.push(`Session: ${n.sessionId}`);
345
+ return lines.join('\n');
346
+ }
347
+
348
+ function parseHM(v) {
349
+ if (!v)
350
+ return null;
351
+ const m = /^(\d{1,2}):(\d{2})$/.exec(v.trim());
352
+ if (!m)
353
+ return null;
354
+ const h = Number(m[1]);
355
+ const mi = Number(m[2]);
356
+ if (h > 23 || mi > 59)
357
+ return null;
358
+ return h * 60 + mi;
359
+ }
360
+
361
+ function inDnd(dnd, now = new Date()) {
362
+ const s = parseHM(dnd?.start);
363
+ const e = parseHM(dnd?.end);
364
+ if (s === null || e === null || s === e)
365
+ return false;
366
+ const cur = now.getHours() * 60 + now.getMinutes();
367
+ return s < e ? cur >= s && cur < e : cur >= s || cur < e;
368
+ }
369
+
370
+ function formatDuration(ms) {
371
+ const seconds = Math.round(ms / 1000);
372
+ if (seconds < 60)
373
+ return `${seconds}s`;
374
+ const minutes = Math.floor(seconds / 60);
375
+ const rest = seconds % 60;
376
+ return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
377
+ }
378
+
379
+ function reasonLabel(reason) {
380
+ switch (reason.kind) {
381
+ case 'completed': return 'completed';
382
+ case 'error': return 'error';
383
+ case 'aborted': return 'aborted';
384
+ case 'blocked': return 'blocked';
385
+ case 'max-tokens': return 'max-tokens';
386
+ case 'interrupted': return 'interrupted';
387
+ default: return reason.kind;
388
+ }
389
+ }
390
+
391
+ function textOf(content) {
392
+ let out = '';
393
+ for (const block of content) {
394
+ if (typeof block === 'object' && block !== null && block.type === 'text') {
395
+ const text = block.text;
396
+ if (typeof text === 'string')
397
+ out += text;
398
+ }
399
+ }
400
+ return out;
401
+ }
402
+
403
+ function sessionTitle(session) {
404
+ for (const event of session.events) {
405
+ if (event.type === 'user/message') {
406
+ const text = textOf(event.data.content).replace(/\s+/g, ' ').trim();
407
+ if (text)
408
+ return text.length > 60 ? `${text.slice(0, 60)}…` : text;
409
+ }
410
+ }
411
+ return String(session.id);
412
+ }
413
+
414
+ function summarizeTurn(session, turn) {
415
+ let toolCalls = 0;
416
+ let lastText = '';
417
+ for (const event of session.events) {
418
+ if (event.type === 'tool/call' && event.data.turn === turn)
419
+ toolCalls += 1;
420
+ if (event.type === 'assistant/message' && event.data.turn === turn) {
421
+ const text = textOf(event.data.message.content);
422
+ if (text)
423
+ lastText = text;
424
+ }
425
+ }
426
+ const parts = [];
427
+ if (lastText) {
428
+ const trimmed = lastText.replace(/\s+/g, ' ').trim();
429
+ parts.push(trimmed.length > 120 ? `${trimmed.slice(0, 120)}…` : trimmed);
430
+ }
431
+ if (toolCalls > 0)
432
+ parts.push(`called ${toolCalls} tools`);
433
+ return parts.join('; ') || '(no text output)';
434
+ }
435
+
436
+ function notifyLocal(title, text) {
437
+ if (process.platform !== 'darwin')
438
+ return;
439
+ const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
440
+ const script = `display notification "${esc(text)}" with title "${esc(title)}"`;
441
+ spawn('osascript', ['-e', script], { stdio: 'ignore' })
442
+ .on('error', () => { })
443
+ .unref();
444
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@goodandready/dsh-plugin-notify",
3
+ "version": "0.3.0",
4
+ "description": "DSH plugin: audio chimes, cross-session toasts, desktop push, and IM webhooks for turn completion, errors, and approvals.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./lib/index.js",
8
+ "exports": {
9
+ ".": "./lib/index.js",
10
+ "./client": "./lib/client.js"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "cordis.patch.yml",
15
+ "LICENSE",
16
+ "CHANGELOG.md",
17
+ "README.md",
18
+ "README.zh.md",
19
+ "README.ru.md"
20
+ ],
21
+ "dsh": {
22
+ "bundle": {
23
+ "patch": "./cordis.patch.yml"
24
+ },
25
+ "client": {
26
+ "platform": "web",
27
+ "inject": [
28
+ "@deepseek-ai/dsh-client-locale",
29
+ "@deepseek-ai/dsh-client-ui-settings"
30
+ ]
31
+ }
32
+ },
33
+ "scripts": {
34
+ "pretest": "node --check lib/index.js && node --check lib/client.js",
35
+ "test": "node --test test/*.test.mjs"
36
+ },
37
+ "peerDependencies": {
38
+ "@deepseek-ai/cordis": "^4.0.1",
39
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
40
+ "@deepseek-ai/dsh-credentials": "^0.1.0-rc.8",
41
+ "@deepseek-ai/schemastery": "^3.18.1"
42
+ },
43
+ "devDependencies": {
44
+ "@deepseek-ai/cordis": "^4.0.1",
45
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
46
+ "@deepseek-ai/dsh-credentials": "^0.1.0-rc.8",
47
+ "@deepseek-ai/schemastery": "^3.18.1"
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/GooDAnDReaDY/dsh-plugin-notify.git"
52
+ },
53
+ "homepage": "https://github.com/GooDAnDReaDY/dsh-plugin-notify#readme",
54
+ "bugs": {
55
+ "url": "https://github.com/GooDAnDReaDY/dsh-plugin-notify/issues"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ }
60
+ }