@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
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置 HTTP 通道:webui 插件配置卡片的后端。
|
|
3
|
+
* 背景:官方 apiproxy 的 settings.* RPC 对 namespace 有硬编码白名单,第三方
|
|
4
|
+
* namespace 一律 settings-not-exposed;故卡片数据走自建 webServer 路由,
|
|
5
|
+
* 读写仍经 ctx.settings 用户层(持久化 $DSH_HOME/settings.yaml)。
|
|
6
|
+
* 仅监听 dsh web 同源(webserver 默认 127.0.0.1),与 GUI 其余面同等暴露面。
|
|
7
|
+
* @module notify-email/config-api
|
|
8
|
+
*/
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
10
|
+
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
12
|
+
|
|
13
|
+
import { SETTINGS_NS, toUserPatch, toWire, WirePatch, type WirePatchInput } from './config.ts'
|
|
14
|
+
import type { NotifyEmailService } from './service.ts'
|
|
15
|
+
|
|
16
|
+
const ROUTE_CONFIG = '/dsh-plus/notify-email/config'
|
|
17
|
+
const ROUTE_TEST = '/dsh-plus/notify-email/test'
|
|
18
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
19
|
+
|
|
20
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
21
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
22
|
+
res.end(JSON.stringify(body))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const chunks: Buffer[] = []
|
|
28
|
+
let size = 0
|
|
29
|
+
req.on('data', (chunk: Buffer) => {
|
|
30
|
+
size += chunk.length
|
|
31
|
+
if (size > MAX_BODY_BYTES) {
|
|
32
|
+
reject(new Error('request body too large'))
|
|
33
|
+
req.destroy()
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
chunks.push(chunk)
|
|
37
|
+
})
|
|
38
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
39
|
+
req.on('error', reject)
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function readPatch(req: IncomingMessage): Promise<WirePatchInput> {
|
|
44
|
+
const raw = await readBody(req)
|
|
45
|
+
let parsed: unknown
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(raw)
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error('request body is not valid JSON')
|
|
50
|
+
}
|
|
51
|
+
return WirePatch(parsed) as WirePatchInput
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function handleConfig(
|
|
55
|
+
ctx: Context,
|
|
56
|
+
service: NotifyEmailService,
|
|
57
|
+
req: IncomingMessage,
|
|
58
|
+
res: ServerResponse,
|
|
59
|
+
): Promise<void> {
|
|
60
|
+
const settings = ctx.get('settings')
|
|
61
|
+
if (req.method === 'GET') {
|
|
62
|
+
sendJson(res, 200, toWire(service.currentConfig(), settings !== undefined))
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (req.method !== 'PUT') {
|
|
66
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (settings === undefined) {
|
|
70
|
+
sendJson(res, 503, { error: 'settings provider 不可用,无法在线保存;请编辑 settings.yaml' })
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
const patch = toUserPatch(await readPatch(req))
|
|
74
|
+
await settings.update(SETTINGS_NS, patch)
|
|
75
|
+
sendJson(res, 200, toWire(service.currentConfig(), true))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function handleTest(service: NotifyEmailService, res: ServerResponse): Promise<void> {
|
|
79
|
+
const result = await service.sendTest()
|
|
80
|
+
sendJson(res, result.ok ? 200 : 502, result)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 注册配置读写与测试发信路由;返回是否注册成功(webServer 缺失时由调用方保证不调用)。 */
|
|
84
|
+
export function registerConfigApi(ctx: Context, service: NotifyEmailService): void {
|
|
85
|
+
const logger = ctx.logger('notify-email')
|
|
86
|
+
const guard = (
|
|
87
|
+
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>,
|
|
88
|
+
): ((req: IncomingMessage, res: ServerResponse) => Promise<void>) => {
|
|
89
|
+
return async (req, res) => {
|
|
90
|
+
try {
|
|
91
|
+
await handler(req, res)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
94
|
+
logger.warn(`config api ${req.method ?? '?'} ${req.url ?? '?'} failed: ${message}`)
|
|
95
|
+
if (!res.headersSent) sendJson(res, 400, { error: message })
|
|
96
|
+
else res.end()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
ctx.webServer.register({
|
|
101
|
+
kind: 'exact',
|
|
102
|
+
path: ROUTE_CONFIG,
|
|
103
|
+
handler: guard((req, res) => handleConfig(ctx, service, req, res)),
|
|
104
|
+
})
|
|
105
|
+
ctx.webServer.register({
|
|
106
|
+
kind: 'exact',
|
|
107
|
+
path: ROUTE_TEST,
|
|
108
|
+
handler: guard(async (req, res) => {
|
|
109
|
+
if (req.method !== 'POST') {
|
|
110
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
await handleTest(service, res)
|
|
114
|
+
}),
|
|
115
|
+
})
|
|
116
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置单一事实源:cordis 行级 Config(组合默认值,dev/prod patch 层可覆盖)
|
|
3
|
+
* 与 settings namespace(用户层,经 dsh-settings-file 持久化到 $DSH_HOME/settings.yaml)
|
|
4
|
+
* 共用同一 schemastery schema。SMTP 密码标记 secret 角色,任何读取通道不得回传。
|
|
5
|
+
* @module notify-email/config
|
|
6
|
+
*/
|
|
7
|
+
import z from '@deepseek-ai/schemastery'
|
|
8
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
9
|
+
|
|
10
|
+
/** settings 命名空间;webui 配置卡片与插件运行期读取同一份。 */
|
|
11
|
+
export const SETTINGS_NS = settingsNamespace('dsh-plus-notify-email')
|
|
12
|
+
|
|
13
|
+
const SmtpSchema = z.object({
|
|
14
|
+
host: z.string().description('SMTP 服务器主机名').default(''),
|
|
15
|
+
port: z.natural().max(65535).description('SMTP 端口').default(465),
|
|
16
|
+
secure: z.boolean().description('使用 TLS 直连(465 端口通常为 true)').default(true),
|
|
17
|
+
user: z.string().description('SMTP 登录用户名(通常为发件邮箱)').default(''),
|
|
18
|
+
pass: z.string().role('secret').description('SMTP 密码/授权码').default(''),
|
|
19
|
+
from: z.string().description('发件地址(From 头)').default(''),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const TriggerSchema = z.object({
|
|
23
|
+
onComplete: z.boolean().description('任务执行完毕时通知').default(true),
|
|
24
|
+
onError: z.boolean().description('任务报错/失败停止时通知').default(true),
|
|
25
|
+
onAborted: z.boolean().description('任务被用户取消时通知').default(false),
|
|
26
|
+
onQuestion: z.boolean().description('agent 提问等待回答时通知').default(true),
|
|
27
|
+
onPlanReview: z.boolean().description('plan 待审批时通知').default(true),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export const Config = z.object({
|
|
31
|
+
enabled: z.boolean().description('总开关').default(false),
|
|
32
|
+
smtp: SmtpSchema.default({ host: '', port: 465, secure: true, user: '', pass: '', from: '' }),
|
|
33
|
+
to: z.array(z.string()).description('收件邮箱列表').default([]),
|
|
34
|
+
triggers: TriggerSchema.default({
|
|
35
|
+
onComplete: true, onError: true, onAborted: false, onQuestion: true, onPlanReview: true,
|
|
36
|
+
}),
|
|
37
|
+
idleDebounceMs: z.natural().description('turn 结束后确认无后续工作的等待毫秒数').default(3000),
|
|
38
|
+
maxBodyChars: z.natural().min(200).description('邮件正文内容截断长度').default(4000),
|
|
39
|
+
dryRun: z.boolean().description('仅记录日志不真实发送(开发调试用)').default(false),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
export type NotifyEmailConfig = Schemastery.TypeT<typeof Config>
|
|
43
|
+
export type TriggerToggles = NotifyEmailConfig['triggers']
|
|
44
|
+
|
|
45
|
+
/** 配置卡片读取用的传输对象:pass 永不回传,只报告是否已配置。 */
|
|
46
|
+
export interface WireConfig {
|
|
47
|
+
enabled: boolean
|
|
48
|
+
smtp: {
|
|
49
|
+
host: string
|
|
50
|
+
port: number
|
|
51
|
+
secure: boolean
|
|
52
|
+
user: string
|
|
53
|
+
from: string
|
|
54
|
+
passConfigured: boolean
|
|
55
|
+
}
|
|
56
|
+
to: string[]
|
|
57
|
+
triggers: TriggerToggles
|
|
58
|
+
idleDebounceMs: number
|
|
59
|
+
maxBodyChars: number
|
|
60
|
+
dryRun: boolean
|
|
61
|
+
/** 是否存在可写的 settings provider(决定卡片是否允许编辑)。 */
|
|
62
|
+
writable: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function toWire(cfg: NotifyEmailConfig, writable: boolean): WireConfig {
|
|
66
|
+
return {
|
|
67
|
+
enabled: cfg.enabled,
|
|
68
|
+
smtp: {
|
|
69
|
+
host: cfg.smtp.host,
|
|
70
|
+
port: cfg.smtp.port,
|
|
71
|
+
secure: cfg.smtp.secure,
|
|
72
|
+
user: cfg.smtp.user,
|
|
73
|
+
from: cfg.smtp.from,
|
|
74
|
+
passConfigured: cfg.smtp.pass.length > 0,
|
|
75
|
+
},
|
|
76
|
+
to: [...cfg.to],
|
|
77
|
+
triggers: { ...cfg.triggers },
|
|
78
|
+
idleDebounceMs: cfg.idleDebounceMs,
|
|
79
|
+
maxBodyChars: cfg.maxBodyChars,
|
|
80
|
+
dryRun: cfg.dryRun,
|
|
81
|
+
writable,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 是否具备发信条件(enabled 之外的最小完整性)。 */
|
|
86
|
+
export function isDeliverable(cfg: NotifyEmailConfig): boolean {
|
|
87
|
+
return cfg.enabled && cfg.smtp.host.length > 0 && cfg.smtp.from.length > 0 && cfg.to.length > 0
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 配置卡片写回用的 patch schema:字段全部可选(无默认值),
|
|
92
|
+
* 只携带用户真实编辑过的字段,交给 settings.update 深合并进用户层。
|
|
93
|
+
*/
|
|
94
|
+
export const WirePatch = z.object({
|
|
95
|
+
enabled: z.boolean(),
|
|
96
|
+
smtp: z.object({
|
|
97
|
+
host: z.string(),
|
|
98
|
+
port: z.natural().max(65535),
|
|
99
|
+
secure: z.boolean(),
|
|
100
|
+
user: z.string(),
|
|
101
|
+
pass: z.string(),
|
|
102
|
+
from: z.string(),
|
|
103
|
+
}),
|
|
104
|
+
to: z.array(z.string()),
|
|
105
|
+
triggers: z.object({
|
|
106
|
+
onComplete: z.boolean(),
|
|
107
|
+
onError: z.boolean(),
|
|
108
|
+
onAborted: z.boolean(),
|
|
109
|
+
onQuestion: z.boolean(),
|
|
110
|
+
onPlanReview: z.boolean(),
|
|
111
|
+
}),
|
|
112
|
+
idleDebounceMs: z.natural(),
|
|
113
|
+
maxBodyChars: z.natural().min(200),
|
|
114
|
+
dryRun: z.boolean(),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
export type WirePatchInput = Schemastery.TypeS<typeof WirePatch>
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 把卡片提交的 patch 规整为 settings.update 的用户层 patch。
|
|
121
|
+
* smtp.pass 为空串表示「保持不变」,从 patch 中剔除,避免覆盖已存密码。
|
|
122
|
+
*/
|
|
123
|
+
export function toUserPatch(input: WirePatchInput): Record<string, unknown> {
|
|
124
|
+
const patch: Record<string, unknown> = { ...input }
|
|
125
|
+
if (input.smtp !== undefined && input.smtp !== null) {
|
|
126
|
+
const smtp: Record<string, unknown> = { ...input.smtp }
|
|
127
|
+
if (smtp['pass'] === '') delete smtp['pass']
|
|
128
|
+
patch['smtp'] = smtp
|
|
129
|
+
}
|
|
130
|
+
return patch
|
|
131
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh 插件:任务结束邮件通知。
|
|
3
|
+
* 在 agent 停止工作的三类时机经配置的 SMTP 邮箱向指定收件人发邮件:
|
|
4
|
+
* 任务执行完毕(最后一条交付消息)、等待用户决策(提问/plan 审批)、报错停止。
|
|
5
|
+
* 扩展接口:第三方插件 ctx.inject(['notifyEmail']) 后 registerTrigger() 注册自定义通知。
|
|
6
|
+
* @module @dsh-plus/notify-email
|
|
7
|
+
*/
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
9
|
+
|
|
10
|
+
import { Config, type NotifyEmailConfig } from './config.ts'
|
|
11
|
+
import { NotifyEmailService } from './service.ts'
|
|
12
|
+
|
|
13
|
+
export const name = 'dsh-plus-notify-email'
|
|
14
|
+
|
|
15
|
+
export const inject = ['agents', 'tools'] as const
|
|
16
|
+
|
|
17
|
+
export { Config }
|
|
18
|
+
|
|
19
|
+
export type { EmailNotice, DecisionCall, TurnEndInfo, NotifyTrigger } from './triggers/types.ts'
|
|
20
|
+
export { NotifyEmailService } from './service.ts'
|
|
21
|
+
|
|
22
|
+
export function apply(ctx: Context, config: NotifyEmailConfig): void {
|
|
23
|
+
ctx.plugin(NotifyEmailService, config)
|
|
24
|
+
}
|
package/src/mailer.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 邮件发送:nodemailer 封装 + dry-run 兜底。
|
|
3
|
+
* Transport 抽象为函数注入,单元测试以假 transport 断言参数,零网络。
|
|
4
|
+
* @module notify-email/mailer
|
|
5
|
+
*/
|
|
6
|
+
import nodemailer from 'nodemailer'
|
|
7
|
+
|
|
8
|
+
import { buildAuditRecord, type AuditSink } from './audit.ts'
|
|
9
|
+
import { isDeliverable, type NotifyEmailConfig } from './config.ts'
|
|
10
|
+
|
|
11
|
+
/** 一封待发邮件(触发器产物的最小契约)。 */
|
|
12
|
+
export interface MailMessage {
|
|
13
|
+
subject: string
|
|
14
|
+
text: string
|
|
15
|
+
html?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SendResult {
|
|
19
|
+
ok: boolean
|
|
20
|
+
/** 'sent' | 'dry-run' | 'disabled' | 'incomplete' | 错误消息。 */
|
|
21
|
+
detail: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 发送通道:按生效配置投递一封邮件;失败抛错由调用方归一化。 */
|
|
25
|
+
export type Transport = (cfg: NotifyEmailConfig, msg: MailMessage) => Promise<void>
|
|
26
|
+
|
|
27
|
+
export interface MailLogger {
|
|
28
|
+
info(msg: string): void
|
|
29
|
+
warn(msg: string): void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** nodemailer 连接/问候/套接字超时(毫秒),防 SMTP 不可达时挂住通知链路。 */
|
|
33
|
+
const CONNECTION_TIMEOUT_MS = 10_000
|
|
34
|
+
const GREETING_TIMEOUT_MS = 10_000
|
|
35
|
+
const SOCKET_TIMEOUT_MS = 30_000
|
|
36
|
+
|
|
37
|
+
/** 真实 SMTP 通道:每次发送按当前配置新建 transport(配置热改即生效,发送低频无池化需求)。 */
|
|
38
|
+
export const smtpTransport: Transport = async (cfg, msg) => {
|
|
39
|
+
const transporter = nodemailer.createTransport({
|
|
40
|
+
host: cfg.smtp.host,
|
|
41
|
+
port: cfg.smtp.port,
|
|
42
|
+
secure: cfg.smtp.secure,
|
|
43
|
+
auth: cfg.smtp.user.length > 0 ? { user: cfg.smtp.user, pass: cfg.smtp.pass } : undefined,
|
|
44
|
+
connectionTimeout: CONNECTION_TIMEOUT_MS,
|
|
45
|
+
greetingTimeout: GREETING_TIMEOUT_MS,
|
|
46
|
+
socketTimeout: SOCKET_TIMEOUT_MS,
|
|
47
|
+
})
|
|
48
|
+
await transporter.sendMail({
|
|
49
|
+
from: cfg.smtp.from,
|
|
50
|
+
to: cfg.to.join(', '),
|
|
51
|
+
subject: msg.subject,
|
|
52
|
+
text: msg.text,
|
|
53
|
+
...(msg.html !== undefined ? { html: msg.html } : {}),
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class Mailer {
|
|
58
|
+
private readonly resolveConfig: () => NotifyEmailConfig
|
|
59
|
+
private readonly logger: MailLogger
|
|
60
|
+
private readonly transport: Transport
|
|
61
|
+
private readonly audit?: AuditSink
|
|
62
|
+
|
|
63
|
+
constructor(
|
|
64
|
+
resolveConfig: () => NotifyEmailConfig,
|
|
65
|
+
logger: MailLogger,
|
|
66
|
+
transport: Transport = smtpTransport,
|
|
67
|
+
audit?: AuditSink,
|
|
68
|
+
) {
|
|
69
|
+
this.resolveConfig = resolveConfig
|
|
70
|
+
this.logger = logger
|
|
71
|
+
this.transport = transport
|
|
72
|
+
this.audit = audit
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 常规通知发送:受 enabled 与配置完整性门禁。
|
|
77
|
+
* @param force 为 true 时跳过 enabled 门禁(配置卡片的「发送测试邮件」),仍需 SMTP 完整。
|
|
78
|
+
*/
|
|
79
|
+
async send(msg: MailMessage, force = false): Promise<SendResult> {
|
|
80
|
+
const cfg = this.resolveConfig()
|
|
81
|
+
if (!force && !cfg.enabled) return this.record(msg, cfg, { ok: false, detail: 'disabled' })
|
|
82
|
+
if (cfg.smtp.host.length === 0 || cfg.smtp.from.length === 0 || cfg.to.length === 0) {
|
|
83
|
+
return this.record(msg, cfg, { ok: false, detail: 'incomplete' })
|
|
84
|
+
}
|
|
85
|
+
if (cfg.dryRun) {
|
|
86
|
+
this.logger.info(`[dry-run] subject=${msg.subject}\n${msg.text}`)
|
|
87
|
+
return this.record(msg, cfg, { ok: true, detail: 'dry-run' })
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
await this.transport(cfg, msg)
|
|
91
|
+
return this.record(msg, cfg, { ok: true, detail: 'sent' })
|
|
92
|
+
} catch (error) {
|
|
93
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
94
|
+
this.logger.warn(`send failed: ${message} (subject=${msg.subject})`)
|
|
95
|
+
return this.record(msg, cfg, { ok: false, detail: message })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private record(msg: MailMessage, cfg: NotifyEmailConfig, result: SendResult): SendResult {
|
|
100
|
+
this.audit?.(buildAuditRecord(msg.subject, msg.text, cfg.to, result.detail))
|
|
101
|
+
return result
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NotifyEmailService:通知编排中枢。
|
|
3
|
+
* - 配置:installSettingsSection 接入 settings 用户层($DSH_HOME/settings.yaml,热生效),
|
|
4
|
+
* 无 settings provider 时退化为 cordis 行级 config。
|
|
5
|
+
* - 触发器:registerTrigger 是第三方插件的扩展接口;内置官方适配器同路径注册。
|
|
6
|
+
* - 投递:首个非空 EmailNotice 经 Mailer 发送;单个触发器抛错不影响其余。
|
|
7
|
+
* @module @dsh-plus/notify-email
|
|
8
|
+
*/
|
|
9
|
+
import { Context, Service } from '@deepseek-ai/cordis'
|
|
10
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
|
|
11
|
+
|
|
12
|
+
import { createJsonlAuditSink } from './audit.ts'
|
|
13
|
+
import { Config, SETTINGS_NS, type NotifyEmailConfig } from './config.ts'
|
|
14
|
+
import { Mailer } from './mailer.ts'
|
|
15
|
+
import { installSettingsSection } from '@deepseek-ai/dsh-settings'
|
|
16
|
+
import { registerConfigApi } from './config-api.ts'
|
|
17
|
+
import { createDecisionTrigger, createTurnEndTrigger } from './triggers/builtin.ts'
|
|
18
|
+
import type { DecisionCall, NotifyTrigger, TurnEndInfo } from './triggers/types.ts'
|
|
19
|
+
import { installDecisionWatcher } from './watchers/decision.ts'
|
|
20
|
+
import { installTurnEndWatcher } from './watchers/turn-end.ts'
|
|
21
|
+
|
|
22
|
+
declare module '@deepseek-ai/cordis' {
|
|
23
|
+
interface Context {
|
|
24
|
+
notifyEmail: NotifyEmailService
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class NotifyEmailService extends Service {
|
|
29
|
+
static [Context.inject] = ['agents', 'tools']
|
|
30
|
+
|
|
31
|
+
private readonly triggers: NotifyTrigger[] = []
|
|
32
|
+
private readonly mailer: Mailer
|
|
33
|
+
private current: () => NotifyEmailConfig
|
|
34
|
+
|
|
35
|
+
constructor(ctx: Context, config: NotifyEmailConfig) {
|
|
36
|
+
super(ctx, 'notifyEmail')
|
|
37
|
+
this.current = () => config
|
|
38
|
+
installSettingsSection(ctx, SETTINGS_NS, Config, config, {
|
|
39
|
+
setSource: (source) => {
|
|
40
|
+
this.current = source
|
|
41
|
+
},
|
|
42
|
+
onChange: () => {},
|
|
43
|
+
})
|
|
44
|
+
const logger = ctx.logger('notify-email')
|
|
45
|
+
const audit = createJsonlAuditSink(
|
|
46
|
+
dshHomePath('logs', 'notify-email.jsonl'),
|
|
47
|
+
(message) => logger.warn(`audit write failed: ${message}`),
|
|
48
|
+
)
|
|
49
|
+
this.mailer = new Mailer(() => this.current(), logger, undefined, audit)
|
|
50
|
+
this.registerTrigger(createDecisionTrigger(() => this.current()))
|
|
51
|
+
this.registerTrigger(createTurnEndTrigger(() => this.current()))
|
|
52
|
+
installDecisionWatcher(ctx, this)
|
|
53
|
+
installTurnEndWatcher(ctx, this, () => this.current())
|
|
54
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
55
|
+
registerConfigApi(webCtx, this)
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 注册触发器(第三方扩展接口);返回注销函数,随调用方 fiber 释放。 */
|
|
60
|
+
registerTrigger(trigger: NotifyTrigger): () => void {
|
|
61
|
+
if (this.triggers.some((t) => t.id === trigger.id)) {
|
|
62
|
+
throw new Error(`notify-email: duplicate trigger id ${JSON.stringify(trigger.id)}`)
|
|
63
|
+
}
|
|
64
|
+
this.triggers.push(trigger)
|
|
65
|
+
return this.ctx.effect(
|
|
66
|
+
() => () => {
|
|
67
|
+
const index = this.triggers.indexOf(trigger)
|
|
68
|
+
if (index >= 0) this.triggers.splice(index, 1)
|
|
69
|
+
},
|
|
70
|
+
`notify-email: trigger ${trigger.id}`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 当前生效配置(settings 用户层解析结果或 cordis 行级 config)。 */
|
|
75
|
+
currentConfig(): NotifyEmailConfig {
|
|
76
|
+
return this.current()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 决策类观察入口(decision watcher 调用)。 */
|
|
80
|
+
async dispatchDecision(call: DecisionCall): Promise<void> {
|
|
81
|
+
await this.dispatch('decision', call, (t) => t.onDecision?.(call))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 任务停止类观察入口(turn-end watcher 调用)。 */
|
|
85
|
+
async dispatchTurnEnd(info: TurnEndInfo): Promise<void> {
|
|
86
|
+
await this.dispatch('turn-end', info, (t) => t.onTurnEnd?.(info))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 配置卡片「发送测试邮件」:绕过 enabled 门禁,仍需 SMTP 完整。 */
|
|
90
|
+
async sendTest(): Promise<{ ok: boolean; detail: string }> {
|
|
91
|
+
return this.mailer.send(
|
|
92
|
+
{ subject: '[DSH] 邮件通知测试', text: '这是一封来自 dsh notify-email 插件的测试邮件。' },
|
|
93
|
+
true,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async dispatch<T>(
|
|
98
|
+
kind: string,
|
|
99
|
+
payload: T,
|
|
100
|
+
produce: (t: NotifyTrigger) => { subject: string; text: string; html?: string } | undefined,
|
|
101
|
+
): Promise<void> {
|
|
102
|
+
for (const trigger of [...this.triggers]) {
|
|
103
|
+
let notice
|
|
104
|
+
try {
|
|
105
|
+
notice = produce(trigger)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
this.ctx.logger('notify-email').warn(
|
|
108
|
+
`trigger ${trigger.id} failed on ${kind}: ${error instanceof Error ? error.message : String(error)}`,
|
|
109
|
+
)
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
if (notice === undefined) continue
|
|
113
|
+
const result = await this.mailer.send(notice)
|
|
114
|
+
if (!result.ok && result.detail !== 'disabled' && result.detail !== 'incomplete') {
|
|
115
|
+
this.ctx.logger('notify-email').warn(
|
|
116
|
+
`deliver failed via trigger ${trigger.id}: ${result.detail}`,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 内置官方适配器:ask_user_question / exit_plan_mode 决策通知,
|
|
3
|
+
* 以及 turn 结束(completed/error/aborted)通知。按配置 toggles 门控。
|
|
4
|
+
* @module notify-email/triggers/builtin
|
|
5
|
+
*/
|
|
6
|
+
import type { NotifyEmailConfig } from '../config.ts'
|
|
7
|
+
import {
|
|
8
|
+
renderAbortedNotice, renderCompletionNotice, renderErrorNotice,
|
|
9
|
+
renderPlanNotice, renderQuestionNotice,
|
|
10
|
+
} from './render.ts'
|
|
11
|
+
import type { NotifyTrigger } from './types.ts'
|
|
12
|
+
|
|
13
|
+
/** 官方决策工具名 → 渲染函数 + 对应 toggle。 */
|
|
14
|
+
export function createDecisionTrigger(resolveConfig: () => NotifyEmailConfig): NotifyTrigger {
|
|
15
|
+
return {
|
|
16
|
+
id: 'dsh-plus-official-decision',
|
|
17
|
+
onDecision(call) {
|
|
18
|
+
const cfg = resolveConfig()
|
|
19
|
+
if (call.name === 'ask_user_question' && cfg.triggers.onQuestion) {
|
|
20
|
+
return renderQuestionNotice(call, cfg.maxBodyChars)
|
|
21
|
+
}
|
|
22
|
+
if (call.name === 'exit_plan_mode' && cfg.triggers.onPlanReview) {
|
|
23
|
+
return renderPlanNotice(call, cfg.maxBodyChars)
|
|
24
|
+
}
|
|
25
|
+
return undefined
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createTurnEndTrigger(resolveConfig: () => NotifyEmailConfig): NotifyTrigger {
|
|
31
|
+
return {
|
|
32
|
+
id: 'dsh-plus-official-turn-end',
|
|
33
|
+
onTurnEnd(info) {
|
|
34
|
+
const cfg = resolveConfig()
|
|
35
|
+
if (info.kind === 'completed' && cfg.triggers.onComplete) {
|
|
36
|
+
return renderCompletionNotice(info, cfg.maxBodyChars)
|
|
37
|
+
}
|
|
38
|
+
if (info.kind === 'error' && cfg.triggers.onError) {
|
|
39
|
+
return renderErrorNotice(info, cfg.maxBodyChars)
|
|
40
|
+
}
|
|
41
|
+
if (info.kind === 'aborted' && cfg.triggers.onAborted) {
|
|
42
|
+
return renderAbortedNotice(info)
|
|
43
|
+
}
|
|
44
|
+
return undefined
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 触发器产物的纯函数渲染:各类通知 → 邮件 subject/text。
|
|
3
|
+
* 不依赖 cordis,单测直接覆盖;模板中文、正文超长统一截断。
|
|
4
|
+
* @module notify-email/triggers/render
|
|
5
|
+
*/
|
|
6
|
+
import type { DecisionCall, EmailNotice, TurnEndInfo } from './types.ts'
|
|
7
|
+
|
|
8
|
+
function truncate(text: string, maxChars: number): string {
|
|
9
|
+
if (text.length <= maxChars) return text
|
|
10
|
+
return `${text.slice(0, maxChars)}\n……(内容过长已截断)`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function shortSession(sessionId: string): string {
|
|
14
|
+
return sessionId.length > 8 ? sessionId.slice(0, 8) : sessionId
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function header(reason: string, info: { sessionId: string; turn?: number }): string {
|
|
18
|
+
const turnPart = info.turn === undefined ? '' : ` · turn ${info.turn}`
|
|
19
|
+
return `通知原因:${reason}\n会话:${shortSession(info.sessionId)}${turnPart}\n时间:${new Date().toISOString()}\n`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface QuestionOption {
|
|
23
|
+
label?: unknown
|
|
24
|
+
description?: unknown
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface QuestionItem {
|
|
28
|
+
id?: unknown
|
|
29
|
+
header?: unknown
|
|
30
|
+
question?: unknown
|
|
31
|
+
options?: unknown
|
|
32
|
+
multi_select?: unknown
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderOption(option: QuestionOption, index: number): string {
|
|
36
|
+
const label = typeof option?.label === 'string' ? option.label : `选项 ${index + 1}`
|
|
37
|
+
const desc = typeof option?.description === 'string' ? ` — ${option.description}` : ''
|
|
38
|
+
return ` ${index + 1}. ${label}${desc}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function renderQuestion(item: QuestionItem, index: number): string {
|
|
42
|
+
const title = typeof item?.question === 'string' ? item.question : '(未命名问题)'
|
|
43
|
+
const head = typeof item?.header === 'string' ? `【${item.header}】` : ''
|
|
44
|
+
const multi = item?.multi_select === true ? '(可多选)' : ''
|
|
45
|
+
const options = Array.isArray(item?.options) ? item.options : []
|
|
46
|
+
const lines = options.map((o, i) => renderOption(o as QuestionOption, i))
|
|
47
|
+
return [`问题 ${index + 1}:${head}${title}${multi}`, ...lines].join('\n')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** ask_user_question 工具参数 → 通知(问题 + 选项)。 */
|
|
51
|
+
export function renderQuestionNotice(call: DecisionCall, maxChars: number): EmailNotice {
|
|
52
|
+
const questions = Array.isArray(call.args['questions']) ? call.args['questions'] : []
|
|
53
|
+
const body = questions.map((q, i) => renderQuestion(q as QuestionItem, i)).join('\n\n')
|
|
54
|
+
return {
|
|
55
|
+
subject: `[DSH] 等待你的回答 · 会话 ${shortSession(call.sessionId)}`,
|
|
56
|
+
text: `${header('agent 提问,等待用户回答', call)}\n${truncate(body, maxChars)}`,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** exit_plan_mode 工具参数 → 通知(plan 全文)。 */
|
|
61
|
+
export function renderPlanNotice(call: DecisionCall, maxChars: number): EmailNotice {
|
|
62
|
+
const plan = typeof call.args['plan'] === 'string' ? call.args['plan'] : '(plan 内容缺失)'
|
|
63
|
+
return {
|
|
64
|
+
subject: `[DSH] Plan 待审批 · 会话 ${shortSession(call.sessionId)}`,
|
|
65
|
+
text: `${header('agent 提交了计划,等待审批', call)}\n${truncate(plan, maxChars)}`,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** turn 正常结束 → 通知(最后一条 assistant 交付消息)。 */
|
|
70
|
+
export function renderCompletionNotice(info: TurnEndInfo, maxChars: number): EmailNotice {
|
|
71
|
+
const delivery = info.lastDelivery ?? '(本轮无文本交付内容)'
|
|
72
|
+
return {
|
|
73
|
+
subject: `[DSH] 任务执行完毕 · 会话 ${shortSession(info.sessionId)}`,
|
|
74
|
+
text: `${header('任务执行完毕', info)}\n${truncate(delivery, maxChars)}`,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** turn 出错 → 通知(错误信息)。 */
|
|
79
|
+
export function renderErrorNotice(info: TurnEndInfo, maxChars: number): EmailNotice {
|
|
80
|
+
const detail = info.errorMessage ?? '(错误详情缺失)'
|
|
81
|
+
return {
|
|
82
|
+
subject: `[DSH] 任务出错停止 · 会话 ${shortSession(info.sessionId)}`,
|
|
83
|
+
text: `${header('任务报错/失败停止', info)}\n${truncate(detail, maxChars)}`,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** turn 被取消 → 通知。 */
|
|
88
|
+
export function renderAbortedNotice(info: TurnEndInfo): EmailNotice {
|
|
89
|
+
return {
|
|
90
|
+
subject: `[DSH] 任务被取消 · 会话 ${shortSession(info.sessionId)}`,
|
|
91
|
+
text: header('任务被用户取消', info),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 触发器契约:第三方插件经 ctx.notifyEmail.registerTrigger() 注册,
|
|
3
|
+
* 返回 undefined 表示「不关心该观察」;首个非空产物被投递。
|
|
4
|
+
* 内置官方适配器(decision/turn-end)也经同一接口注册,无特权路径。
|
|
5
|
+
* @module notify-email/triggers/types
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** 一封待投递通知:触发器的产出。 */
|
|
9
|
+
export interface EmailNotice {
|
|
10
|
+
subject: string
|
|
11
|
+
text: string
|
|
12
|
+
html?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 决策类观察:工具调用即将阻塞等待用户(tools/pre-execute 观察点,参数已校验)。 */
|
|
16
|
+
export interface DecisionCall {
|
|
17
|
+
callId: string
|
|
18
|
+
sessionId: string
|
|
19
|
+
name: string
|
|
20
|
+
args: Record<string, unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 任务停止类观察:runtime root agent 的 turn 结束且空闲防抖后。 */
|
|
24
|
+
export interface TurnEndInfo {
|
|
25
|
+
sessionId: string
|
|
26
|
+
turn: number
|
|
27
|
+
/** turn/end reason.kind:completed | error | aborted(其余 kind 不下发)。 */
|
|
28
|
+
kind: string
|
|
29
|
+
errorMessage?: string
|
|
30
|
+
/** 该会话最后一条 assistant 消息的文本内容。 */
|
|
31
|
+
lastDelivery?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface NotifyTrigger {
|
|
35
|
+
readonly id: string
|
|
36
|
+
/** 决策类通知:返回 undefined 跳过。 */
|
|
37
|
+
onDecision?(call: DecisionCall): EmailNotice | undefined
|
|
38
|
+
/** 任务停止类通知:返回 undefined 跳过。 */
|
|
39
|
+
onTurnEnd?(info: TurnEndInfo): EmailNotice | undefined
|
|
40
|
+
}
|