@cowwo/dsh-lookout 0.2.0 → 0.5.0
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 +34 -8
- package/client/client.js +938 -1084
- package/lib/index.js +29 -3
- package/lib/notify/config.js +119 -153
- package/lib/notify/index.js +47 -112
- package/lib/notify/render.js +4 -18
- package/lib/notify/send/index.js +10 -13
- package/lib/notify/service.js +4 -16
- package/lib/notify/test-send.js +5 -8
- package/lib/notify/typert.host.js +2 -24
- package/lib/typert.host.js +2 -23
- package/package.json +1 -1
- package/lib/notify/state.js +0 -102
package/lib/notify/index.js
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
// dsh-notify-p · Host 半(入口)
|
|
2
2
|
//
|
|
3
3
|
// 一句话:会话干完活 / 出错 / 卡住 / 等你审批时,发一封邮件到你的邮箱,
|
|
4
|
-
// 正文写清"是哪个会话"
|
|
4
|
+
// 正文写清"是哪个会话"(不带任何链接——"邮件直达会话"这条能力已整块删除)。
|
|
5
5
|
//
|
|
6
6
|
// 四条纪律(都来自对同类插件的调研,写在代码里免得以后改回去):
|
|
7
7
|
// 1. aborted 必须按 cause 细分(重启/热更会以 disposed 结束回合,不是"你主动停止")
|
|
8
8
|
// 2. 子代理会话默认跳过(一个 workflow 能扇出几十个子会话)
|
|
9
|
-
// 3. 一个会话的连续多轮只提醒一次(turn/end 后等
|
|
9
|
+
// 3. 一个会话的连续多轮只提醒一次(turn/end 后等 SETTLE_MS,期间又开新轮就取消)
|
|
10
10
|
// 4. 每个订阅处理器都要自吞异常:cordis 的 emit 是 stop-on-throw,
|
|
11
11
|
// 一个插件抛错会饿死排在后面的订阅者(含 DSH 自己的持久化/遥测)
|
|
12
|
-
//
|
|
13
|
-
// 本插件**不实现** `?session=` 的接收端:链接的识别由 dsh-deeplink(或 dsh-session-link)负责,
|
|
14
|
-
// 这里只负责把链接拼进邮件正文。
|
|
15
12
|
|
|
16
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
resolveConfig,
|
|
15
|
+
parseRecipients,
|
|
16
|
+
recipientIssues,
|
|
17
|
+
SETTLE_MS,
|
|
18
|
+
MIN_DURATION_MS,
|
|
19
|
+
RATE_PER_MINUTE,
|
|
20
|
+
} from './config.js';
|
|
17
21
|
import {
|
|
18
22
|
OUTCOME,
|
|
19
23
|
classifyTurnEnd,
|
|
@@ -26,19 +30,22 @@ import {
|
|
|
26
30
|
import { renderMail } from './render.js';
|
|
27
31
|
import { createSendQueue } from './queue.js';
|
|
28
32
|
import { sendMail, MailSendError } from './send/index.js';
|
|
29
|
-
import { createLedger } from './state.js';
|
|
30
33
|
import { MailNotifyRemoteService } from './service.js';
|
|
31
|
-
import { sendTestMail
|
|
34
|
+
import { sendTestMail } from './test-send.js';
|
|
32
35
|
|
|
33
36
|
export const name = 'dsh-notify-p';
|
|
34
37
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
38
|
+
/**
|
|
39
|
+
* sessions:扫已有会话补标题、订阅会话事件。
|
|
40
|
+
*
|
|
41
|
+
* 这里**不 inject settings**:配置不是从 settings 服务读的,而是 apply 收到的
|
|
42
|
+
* 入口 config(见下方 readConfig),凭据走 credentials 服务(可用时)或环境变量。
|
|
43
|
+
* 代码里没有任何 `ctx.settings` 调用,多挂一个服务只会让人误判依赖。
|
|
44
|
+
*/
|
|
45
|
+
export const inject = ['sessions'];
|
|
37
46
|
|
|
38
47
|
/** 去重表上限,防长跑进程无界增长。 */
|
|
39
48
|
const SENT_LIMIT = 5000;
|
|
40
|
-
/** 最近投递结果保留条数(目前只进日志,第二期可挂到卡片上)。 */
|
|
41
|
-
const RECENT_LIMIT = 20;
|
|
42
49
|
|
|
43
50
|
/**
|
|
44
51
|
* 插件入口。
|
|
@@ -46,7 +53,7 @@ const RECENT_LIMIT = 20;
|
|
|
46
53
|
* 这里是最后一道保险:本插件的任何异常都不允许把 DSH 的启动带崩。
|
|
47
54
|
* 真正的装配逻辑在 start() 里,外面只负责"兜住并降级"。
|
|
48
55
|
* @param {import('@deepseek-ai/cordis').Context} ctx 插件上下文
|
|
49
|
-
* @param {object} [entryConfig] cordis 行的 config
|
|
56
|
+
* @param {object} [entryConfig] cordis 行的 config(组合层 patch 里本插件 entry 的 config;缺省字段由 MailSchema 兜底)
|
|
50
57
|
* @returns {void}
|
|
51
58
|
*/
|
|
52
59
|
export function apply(ctx, entryConfig) {
|
|
@@ -69,25 +76,20 @@ export function apply(ctx, entryConfig) {
|
|
|
69
76
|
*/
|
|
70
77
|
function start(ctx, entryConfig) {
|
|
71
78
|
const logger = ctx.logger ?? console;
|
|
72
|
-
const entry = resolveConfig(entryConfig);
|
|
73
79
|
|
|
74
|
-
// ----
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
logger.warn?.(`dsh-notify-p: settings 命名空间注册失败,退回组合层配置 / settings registration failed: ${String(error)}`);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
const readConfig = () => source();
|
|
80
|
+
// ---- 配置:**每次读活的**,绝不在装配时快照 ----
|
|
81
|
+
//
|
|
82
|
+
// 设置页保存走 cordis-plugin-loader 的 `_commitVolatile`:它把新值**原地**写进
|
|
83
|
+
// apply 收到的那个 config 对象里的 volatile 引用,**不会重跑 apply**。
|
|
84
|
+
// 所以这里若写成 `const entry = resolveConfig(entryConfig)` 再反复用它,
|
|
85
|
+
// 表现就是「开关能点、也存住了,但插件行为一直不变,非要重启才生效」。
|
|
86
|
+
//
|
|
87
|
+
// 同理,这里也**不碰 settings 服务**:本插件不注册命名空间、不读 `ctx.settings`,
|
|
88
|
+
// 配置就是 apply 收到的这份入口 config(schema 由 loader 从入口的 `Config` 导出取,
|
|
89
|
+
// 见 lib/index.js)。所以 inject 里也不需要 settings——它缺席时本插件照常跑。
|
|
90
|
+
//
|
|
91
|
+
// 出参永远是裸值的普通对象:volatile 那层壳由 config.js 的 resolveConfig 拆掉。
|
|
92
|
+
const readConfig = () => resolveConfig(entryConfig);
|
|
91
93
|
|
|
92
94
|
// ---- 运行态 ----
|
|
93
95
|
/** @type {Map<string, string>} 会话 id → 标题 */
|
|
@@ -100,22 +102,13 @@ function start(ctx, entryConfig) {
|
|
|
100
102
|
const agentErrors = new Map();
|
|
101
103
|
/** @type {Set<string>} 已发过的去重键 */
|
|
102
104
|
const sent = new Set();
|
|
103
|
-
/** @type {object[]} 最近投递结果 */
|
|
104
|
-
const recent = [];
|
|
105
105
|
|
|
106
106
|
const queue = createSendQueue({
|
|
107
107
|
logger,
|
|
108
|
-
limitPerMinute: () =>
|
|
109
|
-
const value = readConfig().ratePerMinute;
|
|
110
|
-
return typeof value === 'number' && value > 0 ? value : 10;
|
|
111
|
-
},
|
|
108
|
+
limitPerMinute: () => RATE_PER_MINUTE,
|
|
112
109
|
});
|
|
113
110
|
|
|
114
|
-
//
|
|
115
|
-
// 所以"到底发出去没有"要靠这个文件,`cat` 一下就知道了。
|
|
116
|
-
const ledger = createLedger({ path: readConfig().statePath, logger });
|
|
117
|
-
|
|
118
|
-
// ---- Remote 服务:设置页的「发送测试邮件」与「最近一次投递」----
|
|
111
|
+
// ---- Remote 服务:设置页的「发送测试邮件」----
|
|
119
112
|
//
|
|
120
113
|
// 走 Typert:清单在 lib/typert.host.js,由 dsh-typert-loader 在启动时扫描注册,
|
|
121
114
|
// 浏览器侧通过 connection.rpc.call('/api', 'mailNotify/test') 调用。
|
|
@@ -124,14 +117,6 @@ function start(ctx, entryConfig) {
|
|
|
124
117
|
ctx.plugin(MailNotifyRemoteService, {
|
|
125
118
|
/** 立刻发一封测试邮件(不等合并窗口、不占限流额度)。 */
|
|
126
119
|
test: async () => runTestDelivery(),
|
|
127
|
-
/** 投递账本快照。 */
|
|
128
|
-
state: () => {
|
|
129
|
-
const snapshot = ledger.snapshot();
|
|
130
|
-
return {
|
|
131
|
-
counts: { sent: snapshot.counts?.sent ?? 0, failed: snapshot.counts?.failed ?? 0 },
|
|
132
|
-
recent: (snapshot.recent ?? []).slice(-10).reverse().map(toDeliveryView),
|
|
133
|
-
};
|
|
134
|
-
},
|
|
135
120
|
});
|
|
136
121
|
} catch (error) {
|
|
137
122
|
logger.warn?.(`dsh-notify-p: Remote 服务注册失败,「发送测试邮件」不可用 / remote service registration failed: ${String(error)}`);
|
|
@@ -140,21 +125,18 @@ function start(ctx, entryConfig) {
|
|
|
140
125
|
// ---- 小工具 ----
|
|
141
126
|
|
|
142
127
|
/**
|
|
143
|
-
*
|
|
128
|
+
* 按当前配置真的发一封测试邮件。
|
|
144
129
|
*
|
|
145
130
|
* 实现放在 lib/test-send.js:设置页、命令行(test/send-test.mjs)与单元测试共用同一份,
|
|
146
|
-
* 免得"设置页测通了、命令行测不通"
|
|
131
|
+
* 免得"设置页测通了、命令行测不通"。返回值仍是同一份扁平结果(toDeliveryView 的形状)。
|
|
147
132
|
* @returns {Promise<object>} 扁平结果
|
|
148
133
|
*/
|
|
149
134
|
async function runTestDelivery() {
|
|
150
|
-
|
|
135
|
+
return sendTestMail({
|
|
151
136
|
config: readConfig(),
|
|
152
137
|
logger,
|
|
153
138
|
resolveCredential,
|
|
154
|
-
webUrl: detectWebUrl(),
|
|
155
139
|
});
|
|
156
|
-
recordResult(result);
|
|
157
|
-
return result;
|
|
158
140
|
}
|
|
159
141
|
|
|
160
142
|
/**
|
|
@@ -180,18 +162,6 @@ function start(ctx, entryConfig) {
|
|
|
180
162
|
return typeof env === 'string' && env !== '' ? env : undefined;
|
|
181
163
|
}
|
|
182
164
|
|
|
183
|
-
/**
|
|
184
|
-
* 探测 Web GUI 地址,用于拼直达链接(用户显式配置的 publicBaseUrl 优先,见 resolveBaseUrl)。
|
|
185
|
-
* @returns {string}
|
|
186
|
-
*/
|
|
187
|
-
function detectWebUrl() {
|
|
188
|
-
const env = process.env.DSH_WEB_URL;
|
|
189
|
-
if (typeof env === 'string' && env !== '') return env;
|
|
190
|
-
const webServer = typeof ctx.get === 'function' ? ctx.get('webServer') : undefined;
|
|
191
|
-
if (webServer && typeof webServer.port === 'number') return `http://127.0.0.1:${webServer.port}`;
|
|
192
|
-
return '';
|
|
193
|
-
}
|
|
194
|
-
|
|
195
165
|
/**
|
|
196
166
|
* 读会话标题:优先标题服务,其次本地折叠表,最后空串(渲染层会降级成 ID 前 8 位)。
|
|
197
167
|
* @param {object} session 会话
|
|
@@ -214,17 +184,6 @@ function start(ctx, entryConfig) {
|
|
|
214
184
|
return '';
|
|
215
185
|
}
|
|
216
186
|
|
|
217
|
-
/**
|
|
218
|
-
* 记录一条投递结果(内存环形缓冲 + 落盘账本)。
|
|
219
|
-
* @param {object} record 结果
|
|
220
|
-
* @returns {void}
|
|
221
|
-
*/
|
|
222
|
-
function recordResult(record) {
|
|
223
|
-
recent.push(record);
|
|
224
|
-
while (recent.length > RECENT_LIMIT) recent.shift();
|
|
225
|
-
ledger.record(record);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
187
|
/**
|
|
229
188
|
* 标记已发;返回 false 表示这条已经发过。
|
|
230
189
|
* 错误类只看 `会话+轮次`(把 turn/end(error) 与 agent/error 折叠成一封)。
|
|
@@ -266,7 +225,6 @@ function start(ctx, entryConfig) {
|
|
|
266
225
|
* @returns {void}
|
|
267
226
|
*/
|
|
268
227
|
function schedule(session, payload) {
|
|
269
|
-
const cfg = readConfig();
|
|
270
228
|
const sessionId = session.id;
|
|
271
229
|
cancelPending(sessionId, 'superseded');
|
|
272
230
|
const enriched = {
|
|
@@ -282,7 +240,7 @@ function start(ctx, entryConfig) {
|
|
|
282
240
|
} catch (error) {
|
|
283
241
|
logger.warn?.(`dsh-notify-p: 提醒发送异常 / fire threw: ${String(error)}`);
|
|
284
242
|
}
|
|
285
|
-
}, Math.max(0,
|
|
243
|
+
}, Math.max(0, SETTLE_MS));
|
|
286
244
|
timer.unref?.();
|
|
287
245
|
pending.set(sessionId, { timer, payload: enriched });
|
|
288
246
|
}
|
|
@@ -300,29 +258,6 @@ function start(ctx, entryConfig) {
|
|
|
300
258
|
queue.enqueue({
|
|
301
259
|
label: `${payload.outcome} ${payload.sessionId}`,
|
|
302
260
|
run: () => deliver(payload),
|
|
303
|
-
onSuccess: (attempts, result) => recordResult({
|
|
304
|
-
at: Date.now(),
|
|
305
|
-
ok: true,
|
|
306
|
-
outcome: payload.outcome,
|
|
307
|
-
sessionId: payload.sessionId,
|
|
308
|
-
subject: result?.subject,
|
|
309
|
-
recipients: result?.recipients,
|
|
310
|
-
transport: result?.transport,
|
|
311
|
-
channels: result?.channels,
|
|
312
|
-
attempts,
|
|
313
|
-
}),
|
|
314
|
-
onFail: (error, attempts) => recordResult({
|
|
315
|
-
at: Date.now(),
|
|
316
|
-
ok: false,
|
|
317
|
-
outcome: payload.outcome,
|
|
318
|
-
sessionId: payload.sessionId,
|
|
319
|
-
attempts,
|
|
320
|
-
errorCode: error?.code,
|
|
321
|
-
error: error?.message ?? String(error),
|
|
322
|
-
// 逐通道结果:账本里要看得出"哪个通道成了、哪个没成"
|
|
323
|
-
transport: error?.transport,
|
|
324
|
-
channels: error?.channels,
|
|
325
|
-
}),
|
|
326
261
|
});
|
|
327
262
|
}
|
|
328
263
|
|
|
@@ -339,7 +274,7 @@ function start(ctx, entryConfig) {
|
|
|
339
274
|
if (invalid.length > 0) {
|
|
340
275
|
logger.warn?.(`dsh-notify-p: 忽略无法识别的收件人 / ignored invalid recipients: ${invalid.join(', ')}`);
|
|
341
276
|
}
|
|
342
|
-
//
|
|
277
|
+
// 收件人为空要抛出去:让队列把它记成一条失败并打出 warn,
|
|
343
278
|
// 否则"没配收件人"会静默成功,用户永远不知道。
|
|
344
279
|
const globalIssues = recipientIssues(valid.length);
|
|
345
280
|
if (globalIssues.length > 0) {
|
|
@@ -359,8 +294,6 @@ function start(ctx, entryConfig) {
|
|
|
359
294
|
durationMs: payload.durationMs,
|
|
360
295
|
errorText: payload.detail,
|
|
361
296
|
approval: payload.approval,
|
|
362
|
-
config: cfg,
|
|
363
|
-
baseUrl: resolveBaseUrl(cfg, detectWebUrl()),
|
|
364
297
|
});
|
|
365
298
|
|
|
366
299
|
// 发信:拿不到地址、缺主机/用户名之类的问题都在 sendMail 里先查一遍,
|
|
@@ -374,7 +307,7 @@ function start(ctx, entryConfig) {
|
|
|
374
307
|
|
|
375
308
|
if (!summary.ok) {
|
|
376
309
|
const error = new MailSendError(summary.error, { code: 'CHANNEL', retryable: summary.retryable });
|
|
377
|
-
//
|
|
310
|
+
// 把逐通道结果挂在错误上:排错时要看得出"哪个通道成了、哪个没成",而不是一句合并后的黑箱。
|
|
378
311
|
error.channels = summary.channels;
|
|
379
312
|
error.transport = summary.transport;
|
|
380
313
|
throw error;
|
|
@@ -407,8 +340,8 @@ function start(ctx, entryConfig) {
|
|
|
407
340
|
const durationMs = typeof startedAt === 'number' ? Math.max(0, endedAt - startedAt) : null;
|
|
408
341
|
|
|
409
342
|
// 短任务不打扰只对"完成"生效:出错/卡住/审批永远值得打扰
|
|
410
|
-
if (classified.outcome === OUTCOME.COMPLETED && durationMs !== null && durationMs <
|
|
411
|
-
logger.debug?.(`dsh-notify-p: 任务过短不打扰 / short task skipped (${durationMs}ms < ${
|
|
343
|
+
if (classified.outcome === OUTCOME.COMPLETED && durationMs !== null && durationMs < MIN_DURATION_MS) {
|
|
344
|
+
logger.debug?.(`dsh-notify-p: 任务过短不打扰 / short task skipped (${durationMs}ms < ${MIN_DURATION_MS}ms)`);
|
|
412
345
|
return;
|
|
413
346
|
}
|
|
414
347
|
|
|
@@ -576,6 +509,8 @@ function start(ctx, entryConfig) {
|
|
|
576
509
|
pending.clear();
|
|
577
510
|
}, 'dsh-notify-p: pending timers');
|
|
578
511
|
|
|
579
|
-
|
|
580
|
-
|
|
512
|
+
// 启动日志读一次就够(readConfig 每次都会重新解析,别在这里连读四遍)
|
|
513
|
+
const initial = readConfig();
|
|
514
|
+
logger.info?.(`dsh-notify-p 已加载 / loaded(SMTP ${initial.smtpHost === '' ? '未配置' : initial.smtpHost},`
|
|
515
|
+
+ `收件人 ${parseRecipients(initial.recipients).valid.length} 个)`);
|
|
581
516
|
}
|
package/lib/notify/render.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
// dsh-notify-p · 邮件渲染层(纯函数,无 cordis / 无 IO,可直接单测)
|
|
2
2
|
//
|
|
3
3
|
// 一封邮件要回答的唯一问题:"是哪个会话、发生了什么、我该不该现在去看"。
|
|
4
|
-
// 因此正文固定包含:事件、会话标题、工作区、时间、用时、会话 ID
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
import { sessionLink } from './config.js';
|
|
4
|
+
// 因此正文固定包含:事件、会话标题、工作区、时间、用时、会话 ID。
|
|
5
|
+
// **不带任何链接**:"邮件直达会话"这条能力已整块删除,正文没有 URL,HTML 也没有按钮。
|
|
8
6
|
|
|
9
7
|
/** 结局 → 中英并列文案。 */
|
|
10
8
|
export const OUTCOME_LABEL = {
|
|
@@ -99,10 +97,8 @@ export function displayTitle(title, sessionId) {
|
|
|
99
97
|
* @param {number} [input.durationMs] 用时
|
|
100
98
|
* @param {string} [input.errorText] 失败原因
|
|
101
99
|
* @param {{toolName?: string, reason?: string, command?: string}} [input.approval] 审批信息
|
|
102
|
-
* @param {object} input.config 已解析配置
|
|
103
|
-
* @param {string} [input.baseUrl] 链接基地址
|
|
104
100
|
* @param {(cwd: string, sessionId: string) => string} [input.resolveWorkspace] 可选的 cwd→工作区名覆盖
|
|
105
|
-
* @returns {{ subject: string, text: string, html: string,
|
|
101
|
+
* @returns {{ subject: string, text: string, html: string, title: string }}
|
|
106
102
|
*/
|
|
107
103
|
export function renderMail(input) {
|
|
108
104
|
const {
|
|
@@ -115,8 +111,6 @@ export function renderMail(input) {
|
|
|
115
111
|
durationMs,
|
|
116
112
|
errorText,
|
|
117
113
|
approval,
|
|
118
|
-
config,
|
|
119
|
-
baseUrl,
|
|
120
114
|
resolveWorkspace,
|
|
121
115
|
} = input;
|
|
122
116
|
|
|
@@ -128,7 +122,6 @@ export function renderMail(input) {
|
|
|
128
122
|
: workspaceName(cwd ?? '');
|
|
129
123
|
const duration = formatDuration(durationMs);
|
|
130
124
|
const time = formatTime(typeof endedAt === 'number' ? endedAt : Date.now());
|
|
131
|
-
const link = config?.linkEnabled === true ? sessionLink(baseUrl, sessionId) : '';
|
|
132
125
|
|
|
133
126
|
const subject = `[DSH] ${subjectLabel} · ${title}`;
|
|
134
127
|
|
|
@@ -144,7 +137,6 @@ export function renderMail(input) {
|
|
|
144
137
|
}
|
|
145
138
|
if (typeof turn === 'number') rows.push(['轮次 / Turn', String(turn)]);
|
|
146
139
|
rows.push(['会话 ID / Session ID', sessionId]);
|
|
147
|
-
if (link !== '') rows.push(['直达会话 / Open session', link]);
|
|
148
140
|
|
|
149
141
|
const lines = rows.map(([k, v]) => `${k}:${v}`);
|
|
150
142
|
if (typeof errorText === 'string' && errorText !== '') {
|
|
@@ -182,17 +174,11 @@ export function renderMail(input) {
|
|
|
182
174
|
+ `<pre style="margin:0;padding:10px;background:#f6f6f6;border-radius:6px;white-space:pre-wrap;font-size:12px">${esc(detail)}</pre>`);
|
|
183
175
|
}
|
|
184
176
|
}
|
|
185
|
-
const button = link === ''
|
|
186
|
-
? ''
|
|
187
|
-
: `<p style="margin:20px 0 0"><a href="${esc(link)}" style="display:inline-block;padding:9px 16px;background:#1a56db;color:#fff;text-decoration:none;border-radius:6px;font-size:14px">打开该会话 / Open session</a></p>`
|
|
188
|
-
+ `<p style="margin:8px 0 0;color:#888;font-size:12px">${esc(link)}</p>`;
|
|
189
|
-
|
|
190
177
|
const html = `<!DOCTYPE html><html><body style="margin:0;padding:16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'PingFang SC','Microsoft YaHei',sans-serif;font-size:14px;line-height:1.6;color:#111">`
|
|
191
178
|
+ `<table style="border-collapse:collapse">${htmlRows}</table>`
|
|
192
179
|
+ detailBlocks.join('')
|
|
193
|
-
+ button
|
|
194
180
|
+ `<p style="margin:20px 0 0;color:#999;font-size:12px">本邮件由 dsh-notify-p 自动发送 / sent by dsh-notify-p</p>`
|
|
195
181
|
+ `</body></html>`;
|
|
196
182
|
|
|
197
|
-
return { subject, text, html,
|
|
183
|
+
return { subject, text, html, title };
|
|
198
184
|
}
|
package/lib/notify/send/index.js
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
// 只有 SMTP 一条通道:send({ from, to, subject, text, html }) → { ok, id }
|
|
4
4
|
//
|
|
5
5
|
// 这里曾经有「只记录」与 Resend 两条通道,已删除。结果形状里的 `transport` 与 `channels[]`
|
|
6
|
-
//
|
|
7
|
-
//
|
|
6
|
+
// **刻意保留**:它同时是 typert 清单里声明的出参、以及设置页「发送测试邮件」结果的
|
|
7
|
+
// 渲染依据,删通道不需要动这个契约。
|
|
8
8
|
|
|
9
9
|
import { createSmtpSender, MailSendError } from './smtp.js';
|
|
10
|
-
import {
|
|
10
|
+
import { DEFAULT_SENDER_NAME, smtpIssues } from '../config.js';
|
|
11
11
|
|
|
12
12
|
export { MailSendError };
|
|
13
13
|
|
|
@@ -44,18 +44,15 @@ export async function createSender(config, deps) {
|
|
|
44
44
|
/**
|
|
45
45
|
* 拼发件人字符串。
|
|
46
46
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
47
|
+
* 发件人清单已整块删除,只剩一条规则:**地址就是 smtpUser**,显示名是固定常量
|
|
48
|
+
* (DEFAULT_SENDER_NAME)。用户名都没填时只给显示名——SMTP 层随后会为"用户名为空"
|
|
49
|
+
* 报出可读的配置错误,不必在这里再拼一个空地址。
|
|
49
50
|
* @param {object} config 已解析配置
|
|
50
|
-
* @returns {string} "名字
|
|
51
|
+
* @returns {string} "名字 <smtpUser>",或拿不到用户名时只返回显示名
|
|
51
52
|
*/
|
|
52
53
|
export function resolveFrom(config) {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
let address = sender.address;
|
|
56
|
-
if (address === '') address = String(config.smtpUser ?? '').trim();
|
|
57
|
-
if (address === '') return name;
|
|
58
|
-
return name === '' ? address : `${name} <${address}>`;
|
|
54
|
+
const address = String(config?.smtpUser ?? '').trim();
|
|
55
|
+
return address === '' ? DEFAULT_SENDER_NAME : `${DEFAULT_SENDER_NAME} <${address}>`;
|
|
59
56
|
}
|
|
60
57
|
|
|
61
58
|
/**
|
|
@@ -75,7 +72,7 @@ export function checkSenderConfig(config, recipientCount) {
|
|
|
75
72
|
* 发一封信(走 SMTP)。
|
|
76
73
|
*
|
|
77
74
|
* 返回值沿用原来「逐通道结果 + 汇总」的形状,只是 `channels` 现在恒为一项:
|
|
78
|
-
*
|
|
75
|
+
* 设置页按这个形状读,改形状要连带改 typert 清单。
|
|
79
76
|
* @param {object} config 已解析配置
|
|
80
77
|
* @param {{to: string[], subject: string, text: string, html?: string}} message 邮件内容(不含 from)
|
|
81
78
|
* @param {object} deps 依赖(logger / resolveCredential)
|
package/lib/notify/service.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// dsh-notify-p · Host 半的 Remote 服务
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// 这是本插件唯一对浏览器暴露的 API 面。它刻意很窄:只有 test
|
|
5
|
-
//
|
|
3
|
+
// 设置页上的「发送测试邮件」需要一个 Host 动作,
|
|
4
|
+
// 这是本插件唯一对浏览器暴露的 API 面。它刻意很窄:只有 test 一个方法,
|
|
5
|
+
// 不接受参数——设置页要改的东西一律走 settings 命名空间,不走这里。
|
|
6
6
|
//
|
|
7
7
|
// 装配方式:宿主在 start() 里 ctx.plugin(MailNotifyRemoteService, runtime),
|
|
8
8
|
// runtime 由装配逻辑提供(见 lib/index.js),所以本文件不碰任何插件状态。
|
|
@@ -17,7 +17,7 @@ import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
|
17
17
|
export class MailNotifyRemoteService extends TypertRemoteService {
|
|
18
18
|
/**
|
|
19
19
|
* @param {object} ctx cordis 上下文
|
|
20
|
-
* @param {object} runtime 装配逻辑给的运行态接口 { test
|
|
20
|
+
* @param {object} runtime 装配逻辑给的运行态接口 { test }
|
|
21
21
|
*/
|
|
22
22
|
constructor(ctx, runtime) {
|
|
23
23
|
super(ctx, 'mailNotify');
|
|
@@ -36,18 +36,6 @@ export class MailNotifyRemoteService extends TypertRemoteService {
|
|
|
36
36
|
}
|
|
37
37
|
return runtime.test();
|
|
38
38
|
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* 投递账本快照(计数 + 最近若干条)。
|
|
42
|
-
* @returns {object} 账本快照
|
|
43
|
-
*/
|
|
44
|
-
async state() {
|
|
45
|
-
const runtime = this.runtime ?? {};
|
|
46
|
-
if (typeof runtime.state !== 'function') {
|
|
47
|
-
throw new Error('mailNotify/state: runtime is not wired');
|
|
48
|
-
}
|
|
49
|
-
return runtime.state();
|
|
50
|
-
}
|
|
51
39
|
}
|
|
52
40
|
|
|
53
41
|
export default MailNotifyRemoteService;
|
package/lib/notify/test-send.js
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
// 2. `node test/send-test.mjs`(不依赖 DSH 运行、不需要重启)
|
|
6
6
|
// 3. 单元测试
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
8
|
+
// 投递账本已删除(见 docs/adr/0003):这里只发信、只把结果原样返回给调用方,
|
|
9
|
+
// 不落盘、不留历史,命令行与设置页因此天然不会互相覆盖。
|
|
10
10
|
import { OUTCOME } from './policy.js';
|
|
11
11
|
import { renderMail } from './render.js';
|
|
12
|
-
import { parseRecipients,
|
|
12
|
+
import { parseRecipients, recipientIssues } from './config.js';
|
|
13
13
|
import { sendMail } from './send/index.js';
|
|
14
14
|
|
|
15
15
|
/** 测试邮件的会话标题(收件人一眼能看出这是插件自己发的)。 */
|
|
@@ -19,8 +19,8 @@ export const TEST_TITLE = '测试邮件 / Test email';
|
|
|
19
19
|
* 把任意一条投递记录整理成固定形状。
|
|
20
20
|
*
|
|
21
21
|
* 形状必须稳定:typert 清单里声明的结果是 strict schema,缺字段会在浏览器侧炸。
|
|
22
|
-
* `channels`
|
|
23
|
-
*
|
|
22
|
+
* `channels` 恒为一项(只剩 SMTP 一条通道),保留它是为了不改 typert 清单里
|
|
23
|
+
* 声明的 strict schema:设置页只需要处理一种形状。
|
|
24
24
|
* @param {object} entry 投递记录
|
|
25
25
|
* @returns {{at: number, ok: boolean, transport: string, recipients: string[], subject: string, ms: number, error: string, channels: object[]}} 扁平结果
|
|
26
26
|
*/
|
|
@@ -57,7 +57,6 @@ export function toDeliveryView(entry) {
|
|
|
57
57
|
* @param {object} options.config 已解析配置(resolveConfig 的产物)
|
|
58
58
|
* @param {object} [options.logger] 日志器
|
|
59
59
|
* @param {(ref: string) => Promise<string|undefined>} [options.resolveCredential] 凭据解析
|
|
60
|
-
* @param {string} [options.webUrl] 运行时探测到的 Web 地址(拼链接用)
|
|
61
60
|
* @returns {Promise<object>} 扁平结果(见 toDeliveryView)
|
|
62
61
|
*/
|
|
63
62
|
export async function sendTestMail(options) {
|
|
@@ -92,8 +91,6 @@ export async function sendTestMail(options) {
|
|
|
92
91
|
cwd: '',
|
|
93
92
|
endedAt: Date.now(),
|
|
94
93
|
durationMs: 0,
|
|
95
|
-
config,
|
|
96
|
-
baseUrl: resolveBaseUrl(config, options?.webUrl),
|
|
97
94
|
});
|
|
98
95
|
|
|
99
96
|
const summary = await sendMail(config, {
|
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
// 为什么手写:本插件没有 TypeScript 构建步骤,而 dsh-typert-loader 只要求包导出
|
|
4
4
|
// `./typert` 且其模块带一个 TYPERT 清单对象。清单里的 codec 由 zod v4 实例提供。
|
|
5
5
|
//
|
|
6
|
-
//
|
|
6
|
+
// 只声明一个方法,不带参数:
|
|
7
7
|
// mailNotify/test 立刻发一封测试邮件(失败作为结果返回,不抛异常)
|
|
8
|
-
// mailNotify/state 投递账本快照
|
|
9
8
|
//
|
|
10
9
|
// 结果一律是"扁平 + 全部可选字段都有默认"的形状,方便严格校验:
|
|
11
10
|
// 失败时 ok=false 且 error 非空,而不是换成另一种结构。
|
|
@@ -17,7 +16,7 @@
|
|
|
17
16
|
// 所以这里照官方生成物的形状,写成惰性记忆化工厂。
|
|
18
17
|
import { z } from 'zod';
|
|
19
18
|
|
|
20
|
-
/**
|
|
19
|
+
/** 一个通道这一次的成败(多通道扇出后,要看得出是哪个通道)。 */
|
|
21
20
|
let channelSchema$value;
|
|
22
21
|
const channelSchema = () => (channelSchema$value ??= z.object({
|
|
23
22
|
transport: z.string(),
|
|
@@ -39,13 +38,6 @@ const deliverySchema = () => (deliverySchema$value ??= z.object({
|
|
|
39
38
|
channels: z.array(channelSchema()),
|
|
40
39
|
}));
|
|
41
40
|
|
|
42
|
-
/** 账本快照。 */
|
|
43
|
-
let stateSchema$value;
|
|
44
|
-
const stateSchema = () => (stateSchema$value ??= z.object({
|
|
45
|
-
counts: z.object({ sent: z.number(), failed: z.number() }),
|
|
46
|
-
recent: z.array(deliverySchema()),
|
|
47
|
-
}));
|
|
48
|
-
|
|
49
41
|
export const TYPERT = {
|
|
50
42
|
package: 'dsh-notify-p',
|
|
51
43
|
face: 'host',
|
|
@@ -65,20 +57,6 @@ export const TYPERT = {
|
|
|
65
57
|
},
|
|
66
58
|
sourceLocation: { file: 'lib/service.js', line: 1, column: 1 },
|
|
67
59
|
},
|
|
68
|
-
{
|
|
69
|
-
id: 'dsh-notify-p#mailNotify/state',
|
|
70
|
-
service: 'mailNotify',
|
|
71
|
-
namespace: 'mailNotify',
|
|
72
|
-
method: 'state',
|
|
73
|
-
invocation: { kind: 'direct' },
|
|
74
|
-
parameters: [],
|
|
75
|
-
result: {
|
|
76
|
-
mode: 'strict',
|
|
77
|
-
typeSymbol: 'dsh-notify-p#mailNotify/state:result',
|
|
78
|
-
create: stateSchema,
|
|
79
|
-
},
|
|
80
|
-
sourceLocation: { file: 'lib/service.js', line: 1, column: 1 },
|
|
81
|
-
},
|
|
82
60
|
],
|
|
83
61
|
model: {
|
|
84
62
|
services: [],
|
package/lib/typert.host.js
CHANGED
|
@@ -150,7 +150,7 @@ const setIconResultSchema = statusSchema
|
|
|
150
150
|
|
|
151
151
|
|
|
152
152
|
|
|
153
|
-
/**
|
|
153
|
+
/** 一个通道这一次的成败(多通道扇出后,要看得出是哪个通道)。 */
|
|
154
154
|
let channelSchema$value;
|
|
155
155
|
const channelSchema = () => (channelSchema$value ??= z.object({
|
|
156
156
|
transport: z.string(),
|
|
@@ -172,13 +172,6 @@ const deliverySchema = () => (deliverySchema$value ??= z.object({
|
|
|
172
172
|
channels: z.array(channelSchema()),
|
|
173
173
|
}));
|
|
174
174
|
|
|
175
|
-
/** 账本快照。 */
|
|
176
|
-
let stateSchema$value;
|
|
177
|
-
const stateSchema = () => (stateSchema$value ??= z.object({
|
|
178
|
-
counts: z.object({ sent: z.number(), failed: z.number() }),
|
|
179
|
-
recent: z.array(deliverySchema()),
|
|
180
|
-
}));
|
|
181
|
-
|
|
182
175
|
|
|
183
176
|
|
|
184
177
|
/** 合并后的 service 模型:A 的 iconCustom + B 的 mailNotify(B 的 model.services 为空)。 */
|
|
@@ -304,7 +297,7 @@ const services = [
|
|
|
304
297
|
}
|
|
305
298
|
]
|
|
306
299
|
|
|
307
|
-
/** 合并后的宿主端点:A 的 12 条 iconCustom/* + B 的
|
|
300
|
+
/** 合并后的宿主端点:A 的 12 条 iconCustom/* + B 的 1 条 mailNotify/*。 */
|
|
308
301
|
const invocations = [
|
|
309
302
|
{
|
|
310
303
|
id: 'dsh-lookout#iconCustom/getStatus',
|
|
@@ -565,20 +558,6 @@ const invocations = [
|
|
|
565
558
|
},
|
|
566
559
|
sourceLocation: { file: 'lib/service.js', line: 1, column: 1 },
|
|
567
560
|
},
|
|
568
|
-
{
|
|
569
|
-
id: 'dsh-lookout#mailNotify/state',
|
|
570
|
-
service: 'mailNotify',
|
|
571
|
-
namespace: 'mailNotify',
|
|
572
|
-
method: 'state',
|
|
573
|
-
invocation: { kind: 'direct' },
|
|
574
|
-
parameters: [],
|
|
575
|
-
result: {
|
|
576
|
-
mode: 'strict',
|
|
577
|
-
typeSymbol: 'dsh-lookout#mailNotify/state:result',
|
|
578
|
-
create: stateSchema,
|
|
579
|
-
},
|
|
580
|
-
sourceLocation: { file: 'lib/service.js', line: 1, column: 1 },
|
|
581
|
-
},
|
|
582
561
|
]
|
|
583
562
|
|
|
584
563
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cowwo/dsh-lookout",
|
|
3
3
|
"description": "瞭望哨:盯着你的每个会话——谁跑完了、谁出错了、谁卡住了、谁在等你审批,用浏览器图标角标、红点/黄点和邮件通知你;顺带显示当前会话 ID。· Lookout: watches every session — who finished, who errored, who is stuck, and who is waiting for your approval — and notifies you through the browser favicon badge, red/yellow dots, and email, while also showing the current session ID.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|