@dsh-plus/notify-email 0.1.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/LICENSE +21 -0
- package/lib/client.js +624 -0
- package/lib/index.d.ts +149 -0
- package/lib/index.js +579 -0
- package/package.json +62 -0
- package/src/audit.ts +48 -0
- package/src/client/api.ts +83 -0
- package/src/client/card.tsx +239 -0
- package/src/client/client.ts +63 -0
- package/src/client/fields.tsx +75 -0
- package/src/client/i18n.ts +104 -0
- package/src/client/styles.ts +64 -0
- package/src/config-api.ts +116 -0
- package/src/config.ts +131 -0
- package/src/index.ts +24 -0
- package/src/mailer.ts +103 -0
- package/src/service.ts +122 -0
- package/src/triggers/builtin.ts +47 -0
- package/src/triggers/render.ts +93 -0
- package/src/triggers/types.ts +40 -0
- package/src/watchers/decision.ts +33 -0
- package/src/watchers/turn-end.ts +135 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
3
|
+
import { Context, Service } from "@deepseek-ai/cordis";
|
|
4
|
+
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
5
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
import nodemailer from "nodemailer";
|
|
8
|
+
|
|
9
|
+
//#region src/config.ts
|
|
10
|
+
/** settings 命名空间;webui 配置卡片与插件运行期读取同一份。 */
|
|
11
|
+
const SETTINGS_NS = settingsNamespace("dsh-plus-notify-email");
|
|
12
|
+
const SmtpSchema = z.object({
|
|
13
|
+
host: z.string().description("SMTP 服务器主机名").default(""),
|
|
14
|
+
port: z.natural().max(65535).description("SMTP 端口").default(465),
|
|
15
|
+
secure: z.boolean().description("使用 TLS 直连(465 端口通常为 true)").default(true),
|
|
16
|
+
user: z.string().description("SMTP 登录用户名(通常为发件邮箱)").default(""),
|
|
17
|
+
pass: z.string().role("secret").description("SMTP 密码/授权码").default(""),
|
|
18
|
+
from: z.string().description("发件地址(From 头)").default("")
|
|
19
|
+
});
|
|
20
|
+
const TriggerSchema = z.object({
|
|
21
|
+
onComplete: z.boolean().description("任务执行完毕时通知").default(true),
|
|
22
|
+
onError: z.boolean().description("任务报错/失败停止时通知").default(true),
|
|
23
|
+
onAborted: z.boolean().description("任务被用户取消时通知").default(false),
|
|
24
|
+
onQuestion: z.boolean().description("agent 提问等待回答时通知").default(true),
|
|
25
|
+
onPlanReview: z.boolean().description("plan 待审批时通知").default(true)
|
|
26
|
+
});
|
|
27
|
+
const Config = z.object({
|
|
28
|
+
enabled: z.boolean().description("总开关").default(false),
|
|
29
|
+
smtp: SmtpSchema.default({
|
|
30
|
+
host: "",
|
|
31
|
+
port: 465,
|
|
32
|
+
secure: true,
|
|
33
|
+
user: "",
|
|
34
|
+
pass: "",
|
|
35
|
+
from: ""
|
|
36
|
+
}),
|
|
37
|
+
to: z.array(z.string()).description("收件邮箱列表").default([]),
|
|
38
|
+
triggers: TriggerSchema.default({
|
|
39
|
+
onComplete: true,
|
|
40
|
+
onError: true,
|
|
41
|
+
onAborted: false,
|
|
42
|
+
onQuestion: true,
|
|
43
|
+
onPlanReview: true
|
|
44
|
+
}),
|
|
45
|
+
idleDebounceMs: z.natural().description("turn 结束后确认无后续工作的等待毫秒数").default(3e3),
|
|
46
|
+
maxBodyChars: z.natural().min(200).description("邮件正文内容截断长度").default(4e3),
|
|
47
|
+
dryRun: z.boolean().description("仅记录日志不真实发送(开发调试用)").default(false)
|
|
48
|
+
});
|
|
49
|
+
function toWire(cfg, writable) {
|
|
50
|
+
return {
|
|
51
|
+
enabled: cfg.enabled,
|
|
52
|
+
smtp: {
|
|
53
|
+
host: cfg.smtp.host,
|
|
54
|
+
port: cfg.smtp.port,
|
|
55
|
+
secure: cfg.smtp.secure,
|
|
56
|
+
user: cfg.smtp.user,
|
|
57
|
+
from: cfg.smtp.from,
|
|
58
|
+
passConfigured: cfg.smtp.pass.length > 0
|
|
59
|
+
},
|
|
60
|
+
to: [...cfg.to],
|
|
61
|
+
triggers: { ...cfg.triggers },
|
|
62
|
+
idleDebounceMs: cfg.idleDebounceMs,
|
|
63
|
+
maxBodyChars: cfg.maxBodyChars,
|
|
64
|
+
dryRun: cfg.dryRun,
|
|
65
|
+
writable
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 配置卡片写回用的 patch schema:字段全部可选(无默认值),
|
|
70
|
+
* 只携带用户真实编辑过的字段,交给 settings.update 深合并进用户层。
|
|
71
|
+
*/
|
|
72
|
+
const WirePatch = z.object({
|
|
73
|
+
enabled: z.boolean(),
|
|
74
|
+
smtp: z.object({
|
|
75
|
+
host: z.string(),
|
|
76
|
+
port: z.natural().max(65535),
|
|
77
|
+
secure: z.boolean(),
|
|
78
|
+
user: z.string(),
|
|
79
|
+
pass: z.string(),
|
|
80
|
+
from: z.string()
|
|
81
|
+
}),
|
|
82
|
+
to: z.array(z.string()),
|
|
83
|
+
triggers: z.object({
|
|
84
|
+
onComplete: z.boolean(),
|
|
85
|
+
onError: z.boolean(),
|
|
86
|
+
onAborted: z.boolean(),
|
|
87
|
+
onQuestion: z.boolean(),
|
|
88
|
+
onPlanReview: z.boolean()
|
|
89
|
+
}),
|
|
90
|
+
idleDebounceMs: z.natural(),
|
|
91
|
+
maxBodyChars: z.natural().min(200),
|
|
92
|
+
dryRun: z.boolean()
|
|
93
|
+
});
|
|
94
|
+
/**
|
|
95
|
+
* 把卡片提交的 patch 规整为 settings.update 的用户层 patch。
|
|
96
|
+
* smtp.pass 为空串表示「保持不变」,从 patch 中剔除,避免覆盖已存密码。
|
|
97
|
+
*/
|
|
98
|
+
function toUserPatch(input) {
|
|
99
|
+
const patch = { ...input };
|
|
100
|
+
if (input.smtp !== void 0 && input.smtp !== null) {
|
|
101
|
+
const smtp = { ...input.smtp };
|
|
102
|
+
if (smtp["pass"] === "") delete smtp["pass"];
|
|
103
|
+
patch["smtp"] = smtp;
|
|
104
|
+
}
|
|
105
|
+
return patch;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/audit.ts
|
|
110
|
+
const BODY_EXCERPT_CHARS = 2e3;
|
|
111
|
+
function buildAuditRecord(subject, text, to, result) {
|
|
112
|
+
return {
|
|
113
|
+
time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
114
|
+
subject,
|
|
115
|
+
to: [...to],
|
|
116
|
+
result,
|
|
117
|
+
bodyExcerpt: text.length > BODY_EXCERPT_CHARS ? `${text.slice(0, BODY_EXCERPT_CHARS)}…` : text
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** 构造 JSONL 落盘 sink(fire-and-forget;写入串行排队保证行序,失败经 onError 告警)。 */
|
|
121
|
+
function createJsonlAuditSink(path, onError) {
|
|
122
|
+
let queue = mkdir(dirname(path), { recursive: true }).then(() => void 0);
|
|
123
|
+
return (record) => {
|
|
124
|
+
queue = queue.then(() => appendFile(path, `${JSON.stringify(record)}\n`, "utf8")).catch((error) => {
|
|
125
|
+
onError(error instanceof Error ? error.message : String(error));
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/mailer.ts
|
|
132
|
+
/** nodemailer 连接/问候/套接字超时(毫秒),防 SMTP 不可达时挂住通知链路。 */
|
|
133
|
+
const CONNECTION_TIMEOUT_MS = 1e4;
|
|
134
|
+
const GREETING_TIMEOUT_MS = 1e4;
|
|
135
|
+
const SOCKET_TIMEOUT_MS = 3e4;
|
|
136
|
+
/** 真实 SMTP 通道:每次发送按当前配置新建 transport(配置热改即生效,发送低频无池化需求)。 */
|
|
137
|
+
const smtpTransport = async (cfg, msg) => {
|
|
138
|
+
await nodemailer.createTransport({
|
|
139
|
+
host: cfg.smtp.host,
|
|
140
|
+
port: cfg.smtp.port,
|
|
141
|
+
secure: cfg.smtp.secure,
|
|
142
|
+
auth: cfg.smtp.user.length > 0 ? {
|
|
143
|
+
user: cfg.smtp.user,
|
|
144
|
+
pass: cfg.smtp.pass
|
|
145
|
+
} : void 0,
|
|
146
|
+
connectionTimeout: CONNECTION_TIMEOUT_MS,
|
|
147
|
+
greetingTimeout: GREETING_TIMEOUT_MS,
|
|
148
|
+
socketTimeout: SOCKET_TIMEOUT_MS
|
|
149
|
+
}).sendMail({
|
|
150
|
+
from: cfg.smtp.from,
|
|
151
|
+
to: cfg.to.join(", "),
|
|
152
|
+
subject: msg.subject,
|
|
153
|
+
text: msg.text,
|
|
154
|
+
...msg.html !== void 0 ? { html: msg.html } : {}
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
var Mailer = class {
|
|
158
|
+
resolveConfig;
|
|
159
|
+
logger;
|
|
160
|
+
transport;
|
|
161
|
+
audit;
|
|
162
|
+
constructor(resolveConfig, logger, transport = smtpTransport, audit) {
|
|
163
|
+
this.resolveConfig = resolveConfig;
|
|
164
|
+
this.logger = logger;
|
|
165
|
+
this.transport = transport;
|
|
166
|
+
this.audit = audit;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* 常规通知发送:受 enabled 与配置完整性门禁。
|
|
170
|
+
* @param force 为 true 时跳过 enabled 门禁(配置卡片的「发送测试邮件」),仍需 SMTP 完整。
|
|
171
|
+
*/
|
|
172
|
+
async send(msg, force = false) {
|
|
173
|
+
const cfg = this.resolveConfig();
|
|
174
|
+
if (!force && !cfg.enabled) return this.record(msg, cfg, {
|
|
175
|
+
ok: false,
|
|
176
|
+
detail: "disabled"
|
|
177
|
+
});
|
|
178
|
+
if (cfg.smtp.host.length === 0 || cfg.smtp.from.length === 0 || cfg.to.length === 0) return this.record(msg, cfg, {
|
|
179
|
+
ok: false,
|
|
180
|
+
detail: "incomplete"
|
|
181
|
+
});
|
|
182
|
+
if (cfg.dryRun) {
|
|
183
|
+
this.logger.info(`[dry-run] subject=${msg.subject}\n${msg.text}`);
|
|
184
|
+
return this.record(msg, cfg, {
|
|
185
|
+
ok: true,
|
|
186
|
+
detail: "dry-run"
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
await this.transport(cfg, msg);
|
|
191
|
+
return this.record(msg, cfg, {
|
|
192
|
+
ok: true,
|
|
193
|
+
detail: "sent"
|
|
194
|
+
});
|
|
195
|
+
} catch (error) {
|
|
196
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
197
|
+
this.logger.warn(`send failed: ${message} (subject=${msg.subject})`);
|
|
198
|
+
return this.record(msg, cfg, {
|
|
199
|
+
ok: false,
|
|
200
|
+
detail: message
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
record(msg, cfg, result) {
|
|
205
|
+
this.audit?.(buildAuditRecord(msg.subject, msg.text, cfg.to, result.detail));
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
//#endregion
|
|
211
|
+
//#region src/config-api.ts
|
|
212
|
+
const ROUTE_CONFIG = "/dsh-plus/notify-email/config";
|
|
213
|
+
const ROUTE_TEST = "/dsh-plus/notify-email/test";
|
|
214
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
215
|
+
function sendJson(res, status, body) {
|
|
216
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
217
|
+
res.end(JSON.stringify(body));
|
|
218
|
+
}
|
|
219
|
+
function readBody(req) {
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
const chunks = [];
|
|
222
|
+
let size = 0;
|
|
223
|
+
req.on("data", (chunk) => {
|
|
224
|
+
size += chunk.length;
|
|
225
|
+
if (size > MAX_BODY_BYTES) {
|
|
226
|
+
reject(/* @__PURE__ */ new Error("request body too large"));
|
|
227
|
+
req.destroy();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
chunks.push(chunk);
|
|
231
|
+
});
|
|
232
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
233
|
+
req.on("error", reject);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
async function readPatch(req) {
|
|
237
|
+
const raw = await readBody(req);
|
|
238
|
+
let parsed;
|
|
239
|
+
try {
|
|
240
|
+
parsed = JSON.parse(raw);
|
|
241
|
+
} catch {
|
|
242
|
+
throw new Error("request body is not valid JSON");
|
|
243
|
+
}
|
|
244
|
+
return WirePatch(parsed);
|
|
245
|
+
}
|
|
246
|
+
async function handleConfig(ctx, service, req, res) {
|
|
247
|
+
const settings = ctx.get("settings");
|
|
248
|
+
if (req.method === "GET") {
|
|
249
|
+
sendJson(res, 200, toWire(service.currentConfig(), settings !== void 0));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (req.method !== "PUT") {
|
|
253
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (settings === void 0) {
|
|
257
|
+
sendJson(res, 503, { error: "settings provider 不可用,无法在线保存;请编辑 settings.yaml" });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const patch = toUserPatch(await readPatch(req));
|
|
261
|
+
await settings.update(SETTINGS_NS, patch);
|
|
262
|
+
sendJson(res, 200, toWire(service.currentConfig(), true));
|
|
263
|
+
}
|
|
264
|
+
async function handleTest(service, res) {
|
|
265
|
+
const result = await service.sendTest();
|
|
266
|
+
sendJson(res, result.ok ? 200 : 502, result);
|
|
267
|
+
}
|
|
268
|
+
/** 注册配置读写与测试发信路由;返回是否注册成功(webServer 缺失时由调用方保证不调用)。 */
|
|
269
|
+
function registerConfigApi(ctx, service) {
|
|
270
|
+
const logger = ctx.logger("notify-email");
|
|
271
|
+
const guard = (handler) => {
|
|
272
|
+
return async (req, res) => {
|
|
273
|
+
try {
|
|
274
|
+
await handler(req, res);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
277
|
+
logger.warn(`config api ${req.method ?? "?"} ${req.url ?? "?"} failed: ${message}`);
|
|
278
|
+
if (!res.headersSent) sendJson(res, 400, { error: message });
|
|
279
|
+
else res.end();
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
};
|
|
283
|
+
ctx.webServer.register({
|
|
284
|
+
kind: "exact",
|
|
285
|
+
path: ROUTE_CONFIG,
|
|
286
|
+
handler: guard((req, res) => handleConfig(ctx, service, req, res))
|
|
287
|
+
});
|
|
288
|
+
ctx.webServer.register({
|
|
289
|
+
kind: "exact",
|
|
290
|
+
path: ROUTE_TEST,
|
|
291
|
+
handler: guard(async (req, res) => {
|
|
292
|
+
if (req.method !== "POST") {
|
|
293
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
await handleTest(service, res);
|
|
297
|
+
})
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
//#endregion
|
|
302
|
+
//#region src/triggers/render.ts
|
|
303
|
+
function truncate(text, maxChars) {
|
|
304
|
+
if (text.length <= maxChars) return text;
|
|
305
|
+
return `${text.slice(0, maxChars)}\n……(内容过长已截断)`;
|
|
306
|
+
}
|
|
307
|
+
function shortSession(sessionId) {
|
|
308
|
+
return sessionId.length > 8 ? sessionId.slice(0, 8) : sessionId;
|
|
309
|
+
}
|
|
310
|
+
function header(reason, info) {
|
|
311
|
+
const turnPart = info.turn === void 0 ? "" : ` · turn ${info.turn}`;
|
|
312
|
+
return `通知原因:${reason}\n会话:${shortSession(info.sessionId)}${turnPart}\n时间:${(/* @__PURE__ */ new Date()).toISOString()}\n`;
|
|
313
|
+
}
|
|
314
|
+
function renderOption(option, index) {
|
|
315
|
+
const label = typeof option?.label === "string" ? option.label : `选项 ${index + 1}`;
|
|
316
|
+
const desc = typeof option?.description === "string" ? ` — ${option.description}` : "";
|
|
317
|
+
return ` ${index + 1}. ${label}${desc}`;
|
|
318
|
+
}
|
|
319
|
+
function renderQuestion(item, index) {
|
|
320
|
+
const title = typeof item?.question === "string" ? item.question : "(未命名问题)";
|
|
321
|
+
const head = typeof item?.header === "string" ? `【${item.header}】` : "";
|
|
322
|
+
const multi = item?.multi_select === true ? "(可多选)" : "";
|
|
323
|
+
const lines = (Array.isArray(item?.options) ? item.options : []).map((o, i) => renderOption(o, i));
|
|
324
|
+
return [`问题 ${index + 1}:${head}${title}${multi}`, ...lines].join("\n");
|
|
325
|
+
}
|
|
326
|
+
/** ask_user_question 工具参数 → 通知(问题 + 选项)。 */
|
|
327
|
+
function renderQuestionNotice(call, maxChars) {
|
|
328
|
+
const body = (Array.isArray(call.args["questions"]) ? call.args["questions"] : []).map((q, i) => renderQuestion(q, i)).join("\n\n");
|
|
329
|
+
return {
|
|
330
|
+
subject: `[DSH] 等待你的回答 · 会话 ${shortSession(call.sessionId)}`,
|
|
331
|
+
text: `${header("agent 提问,等待用户回答", call)}\n${truncate(body, maxChars)}`
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/** exit_plan_mode 工具参数 → 通知(plan 全文)。 */
|
|
335
|
+
function renderPlanNotice(call, maxChars) {
|
|
336
|
+
const plan = typeof call.args["plan"] === "string" ? call.args["plan"] : "(plan 内容缺失)";
|
|
337
|
+
return {
|
|
338
|
+
subject: `[DSH] Plan 待审批 · 会话 ${shortSession(call.sessionId)}`,
|
|
339
|
+
text: `${header("agent 提交了计划,等待审批", call)}\n${truncate(plan, maxChars)}`
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
/** turn 正常结束 → 通知(最后一条 assistant 交付消息)。 */
|
|
343
|
+
function renderCompletionNotice(info, maxChars) {
|
|
344
|
+
const delivery = info.lastDelivery ?? "(本轮无文本交付内容)";
|
|
345
|
+
return {
|
|
346
|
+
subject: `[DSH] 任务执行完毕 · 会话 ${shortSession(info.sessionId)}`,
|
|
347
|
+
text: `${header("任务执行完毕", info)}\n${truncate(delivery, maxChars)}`
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/** turn 出错 → 通知(错误信息)。 */
|
|
351
|
+
function renderErrorNotice(info, maxChars) {
|
|
352
|
+
const detail = info.errorMessage ?? "(错误详情缺失)";
|
|
353
|
+
return {
|
|
354
|
+
subject: `[DSH] 任务出错停止 · 会话 ${shortSession(info.sessionId)}`,
|
|
355
|
+
text: `${header("任务报错/失败停止", info)}\n${truncate(detail, maxChars)}`
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
/** turn 被取消 → 通知。 */
|
|
359
|
+
function renderAbortedNotice(info) {
|
|
360
|
+
return {
|
|
361
|
+
subject: `[DSH] 任务被取消 · 会话 ${shortSession(info.sessionId)}`,
|
|
362
|
+
text: header("任务被用户取消", info)
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
//#endregion
|
|
367
|
+
//#region src/triggers/builtin.ts
|
|
368
|
+
/** 官方决策工具名 → 渲染函数 + 对应 toggle。 */
|
|
369
|
+
function createDecisionTrigger(resolveConfig) {
|
|
370
|
+
return {
|
|
371
|
+
id: "dsh-plus-official-decision",
|
|
372
|
+
onDecision(call) {
|
|
373
|
+
const cfg = resolveConfig();
|
|
374
|
+
if (call.name === "ask_user_question" && cfg.triggers.onQuestion) return renderQuestionNotice(call, cfg.maxBodyChars);
|
|
375
|
+
if (call.name === "exit_plan_mode" && cfg.triggers.onPlanReview) return renderPlanNotice(call, cfg.maxBodyChars);
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
function createTurnEndTrigger(resolveConfig) {
|
|
380
|
+
return {
|
|
381
|
+
id: "dsh-plus-official-turn-end",
|
|
382
|
+
onTurnEnd(info) {
|
|
383
|
+
const cfg = resolveConfig();
|
|
384
|
+
if (info.kind === "completed" && cfg.triggers.onComplete) return renderCompletionNotice(info, cfg.maxBodyChars);
|
|
385
|
+
if (info.kind === "error" && cfg.triggers.onError) return renderErrorNotice(info, cfg.maxBodyChars);
|
|
386
|
+
if (info.kind === "aborted" && cfg.triggers.onAborted) return renderAbortedNotice(info);
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
//#endregion
|
|
392
|
+
//#region src/watchers/decision.ts
|
|
393
|
+
function installDecisionWatcher(ctx, service) {
|
|
394
|
+
const seen = /* @__PURE__ */ new Set();
|
|
395
|
+
const logger = ctx.logger("notify-email");
|
|
396
|
+
ctx.on("tools/pre-execute", (exec, next) => {
|
|
397
|
+
if (seen.has(exec.callId)) return next();
|
|
398
|
+
seen.add(exec.callId);
|
|
399
|
+
const sessionId = exec.agent?.id ?? "unknown";
|
|
400
|
+
service.dispatchDecision({
|
|
401
|
+
callId: exec.callId,
|
|
402
|
+
sessionId,
|
|
403
|
+
name: exec.name,
|
|
404
|
+
args: exec.arguments ?? {}
|
|
405
|
+
}).catch((error) => {
|
|
406
|
+
logger.warn(`decision dispatch failed (${exec.name}, call ${exec.callId}): ` + (error instanceof Error ? error.message : String(error)));
|
|
407
|
+
});
|
|
408
|
+
return next();
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
//#endregion
|
|
413
|
+
//#region src/watchers/turn-end.ts
|
|
414
|
+
/** 只有这三类 turn 结束对用户有可决策价值;blocked/max-tokens/interrupted 不通知。 */
|
|
415
|
+
const NOTIFY_KINDS = new Set([
|
|
416
|
+
"completed",
|
|
417
|
+
"error",
|
|
418
|
+
"aborted"
|
|
419
|
+
]);
|
|
420
|
+
/** 从 assistant 消息 content 提取纯文本交付内容(仅 text 块,忽略工具调用等)。 */
|
|
421
|
+
function extractDeliveryText(content) {
|
|
422
|
+
const text = content.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n").trim();
|
|
423
|
+
return text.length > 0 ? text : void 0;
|
|
424
|
+
}
|
|
425
|
+
function extractErrorMessage(reason) {
|
|
426
|
+
if (typeof reason !== "object" || reason === null) return void 0;
|
|
427
|
+
const error = reason.error;
|
|
428
|
+
if (typeof error !== "object" || error === null) return void 0;
|
|
429
|
+
const message = error.message;
|
|
430
|
+
return typeof message === "string" ? message : void 0;
|
|
431
|
+
}
|
|
432
|
+
function installTurnEndWatcher(ctx, service, resolveConfig) {
|
|
433
|
+
const watches = /* @__PURE__ */ new Map();
|
|
434
|
+
const logger = ctx.logger("notify-email");
|
|
435
|
+
const watchOf = (sessionId) => {
|
|
436
|
+
let watch = watches.get(sessionId);
|
|
437
|
+
if (watch === void 0) {
|
|
438
|
+
watch = { notified: /* @__PURE__ */ new Set() };
|
|
439
|
+
watches.set(sessionId, watch);
|
|
440
|
+
}
|
|
441
|
+
return watch;
|
|
442
|
+
};
|
|
443
|
+
const fire = (session, turn, kind, errorMessage) => {
|
|
444
|
+
const agent = ctx.agents.get(session.id);
|
|
445
|
+
if (agent === void 0) return;
|
|
446
|
+
if (agent.status !== "idle" || agent.inbox.hasPending) return;
|
|
447
|
+
if (!ctx.agents.roots().includes(agent)) return;
|
|
448
|
+
const watch = watchOf(session.id);
|
|
449
|
+
const key = `${turn}:${kind}`;
|
|
450
|
+
if (watch.notified.has(key)) return;
|
|
451
|
+
watch.notified.add(key);
|
|
452
|
+
service.dispatchTurnEnd({
|
|
453
|
+
sessionId: session.id,
|
|
454
|
+
turn,
|
|
455
|
+
kind,
|
|
456
|
+
...errorMessage !== void 0 ? { errorMessage } : {},
|
|
457
|
+
...watch.lastDelivery !== void 0 ? { lastDelivery: watch.lastDelivery } : {}
|
|
458
|
+
}).catch((error) => {
|
|
459
|
+
logger.warn(`turn-end dispatch failed (${session.id}, turn ${turn}, ${kind}): ` + (error instanceof Error ? error.message : String(error)));
|
|
460
|
+
});
|
|
461
|
+
};
|
|
462
|
+
const onTurnEnd = (session, turn, reason) => {
|
|
463
|
+
if (!NOTIFY_KINDS.has(reason.kind)) return;
|
|
464
|
+
const watch = watchOf(session.id);
|
|
465
|
+
if (watch.timer !== void 0) clearTimeout(watch.timer);
|
|
466
|
+
const errorMessage = extractErrorMessage(reason);
|
|
467
|
+
watch.timer = setTimeout(() => {
|
|
468
|
+
watch.timer = void 0;
|
|
469
|
+
fire(session, turn, reason.kind, errorMessage);
|
|
470
|
+
}, resolveConfig().idleDebounceMs);
|
|
471
|
+
};
|
|
472
|
+
ctx.on("session/event", (session, event) => {
|
|
473
|
+
if (event.type === "assistant/message") {
|
|
474
|
+
const text = extractDeliveryText(event.data.message.content);
|
|
475
|
+
if (text !== void 0) watchOf(session.id).lastDelivery = text;
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (event.type === "turn/start") {
|
|
479
|
+
const watch = watches.get(session.id);
|
|
480
|
+
if (watch?.timer !== void 0) {
|
|
481
|
+
clearTimeout(watch.timer);
|
|
482
|
+
watch.timer = void 0;
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (event.type === "turn/end") onTurnEnd(session, event.data.turn, event.data.reason);
|
|
487
|
+
});
|
|
488
|
+
ctx.on("session/disposed", (session) => {
|
|
489
|
+
const watch = watches.get(session.id);
|
|
490
|
+
if (watch?.timer !== void 0) clearTimeout(watch.timer);
|
|
491
|
+
watches.delete(session.id);
|
|
492
|
+
});
|
|
493
|
+
ctx.effect(() => () => {
|
|
494
|
+
for (const watch of watches.values()) if (watch.timer !== void 0) clearTimeout(watch.timer);
|
|
495
|
+
watches.clear();
|
|
496
|
+
}, "notify-email: turn-end timers");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
//#endregion
|
|
500
|
+
//#region src/service.ts
|
|
501
|
+
var NotifyEmailService = class extends Service {
|
|
502
|
+
static [Context.inject] = ["agents", "tools"];
|
|
503
|
+
triggers = [];
|
|
504
|
+
mailer;
|
|
505
|
+
current;
|
|
506
|
+
constructor(ctx, config) {
|
|
507
|
+
super(ctx, "notifyEmail");
|
|
508
|
+
this.current = () => config;
|
|
509
|
+
installSettingsSection(ctx, SETTINGS_NS, Config, config, {
|
|
510
|
+
setSource: (source) => {
|
|
511
|
+
this.current = source;
|
|
512
|
+
},
|
|
513
|
+
onChange: () => {}
|
|
514
|
+
});
|
|
515
|
+
const logger = ctx.logger("notify-email");
|
|
516
|
+
this.mailer = new Mailer(() => this.current(), logger, void 0, createJsonlAuditSink(dshHomePath("logs", "notify-email.jsonl"), (message) => logger.warn(`audit write failed: ${message}`)));
|
|
517
|
+
this.registerTrigger(createDecisionTrigger(() => this.current()));
|
|
518
|
+
this.registerTrigger(createTurnEndTrigger(() => this.current()));
|
|
519
|
+
installDecisionWatcher(ctx, this);
|
|
520
|
+
installTurnEndWatcher(ctx, this, () => this.current());
|
|
521
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
522
|
+
registerConfigApi(webCtx, this);
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
/** 注册触发器(第三方扩展接口);返回注销函数,随调用方 fiber 释放。 */
|
|
526
|
+
registerTrigger(trigger) {
|
|
527
|
+
if (this.triggers.some((t) => t.id === trigger.id)) throw new Error(`notify-email: duplicate trigger id ${JSON.stringify(trigger.id)}`);
|
|
528
|
+
this.triggers.push(trigger);
|
|
529
|
+
return this.ctx.effect(() => () => {
|
|
530
|
+
const index = this.triggers.indexOf(trigger);
|
|
531
|
+
if (index >= 0) this.triggers.splice(index, 1);
|
|
532
|
+
}, `notify-email: trigger ${trigger.id}`);
|
|
533
|
+
}
|
|
534
|
+
/** 当前生效配置(settings 用户层解析结果或 cordis 行级 config)。 */
|
|
535
|
+
currentConfig() {
|
|
536
|
+
return this.current();
|
|
537
|
+
}
|
|
538
|
+
/** 决策类观察入口(decision watcher 调用)。 */
|
|
539
|
+
async dispatchDecision(call) {
|
|
540
|
+
await this.dispatch("decision", call, (t) => t.onDecision?.(call));
|
|
541
|
+
}
|
|
542
|
+
/** 任务停止类观察入口(turn-end watcher 调用)。 */
|
|
543
|
+
async dispatchTurnEnd(info) {
|
|
544
|
+
await this.dispatch("turn-end", info, (t) => t.onTurnEnd?.(info));
|
|
545
|
+
}
|
|
546
|
+
/** 配置卡片「发送测试邮件」:绕过 enabled 门禁,仍需 SMTP 完整。 */
|
|
547
|
+
async sendTest() {
|
|
548
|
+
return this.mailer.send({
|
|
549
|
+
subject: "[DSH] 邮件通知测试",
|
|
550
|
+
text: "这是一封来自 dsh notify-email 插件的测试邮件。"
|
|
551
|
+
}, true);
|
|
552
|
+
}
|
|
553
|
+
async dispatch(kind, payload, produce) {
|
|
554
|
+
for (const trigger of [...this.triggers]) {
|
|
555
|
+
let notice;
|
|
556
|
+
try {
|
|
557
|
+
notice = produce(trigger);
|
|
558
|
+
} catch (error) {
|
|
559
|
+
this.ctx.logger("notify-email").warn(`trigger ${trigger.id} failed on ${kind}: ${error instanceof Error ? error.message : String(error)}`);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (notice === void 0) continue;
|
|
563
|
+
const result = await this.mailer.send(notice);
|
|
564
|
+
if (!result.ok && result.detail !== "disabled" && result.detail !== "incomplete") this.ctx.logger("notify-email").warn(`deliver failed via trigger ${trigger.id}: ${result.detail}`);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
//#endregion
|
|
571
|
+
//#region src/index.ts
|
|
572
|
+
const name = "dsh-plus-notify-email";
|
|
573
|
+
const inject = ["agents", "tools"];
|
|
574
|
+
function apply(ctx, config) {
|
|
575
|
+
ctx.plugin(NotifyEmailService, config);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
//#endregion
|
|
579
|
+
export { Config, NotifyEmailService, apply, inject, name };
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dsh-plus/notify-email",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "dsh-plus service+ui plugin: 任务结束/等待决策/出错时向指定邮箱发送邮件通知",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"default": "./lib/client.js"
|
|
15
|
+
},
|
|
16
|
+
"./src/*": "./src/*",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"lib",
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"dsh": {
|
|
24
|
+
"client": {
|
|
25
|
+
"inject": [
|
|
26
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
27
|
+
"@deepseek-ai/dsh-client-locale"
|
|
28
|
+
],
|
|
29
|
+
"platform": "web"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@deepseek-ai/cordis": "4.0.1",
|
|
34
|
+
"@deepseek-ai/dsh-home-paths": "0.1.0-rc.6",
|
|
35
|
+
"@deepseek-ai/dsh-settings": "0.1.0-rc.6",
|
|
36
|
+
"@deepseek-ai/schemastery": "3.18.1",
|
|
37
|
+
"nodemailer": "9.0.5"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@deepseek-ai/dsh-agent": "0.1.0-rc.6",
|
|
41
|
+
"@deepseek-ai/dsh-host-webserver": "0.1.0-rc.6",
|
|
42
|
+
"@deepseek-ai/dsh-llm": "0.1.0-rc.6",
|
|
43
|
+
"@deepseek-ai/dsh-session": "0.1.0-rc.6",
|
|
44
|
+
"@deepseek-ai/dsh-tools": "0.1.0-rc.6",
|
|
45
|
+
"@types/nodemailer": "8.0.1",
|
|
46
|
+
"@types/react": "~18.3.1",
|
|
47
|
+
"react": "^18.2.0"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "git+https://github.com/A-G-guy/dsh-plugins.git",
|
|
56
|
+
"directory": "packages/notify-email"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "tsdown",
|
|
60
|
+
"watch": "tsdown --watch"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/audit.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通知投递审计:每次发送尝试追加一行 JSONL 到 $DSH_HOME/logs/notify-email.jsonl。
|
|
3
|
+
* 让 dry-run 可观测(dev 实例 logger 不落盘),也为生产排查「为什么没收到邮件」留痕。
|
|
4
|
+
* 记录正文截断 2000 字符;永远不含 SMTP 凭据。写失败只告警不阻断。
|
|
5
|
+
* @module notify-email/audit
|
|
6
|
+
*/
|
|
7
|
+
import { appendFile, mkdir } from 'node:fs/promises'
|
|
8
|
+
import { dirname } from 'node:path'
|
|
9
|
+
|
|
10
|
+
export interface AuditRecord {
|
|
11
|
+
time: string
|
|
12
|
+
subject: string
|
|
13
|
+
to: string[]
|
|
14
|
+
/** sent | dry-run | disabled | incomplete | 错误消息 */
|
|
15
|
+
result: string
|
|
16
|
+
bodyExcerpt: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const BODY_EXCERPT_CHARS = 2000
|
|
20
|
+
|
|
21
|
+
export function buildAuditRecord(
|
|
22
|
+
subject: string,
|
|
23
|
+
text: string,
|
|
24
|
+
to: string[],
|
|
25
|
+
result: string,
|
|
26
|
+
): AuditRecord {
|
|
27
|
+
return {
|
|
28
|
+
time: new Date().toISOString(),
|
|
29
|
+
subject,
|
|
30
|
+
to: [...to],
|
|
31
|
+
result,
|
|
32
|
+
bodyExcerpt: text.length > BODY_EXCERPT_CHARS ? `${text.slice(0, BODY_EXCERPT_CHARS)}…` : text,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type AuditSink = (record: AuditRecord) => void
|
|
37
|
+
|
|
38
|
+
/** 构造 JSONL 落盘 sink(fire-and-forget;写入串行排队保证行序,失败经 onError 告警)。 */
|
|
39
|
+
export function createJsonlAuditSink(path: string, onError: (message: string) => void): AuditSink {
|
|
40
|
+
let queue: Promise<void> = mkdir(dirname(path), { recursive: true }).then(() => undefined)
|
|
41
|
+
return (record) => {
|
|
42
|
+
queue = queue
|
|
43
|
+
.then(() => appendFile(path, `${JSON.stringify(record)}\n`, 'utf8'))
|
|
44
|
+
.catch((error: unknown) => {
|
|
45
|
+
onError(error instanceof Error ? error.message : String(error))
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
}
|