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 +19 -0
- package/package.json +1 -1
- package/src/client.js +7 -9
- package/src/events.js +6 -8
- package/src/index.js +38 -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
|
+
✅ 会话完成
|
|
46
|
+
帮我重构一下(172s) 「finance」
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- **标题只显示事件类型**,不带会话标题。会话标题在系统通知里常被截断、信息量也低,而通知本身已经能点击直达会话,点进去即可看到完整标题。
|
|
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,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) : '
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
157
|
-
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(
|
|
220
|
+
void deliver(approval, session);
|
|
199
221
|
return;
|
|
200
222
|
}
|
|
201
223
|
const question = classifyQuestion(session, event);
|
|
202
224
|
if (question) {
|
|
203
|
-
void deliver(
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
/**
|
|
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
|
|