dsh-session-tg-notify 0.1.4 → 0.1.6
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/README.md +43 -0
- package/package.json +1 -1
- package/src/client.js +30 -1
- package/src/config.js +8 -2
- package/src/index.js +29 -5
- package/src/presence.js +41 -2
package/README.md
CHANGED
|
@@ -120,6 +120,25 @@ Telegram 的 bot **不能主动给人发消息** —— 它只能回复一个已
|
|
|
120
120
|
|
|
121
121
|
> 如果你另外装了 `dsh-connect-telegram` 这类也用 `getUpdates` 长轮询的插件,两者会抢夺更新,需要错开(例如只保留其一)。
|
|
122
122
|
|
|
123
|
+
## 各运行形态的表现
|
|
124
|
+
|
|
125
|
+
同一份代码同时用于 **Web**(浏览器)、**桌面版**(Electron)与 **headless**,无需按 profile 分叉——判定源和数据通路在三端一致。下表是实测结论:
|
|
126
|
+
|
|
127
|
+
| 能力 | 机制 | Web | 桌面版 | headless |
|
|
128
|
+
|---|---|---|---|---|
|
|
129
|
+
| APP/页面内 toast | 往 `document.body` 插 DOM | ✅ | ✅ | — |
|
|
130
|
+
| 系统通知 | 页面侧 `new Notification()` | ✅ | ✅(需系统已授权) | — |
|
|
131
|
+
| 前台/后台判定 | `document.visibilityState` + `document.hasFocus()` | ✅ | ✅ | — |
|
|
132
|
+
| 锁屏判定 | 宿主 `ioreg` 读 `CGSSessionScreenIsLocked` | ✅ | ✅ | ✅ |
|
|
133
|
+
|
|
134
|
+
- **桌面版不需要特殊适配。** Electron 单窗口常见问题(`document.hasFocus()` 恒为 `true` 导致永远判不出后台)在 DSH 桌面版上**不存在**:实测连续切换到 Finder / TextEdit / Safari,每次都正确翻成 `background`,且不会自抢焦点。
|
|
135
|
+
- **系统通知由页面发出**这一条对桌面版同样成立——Electron 渲染进程有完整 DOM,主进程也带 `setPermissionRequestHandler`(会处理通知权限请求)。
|
|
136
|
+
- **锁屏判定走宿主**,与客户端无关,所以「网页后台且锁屏时推送」那一档在桌面版照常生效。
|
|
137
|
+
- **桌面版只发系统通知的条件就是「APP 不在前台」**,不做前后台 toast 区分——窗口在前台时走页面内提示,失焦时走系统通知。
|
|
138
|
+
- `headless` 没有客户端,SSE 恒无连接 → 始终判为「离线」,因此只走 Telegram。
|
|
139
|
+
|
|
140
|
+
如果桌面版首次收不到系统通知,先确认 macOS **系统设置 → 通知 → DeepSeek Harness** 已允许;未授权时客户端会静默降级为页面内提示。
|
|
141
|
+
|
|
123
142
|
## 设计要点
|
|
124
143
|
|
|
125
144
|
- **SSE 长连接即存活信号**:页面与后端之间只有一条 SSE 通道,它同时承担存活判定、前后台状态上报和通知下发。**在线只看连接是否存在**,页面关闭 → 连接断开 → 后端自动改走 Telegram。心跳(10s)只用来判断焦点信息是否还新鲜,不参与生死判定 —— 否则会被 Chrome 的后台标签页定时器节流误伤(隐藏超约 5 分钟后 `setInterval` 收缩到约 1 次/分钟,会把开着的页面误判成离线)。焦点信息过期时退化为「后台」,而不是「离线」。
|
|
@@ -164,8 +183,32 @@ Telegram 的 bot **不能主动给人发消息** —— 它只能回复一个已
|
|
|
164
183
|
| `POST /session-notify/telegram-check` | 校验 Bot Token 并返回 bot 身份 |
|
|
165
184
|
|
|
166
185
|
|
|
186
|
+
## 排查收不到通知
|
|
187
|
+
|
|
188
|
+
先看宿主记录,它会直接告诉你**有没有下发过帧、给了谁、下的是什么帧**:
|
|
189
|
+
|
|
190
|
+
```sh
|
|
191
|
+
curl -s http://127.0.0.1:3080/session-notify/state | python3 -m json.tool
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
看 `recentFrames`(最近 20 次下发)与 `presence`:
|
|
195
|
+
|
|
196
|
+
| 现象 | 含义 |
|
|
197
|
+
|---|---|
|
|
198
|
+
| `recentFrames` 为空 | 事件没走到投递环节。检查日志里有没有「被时长过滤丢弃」 |
|
|
199
|
+
| 有记录、事件是 `toast` | 判定为前台,走页面内提示(APP 在前台时这是预期行为) |
|
|
200
|
+
| 有记录、事件是 `webnotify` 但没弹 | 帧已下发,是系统层面没显示 → 查系统通知权限 |
|
|
201
|
+
| `channel` 是 `dropped` | 写 SSE 失败,连接已断(页面重载/被系统回收) |
|
|
202
|
+
| `presence` 是 `offline` | 没有任何客户端在线 → 改走 Telegram,桌面通道不适用 |
|
|
203
|
+
|
|
204
|
+
两个曾经导致「收不到」的坑,已在 0.1.6 修掉:
|
|
205
|
+
|
|
206
|
+
- **同 tag 静默替换**:早期通知 `tag` 只用事件类型,macOS 会把同类型的新通知**静默替换**通知中心里还挂着的那条(不弹横幅、不响铃)。现在 `tag` 拼上 `sessionId + 事件类型 + 宿主时间戳 + 客户端序号`,每条都是独立条目。
|
|
207
|
+
- **短 turn 被静默丢弃**:`minDuration` 早先默认 10 秒且**静默 return**,导致「发个『你好』这种几秒结束的 turn」既收不到通知、也没有任何日志,看起来像插件坏了。现在默认 0(不过滤)、过滤时打日志、设置面板可调。
|
|
208
|
+
|
|
167
209
|
## 已知限制
|
|
168
210
|
|
|
169
211
|
- macOS 系统通知依赖浏览器通知权限;未授权时后台通知退化为页面内 toast(不会静默丢失)。
|
|
170
212
|
- 提示音在浏览器未解锁 autoplay 前由系统默认音替代。
|
|
171
213
|
- 标签页被系统冻结时 SSE 连接可能仍在,此时判定为「后台」而非「离线」;由于冻结的页面收不到帧,通知可能丢失。实际使用中 Chrome 对已打开的标签页一般不冻结。
|
|
214
|
+
- **锁屏探测只在 macOS 宿主上可用。** DSH 若跑在 Linux 服务器(如远程 Agent 服务器),宿主读不到你的屏幕锁屏状态,`screen.supported` 为 `false`,此时「网页后台且锁屏时推送」这一档**永不触发**(后台仍发系统通知,网页关闭仍走 Telegram)。宿主与你的机器不在同一台时,可改用 `DSH_SESSION_TG_NOTIFY_SCREENLOCK=off` 显式关闭该档,避免误以为它在工作。
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -295,6 +295,10 @@ window.__ModuleLoader__.load({
|
|
|
295
295
|
return function () { style.remove(); };
|
|
296
296
|
}, "dsh-session-tg-notify: styles");
|
|
297
297
|
|
|
298
|
+
// 通知序号:宿主给的是毫秒时间戳,同一毫秒内连续到达的两条事件会撞 tag,
|
|
299
|
+
// 又会退回「静默替换」。这里用一个单调递增的本地序号兜底。
|
|
300
|
+
var notifySeq = 0;
|
|
301
|
+
|
|
298
302
|
// ---- 通知呈现
|
|
299
303
|
ctx.effect(function () {
|
|
300
304
|
tone.attachUnlock();
|
|
@@ -316,7 +320,13 @@ window.__ModuleLoader__.load({
|
|
|
316
320
|
var note = new Notification(payload.title || "DSH 通知", {
|
|
317
321
|
body: payload.body || "",
|
|
318
322
|
// body 末尾已带「工作区名称」,无需再拼页脚
|
|
319
|
-
|
|
323
|
+
//
|
|
324
|
+
// tag 必须每条唯一。早先只用事件类型做 tag,于是同一类型的
|
|
325
|
+
// 第二条通知会**静默替换**通知中心里还挂着的那条:macOS 不再弹
|
|
326
|
+
// 横幅、也不再响铃。表现就是「偶尔收不到」,且很难复现——间隔久了
|
|
327
|
+
// 旧通知已被清走就又能弹。所以这里拼上会话、事件类型和宿主给的
|
|
328
|
+
// 唯一 at(毫秒时间戳),保证每条都是独立条目。
|
|
329
|
+
tag: "dsn:" + (payload.sessionId || "na") + ":" + (payload.kind || "event") + ":" + (payload.at || 0) + ":" + (++notifySeq),
|
|
320
330
|
// 合成音已播放时抑制系统音;未解锁则让系统出默认声,保证一定有声音
|
|
321
331
|
silent: played
|
|
322
332
|
});
|
|
@@ -530,6 +540,25 @@ window.__ModuleLoader__.load({
|
|
|
530
540
|
head.append(top);
|
|
531
541
|
body.append(head);
|
|
532
542
|
|
|
543
|
+
// ---- 「会话完成」的最短时长阈值
|
|
544
|
+
// 以前这个阈值只能改配置文件、面板里完全没有入口,于是「发个『你好』
|
|
545
|
+
// 这种几秒结束的 turn 收不到通知」看起来像插件坏了。
|
|
546
|
+
var minRow = el("div", "dsn-row");
|
|
547
|
+
minRow.append(el("span", "dsn-grow", "最短完成时长(秒,0 = 不过滤)"));
|
|
548
|
+
var minInput = el("input", "dsn-input");
|
|
549
|
+
minInput.type = "number";
|
|
550
|
+
minInput.min = "0";
|
|
551
|
+
minInput.step = "1";
|
|
552
|
+
minInput.style.width = "80px";
|
|
553
|
+
minInput.value = String(config.minDuration === undefined ? 0 : config.minDuration);
|
|
554
|
+
minInput.addEventListener("change", function () {
|
|
555
|
+
var n = parseInt(minInput.value, 10);
|
|
556
|
+
if (!isFinite(n) || n < 0) { minInput.value = String(config.minDuration || 0); return; }
|
|
557
|
+
save({ minDuration: n }, n === 0 ? "已关闭时长过滤" : "已设为 " + n + " 秒");
|
|
558
|
+
});
|
|
559
|
+
minRow.append(minInput);
|
|
560
|
+
body.append(minRow);
|
|
561
|
+
|
|
533
562
|
// ---- 逐事件:桌面 / Telegram / 测试
|
|
534
563
|
var evSec = el("div", "dsn-sec");
|
|
535
564
|
evSec.append(el("h4", null, "事件与通道"));
|
package/src/config.js
CHANGED
|
@@ -41,8 +41,14 @@ export const TONES = Object.freeze(['ding', 'alert', 'chime', 'none']);
|
|
|
41
41
|
/** 默认配置。 */
|
|
42
42
|
export const DEFAULT_CONFIG = Object.freeze({
|
|
43
43
|
enabled: true,
|
|
44
|
-
/**
|
|
45
|
-
|
|
44
|
+
/**
|
|
45
|
+
* complete 事件的最短 turn 时长(秒),低于此值不通知。0 = 不做时长过滤。
|
|
46
|
+
*
|
|
47
|
+
* 默认 0(全部通知)。早先默认 10,结果「发个『你好』这种几秒就结束的
|
|
48
|
+
* turn」被静默丢弃——既不通知也没日志,看起来像插件坏了。想防短任务刷屏
|
|
49
|
+
* 请在设置面板里自行调大。
|
|
50
|
+
*/
|
|
51
|
+
minDuration: 0,
|
|
46
52
|
events: Object.freeze({
|
|
47
53
|
complete: Object.freeze({ desktop: true, telegram: false }),
|
|
48
54
|
approval: Object.freeze({ desktop: true, telegram: true }),
|
package/src/index.js
CHANGED
|
@@ -34,7 +34,7 @@ import { readScreenLocked } from './screenlock.js';
|
|
|
34
34
|
import { sendTelegram, getTelegramMe, getTelegramChats, getTelegramChat } from './telegram.js';
|
|
35
35
|
|
|
36
36
|
export const name = 'session-notify';
|
|
37
|
-
export const VERSION = '0.1.
|
|
37
|
+
export const VERSION = '0.1.6';
|
|
38
38
|
/** 只消费事件与 webServer,不依赖其他服务。 */
|
|
39
39
|
export const inject = [];
|
|
40
40
|
|
|
@@ -54,6 +54,17 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
|
|
|
54
54
|
const send = options.sendTelegram ?? sendTelegram;
|
|
55
55
|
|
|
56
56
|
// ------------------------------------------------------------ 锁屏探测
|
|
57
|
+
/**
|
|
58
|
+
* 锁屏档可以被环境变量显式关闭。存在的理由:DSH 跑在 Linux 远程服务器上时
|
|
59
|
+
* 宿主读不到你的屏幕锁屏状态,那一档本来就不会触发;显式关掉能让设置面板
|
|
60
|
+
* 诚实地显示「已禁用」,而不是让人以为开关在工作。
|
|
61
|
+
* DSH_SESSION_TG_NOTIFY_SCREENLOCK=off → 永远报告 supported:false
|
|
62
|
+
*/
|
|
63
|
+
const screenlockEnabled = String(process.env.DSH_SESSION_TG_NOTIFY_SCREENLOCK ?? '').toLowerCase() !== 'off';
|
|
64
|
+
if (!screenlockEnabled) {
|
|
65
|
+
console.log('[session-notify] 锁屏探测已被 DSH_SESSION_TG_NOTIFY_SCREENLOCK=off 关闭');
|
|
66
|
+
}
|
|
67
|
+
|
|
57
68
|
/**
|
|
58
69
|
* 最近一次锁屏探测结果。锁屏只在「后台 + 订阅了 TG + 开了锁屏推送」这一种
|
|
59
70
|
* 组合下影响路由,所以平时不轮询、按需探测;设置页读取时也会强制探一次,
|
|
@@ -63,7 +74,7 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
|
|
|
63
74
|
let lastScreen = { supported: null, locked: false, signal: 'unknown', at: 0 };
|
|
64
75
|
|
|
65
76
|
const probeScreenLocked = async () => {
|
|
66
|
-
const result = await lockProbe();
|
|
77
|
+
const result = screenlockEnabled ? await lockProbe() : { supported: false, locked: false, signal: 'disabled' };
|
|
67
78
|
lastScreen = { ...result, at: Date.now() };
|
|
68
79
|
return lastScreen;
|
|
69
80
|
};
|
|
@@ -76,7 +87,7 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
|
|
|
76
87
|
* 便于区分是 ioreg 命中还是屏保兜底。
|
|
77
88
|
*/
|
|
78
89
|
const lockPollMs = Number.isSafeInteger(options.lockPollMs) ? options.lockPollMs : 20000;
|
|
79
|
-
if (lockPollMs > 0 && typeof ctx.effect === 'function') {
|
|
90
|
+
if (screenlockEnabled && lockPollMs > 0 && typeof ctx.effect === 'function') {
|
|
80
91
|
ctx.effect(() => {
|
|
81
92
|
let previous = null;
|
|
82
93
|
const timer = setInterval(() => {
|
|
@@ -242,8 +253,18 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
|
|
|
242
253
|
const complete = classifyTurnEnd(session, turns2.onTurnEnd(session, event));
|
|
243
254
|
if (!complete) return;
|
|
244
255
|
const durationMs = complete.detail?.durationMs;
|
|
245
|
-
// 时长过滤:短于 minDuration 的 turn
|
|
246
|
-
|
|
256
|
+
// 时长过滤:短于 minDuration 的 turn 不打扰。
|
|
257
|
+
//
|
|
258
|
+
// 这里**必须留日志**:早先直接 return,导致「发个『你好』这种短消息
|
|
259
|
+
// 收不到通知」既没有任何通知、也没有任何记录,看起来像是插件坏了。
|
|
260
|
+
// 静默丢弃 + 无痕迹 = 最难排查的一类问题。
|
|
261
|
+
if (typeof durationMs === 'number' && durationMs < config.minDuration * 1000) {
|
|
262
|
+
console.log(
|
|
263
|
+
`[session-notify] ⏱ complete 被时长过滤丢弃(${Math.round(durationMs / 1000)}s < minDuration=${config.minDuration}s)` +
|
|
264
|
+
`${config.minDuration > 0 ? ',可在设置里调小或设为 0' : ''}`
|
|
265
|
+
);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
247
268
|
void deliver(complete, session);
|
|
248
269
|
}
|
|
249
270
|
});
|
|
@@ -279,6 +300,9 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
|
|
|
279
300
|
presence: hub.snapshot(),
|
|
280
301
|
// 面板的状态行要显示「锁屏推送」是否真的生效,所以这里带上实时锁屏状态
|
|
281
302
|
screen: lastScreen,
|
|
303
|
+
// 诊断:最近下发过哪些帧。面板测试能弹但实际事件不弹时,用它区分
|
|
304
|
+
//「路由选错了帧」与「客户端收到了但没执行」。
|
|
305
|
+
recentFrames: hub.recentFrames,
|
|
282
306
|
events: EVENT_META,
|
|
283
307
|
// configPath 给 curl/运维用;configPathDisplay 是收敛成 ~/... 的版本,
|
|
284
308
|
// 界面只允许用后者,避免把用户名渲染进 UI。
|
package/src/presence.js
CHANGED
|
@@ -87,8 +87,10 @@ export function createPresenceHub(options = {}) {
|
|
|
87
87
|
clients.set(clientId, { res, lastSeen: now(), visibility: 'visible', focused: false });
|
|
88
88
|
writeFrame(res, 'ready', { ...ready, clientId });
|
|
89
89
|
res.on('close', () => {
|
|
90
|
-
|
|
90
|
+
// 先 detach 再记录剩余数:反过来的话 clients.size 还没减,
|
|
91
|
+
// 只能靠 -1 硬凑,日志会打出「剩余 -1 个」这种误导性的值。
|
|
91
92
|
detach(clientId);
|
|
93
|
+
console.log(`[session-notify] 页面连接断开(剩余 ${clients.size} 个)`);
|
|
92
94
|
});
|
|
93
95
|
console.log(`[session-notify] 页面连接建立(clientId=${clientId},共 ${clients.size} 个)`);
|
|
94
96
|
startHeartbeat();
|
|
@@ -105,15 +107,48 @@ export function createPresenceHub(options = {}) {
|
|
|
105
107
|
return true;
|
|
106
108
|
};
|
|
107
109
|
|
|
110
|
+
/**
|
|
111
|
+
* 最近若干次下发记录(诊断用)。
|
|
112
|
+
*
|
|
113
|
+
* 为什么需要:`/test` 端点返回 `delivered:true` 只代表「写进了 SSE 连接」,
|
|
114
|
+
* 不代表客户端真的执行了。一旦「面板测试能弹、实际事件不弹」,光看宿主日志
|
|
115
|
+
* 无法区分是**路由选错了帧**(该发 webnotify 却发了 toast)还是**客户端收到
|
|
116
|
+
* 后没执行**。把最近下发的帧记下来就能一眼定位。
|
|
117
|
+
*
|
|
118
|
+
* 两条下发路径都要记:`sendTo`(真实事件与面板测试都走它)与 `broadcast`
|
|
119
|
+
* (`/test` 未指定 clientId 时的回退)。早先只记了 sendTo,导致手测
|
|
120
|
+
* `/test` 明明 delivered 却查不到任何记录,一度误判成「插件没加载新代码」。
|
|
121
|
+
*/
|
|
122
|
+
let recentFrames = [];
|
|
123
|
+
const rememberFrame = (channel, event, clientId, data) => {
|
|
124
|
+
recentFrames.unshift({
|
|
125
|
+
channel,
|
|
126
|
+
event,
|
|
127
|
+
clientId,
|
|
128
|
+
kind: data?.kind ?? null,
|
|
129
|
+
title: data?.title ?? null,
|
|
130
|
+
// body 与 sessionId 也要记:真实事件和面板测试的差别可能藏在正文里
|
|
131
|
+
// (例如正文为空时某些平台会不弹横幅),只记 title 会漏掉这类线索。
|
|
132
|
+
body: data?.body ?? null,
|
|
133
|
+
sessionId: data?.sessionId ?? null,
|
|
134
|
+
tone: data?.tone ?? null,
|
|
135
|
+
at: new Date().toISOString()
|
|
136
|
+
});
|
|
137
|
+
if (recentFrames.length > 20) recentFrames = recentFrames.slice(0, 20);
|
|
138
|
+
};
|
|
139
|
+
|
|
108
140
|
/** 广播一帧给所有在线客户端。 */
|
|
109
141
|
const broadcast = (event, data) => {
|
|
110
142
|
for (const client of clients.values()) writeFrame(client.res, event, data);
|
|
143
|
+
rememberFrame('broadcast', event, null, data);
|
|
111
144
|
};
|
|
112
145
|
|
|
113
146
|
/** 推给单个客户端(通知类帧用,避免多标签重复弹窗)。 */
|
|
114
147
|
const sendTo = (clientId, event, data) => {
|
|
115
148
|
const client = clients.get(clientId);
|
|
116
|
-
|
|
149
|
+
const ok = client ? writeFrame(client.res, event, data) : false;
|
|
150
|
+
rememberFrame(ok ? 'desktop' : 'dropped', event, clientId, data);
|
|
151
|
+
return ok;
|
|
117
152
|
};
|
|
118
153
|
|
|
119
154
|
/**
|
|
@@ -170,6 +205,10 @@ export function createPresenceHub(options = {}) {
|
|
|
170
205
|
sendTo,
|
|
171
206
|
snapshot,
|
|
172
207
|
dispose,
|
|
208
|
+
/** 最近下发记录(诊断用,见 rememberFrame 的说明)。 */
|
|
209
|
+
get recentFrames() {
|
|
210
|
+
return recentFrames;
|
|
211
|
+
},
|
|
173
212
|
get size() {
|
|
174
213
|
return clients.size;
|
|
175
214
|
}
|