dsh-session-tg-notify 0.1.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/src/client.js ADDED
@@ -0,0 +1,777 @@
1
+ // dsh-session-tg-notify — 浏览器端(client bundle)
2
+ //
3
+ // 三件事:
4
+ // 1. 状态上报 —— 每 10 秒 + 每次 visibility/focus/blur 变化,把
5
+ // { visibility, focused } POST 给后端;后端据此判定前台/后台/离线。
6
+ // 存活信号是 SSE 长连接本身(页面关闭 → 连接断 → 后端改走 Telegram)。
7
+ // 2. 通知呈现 —— 收到 toast 帧就画页面内提示;收到 webnotify 帧就发
8
+ // macOS 系统通知(Chrome 的通知中心条目,点击天然切回本标签页),
9
+ // 并按配置播放提示音。
10
+ // 3. 设置面板 —— 对话页顶栏铃铛打开:逐事件勾选桌面/Telegram、逐事件
11
+ // 测试推送、桌面声音与音色、Telegram Bot Token 与 Chat ID。
12
+ //
13
+ // 本文件是打包产物格式的浏览器 bundle:注册到 window.__ModuleLoader__,
14
+ // 工厂函数内通过 require() 解析平台种子模块(react),与官方 dsh-client-*
15
+ // 包发布的 client.js 结构一致。
16
+
17
+ window.__ModuleLoader__.load({
18
+ id: "dsh-session-tg-notify",
19
+ factory: (require) => {
20
+ var module = { exports: {} };
21
+ var exports = module.exports;
22
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
23
+
24
+ var react = require("react");
25
+
26
+ var API = "/session-notify";
27
+ var CLIENT_ID_KEY = "dsh-session-tg-notify:client-id";
28
+ var HEARTBEAT_MS = 10000;
29
+ var TOAST_MS = 9000;
30
+
31
+ //#region 样式
32
+ var CSS = [
33
+ // 顶栏按钮(与 Session log 按钮同风格)
34
+ ".dsn-entry{position:relative;flex:none;display:inline-flex}",
35
+ ".dsn-bell{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);width:32px;height:32px;color:var(--dsw-alias-label-primary);background:0 0;border-radius:16px;justify-content:center;align-items:center;padding:0;display:inline-flex;flex:none;cursor:pointer}",
36
+ ".dsn-bell:hover{background:var(--dsw-alias-interactive-bg-hover)}",
37
+ ".dsn-bell:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}",
38
+ ".dsn-bell[data-active=\"false\"]{color:var(--dsw-alias-label-dimmed)}",
39
+ // 模态面板
40
+ ".dsn-mask{position:fixed;inset:0;z-index:2147483000;background:rgba(0,0,0,.38);display:flex;align-items:center;justify-content:center;padding:24px;font-family:var(--dsw-font-family,system-ui);}",
41
+ ".dsn-panel{box-sizing:border-box;width:560px;max-width:100%;max-height:min(760px,88vh);overflow:auto;background:var(--dsw-specific-menu,#fff);color:var(--dsw-alias-label-primary,#111);border:1px solid var(--dsw-alias-border-inverted,rgba(0,0,0,.1));border-radius:14px;box-shadow:var(--dsw-shadow-lv3,0 12px 40px rgba(0,0,0,.28));padding:18px 18px 14px}",
42
+ ".dsn-hd{display:flex;align-items:baseline;gap:8px;margin-bottom:12px}",
43
+ ".dsn-hd h3{margin:0;font-size:16px;font-weight:650;flex:1}",
44
+ ".dsn-ver{font-size:11px;color:var(--dsw-alias-label-tertiary,#888);font-variant-numeric:tabular-nums}",
45
+ ".dsn-x{box-sizing:border-box;border:0;background:0 0;color:var(--dsw-alias-label-secondary,#666);font-size:20px;line-height:1;cursor:pointer;padding:2px 6px;border-radius:6px}",
46
+ ".dsn-x:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06))}",
47
+ ".dsn-sec{margin:14px 0 0;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1))}",
48
+ ".dsn-sec:first-of-type{border-top:0;padding-top:0;margin-top:6px}",
49
+ ".dsn-sec h4{margin:0 0 8px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-secondary,#555)}",
50
+ ".dsn-row{display:flex;align-items:center;gap:10px;padding:6px 0;font-size:13px}",
51
+ ".dsn-row .dsn-grow{flex:1}",
52
+ ".dsn-grid{display:grid;grid-template-columns:1fr 62px 62px 116px;gap:6px 8px;align-items:center;font-size:13px}",
53
+ ".dsn-grid .dsn-gh{font-size:11px;color:var(--dsw-alias-label-tertiary,#888);text-align:center;font-weight:500}",
54
+ ".dsn-grid .dsn-gl{font-size:11px;color:var(--dsw-alias-label-tertiary,#888);font-weight:500}",
55
+ ".dsn-cell{display:flex;justify-content:center}",
56
+ ".dsn-acts{display:flex;gap:4px;justify-content:center}",
57
+ ".dsn-btn{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.16));background:0 0;color:var(--dsw-alias-label-primary,#111);font-family:inherit;font-size:12px;border-radius:8px;padding:3px 9px;cursor:pointer;white-space:nowrap}",
58
+ ".dsn-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06))}",
59
+ ".dsn-btn:disabled{color:var(--dsw-alias-label-dimmed,#aaa);cursor:default}",
60
+ ".dsn-btn[data-tone=\"primary\"]{background:var(--dsw-alias-brand-primary,#3b6cf6);border-color:transparent;color:#fff}",
61
+ ".dsn-input{box-sizing:border-box;width:100%;font-family:inherit;font-size:13px;padding:6px 9px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.16));background:0 0;color:inherit}",
62
+ ".dsn-note{font-size:11.5px;line-height:1.6;color:var(--dsw-alias-label-tertiary,#888);margin:6px 0 0}",
63
+ ".dsn-state{display:inline-flex;align-items:center;gap:5px;font-size:12px}",
64
+ ".dsn-dot{width:7px;height:7px;border-radius:50%;flex:none}",
65
+ ".dsn-msg{font-size:12px;margin:6px 0 0;min-height:16px}",
66
+ ".dsn-msg[data-kind=\"ok\"]{color:#22a06b}",
67
+ ".dsn-msg[data-kind=\"err\"]{color:var(--dsw-alias-state-error-primary,#e5484d)}",
68
+ // 页面内 toast
69
+ ".dsn-toasts{position:fixed;top:16px;right:16px;z-index:2147483001;display:flex;flex-direction:column;gap:8px;pointer-events:none;font-family:var(--dsw-font-family,system-ui)}",
70
+ ".dsn-toast{box-sizing:border-box;pointer-events:auto;width:320px;padding:10px 12px;border-radius:12px;background:var(--dsw-specific-menu,#fff);color:var(--dsw-alias-label-primary,#111);border:1px solid var(--dsw-alias-border-inverted,rgba(0,0,0,.1));box-shadow:var(--dsw-shadow-lv3,0 10px 30px rgba(0,0,0,.22));cursor:pointer;animation:dsn-in .18s ease-out}",
71
+ "@keyframes dsn-in{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}",
72
+ ".dsn-toast-t{font-size:13px;font-weight:650;margin-bottom:3px;display:flex;gap:6px;align-items:center}",
73
+ ".dsn-toast-b{font-size:12.5px;line-height:1.5;color:var(--dsw-alias-label-secondary,#555);word-break:break-word}",
74
+ ".dsn-toast-f{font-size:11px;color:var(--dsw-alias-label-tertiary,#999);margin-top:4px}",
75
+ ".dsn-toast[data-activatable=\"true\"]:hover{border-color:var(--dsw-alias-brand-primary,#3b6cf6)}"
76
+ ].join("\n");
77
+ //#endregion
78
+
79
+ //#region 小工具
80
+ /** 每个标签页一个稳定 id(sessionStorage 随标签页存活,刷新不变)。 */
81
+ function getClientId() {
82
+ try {
83
+ var existing = sessionStorage.getItem(CLIENT_ID_KEY);
84
+ if (existing) return existing;
85
+ var id = "c" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
86
+ sessionStorage.setItem(CLIENT_ID_KEY, id);
87
+ return id;
88
+ } catch (error) {
89
+ // 隐私模式等禁用 storage:退化为进程内随机 id(新开标签页视为新客户端)
90
+ return "c" + Math.random().toString(36).slice(2, 12);
91
+ }
92
+ }
93
+
94
+ /** 调用后端 API;网络异常收敛成 { ok:false, error },不抛给调用方。 */
95
+ function api(endpoint, options) {
96
+ var opts = options || {};
97
+ var init = { method: opts.method || (opts.body ? "POST" : "GET") };
98
+ if (opts.body) {
99
+ init.headers = { "content-type": "application/json" };
100
+ init.body = JSON.stringify(opts.body);
101
+ }
102
+ return fetch(API + "/" + endpoint, init)
103
+ .then(function (res) { return res.json(); })
104
+ .catch(function (error) { return { ok: false, error: String(error && error.message ? error.message : error) }; });
105
+ }
106
+ //#endregion
107
+
108
+ //#region 提示音(Web Audio 合成,无需素材文件)
109
+ /** 三种音色 → [频率, 起始偏移] 序列。 */
110
+ var TONE_NOTES = {
111
+ ding: [[880, 0]],
112
+ alert: [[880, 0], [660, 0.18]],
113
+ chime: [[660, 0], [880, 0.14], [1175, 0.28]]
114
+ };
115
+
116
+ /**
117
+ * 提示音播放器。浏览器 autoplay 策略要求先有用户手势解锁,
118
+ * 因此首次 pointerdown/keydown 时 resume();未解锁时返回 false,
119
+ * 调用方据此回退到系统默认通知音。
120
+ */
121
+ function createTonePlayer() {
122
+ var player = { ctx: null, unlocked: false, unlockDisposer: null };
123
+
124
+ player.ensure = function () {
125
+ if (player.ctx !== null) return player.ctx;
126
+ var Ctor = typeof window !== "undefined" && (window.AudioContext || window.webkitAudioContext);
127
+ if (!Ctor) return null;
128
+ try {
129
+ player.ctx = new Ctor();
130
+ } catch (error) {
131
+ player.ctx = null;
132
+ }
133
+ return player.ctx;
134
+ };
135
+
136
+ player.unlock = function () {
137
+ var ctx = player.ensure();
138
+ if (!ctx) return;
139
+ if (typeof ctx.resume === "function") ctx.resume();
140
+ if (ctx.state === "running") player.unlocked = true;
141
+ };
142
+
143
+ player.attachUnlock = function () {
144
+ if (player.unlockDisposer !== null) return;
145
+ if (typeof window === "undefined" || typeof window.addEventListener !== "function") return;
146
+ var handler = function () {
147
+ player.unlock();
148
+ if (player.unlocked) player.detachUnlock();
149
+ };
150
+ window.addEventListener("pointerdown", handler, true);
151
+ window.addEventListener("keydown", handler, true);
152
+ player.unlockDisposer = function () {
153
+ window.removeEventListener("pointerdown", handler, true);
154
+ window.removeEventListener("keydown", handler, true);
155
+ };
156
+ };
157
+
158
+ player.detachUnlock = function () {
159
+ if (player.unlockDisposer === null) return;
160
+ var dispose = player.unlockDisposer;
161
+ player.unlockDisposer = null;
162
+ dispose();
163
+ };
164
+
165
+ /** @returns true = 已用合成音播放;false = 未解锁/不支持,调用方回退系统音。 */
166
+ player.play = function (tone) {
167
+ if (!tone || tone === "none") return true; // 用户选择无声,视作已处理
168
+ var ctx = player.ensure();
169
+ if (!ctx) return false;
170
+ if (ctx.state === "suspended" && typeof ctx.resume === "function") ctx.resume();
171
+ if (ctx.state !== "running") return false;
172
+ var notes = TONE_NOTES[tone] || TONE_NOTES.ding;
173
+ var base = ctx.currentTime;
174
+ for (var i = 0; i < notes.length; i++) {
175
+ var freq = notes[i][0];
176
+ var at = base + notes[i][1];
177
+ var osc = ctx.createOscillator();
178
+ var gain = ctx.createGain();
179
+ osc.type = "sine";
180
+ osc.frequency.value = freq;
181
+ gain.gain.setValueAtTime(0.0001, at);
182
+ gain.gain.exponentialRampToValueAtTime(0.22, at + 0.02);
183
+ gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.34);
184
+ osc.connect(gain);
185
+ gain.connect(ctx.destination);
186
+ osc.start(at);
187
+ osc.stop(at + 0.4);
188
+ }
189
+ return true;
190
+ };
191
+
192
+ player.dispose = function () {
193
+ player.detachUnlock();
194
+ if (player.ctx !== null) {
195
+ try { player.ctx.close(); } catch (error) { /* 忽略 */ }
196
+ player.ctx = null;
197
+ }
198
+ };
199
+
200
+ return player;
201
+ }
202
+ //#endregion
203
+
204
+ //#region 页面内 toast
205
+ /**
206
+ * 页面内 toast 容器。
207
+ * @param options.onActivate - 点击通知时调用,参数是该通知的 payload;
208
+ * 用于「点击直达对应会话」。缺省时只把窗口拉到前台。
209
+ */
210
+ function createToastHost(options) {
211
+ var activate = options && typeof options.onActivate === "function" ? options.onActivate : null;
212
+ var host = null;
213
+ var dispose = function () {
214
+ if (host && host.parentNode) host.parentNode.removeChild(host);
215
+ host = null;
216
+ };
217
+ return {
218
+ show: function (payload) {
219
+ if (typeof document === "undefined") return;
220
+ if (host === null) {
221
+ host = document.createElement("div");
222
+ host.className = "dsn-toasts";
223
+ host.setAttribute("data-plugin", "dsh-session-tg-notify");
224
+ document.body.append(host);
225
+ }
226
+ var el = document.createElement("div");
227
+ el.className = "dsn-toast";
228
+ var head = document.createElement("div");
229
+ head.className = "dsn-toast-t";
230
+ head.textContent = (payload.emoji ? payload.emoji + " " : "") + (payload.title || "通知");
231
+ var body = document.createElement("div");
232
+ body.className = "dsn-toast-b";
233
+ body.textContent = payload.body || "";
234
+ el.append(head, body);
235
+ if (payload.sessionLabel) {
236
+ var foot = document.createElement("div");
237
+ foot.className = "dsn-toast-f";
238
+ foot.textContent = payload.sessionLabel;
239
+ el.append(foot);
240
+ }
241
+ var timer = setTimeout(function () {
242
+ if (el.parentNode) el.parentNode.removeChild(el);
243
+ }, TOAST_MS);
244
+ // 可点击时才做成手型,避免给用户「点了没反应」的假承诺
245
+ if (activate !== null && payload.sessionId) el.setAttribute("data-activatable", "true");
246
+ el.addEventListener("click", function () {
247
+ clearTimeout(timer);
248
+ if (el.parentNode) el.parentNode.removeChild(el);
249
+ try { window.focus(); } catch (error) { /* 忽略 */ }
250
+ if (activate !== null) activate(payload);
251
+ });
252
+ host.append(el);
253
+ },
254
+ dispose: dispose
255
+ };
256
+ }
257
+ //#endregion
258
+
259
+ //#region 插件入口
260
+ // uiWorkspace 提供 openSession(sessionId),是客户端唯一「切到指定会话」的
261
+ // 正规入口(dsh-client-ui-workspace 的 UiWorkspaceService,ctx 键 "uiWorkspace")。
262
+ var inject = ["slots", "locale", "uiWorkspace"];
263
+
264
+ function apply(ctx) {
265
+ var clientId = getClientId();
266
+ var tone = createTonePlayer();
267
+
268
+ /**
269
+ * 切到指定会话。这是「点击通知直达会话」的落点。
270
+ * 服务缺失或会话已归档时静默失败——通知点击不该因为导航失败而报错。
271
+ * @returns 是否成功发起导航
272
+ */
273
+ function openSessionById(sessionId) {
274
+ if (typeof sessionId !== "string" || sessionId.length === 0) return false;
275
+ var nav = typeof ctx.get === "function" ? ctx.get("uiWorkspace") : ctx.uiWorkspace;
276
+ if (!nav || typeof nav.openSession !== "function") return false;
277
+ try {
278
+ nav.openSession(sessionId);
279
+ return true;
280
+ } catch (error) {
281
+ console.warn("[session-notify] 打开会话失败:", sessionId, error);
282
+ return false;
283
+ }
284
+ }
285
+
286
+ var toasts = createToastHost({
287
+ onActivate: function (payload) { openSessionById(payload && payload.sessionId); }
288
+ });
289
+ /** 最近一次从后端拿到的状态(设置面板打开时用于渲染)。 */
290
+ var lastState = { config: null, presence: null, events: [], configPath: "" };
291
+ var panel = null;
292
+
293
+ ctx.effect(function () {
294
+ var style = document.createElement("style");
295
+ style.setAttribute("data-plugin", "dsh-session-tg-notify");
296
+ style.textContent = CSS;
297
+ document.head.append(style);
298
+ return function () { style.remove(); };
299
+ }, "dsh-session-tg-notify: styles");
300
+
301
+ // ---- 通知呈现
302
+ ctx.effect(function () {
303
+ tone.attachUnlock();
304
+ return function () { tone.dispose(); };
305
+ }, "dsh-session-tg-notify: audio");
306
+
307
+ function showSystemNotification(payload) {
308
+ if (typeof Notification === "undefined") {
309
+ toasts.show(payload);
310
+ return;
311
+ }
312
+ if (Notification.permission !== "granted") {
313
+ // 没授权就退化为页面内提示,避免静默丢失
314
+ toasts.show(payload);
315
+ return;
316
+ }
317
+ var played = tone.play(payload.tone);
318
+ try {
319
+ var note = new Notification((payload.emoji ? payload.emoji + " " : "") + (payload.title || "DSH 通知"), {
320
+ body: [payload.body, payload.sessionLabel].filter(Boolean).join("\n"),
321
+ tag: "dsh-session-tg-notify:" + (payload.kind || "event"),
322
+ // 合成音已播放时抑制系统音;未解锁则让系统出默认声,保证一定有声音
323
+ silent: played
324
+ });
325
+ note.onclick = function () {
326
+ try { window.focus(); } catch (error) { /* 忽略 */ }
327
+ // 点击系统通知同样直达对应会话
328
+ openSessionById(payload.sessionId);
329
+ note.close();
330
+ };
331
+ } catch (error) {
332
+ toasts.show(payload);
333
+ }
334
+ }
335
+
336
+ // ---- 状态上报(心跳 + 焦点/可见性变化)
337
+ var reportPresence = function () {
338
+ if (typeof document === "undefined") return;
339
+ api("presence", {
340
+ body: {
341
+ clientId: clientId,
342
+ visibility: document.visibilityState === "hidden" ? "hidden" : "visible",
343
+ focused: typeof document.hasFocus === "function" ? document.hasFocus() : false
344
+ }
345
+ });
346
+ };
347
+
348
+ // ---- SSE:存活信号 + 通知下发
349
+ ctx.effect(function () {
350
+ if (typeof EventSource === "undefined") return;
351
+ var es = new EventSource(API + "/events?clientId=" + encodeURIComponent(clientId));
352
+ var handler = function (event) {
353
+ var payload = null;
354
+ try { payload = JSON.parse(event.data); } catch (error) { return; }
355
+ if (payload && payload.kind) showSystemNotification(payload);
356
+ };
357
+ es.addEventListener("toast", function (event) {
358
+ var payload = null;
359
+ try { payload = JSON.parse(event.data); } catch (error) { return; }
360
+ tone.play(payload.tone);
361
+ toasts.show(payload);
362
+ });
363
+ es.addEventListener("webnotify", handler);
364
+ es.addEventListener("ready", function (event) {
365
+ reportPresence();
366
+ console.debug("[session-notify] ready", event.data);
367
+ });
368
+ return function () { es.close(); };
369
+ }, "dsh-session-tg-notify: sse");
370
+
371
+ // ---- 心跳:定期 + 焦点/可见性变化时立即上报
372
+ ctx.effect(function () {
373
+ reportPresence();
374
+ var interval = setInterval(reportPresence, HEARTBEAT_MS);
375
+ document.addEventListener("visibilitychange", reportPresence);
376
+ window.addEventListener("focus", reportPresence);
377
+ window.addEventListener("blur", reportPresence);
378
+ return function () {
379
+ clearInterval(interval);
380
+ document.removeEventListener("visibilitychange", reportPresence);
381
+ window.removeEventListener("focus", reportPresence);
382
+ window.removeEventListener("blur", reportPresence);
383
+ };
384
+ }, "dsh-session-tg-notify: presence");
385
+
386
+ // ---- 设置面板
387
+ panel = createSettingsPanel({
388
+ clientId: clientId,
389
+ open: function () { return lastState; },
390
+ refresh: function () { return api("state").then(function (res) { if (res.ok) lastState = res.value; return res; }); }
391
+ });
392
+ ctx.effect(function () {
393
+ return function () { panel.dispose(); toasts.dispose(); };
394
+ }, "dsh-session-tg-notify: panel");
395
+
396
+ ctx.effect(function () {
397
+ if (!ctx.locale || typeof ctx.locale.register !== "function") return;
398
+ return ctx.locale.register("session-notify", { en: { notifications: "Session notifications" }, zh: { notifications: "会话通知" } });
399
+ }, "dsh-session-tg-notify: locale");
400
+
401
+ ctx.effect(function () {
402
+ return ctx.slots.inject("conversation.session.header.utilities", function () {
403
+ var dispose = ctx.slots.register({
404
+ name: "conversation.session.header.utilities",
405
+ id: "session-notify-entry",
406
+ order: 91,
407
+ locale: "session-notify",
408
+ inject: function () { return {}; }
409
+ }, function NotifyEntry() {
410
+ return react.createElement(
411
+ "span",
412
+ { className: "dsn-entry" },
413
+ react.createElement(
414
+ "button",
415
+ {
416
+ type: "button",
417
+ className: "dsn-bell",
418
+ title: "会话通知设置",
419
+ "aria-label": "会话通知设置",
420
+ onClick: function () { panel.toggle(); }
421
+ },
422
+ react.createElement(
423
+ "svg",
424
+ { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", "aria-hidden": true, focusable: "false" },
425
+ react.createElement("path", {
426
+ d: "M12 2a2 2 0 0 1 2 2v.6A6.5 6.5 0 0 1 18.5 11v3.2l1.32 2.1A1 1 0 0 1 18.99 17.8H5.01a1 1 0 0 1-.83-1.5L5.5 14.2V11A6.5 6.5 0 0 1 10 4.6V4a2 2 0 0 1 2-2Zm0 19a2.5 2.5 0 0 1-2.45-2h4.9A2.5 2.5 0 0 1 12 21Z",
427
+ fill: "currentColor"
428
+ })
429
+ )
430
+ )
431
+ );
432
+ });
433
+ return function () { dispose(); };
434
+ });
435
+ }, "dsh-session-tg-notify: entry");
436
+ }
437
+ //#endregion
438
+
439
+ //#region 设置面板(纯 DOM,便于承载较多控件)
440
+ /**
441
+ * 构建设置面板。所有控件直接改后端配置(保存即生效,无需重启 DSH)。
442
+ * @param deps - { clientId, open(), refresh() }
443
+ */
444
+ function createSettingsPanel(deps) {
445
+ var mask = null;
446
+ var msgTimer = null;
447
+
448
+ function el(tag, className, text) {
449
+ var node = document.createElement(tag);
450
+ if (className) node.className = className;
451
+ if (text !== undefined) node.textContent = text;
452
+ return node;
453
+ }
454
+
455
+ function button(label, onClick, tone) {
456
+ var node = el("button", "dsn-btn", label);
457
+ node.type = "button";
458
+ if (tone) node.setAttribute("data-tone", tone);
459
+ node.addEventListener("click", onClick);
460
+ return node;
461
+ }
462
+
463
+ function setMessage(text, kind) {
464
+ if (!mask) return;
465
+ var target = mask.querySelector(".dsn-msg");
466
+ if (!target) return;
467
+ target.textContent = text || "";
468
+ target.setAttribute("data-kind", kind || "");
469
+ if (msgTimer !== null) clearTimeout(msgTimer);
470
+ if (text) msgTimer = setTimeout(function () { target.textContent = ""; }, 6000);
471
+ }
472
+
473
+ /** 保存局部配置补丁并重绘。 */
474
+ function save(patch, okText) {
475
+ return api("config", { body: patch }).then(function (res) {
476
+ if (res.ok) {
477
+ setMessage(okText || "已保存", "ok");
478
+ render(res.value);
479
+ } else {
480
+ setMessage("保存失败:" + (res.error || "未知错误"), "err");
481
+ }
482
+ return res;
483
+ });
484
+ }
485
+
486
+ function render(state) {
487
+ if (!mask) return;
488
+ var body = mask.querySelector(".dsn-body");
489
+ body.textContent = "";
490
+ // 首次打开时只有空快照(还没从后端读到配置)——必须在这里提前返回:
491
+ // 否则下面访问 config.desktop 会抛异常,连带把 openPanel 里后续的
492
+ // refresh() 调用一起中断,面板就永远停在空白。
493
+ if (!state || !state.config) {
494
+ var loading = el("div", "dsn-sec");
495
+ loading.append(el("p", "dsn-note", "正在读取配置…若此处长时间没有内容,说明后端 /session-notify 接口不可达。"));
496
+ body.append(loading);
497
+ return;
498
+ }
499
+ var config = state.config;
500
+ var presence = state.presence || { state: "offline", count: 0 };
501
+ var desktop = config.desktop || {};
502
+ var telegram = config.telegram || {};
503
+ var events = state.events || [];
504
+
505
+ // ---- 总开关 + 当前状态
506
+ var head = el("div", "dsn-sec");
507
+ var top = el("div", "dsn-row");
508
+ var master = el("input");
509
+ master.type = "checkbox";
510
+ master.checked = config.enabled !== false;
511
+ master.addEventListener("change", function () {
512
+ save({ enabled: master.checked }, master.checked ? "通知已开启" : "通知已关闭");
513
+ });
514
+ var masterLabel = el("span", "dsn-grow", "启用会话通知");
515
+ var stateWrap = el("span", "dsn-state");
516
+ var dot = el("span", "dsn-dot");
517
+ // 后台这一档要区分「只是切走了」和「人真的锁屏走了」——两者的通道
518
+ // 完全不同(前者只发系统通知,后者才可能推 Telegram)。
519
+ var screen = state.screen || {};
520
+ var lockSuffix = screen.supported === false
521
+ ? "(无法探测锁屏)"
522
+ : screen.locked ? "(已锁屏)" : "(未锁屏)";
523
+ var stateMap = {
524
+ foreground: ["#22a06b", "网页前台 · toast"],
525
+ background: ["#e0a400", "网页后台 · 系统通知" + lockSuffix],
526
+ offline: ["#9aa0a6", "网页未打开 · Telegram"]
527
+ };
528
+ var info = stateMap[presence.state] || stateMap.offline;
529
+ dot.style.background = info[0];
530
+ stateWrap.append(dot, el("span", null, info[1] + (presence.count > 1 ? "(" + presence.count + " 个标签页)" : "")));
531
+ top.append(master, masterLabel, stateWrap);
532
+ head.append(top);
533
+ body.append(head);
534
+
535
+ // ---- 逐事件:桌面 / Telegram / 测试
536
+ var evSec = el("div", "dsn-sec");
537
+ evSec.append(el("h4", null, "事件与通道"));
538
+ var grid = el("div", "dsn-grid");
539
+ grid.append(el("div", "dsn-gl", "事件"), el("div", "dsn-gh", "桌面"), el("div", "dsn-gh", "TG"), el("div", "dsn-gh", "测试推送"));
540
+ for (var i = 0; i < events.length; i++) {
541
+ (function (meta) {
542
+ var cfg = (config.events && config.events[meta.kind]) || { desktop: false, telegram: false };
543
+ grid.append(el("div", "dsn-gl", meta.emoji + " " + meta.label));
544
+
545
+ var dCell = el("div", "dsn-cell");
546
+ var dBox = el("input");
547
+ dBox.type = "checkbox";
548
+ dBox.checked = cfg.desktop === true;
549
+ dBox.addEventListener("change", function () {
550
+ var patch = { events: {} };
551
+ patch.events[meta.kind] = { desktop: dBox.checked };
552
+ save(patch);
553
+ });
554
+ dCell.append(dBox);
555
+ grid.append(dCell);
556
+
557
+ var tCell = el("div", "dsn-cell");
558
+ var tBox = el("input");
559
+ tBox.type = "checkbox";
560
+ tBox.checked = cfg.telegram === true;
561
+ tBox.addEventListener("change", function () {
562
+ var patch = { events: {} };
563
+ patch.events[meta.kind] = { telegram: tBox.checked };
564
+ save(patch);
565
+ });
566
+ tCell.append(tBox);
567
+ grid.append(tCell);
568
+
569
+ var acts = el("div", "dsn-acts");
570
+ acts.append(button("桌面", function () {
571
+ api("test", { body: { kind: meta.kind, channel: "desktop", clientId: deps.clientId } }).then(function (res) {
572
+ setMessage(res.ok ? "已发出桌面测试(系统通知)" : "测试失败:" + res.error, res.ok ? "ok" : "err");
573
+ });
574
+ }));
575
+ acts.append(button("TG", function () {
576
+ api("test", { body: { kind: meta.kind, channel: "telegram" } }).then(function (res) {
577
+ setMessage(res.ok ? "已发出 Telegram 测试" : "测试失败:" + res.error, res.ok ? "ok" : "err");
578
+ });
579
+ }));
580
+ grid.append(acts);
581
+ })(events[i]);
582
+ }
583
+ evSec.append(grid);
584
+ evSec.append(el("p", "dsn-note", "勾选 = 订阅该通道(不是立即发送)。桌面:前台页面内提示、后台 macOS 系统通知,点击通知直达对应会话;TG:前台永不发,后台需同时打开下面的「网页后台且锁屏时推送」,网页关闭时直接发 TG。两个都不勾 = 该事件完全不打扰。「测试」中的桌面按钮固定发系统通知,便于验证通知中心与声音。"));
585
+ body.append(evSec);
586
+
587
+ // ---- 桌面通知
588
+ var deskSec = el("div", "dsn-sec");
589
+ deskSec.append(el("h4", null, "桌面通知"));
590
+ var deskRow = el("div", "dsn-row");
591
+ var soundBox = el("input");
592
+ soundBox.type = "checkbox";
593
+ soundBox.checked = desktop.sound !== false;
594
+ soundBox.addEventListener("change", function () { save({ desktop: { sound: soundBox.checked } }); });
595
+ var toneSelect = el("select", "dsn-input");
596
+ toneSelect.style.width = "120px";
597
+ var toneOptions = [["ding", "叮"], ["alert", "双响"], ["chime", "三连音"], ["none", "静音"]];
598
+ for (var t = 0; t < toneOptions.length; t++) {
599
+ var opt = el("option", null, toneOptions[t][1]);
600
+ opt.value = toneOptions[t][0];
601
+ if (desktop.tone === toneOptions[t][0]) opt.selected = true;
602
+ toneSelect.append(opt);
603
+ }
604
+ toneSelect.addEventListener("change", function () { save({ desktop: { tone: toneSelect.value } }); });
605
+ deskRow.append(soundBox, el("span", "dsn-grow", "提示音"), toneSelect);
606
+ deskSec.append(deskRow);
607
+
608
+ var permRow = el("div", "dsn-row");
609
+ var permText = typeof Notification === "undefined"
610
+ ? "当前浏览器不支持通知 API"
611
+ : Notification.permission === "granted" ? "系统通知权限:已授权"
612
+ : Notification.permission === "denied" ? "系统通知权限:已被拒绝(需在浏览器站点设置里恢复)"
613
+ : "系统通知权限:未授权";
614
+ permRow.append(el("span", "dsn-grow", permText));
615
+ var permBtn = button("请求权限", function () {
616
+ if (typeof Notification === "undefined") return;
617
+ Notification.requestPermission().then(function (result) {
618
+ setMessage("权限:" + result, result === "granted" ? "ok" : "err");
619
+ render(deps.open());
620
+ });
621
+ });
622
+ permBtn.disabled = typeof Notification === "undefined" || Notification.permission === "granted";
623
+ permRow.append(permBtn);
624
+ deskSec.append(permRow);
625
+ deskSec.append(el("p", "dsn-note", "系统通知由本页面发出(Chrome 的通知中心条目)。点击通知会切回本标签页,并直接打开对应的那个会话。首次使用需要授权;若权限未授权,后台通知会退化为页面内提示。"));
626
+ body.append(deskSec);
627
+
628
+ // ---- Telegram
629
+ var tgSec = el("div", "dsn-sec");
630
+ tgSec.append(el("h4", null, "Telegram"));
631
+ var tgEnableRow = el("div", "dsn-row");
632
+ var tgEnable = el("input");
633
+ tgEnable.type = "checkbox";
634
+ tgEnable.checked = telegram.enabled === true;
635
+ tgEnable.addEventListener("change", function () { save({ telegram: { enabled: tgEnable.checked } }); });
636
+ var bgBox = el("input");
637
+ bgBox.type = "checkbox";
638
+ bgBox.checked = telegram.notifyWhenLocked === true;
639
+ bgBox.addEventListener("change", function () { save({ telegram: { notifyWhenLocked: bgBox.checked } }); });
640
+ tgEnableRow.append(tgEnable, el("span", "dsn-grow", "启用 Telegram 通道"), bgBox, el("span", null, "网页后台且锁屏时推送"));
641
+ tgSec.append(tgEnableRow);
642
+
643
+ var tokenInput = el("input", "dsn-input");
644
+ tokenInput.type = "password";
645
+ tokenInput.autocomplete = "off";
646
+ tokenInput.placeholder = telegram.hasBotToken ? "Bot Token 已配置(留空则保持不变)" : "123456:ABC-DEF...(@BotFather 获取)";
647
+ var chatInput = el("input", "dsn-input");
648
+ chatInput.type = "text";
649
+ chatInput.autocomplete = "off";
650
+ chatInput.value = telegram.chatId || "";
651
+ chatInput.placeholder = "Chat ID,例如 123456789";
652
+ var f1 = el("div", "dsn-row");
653
+ f1.append(el("span", null, "Bot Token"), tokenInput);
654
+ var f2 = el("div", "dsn-row");
655
+ f2.append(el("span", null, "Chat ID"), chatInput);
656
+ tgSec.append(f1, f2);
657
+
658
+ var tgActs = el("div", "dsn-row");
659
+ tgActs.append(button("保存凭据", function () {
660
+ var patch = { telegram: { chatId: chatInput.value.trim() } };
661
+ // 留空表示不改动已存的 Token
662
+ if (tokenInput.value.trim().length > 0) patch.telegram.botToken = tokenInput.value.trim();
663
+ if (patch.telegram.chatId.length === 0) {
664
+ // 允许只存 token,但必须说清 Chat ID 为空会导致发送失败,
665
+ // 而不是显示一句「已保存」让人以为配好了。
666
+ save(patch, "Bot Token 已保存,但 Chat ID 为空 —— Telegram 不会发出任何消息(bot 无法主动发起会话,请先给 bot 发一条消息再点「获取 Chat ID」)").then(function () { tokenInput.value = ""; });
667
+ return;
668
+ }
669
+ save(patch, "凭据已保存").then(function () { tokenInput.value = ""; });
670
+ }, "primary"));
671
+ tgActs.append(button("获取 Chat ID", function () {
672
+ var probe = { chatId: chatInput.value.trim() };
673
+ if (tokenInput.value.trim().length > 0) probe.botToken = tokenInput.value.trim();
674
+ if (tokenInput.value.trim().length === 0 && !telegram.hasBotToken) {
675
+ setMessage("请先填写 Bot Token", "err");
676
+ return;
677
+ }
678
+ api("telegram-chats", { body: probe }).then(function (res) {
679
+ if (!res.ok) { setMessage("获取失败:" + res.error, "err"); return; }
680
+ var chats = res.chats || [];
681
+ if (chats.length === 0) {
682
+ setMessage("没找到任何会话 —— 请先在 Telegram 里给这个 bot 发一条消息(如 /start),再回来点一次", "err");
683
+ return;
684
+ }
685
+ chatInput.value = chats[0].id;
686
+ if (chats.length === 1) {
687
+ setMessage("已填入 Chat ID " + chats[0].id + "(" + chats[0].label + "),点「保存凭据」生效", "ok");
688
+ return;
689
+ }
690
+ setMessage("发现 " + chats.length + " 个会话,已填入第一个;其他:" + chats.slice(1).map(function (c) { return c.id + " " + c.label; }).join(" / "), "ok");
691
+ });
692
+ }));
693
+ tgActs.append(button("测试连接", function () {
694
+ var probe = { chatId: chatInput.value.trim() };
695
+ if (tokenInput.value.trim().length > 0) probe.botToken = tokenInput.value.trim();
696
+ if (tokenInput.value.trim().length === 0 && !telegram.hasBotToken) {
697
+ setMessage("请先填写 Bot Token", "err");
698
+ return;
699
+ }
700
+ api("telegram-check", { body: probe }).then(function (res) {
701
+ if (!res.ok) { setMessage("连接失败:" + res.error, "err"); return; }
702
+ var bot = "@" + (res.username || res.name || "bot");
703
+ if (res.chatOk) {
704
+ setMessage("连接成功:" + bot + " → " + (res.chat && res.chat.label ? res.chat.label : probe.chatId), "ok");
705
+ } else {
706
+ setMessage("Bot Token 有效(" + bot + "),但 Chat ID 不可用:" + (res.chatError || "未配置"), "err");
707
+ }
708
+ });
709
+ }));
710
+ tgSec.append(tgActs);
711
+ // 只展示 ~/... 形式:绝对路径会把用户名渲染进界面,截图即泄漏。
712
+ var where = state.configPathDisplay || "本机 DSH 的配置文件";
713
+ tgSec.append(el("p", "dsn-note", "凭据只写进 " + where + "(权限 0600),界面不回显。Telegram 的 bot 不能主动给人发消息,所以必须先在 Telegram 里给 bot 发一条消息,再用「获取 Chat ID」把它读出来。网页未打开时,订阅了 TG 的事件会改走 Telegram 投递。"));
714
+ body.append(tgSec);
715
+ }
716
+
717
+ function openPanel() {
718
+ if (mask) return;
719
+ mask = el("div", "dsn-mask");
720
+ var panel = el("div", "dsn-panel");
721
+ var head = el("div", "dsn-hd");
722
+ head.append(el("h3", null, "会话通知设置"));
723
+ head.append(el("span", "dsn-ver", "v" + ((deps.open() || {}).version || "")));
724
+ var close = el("button", "dsn-x", "×");
725
+ close.type = "button";
726
+ close.setAttribute("aria-label", "关闭");
727
+ close.addEventListener("click", closePanel);
728
+ head.append(close);
729
+ panel.append(head);
730
+ panel.append(el("div", "dsn-body"));
731
+ var footer = el("p", "dsn-msg", "");
732
+ panel.append(footer);
733
+ mask.append(panel);
734
+ mask.addEventListener("click", function (event) { if (event.target === mask) closePanel(); });
735
+ document.addEventListener("keydown", onKeydown);
736
+ document.body.append(mask);
737
+ // 先用已有快照渲染(首次打开时是空快照 → 显示“正在读取配置…”),
738
+ // 再向后端要一次最新状态。render 对空快照已做防御,不会中断下面的刷新。
739
+ render(deps.open());
740
+ deps.refresh().then(function (res) {
741
+ if (res && res.ok) {
742
+ render(res.value);
743
+ } else {
744
+ render(null);
745
+ setMessage("读取配置失败:" + ((res && res.error) || "接口不可达"), "err");
746
+ }
747
+ });
748
+ }
749
+
750
+ function onKeydown(event) {
751
+ if (event.key === "Escape") closePanel();
752
+ }
753
+
754
+ function closePanel() {
755
+ if (!mask) return;
756
+ document.removeEventListener("keydown", onKeydown);
757
+ if (msgTimer !== null) clearTimeout(msgTimer);
758
+ msgTimer = null;
759
+ mask.remove();
760
+ mask = null;
761
+ }
762
+
763
+ return {
764
+ toggle: function () { if (mask) closePanel(); else openPanel(); },
765
+ dispose: function () { closePanel(); }
766
+ };
767
+ }
768
+ //#endregion
769
+
770
+ exports.apply = apply;
771
+ exports.inject = inject;
772
+ exports.createTonePlayer = createTonePlayer;
773
+ exports.createToastHost = createToastHost;
774
+ exports.getClientId = getClientId;
775
+ return module.exports;
776
+ }
777
+ });