dsh-session-tg-notify 0.1.0 → 0.1.1

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
@@ -30,6 +30,25 @@ dsh plugin --profile web add link:/绝对路径/dsh-session-tg-notify
30
30
 
31
31
  **点击通知直达会话**:页面内 toast 与 macOS 系统通知都可点击,点后会切回该标签页并打开对应会话(走 DSH 客户端的 `uiWorkspace.openSession`)。会话已归档或已被删除时导航失败会静默忽略。
32
32
 
33
+ ### 通知文案格式
34
+
35
+ 标题与正文由宿主统一组装,**页面内 toast、macOS 系统通知、Telegram 三处完全一致**:
36
+
37
+ ```
38
+ 标题:{emoji} {事件类型}:{会话标题}
39
+ 正文:{事件正文} 「{工作区名称}」
40
+ ```
41
+
42
+ 例如:
43
+
44
+ ```
45
+ ✅ 会话完成:个人开发APP消息推送方案调研
46
+ 帮我重构一下(172s) 「finance」
47
+ ```
48
+
49
+ - **会话标题**取自 `@deepseek-ai/dsh-session-title` 的 `TitleService`(`ctx.get('sessionTitle').get(session).title`)。会话还没生成标题时,标题降级为只有事件类型,不会出现悬空的「:」。
50
+ - **工作区名称**取会话 `cwd` 的末段(DSH 的工作区以目录为单位),拿不到时回退为 session id 前 8 位;两者都拿不到时正文不追加这段。
51
+
33
52
  ### 可通知的事件
34
53
 
35
54
  | 事件 | 触发时机 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-tg-notify",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "DSH 会话通知插件:监控会话事件(完成 / 审批 / 提问 / 受阻 / 出错),按浏览器前后台与锁屏状态路由到页面内 toast、系统通知或 Telegram,点击通知直达对应会话。",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -227,17 +227,14 @@ window.__ModuleLoader__.load({
227
227
  el.className = "dsn-toast";
228
228
  var head = document.createElement("div");
229
229
  head.className = "dsn-toast-t";
230
- head.textContent = (payload.emoji ? payload.emoji + " " : "") + (payload.title || "通知");
230
+ // 宿主已把标题组装成「{emoji} {事件类型}:{会话标题}」,这里原样渲染
231
+ head.textContent = payload.title || "通知";
232
+ head.style.whiteSpace = "normal";
231
233
  var body = document.createElement("div");
232
234
  body.className = "dsn-toast-b";
233
235
  body.textContent = payload.body || "";
234
236
  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
- }
237
+ // 工作区已经作为「工作区名称」附在正文末尾,不再单起页脚
241
238
  var timer = setTimeout(function () {
242
239
  if (el.parentNode) el.parentNode.removeChild(el);
243
240
  }, TOAST_MS);
@@ -316,8 +313,9 @@ window.__ModuleLoader__.load({
316
313
  }
317
314
  var played = tone.play(payload.tone);
318
315
  try {
319
- var note = new Notification((payload.emoji ? payload.emoji + " " : "") + (payload.title || "DSH 通知"), {
320
- body: [payload.body, payload.sessionLabel].filter(Boolean).join("\n"),
316
+ var note = new Notification(payload.title || "DSH 通知", {
317
+ body: payload.body || "",
318
+ // body 末尾已带「工作区名称」,无需再拼页脚
321
319
  tag: "dsh-session-tg-notify:" + (payload.kind || "event"),
322
320
  // 合成音已播放时抑制系统音;未解锁则让系统出默认声,保证一定有声音
323
321
  silent: played
package/src/events.js CHANGED
@@ -27,8 +27,11 @@ export function isMainSession(session) {
27
27
 
28
28
  const isValidTurn = (turn) => Number.isSafeInteger(turn) && turn > 0;
29
29
 
30
- /** 会话的可读短标签:优先 cwd 的末段,其次 session id 前 8 位。 */
31
- export function sessionLabel(session) {
30
+ /**
31
+ * 工作区名称:取会话 cwd 的末段(DSH 的工作区就是以目录为单位),
32
+ * 拿不到时退回 session id 前 8 位,保证通知里总有东西可显示。
33
+ */
34
+ export function workspaceName(session) {
32
35
  const cwd = session?.header?.cwd;
33
36
  if (typeof cwd === 'string' && cwd.length > 0) {
34
37
  const parts = cwd.replace(/\/+$/, '').split('/');
@@ -36,7 +39,26 @@ export function sessionLabel(session) {
36
39
  if (last) return last;
37
40
  }
38
41
  const id = session?.id;
39
- return typeof id === 'string' && id.length > 0 ? id.slice(0, 8) : 'session';
42
+ return typeof id === 'string' && id.length > 0 ? id.slice(0, 8) : '';
43
+ }
44
+
45
+ /**
46
+ * 读会话标题(用于通知标题的「:」之后那一段)。
47
+ *
48
+ * 标题由 `@deepseek-ai/dsh-session-title` 的 TitleService 提供(ctx 键
49
+ * `sessionTitle`),`get(session)` 返回 `{ title, ... }`;会话还没有标题事件
50
+ * 时返回 undefined。标题读不到绝不能影响通知本身,所以这里吞掉所有异常。
51
+ */
52
+ export function readSessionTitle(service, session) {
53
+ if (!service || typeof service.get !== 'function' || !session) return null;
54
+ try {
55
+ const value = service.get(session);
56
+ if (typeof value === 'string' && value.length > 0) return value;
57
+ if (value && typeof value.title === 'string' && value.title.length > 0) return value.title;
58
+ } catch {
59
+ // 标题服务不可用/会话已销毁:降级为「没有标题」,不影响通知
60
+ }
61
+ return null;
40
62
  }
41
63
 
42
64
  /** 截断为单行,避免通知正文过长。 */
@@ -188,7 +210,6 @@ export function classifyApproval(session, event) {
188
210
  return {
189
211
  kind: 'approval',
190
212
  sessionId,
191
- title: '需要审批',
192
213
  body: oneLine(reason === null ? toolName : `${toolName} — ${reason}`),
193
214
  detail: { toolName, reason },
194
215
  dedupeKey: `approval:${sessionId}:${id}`
@@ -220,7 +241,6 @@ export function classifyQuestion(session, event) {
220
241
  return {
221
242
  kind: 'question',
222
243
  sessionId,
223
- title: '需要回答',
224
244
  body: oneLine(text === null ? '(Agent 提问)' : text) + suffix,
225
245
  detail: { questionText: text, optionsCount, questionCount: questions.length },
226
246
  dedupeKey: `question:${sessionId}:${data.callId ?? 'unknown'}`
@@ -236,7 +256,6 @@ export function classifyGoalChange(change) {
236
256
  return {
237
257
  kind: 'block',
238
258
  sessionId: null,
239
- title: '目标受阻',
240
259
  body: oneLine(objective ?? '(未提供目标描述)'),
241
260
  detail: { objective },
242
261
  dedupeKey: ref ? `goal:${ref.id}@${ref.revision}` : `goal:block:${goal?.id ?? 'unknown'}`
@@ -251,7 +270,6 @@ export function classifyAgentError(payload) {
251
270
  return {
252
271
  kind: 'error',
253
272
  sessionId: typeof agent?.id === 'string' && agent.id.length > 0 ? agent.id : null,
254
- title: '运行出错',
255
273
  body: oneLine(message),
256
274
  detail: { message, turn: Number.isFinite(turn) ? turn : null, step: Number.isFinite(step) ? step : null },
257
275
  dedupeKey: `error:${agent?.id ?? 'unknown'}@${turn ?? '-'}@${step ?? '-'}`
@@ -266,7 +284,6 @@ export function classifyTurnEnd(session, complete) {
266
284
  return {
267
285
  kind: 'complete',
268
286
  sessionId,
269
- title: '会话完成',
270
287
  body: oneLine(complete.summary ?? `第 ${complete.turn} 轮`) + durationText,
271
288
  detail: { turn: complete.turn, durationMs: complete.durationMs, summary: complete.summary },
272
289
  dedupeKey: `complete:${sessionId}:${complete.turn}`
package/src/index.js CHANGED
@@ -26,7 +26,8 @@ import {
26
26
  classifyTurnEnd,
27
27
  classifyGoalChange,
28
28
  classifyAgentError,
29
- sessionLabel,
29
+ workspaceName,
30
+ readSessionTitle,
30
31
  oneLine
31
32
  } from './events.js';
32
33
  import { createPresenceHub } from './presence.js';
@@ -34,7 +35,7 @@ import { readScreenLocked } from './screenlock.js';
34
35
  import { sendTelegram, getTelegramMe, getTelegramChats, getTelegramChat } from './telegram.js';
35
36
 
36
37
  export const name = 'session-notify';
37
- export const VERSION = '0.1.0';
38
+ export const VERSION = '0.1.1';
38
39
  /** 只消费事件与 webServer,不依赖其他服务。 */
39
40
  export const inject = [];
40
41
 
@@ -106,6 +107,37 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
106
107
  }
107
108
  };
108
109
 
110
+ /**
111
+ * 会话标题(通知标题里「:」之后那一段)。
112
+ * 服务缺失、或该会话还没生成标题时返回 null —— 通知降级为只有事件类型。
113
+ */
114
+ const sessionTitleOf = (session) => {
115
+ if (!session) return null;
116
+ try {
117
+ return readSessionTitle(typeof ctx.get === 'function' ? ctx.get('sessionTitle') : null, session);
118
+ } catch {
119
+ return null;
120
+ }
121
+ };
122
+
123
+ /**
124
+ * 组装最终通知文案(两个通道共用,保证 toast / 系统通知 / Telegram 完全一致):
125
+ * 标题 = `{emoji} {事件类型}:{会话标题}`,标题缺失时省去「:…」
126
+ * 正文 = `{事件正文} 「{工作区名称}」`
127
+ */
128
+ const compose = (kind, body, session) => {
129
+ const emoji = EVENT_EMOJI[kind] ?? '';
130
+ const label = EVENT_LABELS[kind] ?? kind;
131
+ const head = `${emoji} ${label}`.trim();
132
+ const sessionTitle = sessionTitleOf(session);
133
+ const workspace = session ? workspaceName(session) : '';
134
+ const text = typeof body === 'string' ? body : '';
135
+ return {
136
+ title: sessionTitle ? `${head}:${sessionTitle}` : head,
137
+ body: workspace.length > 0 ? `${text} 「${workspace}」`.trim() : text
138
+ };
139
+ };
140
+
109
141
  /**
110
142
  * Telegram 通道未就绪的原因(就绪返回 null)。
111
143
  * 「行内勾选 TG」表达的是**订阅**,通道是否真的能用由启用开关 + 凭据决定。
@@ -136,14 +168,19 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
136
168
  return true;
137
169
  };
138
170
 
139
- /** 路由并投递一次通知。 */
140
- const deliver = async (notification) => {
171
+ /**
172
+ * 路由并投递一次通知。
173
+ * @param notification - 分类器输出(kind / body / sessionId / dedupeKey / detail)。
174
+ * @param session - 对应会话;goal/error 这类没有会话上下文的事件传 null。
175
+ */
176
+ const deliver = async (notification, session = null) => {
141
177
  if (!notification) return;
142
178
  const eventConfig = config.events[notification.kind];
143
179
  if (!config.enabled || !eventConfig) return;
144
180
  if (!dedupe.admit(notification.sessionId, notification.dedupeKey)) return;
145
181
  // 两个通道都没勾 → 用户明确不想被这个事件打扰。
146
182
  if (!eventConfig.desktop && !eventConfig.telegram) return;
183
+ const composed = compose(notification.kind, notification.body, session);
147
184
 
148
185
  const presence = hub.snapshot();
149
186
  // 只有「后台 + 开了锁屏推送」这一种组合需要真的去问系统锁没锁,
@@ -151,12 +188,11 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
151
188
  const needsLockProbe = presence.state === 'background' && eventConfig.telegram === true && config.telegram.notifyWhenLocked === true;
152
189
  const screen = needsLockProbe ? await probeScreenLocked() : null;
153
190
 
191
+ // title 已含 emoji 与事件类型,body 末尾已带「工作区」——两端渲染时原样使用。
154
192
  const payload = {
155
193
  kind: notification.kind,
156
- title: notification.title,
157
- body: notification.body,
158
- emoji: EVENT_EMOJI[notification.kind] ?? '',
159
- sessionLabel: notification.sessionLabel ?? '',
194
+ title: composed.title,
195
+ body: composed.body,
160
196
  // 供页面内 toast / 系统通知点击后直达对应会话
161
197
  sessionId: notification.sessionId ?? null,
162
198
  at: Date.now()
@@ -195,12 +231,12 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
195
231
 
196
232
  const approval = classifyApproval(session, event);
197
233
  if (approval) {
198
- void deliver({ ...approval, sessionLabel: sessionLabel(session), emoji: EVENT_EMOJI.approval });
234
+ void deliver(approval, session);
199
235
  return;
200
236
  }
201
237
  const question = classifyQuestion(session, event);
202
238
  if (question) {
203
- void deliver({ ...question, sessionLabel: sessionLabel(session), emoji: EVENT_EMOJI.question });
239
+ void deliver(question, session);
204
240
  return;
205
241
  }
206
242
 
@@ -222,18 +258,18 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
222
258
  const durationMs = complete.detail?.durationMs;
223
259
  // 时长过滤:短于 minDuration 的 turn 不打扰(未知时长按通过处理)。
224
260
  if (typeof durationMs === 'number' && durationMs < config.minDuration * 1000) return;
225
- void deliver({ ...complete, sessionLabel: sessionLabel(session), emoji: EVENT_EMOJI.complete });
261
+ void deliver(complete, session);
226
262
  }
227
263
  });
228
264
 
229
265
  ctx.on('goal/changed', ({ change }) => {
230
266
  const block = classifyGoalChange(change);
231
- if (block) void deliver({ ...block, sessionLabel: '', emoji: EVENT_EMOJI.block });
267
+ if (block) void deliver(block);
232
268
  });
233
269
 
234
270
  ctx.on('agent/error', (payload) => {
235
271
  const error = classifyAgentError(payload);
236
- if (error) void deliver({ ...error, sessionLabel: '', emoji: EVENT_EMOJI.error });
272
+ if (error) void deliver(error);
237
273
  });
238
274
 
239
275
  ctx.on('session/disposed', (session) => {
@@ -338,12 +374,11 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
338
374
  const kind = typeof body.kind === 'string' ? body.kind : '';
339
375
  const channel = body.channel === 'telegram' ? 'telegram' : 'desktop';
340
376
  if (!EVENT_LABELS[kind]) return json(res, 400, { ok: false, error: `unknown event kind: ${kind}` });
377
+ // 用与真实通知相同的文案形状,测试才代表实际观感
341
378
  const sample = {
342
379
  kind,
343
- title: EVENT_LABELS[kind],
344
- body: `这是一条来自 dsh-session-tg-notify 的测试通知(${EVENT_LABELS[kind]})`,
345
- emoji: EVENT_EMOJI[kind] ?? '',
346
- sessionLabel: 'settings-test',
380
+ title: `${EVENT_EMOJI[kind] ?? ''} ${EVENT_LABELS[kind]}:测试会话`,
381
+ body: `这是一条测试通知 「settings-test」`,
347
382
  at: Date.now()
348
383
  };
349
384
  if (channel === 'telegram') {
package/src/telegram.js CHANGED
@@ -6,14 +6,18 @@
6
6
  * 通知失败绝不能影响 DSH 会话本身。
7
7
  */
8
8
 
9
- /** 把通知内容渲染成 Telegram 消息文本(HTML 模式,需转义)。 */
9
+ /**
10
+ * 把通知内容渲染成 Telegram 消息文本(HTML 模式,需转义)。
11
+ *
12
+ * title 已由宿主组装成「{emoji} {事件类型}:{会话标题}」,body 末尾已带
13
+ * 「工作区名称」,所以这里只做转义与换行,不再自己拼接片段 —— 保证
14
+ * Telegram 与页面内 toast / 系统通知三处文案完全一致。
15
+ */
10
16
  export function renderTelegramText(notification) {
11
- const emoji = notification.emoji ?? '';
12
17
  const title = notification.title ?? 'DSH 通知';
13
18
  const body = notification.body ?? '';
14
- const lines = [`${emoji} <b>${escapeHtml(title)}</b>`];
19
+ const lines = [`<b>${escapeHtml(title)}</b>`];
15
20
  if (body) lines.push(escapeHtml(body));
16
- if (notification.sessionLabel) lines.push(`<i>${escapeHtml(notification.sessionLabel)}</i>`);
17
21
  return lines.join('\n');
18
22
  }
19
23