dsh-session-tg-notify 0.1.0 → 0.1.2

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
+ ✅ 会话完成
46
+ 帮我重构一下(172s) 「finance」
47
+ ```
48
+
49
+ - **标题只显示事件类型**,不带会话标题。会话标题在系统通知里常被截断、信息量也低,而通知本身已经能点击直达会话,点进去即可看到完整标题。
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.2",
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,7 @@ 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) : '';
40
43
  }
41
44
 
42
45
  /** 截断为单行,避免通知正文过长。 */
@@ -188,7 +191,6 @@ export function classifyApproval(session, event) {
188
191
  return {
189
192
  kind: 'approval',
190
193
  sessionId,
191
- title: '需要审批',
192
194
  body: oneLine(reason === null ? toolName : `${toolName} — ${reason}`),
193
195
  detail: { toolName, reason },
194
196
  dedupeKey: `approval:${sessionId}:${id}`
@@ -220,7 +222,6 @@ export function classifyQuestion(session, event) {
220
222
  return {
221
223
  kind: 'question',
222
224
  sessionId,
223
- title: '需要回答',
224
225
  body: oneLine(text === null ? '(Agent 提问)' : text) + suffix,
225
226
  detail: { questionText: text, optionsCount, questionCount: questions.length },
226
227
  dedupeKey: `question:${sessionId}:${data.callId ?? 'unknown'}`
@@ -236,7 +237,6 @@ export function classifyGoalChange(change) {
236
237
  return {
237
238
  kind: 'block',
238
239
  sessionId: null,
239
- title: '目标受阻',
240
240
  body: oneLine(objective ?? '(未提供目标描述)'),
241
241
  detail: { objective },
242
242
  dedupeKey: ref ? `goal:${ref.id}@${ref.revision}` : `goal:block:${goal?.id ?? 'unknown'}`
@@ -251,7 +251,6 @@ export function classifyAgentError(payload) {
251
251
  return {
252
252
  kind: 'error',
253
253
  sessionId: typeof agent?.id === 'string' && agent.id.length > 0 ? agent.id : null,
254
- title: '运行出错',
255
254
  body: oneLine(message),
256
255
  detail: { message, turn: Number.isFinite(turn) ? turn : null, step: Number.isFinite(step) ? step : null },
257
256
  dedupeKey: `error:${agent?.id ?? 'unknown'}@${turn ?? '-'}@${step ?? '-'}`
@@ -266,7 +265,6 @@ export function classifyTurnEnd(session, complete) {
266
265
  return {
267
266
  kind: 'complete',
268
267
  sessionId,
269
- title: '会话完成',
270
268
  body: oneLine(complete.summary ?? `第 ${complete.turn} 轮`) + durationText,
271
269
  detail: { turn: complete.turn, durationMs: complete.durationMs, summary: complete.summary },
272
270
  dedupeKey: `complete:${sessionId}:${complete.turn}`
package/src/index.js CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  classifyTurnEnd,
27
27
  classifyGoalChange,
28
28
  classifyAgentError,
29
- sessionLabel,
29
+ workspaceName,
30
30
  oneLine
31
31
  } from './events.js';
32
32
  import { createPresenceHub } from './presence.js';
@@ -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.0';
37
+ export const VERSION = '0.1.2';
38
38
  /** 只消费事件与 webServer,不依赖其他服务。 */
39
39
  export const inject = [];
40
40
 
@@ -106,6 +106,24 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
106
106
  }
107
107
  };
108
108
 
109
+ /**
110
+ * 组装最终通知文案(两个通道共用,保证 toast / 系统通知 / Telegram 完全一致):
111
+ * 标题 = `{emoji} {事件类型}:{会话标题}`,标题缺失时省去「:…」
112
+ * 正文 = `{事件正文} 「{工作区名称}」`
113
+ */
114
+ const compose = (kind, body, session) => {
115
+ const emoji = EVENT_EMOJI[kind] ?? '';
116
+ const label = EVENT_LABELS[kind] ?? kind;
117
+ const workspace = session ? workspaceName(session) : '';
118
+ const text = typeof body === 'string' ? body : '';
119
+ return {
120
+ // 标题只显示事件类型(「✅ 会话完成」)。会话标题不进通知 —— 它在
121
+ // 通知里容易被截断且信息量低,点进去就能看到。
122
+ title: `${emoji} ${label}`.trim(),
123
+ body: workspace.length > 0 ? `${text} 「${workspace}」`.trim() : text
124
+ };
125
+ };
126
+
109
127
  /**
110
128
  * Telegram 通道未就绪的原因(就绪返回 null)。
111
129
  * 「行内勾选 TG」表达的是**订阅**,通道是否真的能用由启用开关 + 凭据决定。
@@ -136,14 +154,19 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
136
154
  return true;
137
155
  };
138
156
 
139
- /** 路由并投递一次通知。 */
140
- const deliver = async (notification) => {
157
+ /**
158
+ * 路由并投递一次通知。
159
+ * @param notification - 分类器输出(kind / body / sessionId / dedupeKey / detail)。
160
+ * @param session - 对应会话;goal/error 这类没有会话上下文的事件传 null。
161
+ */
162
+ const deliver = async (notification, session = null) => {
141
163
  if (!notification) return;
142
164
  const eventConfig = config.events[notification.kind];
143
165
  if (!config.enabled || !eventConfig) return;
144
166
  if (!dedupe.admit(notification.sessionId, notification.dedupeKey)) return;
145
167
  // 两个通道都没勾 → 用户明确不想被这个事件打扰。
146
168
  if (!eventConfig.desktop && !eventConfig.telegram) return;
169
+ const composed = compose(notification.kind, notification.body, session);
147
170
 
148
171
  const presence = hub.snapshot();
149
172
  // 只有「后台 + 开了锁屏推送」这一种组合需要真的去问系统锁没锁,
@@ -151,12 +174,11 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
151
174
  const needsLockProbe = presence.state === 'background' && eventConfig.telegram === true && config.telegram.notifyWhenLocked === true;
152
175
  const screen = needsLockProbe ? await probeScreenLocked() : null;
153
176
 
177
+ // title 已含 emoji 与事件类型,body 末尾已带「工作区」——两端渲染时原样使用。
154
178
  const payload = {
155
179
  kind: notification.kind,
156
- title: notification.title,
157
- body: notification.body,
158
- emoji: EVENT_EMOJI[notification.kind] ?? '',
159
- sessionLabel: notification.sessionLabel ?? '',
180
+ title: composed.title,
181
+ body: composed.body,
160
182
  // 供页面内 toast / 系统通知点击后直达对应会话
161
183
  sessionId: notification.sessionId ?? null,
162
184
  at: Date.now()
@@ -195,12 +217,12 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
195
217
 
196
218
  const approval = classifyApproval(session, event);
197
219
  if (approval) {
198
- void deliver({ ...approval, sessionLabel: sessionLabel(session), emoji: EVENT_EMOJI.approval });
220
+ void deliver(approval, session);
199
221
  return;
200
222
  }
201
223
  const question = classifyQuestion(session, event);
202
224
  if (question) {
203
- void deliver({ ...question, sessionLabel: sessionLabel(session), emoji: EVENT_EMOJI.question });
225
+ void deliver(question, session);
204
226
  return;
205
227
  }
206
228
 
@@ -222,18 +244,18 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
222
244
  const durationMs = complete.detail?.durationMs;
223
245
  // 时长过滤:短于 minDuration 的 turn 不打扰(未知时长按通过处理)。
224
246
  if (typeof durationMs === 'number' && durationMs < config.minDuration * 1000) return;
225
- void deliver({ ...complete, sessionLabel: sessionLabel(session), emoji: EVENT_EMOJI.complete });
247
+ void deliver(complete, session);
226
248
  }
227
249
  });
228
250
 
229
251
  ctx.on('goal/changed', ({ change }) => {
230
252
  const block = classifyGoalChange(change);
231
- if (block) void deliver({ ...block, sessionLabel: '', emoji: EVENT_EMOJI.block });
253
+ if (block) void deliver(block);
232
254
  });
233
255
 
234
256
  ctx.on('agent/error', (payload) => {
235
257
  const error = classifyAgentError(payload);
236
- if (error) void deliver({ ...error, sessionLabel: '', emoji: EVENT_EMOJI.error });
258
+ if (error) void deliver(error);
237
259
  });
238
260
 
239
261
  ctx.on('session/disposed', (session) => {
@@ -338,12 +360,11 @@ export function apply(ctx, cordisConfig = {}, options = {}) {
338
360
  const kind = typeof body.kind === 'string' ? body.kind : '';
339
361
  const channel = body.channel === 'telegram' ? 'telegram' : 'desktop';
340
362
  if (!EVENT_LABELS[kind]) return json(res, 400, { ok: false, error: `unknown event kind: ${kind}` });
363
+ // 用与真实通知相同的文案形状,测试才代表实际观感
341
364
  const sample = {
342
365
  kind,
343
- title: EVENT_LABELS[kind],
344
- body: `这是一条来自 dsh-session-tg-notify 的测试通知(${EVENT_LABELS[kind]})`,
345
- emoji: EVENT_EMOJI[kind] ?? '',
346
- sessionLabel: 'settings-test',
366
+ title: `${EVENT_EMOJI[kind] ?? ''} ${EVENT_LABELS[kind]}:测试会话`,
367
+ body: `这是一条测试通知 「settings-test」`,
347
368
  at: Date.now()
348
369
  };
349
370
  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