@goodandready/dsh-plugin-notify 0.3.8 → 0.4.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/CHANGELOG.md +21 -5
- package/README.md +58 -4
- package/README.ru.md +48 -4
- package/README.zh.md +48 -4
- package/lib/client.js +240 -7
- package/lib/index.js +488 -35
- package/package.json +9 -3
package/lib/index.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import fsp from 'node:fs/promises';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
2
7
|
import Schema from '@deepseek-ai/schemastery';
|
|
3
8
|
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
4
9
|
|
|
@@ -11,17 +16,13 @@ export const inject = ['sessions', 'credentials', 'settings', 'webServer', 'conn
|
|
|
11
16
|
|
|
12
17
|
const webhookRef = (label) => Schema.string()
|
|
13
18
|
.role('credential-ref')
|
|
14
|
-
.
|
|
15
|
-
|
|
16
|
-
// Unwrap Volatile boxes before any caller reads a value. A volatile field holds a box
|
|
17
|
-
// rather than its value, and the Loader mutates those boxes in place.
|
|
18
|
-
export function plainConfig(value) {
|
|
19
|
-
if (value === null || typeof value !== 'object') return value
|
|
20
|
-
if (Array.isArray(value)) return value.map(plainConfig)
|
|
21
|
-
if (typeof value.get === 'function') return plainConfig(value.get())
|
|
22
|
-
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]))
|
|
23
|
-
}
|
|
19
|
+
.volatile()
|
|
20
|
+
.description(`${label}: DSH credential name whose value is the full webhook URL (not the URL itself). Empty disables the channel.`)
|
|
24
21
|
|
|
22
|
+
// Everything the settings card writes is marked volatile so dsh 0.2 accepts
|
|
23
|
+
// live edits through the settings controller (0.1 ignores the flag). Only
|
|
24
|
+
// leaves may be volatile: cordis 0.2 rejects volatile fields under a
|
|
25
|
+
// volatile enclosing object.
|
|
25
26
|
export const Config = Schema.object({
|
|
26
27
|
webhooks: Schema.object({
|
|
27
28
|
feishu: webhookRef('Feishu custom bot'),
|
|
@@ -31,23 +32,35 @@ export const Config = Schema.object({
|
|
|
31
32
|
discord: webhookRef('Discord webhook'),
|
|
32
33
|
custom: webhookRef('Custom generic webhook (POST JSON)'),
|
|
33
34
|
}).description('Per-channel credential refs for webhook URLs; leave empty to disable'),
|
|
34
|
-
events: Schema.array(Schema.string())
|
|
35
|
+
events: Schema.array(Schema.string())
|
|
36
|
+
.volatile()
|
|
35
37
|
.description('Events that trigger notifications: task_done / error / approval_requested'),
|
|
36
|
-
local: Schema.boolean().
|
|
37
|
-
enableSound: Schema.boolean().
|
|
38
|
-
enableToasts: Schema.boolean().
|
|
39
|
-
enableDesktopNotifications: Schema.boolean().
|
|
40
|
-
notifyBackgroundOnly: Schema.boolean().
|
|
41
|
-
timeoutMs: Schema.number().
|
|
38
|
+
local: Schema.boolean().default(true).volatile().description('Also emit a local system notification (macOS osascript)'),
|
|
39
|
+
enableSound: Schema.boolean().default(false).volatile().description('Play synthesized audio chime on completion, error, or approval'),
|
|
40
|
+
enableToasts: Schema.boolean().default(false).volatile().description('Show in-app on-screen toast notifications across sessions'),
|
|
41
|
+
enableDesktopNotifications: Schema.boolean().default(false).volatile().description('Show native desktop/OS push notifications (Windows, macOS, Linux)'),
|
|
42
|
+
notifyBackgroundOnly: Schema.boolean().default(false).volatile().description('Notify only if the event occurred in a background/inactive session'),
|
|
43
|
+
timeoutMs: Schema.number().default(5000).volatile().description('Per-webhook request timeout (ms)'),
|
|
42
44
|
dnd: Schema.object({
|
|
43
|
-
start: Schema.string().default('').description('Do-not-disturb start (HH:MM, empty disables)'),
|
|
44
|
-
end: Schema.string().default('').description('Do-not-disturb end (HH:MM, cross-midnight ok)'),
|
|
45
|
+
start: Schema.string().default('').volatile().description('Do-not-disturb start (HH:MM, empty disables)'),
|
|
46
|
+
end: Schema.string().default('').volatile().description('Do-not-disturb end (HH:MM, cross-midnight ok)'),
|
|
45
47
|
}).description('DND window: events are logged but no local/webhook emission'),
|
|
46
|
-
includeSession: Schema.boolean().
|
|
47
|
-
includeDuration: Schema.boolean().
|
|
48
|
-
excludeSessionPrefixes: Schema.array(Schema.string())
|
|
48
|
+
includeSession: Schema.boolean().default(true).volatile().description('Include the session line in notification text'),
|
|
49
|
+
includeDuration: Schema.boolean().default(true).volatile().description('Include the duration line in notification text'),
|
|
50
|
+
excludeSessionPrefixes: Schema.array(Schema.string())
|
|
49
51
|
.default([])
|
|
52
|
+
.volatile()
|
|
50
53
|
.description('Skip notifications when session id starts with any prefix (e.g. msgw- for messenger-gateway)'),
|
|
54
|
+
webPush: Schema.object({
|
|
55
|
+
enabled: Schema.boolean().default(false).volatile()
|
|
56
|
+
.description('Web Push (PWA) channel: send service-worker push notifications that reach devices with every dsh tab closed'),
|
|
57
|
+
onlyWhenAway: Schema.boolean().default(true).volatile()
|
|
58
|
+
.description('Skip web push while a browser client is connected to the SSE stream'),
|
|
59
|
+
subject: Schema.string().default('mailto:notify@localhost').volatile()
|
|
60
|
+
.description('VAPID subject (mailto: or https: contact URL) advertised to push services'),
|
|
61
|
+
stateFile: Schema.string().default('').volatile()
|
|
62
|
+
.description('State file for VAPID keys and push subscriptions (default: $DSH_HOME/plugin-notify-state.json)'),
|
|
63
|
+
}).description('Web Push (PWA) channel configuration'),
|
|
51
64
|
});
|
|
52
65
|
|
|
53
66
|
function isExcludedSession(sessionId, prefixes) {
|
|
@@ -59,6 +72,17 @@ function isExcludedSession(sessionId, prefixes) {
|
|
|
59
72
|
return false;
|
|
60
73
|
}
|
|
61
74
|
|
|
75
|
+
// Unwrap Volatile boxes before any caller reads a value. A volatile field holds a box
|
|
76
|
+
// rather than its value, and the Loader mutates those boxes in place.
|
|
77
|
+
export function plainConfig(value) {
|
|
78
|
+
if (value === null || typeof value !== 'object') return value
|
|
79
|
+
if (Array.isArray(value)) return value.map(plainConfig)
|
|
80
|
+
if (typeof value.get === 'function') return plainConfig(value.get())
|
|
81
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const unwrapConfig = plainConfig
|
|
85
|
+
|
|
62
86
|
const DEFAULT_EVENTS = ['task_done', 'error', 'approval_requested'];
|
|
63
87
|
/** Per-session last `turn/start` epoch ms, for turn-duration reporting. */
|
|
64
88
|
export const turnStarts = new Map();
|
|
@@ -252,13 +276,416 @@ export function closeAllSseClients() {
|
|
|
252
276
|
sseClients.clear();
|
|
253
277
|
}
|
|
254
278
|
|
|
255
|
-
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Web Push (PWA) channel: VAPID keys + subscription state, push routes, sender.
|
|
281
|
+
// Reaches devices with every dsh tab closed — the SSE/local channels cannot.
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
/** Service worker served at GET /dsh-plugin-notify/sw.js (no secrets inside). */
|
|
285
|
+
export const SW_SOURCE = [
|
|
286
|
+
'self.addEventListener("push", (event) => {',
|
|
287
|
+
' let data = {};',
|
|
288
|
+
' try { data = event.data ? event.data.json() : {}; } catch (_) { data = {}; }',
|
|
289
|
+
' const title = data.title || "DeepSeek Harness";',
|
|
290
|
+
' event.waitUntil(self.registration.showNotification(title, {',
|
|
291
|
+
' body: data.body || "",',
|
|
292
|
+
' tag: data.tag || "dsh-notify",',
|
|
293
|
+
' data: { sessionId: data.sessionId || "" },',
|
|
294
|
+
' }));',
|
|
295
|
+
'});',
|
|
296
|
+
'self.addEventListener("notificationclick", (event) => {',
|
|
297
|
+
' event.notification.close();',
|
|
298
|
+
' const sessionId = (event.notification.data && event.notification.data.sessionId) || "";',
|
|
299
|
+
' const target = sessionId ? "./?dshNotifySession=" + encodeURIComponent(sessionId) : "./";',
|
|
300
|
+
' event.waitUntil((async () => {',
|
|
301
|
+
' const clientList = await self.clients.matchAll({ type: "window", includeUncontrolled: true });',
|
|
302
|
+
' for (const client of clientList) {',
|
|
303
|
+
' let pathname = "";',
|
|
304
|
+
' try { pathname = new URL(client.url).pathname; } catch (_) { continue; }',
|
|
305
|
+
' if (pathname.endsWith("/") || pathname.endsWith("/index.html")) {',
|
|
306
|
+
' await client.focus();',
|
|
307
|
+
' if (sessionId) client.postMessage({ type: "dsh-notify-jump", sessionId });',
|
|
308
|
+
' return;',
|
|
309
|
+
' }',
|
|
310
|
+
' }',
|
|
311
|
+
' await self.clients.openWindow(target);',
|
|
312
|
+
' })());',
|
|
313
|
+
'});',
|
|
314
|
+
].join('\n');
|
|
315
|
+
|
|
316
|
+
/** Env overrides so users can keep VAPID keys out of the state file entirely. */
|
|
317
|
+
export const VAPID_PUBLIC_ENV = 'DSH_NOTIFY_VAPID_PUBLIC_KEY';
|
|
318
|
+
export const VAPID_PRIVATE_ENV = 'DSH_NOTIFY_VAPID_PRIVATE_KEY';
|
|
319
|
+
|
|
320
|
+
/** P-256 VAPID keypair as base64url — raw 65-byte point / raw 32-byte scalar,
|
|
321
|
+
* the exact shapes web-push.generateVAPIDKeys() produces (applicationServerKey needs the raw point).
|
|
322
|
+
* ECDH, not DER slicing: the pkcs8 DER ends with the public key, so slice(-32) would grab point bytes. */
|
|
323
|
+
export function generateVapidKeys() {
|
|
324
|
+
const ecdh = crypto.createECDH('prime256v1');
|
|
325
|
+
ecdh.generateKeys();
|
|
326
|
+
return {
|
|
327
|
+
publicKey: ecdh.getPublicKey('base64url'),
|
|
328
|
+
privateKey: ecdh.getPrivateKey('base64url'),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function resolveStatePath(webPushConfig = {}) {
|
|
333
|
+
if (webPushConfig && typeof webPushConfig.stateFile === 'string' && webPushConfig.stateFile.trim())
|
|
334
|
+
return webPushConfig.stateFile.trim();
|
|
335
|
+
const home = (process.env.DSH_HOME && process.env.DSH_HOME.trim())
|
|
336
|
+
|| path.join(os.homedir(), '.dsh');
|
|
337
|
+
return path.join(home, 'plugin-notify-state.json');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export async function loadState(statePath) {
|
|
341
|
+
try {
|
|
342
|
+
const raw = await fsp.readFile(statePath, 'utf8');
|
|
343
|
+
const parsed = JSON.parse(raw);
|
|
344
|
+
return {
|
|
345
|
+
vapid: (parsed && typeof parsed.vapid === 'object' && parsed.vapid) || null,
|
|
346
|
+
subscriptions: Array.isArray(parsed?.subscriptions) ? parsed.subscriptions : [],
|
|
347
|
+
};
|
|
348
|
+
} catch {
|
|
349
|
+
return { vapid: null, subscriptions: [] };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Atomic-ish persist: temp file + rename, serialized per path to avoid RMW races. */
|
|
354
|
+
const writeChains = new Map();
|
|
355
|
+
export function saveState(statePath, state) {
|
|
356
|
+
const prev = writeChains.get(statePath) || Promise.resolve();
|
|
357
|
+
const next = prev.catch(() => {}).then(async () => {
|
|
358
|
+
await fsp.mkdir(path.dirname(statePath), { recursive: true });
|
|
359
|
+
const tmp = `${statePath}.${process.pid}.${Date.now()}.tmp`;
|
|
360
|
+
await fsp.writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
|
|
361
|
+
await fsp.rename(tmp, statePath);
|
|
362
|
+
});
|
|
363
|
+
writeChains.set(statePath, next);
|
|
364
|
+
return next;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Env keys win; otherwise state file; otherwise generate once and persist. */
|
|
368
|
+
export async function getVapidKeys(ctx, webPushConfig = {}) {
|
|
369
|
+
const envPub = process.env[VAPID_PUBLIC_ENV];
|
|
370
|
+
const envPriv = process.env[VAPID_PRIVATE_ENV];
|
|
371
|
+
if (envPub && envPriv) {
|
|
372
|
+
return { publicKey: envPub, privateKey: envPriv };
|
|
373
|
+
}
|
|
374
|
+
const statePath = resolveStatePath(webPushConfig);
|
|
375
|
+
const state = await loadState(statePath);
|
|
376
|
+
if (state.vapid && state.vapid.publicKey && state.vapid.privateKey)
|
|
377
|
+
return state.vapid;
|
|
378
|
+
const keys = generateVapidKeys();
|
|
379
|
+
state.vapid = keys;
|
|
380
|
+
await saveState(statePath, state);
|
|
381
|
+
ctx?.logger?.info?.(`[plugin-notify] generated VAPID keys → ${statePath}`);
|
|
382
|
+
return keys;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function isValidSubscription(sub) {
|
|
386
|
+
return Boolean(
|
|
387
|
+
sub
|
|
388
|
+
&& typeof sub.endpoint === 'string'
|
|
389
|
+
&& /^https:\/\//i.test(sub.endpoint)
|
|
390
|
+
&& sub.keys
|
|
391
|
+
&& typeof sub.keys.p256dh === 'string' && sub.keys.p256dh.length > 0
|
|
392
|
+
&& typeof sub.keys.auth === 'string' && sub.keys.auth.length > 0,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export async function addSubscription(statePath, sub) {
|
|
397
|
+
if (!isValidSubscription(sub))
|
|
398
|
+
throw new Error('invalid push subscription (need https endpoint and keys.p256dh/auth)');
|
|
399
|
+
const state = await loadState(statePath);
|
|
400
|
+
const clean = { endpoint: sub.endpoint, keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth }, addedAt: Date.now() };
|
|
401
|
+
const existing = state.subscriptions.findIndex((s) => s.endpoint === clean.endpoint);
|
|
402
|
+
if (existing >= 0) state.subscriptions[existing] = clean;
|
|
403
|
+
else state.subscriptions.push(clean);
|
|
404
|
+
await saveState(statePath, state);
|
|
405
|
+
return state.subscriptions.length;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export async function removeSubscription(statePath, endpoint) {
|
|
409
|
+
const state = await loadState(statePath);
|
|
410
|
+
const before = state.subscriptions.length;
|
|
411
|
+
state.subscriptions = state.subscriptions.filter((s) => s.endpoint !== endpoint);
|
|
412
|
+
if (state.subscriptions.length !== before)
|
|
413
|
+
await saveState(statePath, state);
|
|
414
|
+
return before - state.subscriptions.length;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export async function listSubscriptions(statePath) {
|
|
418
|
+
const state = await loadState(statePath);
|
|
419
|
+
return state.subscriptions;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
let webPushLibrary = null;
|
|
423
|
+
/** Test seam: inject a stub with async sendNotification(sub, payload, options). */
|
|
424
|
+
export function setWebPushLibrary(lib) {
|
|
425
|
+
webPushLibrary = lib;
|
|
426
|
+
}
|
|
427
|
+
async function getWebPush() {
|
|
428
|
+
if (!webPushLibrary) {
|
|
429
|
+
const mod = await import('web-push');
|
|
430
|
+
webPushLibrary = mod.default ?? mod;
|
|
431
|
+
}
|
|
432
|
+
return webPushLibrary;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Compact payload for the 4 KB push budget: pointer + label, never secret material. */
|
|
436
|
+
export function webPushPayload(n, config = {}) {
|
|
437
|
+
const badge = n.kind === 'task_done' ? '✅' : n.kind === 'error' ? '⚠️' : '⏸️';
|
|
438
|
+
const kindLabel = n.kind === 'task_done' ? 'Task done' : n.kind === 'error' ? 'Error' : 'Approval needed';
|
|
439
|
+
const title = `${badge} ${kindLabel}: ${n.title || n.sessionId || ''}`.trim();
|
|
440
|
+
const bodyLines = [];
|
|
441
|
+
if (n.summary) bodyLines.push(n.summary);
|
|
442
|
+
if (n.reason) bodyLines.push(`Reason: ${n.reason}`);
|
|
443
|
+
if ((config.includeDuration ?? true) && n.durationMs !== undefined)
|
|
444
|
+
bodyLines.push(`Duration: ${formatDuration(n.durationMs)}`);
|
|
445
|
+
if ((config.includeSession ?? true) && n.sessionId)
|
|
446
|
+
bodyLines.push(`Session: ${n.sessionId}`);
|
|
447
|
+
return {
|
|
448
|
+
kind: n.kind,
|
|
449
|
+
title,
|
|
450
|
+
body: bodyLines.join('\n'),
|
|
451
|
+
sessionId: n.sessionId || '',
|
|
452
|
+
tag: `dsh-notify-${n.sessionId || 'any'}`,
|
|
453
|
+
ts: Date.now(),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const inFlightDeliveries = new Set();
|
|
458
|
+
/** Test seam: resolves once every in-flight deliverWebPush has finished. */
|
|
459
|
+
export async function settleWebPushDeliveries() {
|
|
460
|
+
while (inFlightDeliveries.size > 0) {
|
|
461
|
+
await Promise.allSettled([...inFlightDeliveries]);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function deliverWebPush(ctx, n, config = {}) {
|
|
466
|
+
let tracked;
|
|
467
|
+
tracked = (async () => {
|
|
468
|
+
const wp = config.webPush || {};
|
|
469
|
+
const statePath = resolveStatePath(wp);
|
|
470
|
+
const subscriptions = await listSubscriptions(statePath);
|
|
471
|
+
if (subscriptions.length === 0) return 0;
|
|
472
|
+
const keys = await getVapidKeys(ctx, wp);
|
|
473
|
+
const webpush = await getWebPush();
|
|
474
|
+
const payload = JSON.stringify(webPushPayload(n, config));
|
|
475
|
+
let sent = 0;
|
|
476
|
+
for (const sub of subscriptions) {
|
|
477
|
+
try {
|
|
478
|
+
await webpush.sendNotification(sub, payload, {
|
|
479
|
+
vapidDetails: {
|
|
480
|
+
subject: wp.subject || 'mailto:notify@localhost',
|
|
481
|
+
publicKey: keys.publicKey,
|
|
482
|
+
privateKey: keys.privateKey,
|
|
483
|
+
},
|
|
484
|
+
timeout: Math.max(config.timeoutMs ?? 5000, 1000),
|
|
485
|
+
});
|
|
486
|
+
sent++;
|
|
487
|
+
} catch (error) {
|
|
488
|
+
if (error?.statusCode === 404 || error?.statusCode === 410) {
|
|
489
|
+
await removeSubscription(statePath, sub.endpoint);
|
|
490
|
+
ctx?.logger?.info?.(`[plugin-notify] pruned dead push subscription (${error.statusCode})`);
|
|
491
|
+
} else {
|
|
492
|
+
ctx?.logger?.warn?.(`[plugin-notify] web push send failed: ${String(error)}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return sent;
|
|
497
|
+
})().finally(() => {
|
|
498
|
+
inFlightDeliveries.delete(tracked);
|
|
499
|
+
});
|
|
500
|
+
inFlightDeliveries.add(tracked);
|
|
501
|
+
return tracked;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function readJsonBody(req, limit = 64 * 1024) {
|
|
505
|
+
return new Promise((resolve, reject) => {
|
|
506
|
+
let size = 0;
|
|
507
|
+
const chunks = [];
|
|
508
|
+
req.on('data', (chunk) => {
|
|
509
|
+
size += chunk.length;
|
|
510
|
+
if (size > limit) {
|
|
511
|
+
reject(new Error('request body too large'));
|
|
512
|
+
req.destroy();
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
chunks.push(chunk);
|
|
516
|
+
});
|
|
517
|
+
req.on('end', () => {
|
|
518
|
+
try {
|
|
519
|
+
resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {});
|
|
520
|
+
} catch {
|
|
521
|
+
reject(new Error('invalid JSON body'));
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
req.on('error', reject);
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function writeJson(res, status, body, headers = {}) {
|
|
529
|
+
if (typeof res.writeHead === 'function') {
|
|
530
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...headers });
|
|
531
|
+
} else {
|
|
532
|
+
res.statusCode = status;
|
|
533
|
+
}
|
|
534
|
+
if (typeof res.end === 'function') res.end(JSON.stringify(body));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function writeText(res, status, text, headers = {}) {
|
|
538
|
+
if (typeof res.writeHead === 'function') {
|
|
539
|
+
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', ...headers });
|
|
540
|
+
} else {
|
|
541
|
+
res.statusCode = status;
|
|
542
|
+
}
|
|
543
|
+
if (typeof res.end === 'function') res.end(text);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Shared auth for push routes: same fence as the SSE stream. Returns true when accepted. */
|
|
547
|
+
export function authorizePushRequest(req, res, targetCtx) {
|
|
548
|
+
const conn = connectionOf(targetCtx);
|
|
549
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
550
|
+
const rejection = conn.requestRejection(req);
|
|
551
|
+
if (rejection !== void 0) {
|
|
552
|
+
writeText(res, rejection, rejection === 401 ? 'Unauthorized' : 'Forbidden');
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
const remoteAddr = req.socket?.remoteAddress || req.connection?.remoteAddress;
|
|
558
|
+
if (!remoteAddr || !isLoopback(remoteAddr)) {
|
|
559
|
+
writeText(res, 403, 'Forbidden');
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function handleServiceWorker(req, res) {
|
|
566
|
+
if (req.method !== 'GET') {
|
|
567
|
+
writeText(res, 405, 'Method Not Allowed', { allow: 'GET' });
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
// Unauthenticated on purpose: static, no secrets; the header lets the SW
|
|
571
|
+
// control the app root even though the script lives under /dsh-plugin-notify/.
|
|
572
|
+
if (typeof res.writeHead === 'function') {
|
|
573
|
+
res.writeHead(200, {
|
|
574
|
+
'Content-Type': 'text/javascript; charset=utf-8',
|
|
575
|
+
'Service-Worker-Allowed': '/',
|
|
576
|
+
'Cache-Control': 'no-store',
|
|
577
|
+
});
|
|
578
|
+
} else {
|
|
579
|
+
res.statusCode = 200;
|
|
580
|
+
}
|
|
581
|
+
if (typeof res.end === 'function') res.end(SW_SOURCE);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export async function handlePushKey(req, res, targetCtx, getConfig) {
|
|
585
|
+
const getConfigU = () => unwrapConfig(getConfig());
|
|
586
|
+
if (req.method !== 'GET') return writeText(res, 405, 'Method Not Allowed', { allow: 'GET' });
|
|
587
|
+
if (!authorizePushRequest(req, res, targetCtx)) return;
|
|
588
|
+
const cfg = getConfigU();
|
|
589
|
+
if (!(cfg.webPush && cfg.webPush.enabled)) return writeText(res, 404, 'web push disabled');
|
|
590
|
+
const keys = await getVapidKeys(targetCtx, cfg.webPush || {});
|
|
591
|
+
writeJson(res, 200, { publicKey: keys.publicKey, subject: cfg.webPush.subject || 'mailto:notify@localhost' });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export async function handlePushSubscribe(req, res, targetCtx, getConfig) {
|
|
595
|
+
const getConfigU = () => unwrapConfig(getConfig());
|
|
596
|
+
if (req.method !== 'POST') return writeText(res, 405, 'Method Not Allowed', { allow: 'POST' });
|
|
597
|
+
if (!authorizePushRequest(req, res, targetCtx)) return;
|
|
598
|
+
const cfg = getConfigU();
|
|
599
|
+
if (!(cfg.webPush && cfg.webPush.enabled)) return writeText(res, 404, 'web push disabled');
|
|
600
|
+
let sub;
|
|
601
|
+
try {
|
|
602
|
+
sub = await readJsonBody(req);
|
|
603
|
+
} catch (error) {
|
|
604
|
+
return writeText(res, 400, String(error.message || error));
|
|
605
|
+
}
|
|
606
|
+
const statePath = resolveStatePath(cfg.webPush || {});
|
|
607
|
+
try {
|
|
608
|
+
const count = await addSubscription(statePath, sub);
|
|
609
|
+
targetCtx?.logger?.info?.(`[plugin-notify] push subscribed (${count} total)`);
|
|
610
|
+
writeJson(res, 200, { ok: true, count });
|
|
611
|
+
} catch (error) {
|
|
612
|
+
writeText(res, 400, String(error.message || error));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export async function handlePushUnsubscribe(req, res, targetCtx, getConfig) {
|
|
617
|
+
const getConfigU = () => unwrapConfig(getConfig());
|
|
618
|
+
if (req.method !== 'POST') return writeText(res, 405, 'Method Not Allowed', { allow: 'POST' });
|
|
619
|
+
if (!authorizePushRequest(req, res, targetCtx)) return;
|
|
620
|
+
const cfg = getConfigU();
|
|
621
|
+
if (!(cfg.webPush && cfg.webPush.enabled)) return writeText(res, 404, 'web push disabled');
|
|
622
|
+
let body;
|
|
623
|
+
try {
|
|
624
|
+
body = await readJsonBody(req);
|
|
625
|
+
} catch (error) {
|
|
626
|
+
return writeText(res, 400, String(error.message || error));
|
|
627
|
+
}
|
|
628
|
+
if (!body || typeof body.endpoint !== 'string') return writeText(res, 400, 'endpoint required');
|
|
629
|
+
const removed = await removeSubscription(resolveStatePath(cfg.webPush || {}), body.endpoint);
|
|
630
|
+
writeJson(res, 200, { ok: true, removed });
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export async function handlePushTest(req, res, targetCtx, getConfig) {
|
|
634
|
+
const getConfigU = () => unwrapConfig(getConfig());
|
|
635
|
+
if (req.method !== 'POST') return writeText(res, 405, 'Method Not Allowed', { allow: 'POST' });
|
|
636
|
+
if (!authorizePushRequest(req, res, targetCtx)) return;
|
|
637
|
+
const cfg = getConfigU();
|
|
638
|
+
if (!(cfg.webPush && cfg.webPush.enabled)) return writeText(res, 404, 'web push disabled');
|
|
639
|
+
const sent = await deliverWebPush(targetCtx, {
|
|
640
|
+
kind: 'task_done',
|
|
641
|
+
title: 'Test notification',
|
|
642
|
+
sessionId: 'test',
|
|
643
|
+
summary: 'If you can read this, the dsh-plugin-notify web push channel works.',
|
|
644
|
+
}, cfg);
|
|
645
|
+
writeJson(res, 200, { ok: true, sent });
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function mountWebServer(targetCtx, hostCtx, getConfig) {
|
|
256
649
|
if (!targetCtx?.webServer?.register) return () => {};
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
handler
|
|
650
|
+
// Async route wrapper: an unexpected rejection becomes a 500 instead of an
|
|
651
|
+
// unhandled rejection; authorized handlers have already written their response.
|
|
652
|
+
const asyncRoute = (handler) => (req, res) => {
|
|
653
|
+
Promise.resolve(handler(req, res)).catch(() => {
|
|
654
|
+
try { writeText(res, 500, 'internal error'); } catch { /* ignore */ }
|
|
655
|
+
});
|
|
261
656
|
};
|
|
657
|
+
const routeDefs = [
|
|
658
|
+
{
|
|
659
|
+
kind: 'exact',
|
|
660
|
+
path: '/dsh-plugin-notify/events',
|
|
661
|
+
handler: (req, res) => handleSseConnection(req, res, targetCtx),
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
kind: 'exact',
|
|
665
|
+
path: '/dsh-plugin-notify/sw.js',
|
|
666
|
+
handler: (req, res) => handleServiceWorker(req, res),
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
kind: 'exact',
|
|
670
|
+
path: '/dsh-plugin-notify/push/key',
|
|
671
|
+
handler: asyncRoute((req, res) => handlePushKey(req, res, targetCtx, getConfig)),
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
kind: 'exact',
|
|
675
|
+
path: '/dsh-plugin-notify/push/subscribe',
|
|
676
|
+
handler: asyncRoute((req, res) => handlePushSubscribe(req, res, targetCtx, getConfig)),
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
kind: 'exact',
|
|
680
|
+
path: '/dsh-plugin-notify/push/unsubscribe',
|
|
681
|
+
handler: asyncRoute((req, res) => handlePushUnsubscribe(req, res, targetCtx, getConfig)),
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
kind: 'exact',
|
|
685
|
+
path: '/dsh-plugin-notify/push/test',
|
|
686
|
+
handler: asyncRoute((req, res) => handlePushTest(req, res, targetCtx, getConfig)),
|
|
687
|
+
},
|
|
688
|
+
];
|
|
262
689
|
|
|
263
690
|
let timer = null;
|
|
264
691
|
const startHeartbeat = () => {
|
|
@@ -281,8 +708,10 @@ function mountWebServer(targetCtx, hostCtx) {
|
|
|
281
708
|
clearInterval(timer);
|
|
282
709
|
timer = null;
|
|
283
710
|
}
|
|
284
|
-
if (
|
|
285
|
-
|
|
711
|
+
if (Array.isArray(unregister)) {
|
|
712
|
+
for (const fn of unregister) {
|
|
713
|
+
try { fn(); } catch { /* ignore */ }
|
|
714
|
+
}
|
|
286
715
|
unregister = null;
|
|
287
716
|
}
|
|
288
717
|
closeAllSseClients();
|
|
@@ -290,14 +719,14 @@ function mountWebServer(targetCtx, hostCtx) {
|
|
|
290
719
|
|
|
291
720
|
if (typeof targetCtx.effect === 'function') {
|
|
292
721
|
targetCtx.effect(() => {
|
|
293
|
-
unregister = targetCtx.webServer.register(routeDef);
|
|
722
|
+
unregister = routeDefs.map((routeDef) => targetCtx.webServer.register(routeDef));
|
|
294
723
|
return () => cleanup();
|
|
295
724
|
}, 'dsh-plugin-notify: sse events');
|
|
296
725
|
targetCtx.effect(() => {
|
|
297
726
|
return startHeartbeat();
|
|
298
727
|
}, 'dsh-plugin-notify: sse heartbeat');
|
|
299
728
|
} else {
|
|
300
|
-
unregister = targetCtx.webServer.register(routeDef);
|
|
729
|
+
unregister = routeDefs.map((routeDef) => targetCtx.webServer.register(routeDef));
|
|
301
730
|
startHeartbeat();
|
|
302
731
|
}
|
|
303
732
|
|
|
@@ -315,7 +744,7 @@ export function apply(ctx, config = {}) {
|
|
|
315
744
|
if (target?.webServer?.register) {
|
|
316
745
|
webServerMounted = true;
|
|
317
746
|
const effectiveTarget = target?.connection ? target : (ctx?.connection ? ctx : target);
|
|
318
|
-
unmountWebServer = mountWebServer(effectiveTarget, ctx);
|
|
747
|
+
unmountWebServer = mountWebServer(effectiveTarget, ctx, () => getConfig());
|
|
319
748
|
}
|
|
320
749
|
};
|
|
321
750
|
|
|
@@ -338,21 +767,32 @@ export function apply(ctx, config = {}) {
|
|
|
338
767
|
|
|
339
768
|
if (typeof ctx.inject === 'function') {
|
|
340
769
|
ctx.inject(['settings'], (sctx) => {
|
|
770
|
+
if (typeof sctx.settings?.register === 'function') {
|
|
771
|
+
const scope = sctx.settings.register(NS, Config, { base: config ?? {} });
|
|
772
|
+
getConfig = () => scope.get() ?? config ?? {};
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
341
775
|
const row = (() => { try { return sctx.settings?.describe?.().find((r) => r && r.ns === NS); } catch { return null; } })();
|
|
342
776
|
const scope = {
|
|
343
777
|
get: () => row?.value ?? config ?? {},
|
|
344
|
-
replace: async (next) => {
|
|
778
|
+
replace: async (next) => {
|
|
779
|
+
try {
|
|
780
|
+
return await sctx.settings?.replace?.(NS, next, row?.revision);
|
|
781
|
+
} catch (err) {
|
|
782
|
+
ctx?.logger?.warn?.(`[plugin-notify] settings replace failed: ${String(err)}`);
|
|
783
|
+
}
|
|
784
|
+
},
|
|
345
785
|
};
|
|
346
786
|
getConfig = () => scope.get() ?? config ?? {};
|
|
347
787
|
});
|
|
348
|
-
ctx.inject(['webServer'], (wctx) => tryMountWebServer(wctx))
|
|
788
|
+
ctx.inject(['webServer'], (wctx) => tryMountWebServer(wctx))
|
|
349
789
|
}
|
|
350
790
|
if (ctx.webServer) {
|
|
351
791
|
tryMountWebServer(ctx);
|
|
352
792
|
}
|
|
353
793
|
|
|
354
794
|
const dispatch = (n) => {
|
|
355
|
-
const cfg = getConfig();
|
|
795
|
+
const cfg = unwrapConfig(getConfig());
|
|
356
796
|
const dnd = cfg.dnd;
|
|
357
797
|
if (inDnd(dnd)) {
|
|
358
798
|
(ctx?.logger?.debug ?? ctx?.logger?.info)?.(`[plugin-notify] ${n.kind} · ${n.title} · session ${n.sessionId} · DND (${dnd?.start}-${dnd?.end}), logged only`);
|
|
@@ -381,6 +821,19 @@ export function apply(ctx, config = {}) {
|
|
|
381
821
|
.catch((error) => {
|
|
382
822
|
ctx?.logger?.warn?.(`[plugin-notify] webhook resolve/send failed: ${String(error)}`);
|
|
383
823
|
});
|
|
824
|
+
|
|
825
|
+
// Web Push (PWA) channel: reaches devices with every dsh tab closed.
|
|
826
|
+
const wp = cfg.webPush;
|
|
827
|
+
if (wp && wp.enabled) {
|
|
828
|
+
const away = sseClients.size === 0;
|
|
829
|
+
if (away || wp.onlyWhenAway === false) {
|
|
830
|
+
deliverWebPush(ctx, n, cfg).catch((error) => {
|
|
831
|
+
ctx?.logger?.warn?.(`[plugin-notify] web push dispatch failed: ${String(error)}`);
|
|
832
|
+
});
|
|
833
|
+
} else {
|
|
834
|
+
(ctx?.logger?.debug ?? ctx?.logger?.info)?.(`[plugin-notify] ${n.kind} · session ${n.sessionId} · web push skipped (clients connected)`);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
384
837
|
};
|
|
385
838
|
|
|
386
839
|
ctx.on('session/event', (session, event) => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-plugin-notify",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DSH plugin: audio chimes, cross-session toasts, desktop push,
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "DSH plugin: audio chimes, cross-session toasts, desktop push, IM webhooks, and web push (PWA) for turn completion, errors, and approvals.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./lib/index.js",
|
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
"pretest": "node --check lib/index.js && node --check lib/client.js",
|
|
36
36
|
"test": "node --test test/*.test.mjs"
|
|
37
37
|
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"web-push": "^3.6.7"
|
|
40
|
+
},
|
|
38
41
|
"peerDependencies": {
|
|
39
42
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
40
43
|
"@deepseek-ai/dsh-session": "^0.1.7-rc.2 || ^0.2.0-rc.1",
|
|
@@ -45,7 +48,8 @@
|
|
|
45
48
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
46
49
|
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
|
47
50
|
"@deepseek-ai/dsh-credentials": "^0.1.0-rc.8",
|
|
48
|
-
"@deepseek-ai/schemastery": "^3.18.4"
|
|
51
|
+
"@deepseek-ai/schemastery": "^3.18.4",
|
|
52
|
+
"web-push": "^3.6.7"
|
|
49
53
|
},
|
|
50
54
|
"repository": {
|
|
51
55
|
"type": "git",
|
|
@@ -66,6 +70,8 @@
|
|
|
66
70
|
"notifications",
|
|
67
71
|
"webhooks",
|
|
68
72
|
"desktop-push",
|
|
73
|
+
"web-push",
|
|
74
|
+
"pwa",
|
|
69
75
|
"audio-chimes",
|
|
70
76
|
"toasts"
|
|
71
77
|
]
|