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/index.js ADDED
@@ -0,0 +1,392 @@
1
+ /**
2
+ * dsh-session-tg-notify — 宿主(node)半。
3
+ *
4
+ * 职责:
5
+ * 1. 订阅会话事件,归一化成「一次通知」(events.js);
6
+ * 2. 依据浏览器前后台状态(presence.js)把通知投到两个通道:
7
+ * - 桌面:前台 → 页面内 toast;后台 → macOS 系统通知(点击天然切回该标签页);
8
+ * 离线 → 无处可发
9
+ * - Telegram:前台 → 永不;后台 → 需「订阅 TG 且开了后台同时推送」;离线 → 订阅了即发
10
+ * 3. 暴露设置页所需的 HTTP API 与 SSE 通道(不改 DSH 核心)。
11
+ *
12
+ * 两个概念必须分清(这是本插件唯一需要理解的设计):
13
+ * - **订阅**:设置页每行的「桌面」「TG」勾选框,表示"这个事件我愿意通过该通道收到"。
14
+ * - **通道**:全局开关与凭据(桌面通知权限 / Telegram 启用+Token+Chat ID)。
15
+ * 只有「已订阅」且「通道就绪」且「当前网页状态该通道适用」三者同时成立才会发出。
16
+ * 订阅了但通道没起来时,宿主会打印一条明确的 warning,而不是静默丢弃。
17
+ *
18
+ * 子代理会话不通知(events.js 的 isMainSession)。
19
+ */
20
+ import { resolveConfig, defaultConfigPath, applyPatch, redactConfig, saveConfig, displayPath, EVENT_EMOJI, EVENT_LABELS } from './config.js';
21
+ import {
22
+ createTurnTracker,
23
+ createDedupe,
24
+ classifyApproval,
25
+ classifyQuestion,
26
+ classifyTurnEnd,
27
+ classifyGoalChange,
28
+ classifyAgentError,
29
+ sessionLabel,
30
+ oneLine
31
+ } from './events.js';
32
+ import { createPresenceHub } from './presence.js';
33
+ import { readScreenLocked } from './screenlock.js';
34
+ import { sendTelegram, getTelegramMe, getTelegramChats, getTelegramChat } from './telegram.js';
35
+
36
+ export const name = 'session-notify';
37
+ export const VERSION = '0.1.0';
38
+ /** 只消费事件与 webServer,不依赖其他服务。 */
39
+ export const inject = [];
40
+
41
+ /** 设置页需要知道的事件元数据。 */
42
+ const EVENT_META = Object.entries(EVENT_LABELS).map(([kind, label]) => ({ kind, label, emoji: EVENT_EMOJI[kind] }));
43
+
44
+ export function apply(ctx, cordisConfig = {}, options = {}) {
45
+ const configPath = options.configPath ?? defaultConfigPath();
46
+ let { config, warning } = resolveConfig(cordisConfig, configPath);
47
+ if (warning) console.warn(warning);
48
+
49
+ const hub = createPresenceHub({ ttlMs: config.presenceTtlMs });
50
+ const turns = createTurnTracker();
51
+ const dedupe = createDedupe();
52
+ if (typeof ctx.effect === 'function') ctx.effect(() => () => hub.dispose(), 'session-notify presence hub');
53
+
54
+ const send = options.sendTelegram ?? sendTelegram;
55
+
56
+ // ------------------------------------------------------------ 锁屏探测
57
+ /**
58
+ * 最近一次锁屏探测结果。锁屏只在「后台 + 订阅了 TG + 开了锁屏推送」这一种
59
+ * 组合下影响路由,所以平时不轮询、按需探测;设置页读取时也会强制探一次,
60
+ * 让面板上的状态行始终是实时的。
61
+ */
62
+ const lockProbe = options.readScreenLocked ?? readScreenLocked;
63
+ let lastScreen = { supported: null, locked: false, signal: 'unknown', at: 0 };
64
+
65
+ const probeScreenLocked = async () => {
66
+ const result = await lockProbe();
67
+ lastScreen = { ...result, at: Date.now() };
68
+ return lastScreen;
69
+ };
70
+
71
+ /**
72
+ * 诊断轮询:只在有页面连接时运行,且**只在锁屏状态发生变化时**打日志。
73
+ *
74
+ * 存在的理由很具体:我这个 agent 无法自己去锁你的屏幕,所以「锁屏分支到
75
+ * 底能不能识别」只能靠你锁一次、再回来查日志验证。日志里同时带上信号来源,
76
+ * 便于区分是 ioreg 命中还是屏保兜底。
77
+ */
78
+ const lockPollMs = Number.isSafeInteger(options.lockPollMs) ? options.lockPollMs : 20000;
79
+ if (lockPollMs > 0 && typeof ctx.effect === 'function') {
80
+ ctx.effect(() => {
81
+ let previous = null;
82
+ const timer = setInterval(() => {
83
+ if (hub.size === 0) return; // 页面没开时锁屏与否不影响路由
84
+ void probeScreenLocked().then((next) => {
85
+ if (previous !== null && next.locked !== previous) {
86
+ console.log(`[session-notify] 锁屏状态变化: ${next.locked ? '已锁定' : '已解锁'}(信号:${next.signal})`);
87
+ }
88
+ previous = next.locked;
89
+ });
90
+ }, lockPollMs);
91
+ if (typeof timer.unref === 'function') timer.unref();
92
+ return () => clearInterval(timer);
93
+ }, 'session-notify lock poll');
94
+ }
95
+
96
+ /** 持久化修正后的配置(含运行时开关),失败只记日志、不影响通知。 */
97
+ const persist = (next) => {
98
+ config = next;
99
+ try {
100
+ saveConfig(configPath, config);
101
+ return { ok: true };
102
+ } catch (error) {
103
+ const message = error?.message ?? String(error);
104
+ console.warn(`[session-notify] 配置写入失败: ${message}`);
105
+ return { ok: false, error: message };
106
+ }
107
+ };
108
+
109
+ /**
110
+ * Telegram 通道未就绪的原因(就绪返回 null)。
111
+ * 「行内勾选 TG」表达的是**订阅**,通道是否真的能用由启用开关 + 凭据决定。
112
+ */
113
+ const telegramChannelIssue = () => {
114
+ if (config.telegram.enabled !== true) return '未启用 Telegram 通道';
115
+ if (config.telegram.botToken.trim().length === 0) return '缺少 Bot Token';
116
+ if (config.telegram.chatId.trim().length === 0) return '缺少 Chat ID';
117
+ return null;
118
+ };
119
+
120
+ /**
121
+ * 订阅(行内 TG 勾选)× 网页状态 → 是否发送 Telegram。
122
+ *
123
+ * 前台 → 永不(页面可见即视为已送达,不再打扰手机)
124
+ * 后台 → 仅当开了「网页后台且锁屏时推送」**且当前确实锁屏**
125
+ * 离线 → 直接发(页面都关了,这正是 Telegram 存在的意义)
126
+ *
127
+ * 后台这一档刻意要求「锁屏」而非「失焦」:切到别的应用也会让页面失焦,
128
+ * 那时人还在电脑前,推手机是纯打扰。锁屏才是「人走了」的可靠信号。
129
+ *
130
+ * 未订阅的行在三种状态下都不会发。通道本身是否启动由 telegramChannelIssue 判定。
131
+ */
132
+ const shouldTelegram = (eventConfig, state, screenLocked) => {
133
+ if (eventConfig.telegram !== true) return false;
134
+ if (state === 'foreground') return false;
135
+ if (state === 'background') return config.telegram.notifyWhenLocked === true && screenLocked === true;
136
+ return true;
137
+ };
138
+
139
+ /** 路由并投递一次通知。 */
140
+ const deliver = async (notification) => {
141
+ if (!notification) return;
142
+ const eventConfig = config.events[notification.kind];
143
+ if (!config.enabled || !eventConfig) return;
144
+ if (!dedupe.admit(notification.sessionId, notification.dedupeKey)) return;
145
+ // 两个通道都没勾 → 用户明确不想被这个事件打扰。
146
+ if (!eventConfig.desktop && !eventConfig.telegram) return;
147
+
148
+ const presence = hub.snapshot();
149
+ // 只有「后台 + 开了锁屏推送」这一种组合需要真的去问系统锁没锁,
150
+ // 其余情况不付这次子进程开销。
151
+ const needsLockProbe = presence.state === 'background' && eventConfig.telegram === true && config.telegram.notifyWhenLocked === true;
152
+ const screen = needsLockProbe ? await probeScreenLocked() : null;
153
+
154
+ const payload = {
155
+ kind: notification.kind,
156
+ title: notification.title,
157
+ body: notification.body,
158
+ emoji: EVENT_EMOJI[notification.kind] ?? '',
159
+ sessionLabel: notification.sessionLabel ?? '',
160
+ // 供页面内 toast / 系统通知点击后直达对应会话
161
+ sessionId: notification.sessionId ?? null,
162
+ at: Date.now()
163
+ };
164
+
165
+ if (eventConfig.desktop && config.desktop.enabled && presence.state !== 'offline') {
166
+ const frame = presence.state === 'foreground' ? 'toast' : 'webnotify';
167
+ hub.sendTo(presence.targetClientId, frame, {
168
+ ...payload,
169
+ tone: config.desktop.sound ? config.desktop.tone : 'none'
170
+ });
171
+ }
172
+
173
+ if (shouldTelegram(eventConfig, presence.state, screen?.locked === true)) {
174
+ const issue = telegramChannelIssue();
175
+ if (issue !== null) {
176
+ // 订阅了但通道没起来 —— 这是最容易让人困惑的情况,必须显式提示。
177
+ console.warn(`[session-notify] 「${EVENT_LABELS[notification.kind] ?? notification.kind}」已订阅 Telegram,但通道未就绪(${issue}),本条已跳过`);
178
+ } else {
179
+ const result = await send(config.telegram, payload);
180
+ if (!result.ok) console.warn(`[session-notify] Telegram 推送失败: ${result.error}`);
181
+ }
182
+ }
183
+
184
+ const lockText = screen === null ? '' : ` 锁屏=${screen.locked ? '是' : '否'}(${screen.signal})`;
185
+ console.log(`[session-notify] ${EVENT_EMOJI[notification.kind] ?? ''} ${notification.kind} (${presence.state}${lockText}) ${oneLine(notification.body, 80)}`);
186
+ };
187
+
188
+ // ---------------------------------------------------------------- 事件订阅
189
+ const turns2 = turns;
190
+
191
+ ctx.on('session/event', (session, event) => {
192
+ const type = event?.type;
193
+ // tool/call 必须先喂给 turn tracker,否则「最终文本之后没有新 tool/call」判定不完整。
194
+ if (type === 'tool/call') turns2.onToolCall(session, event);
195
+
196
+ const approval = classifyApproval(session, event);
197
+ if (approval) {
198
+ void deliver({ ...approval, sessionLabel: sessionLabel(session), emoji: EVENT_EMOJI.approval });
199
+ return;
200
+ }
201
+ const question = classifyQuestion(session, event);
202
+ if (question) {
203
+ void deliver({ ...question, sessionLabel: sessionLabel(session), emoji: EVENT_EMOJI.question });
204
+ return;
205
+ }
206
+
207
+ if (type === 'turn/start') {
208
+ turns2.onTurnStart(session, event);
209
+ return;
210
+ }
211
+ if (type === 'user/message') {
212
+ turns2.onUserMessage(session, event);
213
+ return;
214
+ }
215
+ if (type === 'assistant/message') {
216
+ turns2.onAssistantMessage(session, event);
217
+ return;
218
+ }
219
+ if (type === 'turn/end') {
220
+ const complete = classifyTurnEnd(session, turns2.onTurnEnd(session, event));
221
+ if (!complete) return;
222
+ const durationMs = complete.detail?.durationMs;
223
+ // 时长过滤:短于 minDuration 的 turn 不打扰(未知时长按通过处理)。
224
+ if (typeof durationMs === 'number' && durationMs < config.minDuration * 1000) return;
225
+ void deliver({ ...complete, sessionLabel: sessionLabel(session), emoji: EVENT_EMOJI.complete });
226
+ }
227
+ });
228
+
229
+ ctx.on('goal/changed', ({ change }) => {
230
+ const block = classifyGoalChange(change);
231
+ if (block) void deliver({ ...block, sessionLabel: '', emoji: EVENT_EMOJI.block });
232
+ });
233
+
234
+ ctx.on('agent/error', (payload) => {
235
+ const error = classifyAgentError(payload);
236
+ if (error) void deliver({ ...error, sessionLabel: '', emoji: EVENT_EMOJI.error });
237
+ });
238
+
239
+ ctx.on('session/disposed', (session) => {
240
+ turns2.onSessionDisposed(session);
241
+ dedupe.forget(session?.id);
242
+ });
243
+
244
+ // ------------------------------------------------------------ HTTP + SSE
245
+ ctx.inject(['webServer'], (webCtx) => {
246
+ const readBody = async (req) => {
247
+ const chunks = [];
248
+ for await (const chunk of req) chunks.push(chunk);
249
+ return Buffer.concat(chunks).toString('utf8');
250
+ };
251
+ const json = (res, status, body) => {
252
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
253
+ res.end(JSON.stringify(body));
254
+ };
255
+ const statePayload = () => ({
256
+ config: redactConfig(config),
257
+ presence: hub.snapshot(),
258
+ // 面板的状态行要显示「锁屏推送」是否真的生效,所以这里带上实时锁屏状态
259
+ screen: lastScreen,
260
+ events: EVENT_META,
261
+ // configPath 给 curl/运维用;configPathDisplay 是收敛成 ~/... 的版本,
262
+ // 界面只允许用后者,避免把用户名渲染进 UI。
263
+ configPath,
264
+ configPathDisplay: displayPath(configPath),
265
+ version: VERSION
266
+ });
267
+
268
+ webCtx.webServer.register({
269
+ kind: 'prefix',
270
+ path: '/session-notify',
271
+ handler: async (req, res) => {
272
+ const url = new URL(req.url ?? '/', 'http://session-notify');
273
+ const endpoint = url.pathname.replace(/^\/session-notify\/?/, '') || 'state';
274
+ try {
275
+ // 跨站防护:本插件的路由不经 DSH 的 token 校验,因此显式拒绝
276
+ // Origin 与 Host 不匹配的请求,避免任意网页在浏览器里替你改配置
277
+ // 或发测试推送。注意这挡不住同网段的直接 curl —— 见 README 的安全说明。
278
+ const origin = req.headers?.origin;
279
+ const host = req.headers?.host;
280
+ if (typeof origin === 'string' && origin.length > 0) {
281
+ let originHost = null;
282
+ try {
283
+ originHost = new URL(origin).host;
284
+ } catch {
285
+ originHost = null;
286
+ }
287
+ if (originHost === null || originHost !== host) {
288
+ return json(res, 403, { ok: false, error: 'cross-origin request rejected' });
289
+ }
290
+ }
291
+
292
+ // SSE:同时是存活信号与通知下发通道。
293
+ if (endpoint === 'events') {
294
+ if (req.method !== 'GET') return json(res, 405, { ok: false, error: 'use GET' });
295
+ const clientId = url.searchParams.get('clientId') || `anon-${Date.now()}`;
296
+ if (!hub.attach(clientId, res, { version: VERSION })) {
297
+ return json(res, 503, { ok: false, error: 'too many connections' });
298
+ }
299
+ return;
300
+ }
301
+
302
+ if (endpoint === 'state' && req.method === 'GET') {
303
+ // 面板打开时强制探一次锁屏,状态行才不会显示过期值
304
+ await probeScreenLocked();
305
+ return json(res, 200, { ok: true, value: statePayload() });
306
+ }
307
+
308
+ if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'use POST' });
309
+
310
+ let body = {};
311
+ try {
312
+ body = JSON.parse((await readBody(req)) || '{}');
313
+ } catch {
314
+ return json(res, 400, { ok: false, error: 'body must be JSON' });
315
+ }
316
+
317
+ // 浏览器上报可见性/焦点(心跳)。
318
+ if (endpoint === 'presence') {
319
+ const clientId = typeof body.clientId === 'string' ? body.clientId : '';
320
+ if (clientId.length === 0) return json(res, 400, { ok: false, error: 'clientId required' });
321
+ hub.update(clientId, body);
322
+ return json(res, 200, { ok: true, value: hub.snapshot() });
323
+ }
324
+
325
+ // 设置页保存配置(局部补丁)。
326
+ if (endpoint === 'config') {
327
+ const next = applyPatch(config, body);
328
+ const written = persist(next);
329
+ return json(res, written.ok ? 200 : 500, {
330
+ ok: written.ok,
331
+ error: written.error,
332
+ value: statePayload()
333
+ });
334
+ }
335
+
336
+ // 逐事件测试推送。
337
+ if (endpoint === 'test') {
338
+ const kind = typeof body.kind === 'string' ? body.kind : '';
339
+ const channel = body.channel === 'telegram' ? 'telegram' : 'desktop';
340
+ if (!EVENT_LABELS[kind]) return json(res, 400, { ok: false, error: `unknown event kind: ${kind}` });
341
+ const sample = {
342
+ kind,
343
+ title: EVENT_LABELS[kind],
344
+ body: `这是一条来自 dsh-session-tg-notify 的测试通知(${EVENT_LABELS[kind]})`,
345
+ emoji: EVENT_EMOJI[kind] ?? '',
346
+ sessionLabel: 'settings-test',
347
+ at: Date.now()
348
+ };
349
+ if (channel === 'telegram') {
350
+ const issue = telegramChannelIssue();
351
+ if (issue !== null) return json(res, 400, { ok: false, error: `Telegram 通道未就绪:${issue}` });
352
+ const result = await send(config.telegram, sample);
353
+ return json(res, result.ok ? 200 : 502, { ok: result.ok, error: result.error });
354
+ }
355
+ // 桌面测试:强制走系统通知,这样能验证 OS 通知 + 声音 + 点击切回标签页。
356
+ const clientId = typeof body.clientId === 'string' ? body.clientId : '';
357
+ const delivered = clientId
358
+ ? hub.sendTo(clientId, 'webnotify', { ...sample, tone: config.desktop.sound ? config.desktop.tone : 'none' })
359
+ : (hub.broadcast('webnotify', { ...sample, tone: config.desktop.sound ? config.desktop.tone : 'none' }), true);
360
+ return json(res, 200, { ok: true, value: { delivered } });
361
+ }
362
+
363
+ // 校验 Telegram 凭据:token 走 getMe,有 Chat ID 时再走 getChat,
364
+ // 这样「测试连接」验证的是整条链路,而不只是 token 有效。
365
+ if (endpoint === 'telegram-check') {
366
+ const probe = applyPatch(config, { telegram: body }).telegram;
367
+ const me = await getTelegramMe(probe);
368
+ if (!me.ok) return json(res, 502, me);
369
+ const chatId = probe.chatId;
370
+ if (typeof chatId !== 'string' || chatId.trim().length === 0) {
371
+ return json(res, 200, { ...me, chatOk: false, chatError: '未配置 Chat ID —— bot 无法主动发起会话,请先给 bot 发一条消息再点「获取 Chat ID」' });
372
+ }
373
+ const chat = await getTelegramChat(probe, chatId);
374
+ return json(res, chat.ok ? 200 : 502, { ...me, chatOk: chat.ok, chatError: chat.ok ? null : chat.error, chat: chat.chat ?? null });
375
+ }
376
+
377
+ // 从 bot 的最近更新里发现可用 chat(获取 Chat ID 的正规途径)。
378
+ if (endpoint === 'telegram-chats') {
379
+ const probe = applyPatch(config, { telegram: body }).telegram;
380
+ const result = await getTelegramChats(probe);
381
+ return json(res, result.ok ? 200 : 502, result);
382
+ }
383
+
384
+ return json(res, 404, { ok: false, error: `unknown endpoint: ${endpoint}` });
385
+ } catch (error) {
386
+ return json(res, 500, { ok: false, error: error?.message ?? String(error) });
387
+ }
388
+ }
389
+ });
390
+ console.log(`[session-notify] v${VERSION} 已加载(配置:${configPath})`);
391
+ });
392
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * dsh-session-tg-notify — 在线状态 + SSE 推送 hub。
3
+ *
4
+ * 关键设计:**SSE 长连接本身就是存活信号**。浏览器端与后端之间只有一条
5
+ * 通道(`GET /session-notify/events`),它同时承担:
6
+ * 1. 存活判定 —— 连接存在即页面开着;连接关闭(含页面关闭、标签被回收)
7
+ * 即视为离线,后端据此把通知改走 Telegram。
8
+ * 2. 前后台判定 —— 客户端通过 `POST /session-notify/presence` 上报
9
+ * `{ visibility, focused }`,二者可随窗口焦点变化即时更新。
10
+ * 3. 通知下发 —— 后端按判定结果推 `toast` / `webnotify` 帧。
11
+ *
12
+ * 三态(在线只看连接,心跳只管焦点):
13
+ * - foreground:存在连接,且**新鲜**的焦点上报是 visible + focused
14
+ * - background:存在连接但没有新鲜的前台信息
15
+ * - offline :**没有任何连接**(页面确实关了)
16
+ *
17
+ * 曾经把「心跳过期」也算成 offline,结果被 Chrome 的后台标签页定时器节流误伤:
18
+ * 页面开着、只是被切走久了,就被判成离线并错误地改走 Telegram。
19
+ *
20
+ * 多标签页:只要**任一**标签页处于前台就算 foreground(用户在看着 DSH);
21
+ * 通知只发给“最合适的那一个”标签页(前台优先,否则最近上报的),避免多标签
22
+ * 重复弹窗。
23
+ */
24
+ export function createPresenceHub(options = {}) {
25
+ const ttlMs = Number.isSafeInteger(options.ttlMs) && options.ttlMs >= 5000 ? options.ttlMs : 30000;
26
+ const maxConnections = Number.isSafeInteger(options.maxConnections) && options.maxConnections > 0
27
+ ? options.maxConnections
28
+ : 16;
29
+
30
+ /** clientId → { res, lastSeen, visibility, focused } */
31
+ const clients = new Map();
32
+ let timer = null;
33
+
34
+ const now = () => Date.now();
35
+
36
+ /** 正常关闭时立即移除;用于推导 offline。 */
37
+ const detach = (clientId) => {
38
+ clients.delete(clientId);
39
+ stopHeartbeatIfIdle();
40
+ };
41
+
42
+ const stopHeartbeatIfIdle = () => {
43
+ if (clients.size > 0 || timer === null) return;
44
+ clearInterval(timer);
45
+ timer = null;
46
+ };
47
+
48
+ const writeFrame = (res, event, data) => {
49
+ try {
50
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ };
56
+
57
+ /** 心跳注释帧,防止中间的代理掐断 idle 连接。 */
58
+ const startHeartbeat = () => {
59
+ if (timer !== null) return;
60
+ timer = setInterval(() => {
61
+ for (const res of [...clients.values()].map((c) => c.res)) {
62
+ try {
63
+ res.write(`: hb ${now()}\n\n`);
64
+ } catch {
65
+ // 断开的连接由 close 事件清理
66
+ }
67
+ }
68
+ }, 15000);
69
+ if (typeof timer.unref === 'function') timer.unref();
70
+ };
71
+
72
+ /**
73
+ * 接管一个 SSE 连接。
74
+ * @param clientId - 浏览器端为每个标签页生成的稳定标识。
75
+ * @param res - node HTTP 响应对象。
76
+ * @param ready - ready 帧载荷(服务端状态快照)。
77
+ * @returns 是否成功接管(false = 超出连接上限)。
78
+ */
79
+ const attach = (clientId, res, ready = {}) => {
80
+ if (clients.size >= maxConnections) return false;
81
+ res.writeHead(200, {
82
+ 'content-type': 'text/event-stream',
83
+ 'cache-control': 'no-cache, no-transform',
84
+ connection: 'keep-alive',
85
+ 'x-accel-buffering': 'no'
86
+ });
87
+ clients.set(clientId, { res, lastSeen: now(), visibility: 'visible', focused: false });
88
+ writeFrame(res, 'ready', { ...ready, clientId });
89
+ res.on('close', () => {
90
+ console.log(`[session-notify] 页面连接断开(剩余 ${clients.size - 1} 个)`);
91
+ detach(clientId);
92
+ });
93
+ console.log(`[session-notify] 页面连接建立(clientId=${clientId},共 ${clients.size} 个)`);
94
+ startHeartbeat();
95
+ return true;
96
+ };
97
+
98
+ /** 更新某个客户端的可见性/焦点;返回是否命中已知连接。 */
99
+ const update = (clientId, patch = {}) => {
100
+ const client = clients.get(clientId);
101
+ if (!client) return false;
102
+ client.lastSeen = now();
103
+ if (patch.visibility === 'visible' || patch.visibility === 'hidden') client.visibility = patch.visibility;
104
+ if (typeof patch.focused === 'boolean') client.focused = patch.focused;
105
+ return true;
106
+ };
107
+
108
+ /** 广播一帧给所有在线客户端。 */
109
+ const broadcast = (event, data) => {
110
+ for (const client of clients.values()) writeFrame(client.res, event, data);
111
+ };
112
+
113
+ /** 推给单个客户端(通知类帧用,避免多标签重复弹窗)。 */
114
+ const sendTo = (clientId, event, data) => {
115
+ const client = clients.get(clientId);
116
+ return client ? writeFrame(client.res, event, data) : false;
117
+ };
118
+
119
+ /**
120
+ * 三态快照;同时返回通知应该落到哪个标签页。
121
+ *
122
+ * 关键区分(曾经在这里踩过坑):
123
+ * - **在线** = SSE 连接还开着。连接存在就是页面存在的证据。
124
+ * - **心跳新鲜度**只用来判断「焦点信息还算不算数」,不用来判断生死。
125
+ *
126
+ * 不能拿心跳当存活依据:Chrome 对隐藏超过约 5 分钟的标签页会把 setInterval
127
+ * 节流到每分钟一次,10 秒的心跳会变成 60 秒,远超任何合理的 TTL ——
128
+ * 页面明明开着却会被判成离线,通知被错误地改走 Telegram。
129
+ *
130
+ * 焦点信息过期时退化为 background(而不是 offline):这是保守的一侧 ——
131
+ * 后台只会发系统通知(点击仍能直达会话),而离线会跳过桌面通道。
132
+ */
133
+ const snapshot = () => {
134
+ if (clients.size === 0) {
135
+ return { state: 'offline', targetClientId: null, count: 0, foregroundCount: 0 };
136
+ }
137
+ const all = [...clients.entries()];
138
+ const fresh = all.filter(([, c]) => now() - c.lastSeen <= ttlMs);
139
+ const foreground = fresh.filter(([, c]) => c.visibility === 'visible' && c.focused === true);
140
+ if (foreground.length > 0) {
141
+ // 多个前台标签页时取最近上报的那个
142
+ const [clientId] = foreground.reduce((a, b) => (b[1].lastSeen > a[1].lastSeen ? b : a));
143
+ return { state: 'foreground', targetClientId: clientId, count: all.length, foregroundCount: foreground.length };
144
+ }
145
+ // 没有新鲜的前台信息:有连接就算后台,优先挑心跳较新的那个
146
+ const pool = fresh.length > 0 ? fresh : all;
147
+ const [clientId] = pool.reduce((a, b) => (b[1].lastSeen > a[1].lastSeen ? b : a));
148
+ return { state: 'background', targetClientId: clientId, count: all.length, foregroundCount: 0 };
149
+ };
150
+
151
+ const dispose = () => {
152
+ if (timer !== null) {
153
+ clearInterval(timer);
154
+ timer = null;
155
+ }
156
+ for (const { res } of clients.values()) {
157
+ try {
158
+ res.end();
159
+ } catch {
160
+ // 已关闭的连接忽略
161
+ }
162
+ }
163
+ clients.clear();
164
+ };
165
+
166
+ return {
167
+ attach,
168
+ update,
169
+ broadcast,
170
+ sendTo,
171
+ snapshot,
172
+ dispose,
173
+ get size() {
174
+ return clients.size;
175
+ }
176
+ };
177
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * dsh-session-tg-notify — 屏幕锁定探测(macOS)。
3
+ *
4
+ * 「人在不在」在 macOS 上没有标准 API 给 Node 用,可靠且零权限的做法是读
5
+ * IOKit 的 console user 信息:
6
+ *
7
+ * 主信号:ioreg -n Root -d1 -a → IOConsoleUsers[].CGSSessionScreenIsLocked
8
+ * 该键**只在锁屏时出现**(未锁屏时整个键不存在),所以「键缺失 = 未锁定」。
9
+ * 兜底:pgrep -x ScreenSaverEngine —— 屏保/锁屏界面进程在跑也算「离开了」。
10
+ * 仅在主信号不可用时使用,避免把纯屏保误报成锁屏而覆盖真实判断。
11
+ *
12
+ * 只跑在 darwin 上;其他平台返回 supported:false,调用方据此退化为「未锁定」,
13
+ * 不会把通知永久吞掉。
14
+ */
15
+ import { execFile } from 'node:child_process';
16
+ import { promisify } from 'node:util';
17
+
18
+ const execFileAsync = promisify(execFile);
19
+
20
+ const TIMEOUT_MS = 4000;
21
+ const MAX_BUFFER = 8 * 1024 * 1024;
22
+
23
+ /**
24
+ * 从 `ioreg -a` 的 plist XML 里解析锁屏状态。
25
+ *
26
+ * 不引入 plist 依赖:只需要一个布尔键,正则足够且不会因 XML 结构变化而整体失败。
27
+ * ioreg 的 plist 是扁平的 `<key>K</key><true/>` 序列,没有同名嵌套。
28
+ *
29
+ * @returns true / false,解析不出结构时返回 null(调用方转兜底信号)。
30
+ */
31
+ export function parseScreenLocked(xml) {
32
+ if (typeof xml !== 'string' || xml.length === 0) return null;
33
+ if (!xml.includes('IOConsoleUsers')) return null;
34
+ // 多个 console user 时只要有一个锁定即视为锁定(取最保守的判断)
35
+ const matches = [...xml.matchAll(/<key>CGSSessionScreenIsLocked<\/key>\s*<(true|false)\s*\/>/g)];
36
+ if (matches.length === 0) return false; // 键缺失 = 未锁定
37
+ return matches.some((m) => m[1] === 'true');
38
+ }
39
+
40
+ /**
41
+ * 读取当前是否锁屏。
42
+ * @returns { supported: boolean, locked: boolean, signal: string, error?: string }
43
+ */
44
+ export async function readScreenLocked(options = {}) {
45
+ const platform = options.platform ?? process.platform;
46
+ const exec = options.execFile ?? execFileAsync;
47
+ if (platform !== 'darwin') return { supported: false, locked: false, signal: 'unsupported' };
48
+
49
+ try {
50
+ const { stdout } = await exec('/usr/sbin/ioreg', ['-n', 'Root', '-d', '1', '-a'], {
51
+ timeout: TIMEOUT_MS,
52
+ maxBuffer: MAX_BUFFER
53
+ });
54
+ const locked = parseScreenLocked(String(stdout));
55
+ if (locked !== null) return { supported: true, locked, signal: 'ioreg' };
56
+ } catch (error) {
57
+ // 落到兜底信号;把原因带出去便于排查
58
+ return readScreensaverFallback(exec, error?.message);
59
+ }
60
+ return readScreensaverFallback(exec, 'ioreg 输出里没有 IOConsoleUsers');
61
+ }
62
+
63
+ /** 兜底:屏保/锁屏界面进程是否在跑。pgrep 无匹配时退出码为 1,属正常「未锁定」。 */
64
+ async function readScreensaverFallback(exec, reason) {
65
+ try {
66
+ const { stdout } = await exec('/usr/bin/pgrep', ['-x', 'ScreenSaverEngine'], { timeout: TIMEOUT_MS });
67
+ return { supported: true, locked: String(stdout).trim().length > 0, signal: 'screensaver', note: reason };
68
+ } catch (error) {
69
+ if (error?.code === 1) return { supported: true, locked: false, signal: 'screensaver', note: reason };
70
+ return { supported: false, locked: false, signal: 'error', error: error?.message ?? String(error), note: reason };
71
+ }
72
+ }