dsh-session-tg-notify 0.1.5 → 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 CHANGED
@@ -183,6 +183,29 @@ Telegram 的 bot **不能主动给人发消息** —— 它只能回复一个已
183
183
  | `POST /session-notify/telegram-check` | 校验 Bot Token 并返回 bot 身份 |
184
184
 
185
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
+
186
209
  ## 已知限制
187
210
 
188
211
  - macOS 系统通知依赖浏览器通知权限;未授权时后台通知退化为页面内 toast(不会静默丢失)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-tg-notify",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "DSH 会话通知插件:监控会话事件(完成 / 审批 / 提问 / 受阻 / 出错),按浏览器前后台与锁屏状态路由到页面内 toast、系统通知或 Telegram,点击通知直达对应会话。",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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
- tag: "dsh-session-tg-notify:" + (payload.kind || "event"),
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
- /** complete 事件的最短 turn 时长(秒),低于此值不通知,避免短任务刷屏。 */
45
- minDuration: 10,
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.5';
37
+ export const VERSION = '0.1.6';
38
38
  /** 只消费事件与 webServer,不依赖其他服务。 */
39
39
  export const inject = [];
40
40
 
@@ -253,8 +253,18 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
253
253
  const complete = classifyTurnEnd(session, turns2.onTurnEnd(session, event));
254
254
  if (!complete) return;
255
255
  const durationMs = complete.detail?.durationMs;
256
- // 时长过滤:短于 minDuration 的 turn 不打扰(未知时长按通过处理)。
257
- if (typeof durationMs === 'number' && durationMs < config.minDuration * 1000) return;
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
+ }
258
268
  void deliver(complete, session);
259
269
  }
260
270
  });
@@ -290,6 +300,9 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
290
300
  presence: hub.snapshot(),
291
301
  // 面板的状态行要显示「锁屏推送」是否真的生效,所以这里带上实时锁屏状态
292
302
  screen: lastScreen,
303
+ // 诊断:最近下发过哪些帧。面板测试能弹但实际事件不弹时,用它区分
304
+ //「路由选错了帧」与「客户端收到了但没执行」。
305
+ recentFrames: hub.recentFrames,
293
306
  events: EVENT_META,
294
307
  // configPath 给 curl/运维用;configPathDisplay 是收敛成 ~/... 的版本,
295
308
  // 界面只允许用后者,避免把用户名渲染进 UI。
package/src/presence.js CHANGED
@@ -107,15 +107,48 @@ export function createPresenceHub(options = {}) {
107
107
  return true;
108
108
  };
109
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
+
110
140
  /** 广播一帧给所有在线客户端。 */
111
141
  const broadcast = (event, data) => {
112
142
  for (const client of clients.values()) writeFrame(client.res, event, data);
143
+ rememberFrame('broadcast', event, null, data);
113
144
  };
114
145
 
115
146
  /** 推给单个客户端(通知类帧用,避免多标签重复弹窗)。 */
116
147
  const sendTo = (clientId, event, data) => {
117
148
  const client = clients.get(clientId);
118
- return client ? writeFrame(client.res, event, data) : false;
149
+ const ok = client ? writeFrame(client.res, event, data) : false;
150
+ rememberFrame(ok ? 'desktop' : 'dropped', event, clientId, data);
151
+ return ok;
119
152
  };
120
153
 
121
154
  /**
@@ -172,6 +205,10 @@ export function createPresenceHub(options = {}) {
172
205
  sendTo,
173
206
  snapshot,
174
207
  dispose,
208
+ /** 最近下发记录(诊断用,见 rememberFrame 的说明)。 */
209
+ get recentFrames() {
210
+ return recentFrames;
211
+ },
175
212
  get size() {
176
213
  return clients.size;
177
214
  }