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 +19 -0
- package/package.json +1 -1
- package/src/client.js +7 -9
- package/src/events.js +25 -8
- package/src/index.js +52 -17
- package/src/telegram.js +8 -4
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
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
|
-
|
|
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
|
-
|
|
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(
|
|
320
|
-
body:
|
|
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
|
-
/**
|
|
31
|
-
|
|
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) : '
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
157
|
-
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(
|
|
234
|
+
void deliver(approval, session);
|
|
199
235
|
return;
|
|
200
236
|
}
|
|
201
237
|
const question = classifyQuestion(session, event);
|
|
202
238
|
if (question) {
|
|
203
|
-
void deliver(
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
/**
|
|
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 = [
|
|
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
|
|