@cowwo/dsh-lookout 0.1.2 → 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 +45 -9
- package/client/client.js +2837 -1084
- package/lib/icon/index.js +54 -0
- package/lib/icon/workspace-prefs.js +129 -0
- 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 +89 -23
- package/package.json +1 -1
- package/lib/notify/state.js +0 -102
package/lib/icon/index.js
CHANGED
|
@@ -41,6 +41,13 @@ import {
|
|
|
41
41
|
pruneSeen
|
|
42
42
|
} from './unread.js'
|
|
43
43
|
import { normalizeRunningConfig } from './running.js'
|
|
44
|
+
import {
|
|
45
|
+
emptyWorkspacePrefs,
|
|
46
|
+
normalizeWorkspacePrefs,
|
|
47
|
+
readWorkspacePrefs,
|
|
48
|
+
workspacePrefsFilePath,
|
|
49
|
+
writeWorkspacePrefs
|
|
50
|
+
} from './workspace-prefs.js'
|
|
44
51
|
|
|
45
52
|
const MAX_BYTES = 10 * 1024 * 1024 // 10 MB
|
|
46
53
|
// Normalized mime -> safe extension. CUR (mouse cursor) is accepted as ICO.
|
|
@@ -344,6 +351,10 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
344
351
|
// list already carries each session's `running`), so nothing about it is
|
|
345
352
|
// stored here — only whether to show the projection of it.
|
|
346
353
|
this.runningPath = join(this.storageDir, 'running.json')
|
|
354
|
+
// Per-workspace preferences (tag / color / pinned / hidden). Same folder as
|
|
355
|
+
// the icon library, but its own file + endpoint pair: a corrupt preference
|
|
356
|
+
// document must never take the favicon or the badge rules down with it.
|
|
357
|
+
this.workspacePrefsPath = workspacePrefsFilePath(this.storageDir)
|
|
347
358
|
// { activeId, icons: { [id]: { id, mime, ext, name, size, savedAt, rev, pwa, logo } } }
|
|
348
359
|
this.library = { activeId: null, icons: {} }
|
|
349
360
|
// The active icon's loaded state: { rev, mime, ext, name, size, savedAt, pwa, logo, bytes } | null
|
|
@@ -355,6 +366,9 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
355
366
|
// Running-mark switch: whether the yellow projection of the official
|
|
356
367
|
// `running` fact is drawn. See lib/running.js.
|
|
357
368
|
this.running = normalizeRunningConfig(null)
|
|
369
|
+
// Workspace preferences: { version: 1, workspaces: { [workspaceId]: { tag, color, pinned, hidden, pinAnchor } } }.
|
|
370
|
+
// The client sends the whole document on every set (full replacement).
|
|
371
|
+
this.workspacePrefs = emptyWorkspacePrefs()
|
|
358
372
|
// Host-side watermark: { [sessionId]: endAt } — see lib/unread.js.
|
|
359
373
|
this.seen = {}
|
|
360
374
|
// Platform PWA manifest (raw + parsed), read once at startup.
|
|
@@ -362,6 +376,7 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
362
376
|
this._load()
|
|
363
377
|
this._loadUnread()
|
|
364
378
|
this._loadRunning()
|
|
379
|
+
this._loadWorkspacePrefs()
|
|
365
380
|
this._loadUnreadSeen()
|
|
366
381
|
// "How did this session's last turn end" as an official session
|
|
367
382
|
// projection. Optional registration on purpose: this plugin is a display
|
|
@@ -477,6 +492,16 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
477
492
|
return writeFile(this.runningPath, JSON.stringify(this.running, null, 2))
|
|
478
493
|
}
|
|
479
494
|
|
|
495
|
+
/** Load workspace preferences (missing or corrupt file = empty document). */
|
|
496
|
+
_loadWorkspacePrefs() {
|
|
497
|
+
this.workspacePrefs = readWorkspacePrefs(this.workspacePrefsPath)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Persist workspace preferences (best effort; atomic tmp+rename in the helper). */
|
|
501
|
+
_saveWorkspacePrefs() {
|
|
502
|
+
return writeWorkspacePrefs(this.workspacePrefsPath, this.workspacePrefs)
|
|
503
|
+
}
|
|
504
|
+
|
|
480
505
|
/**
|
|
481
506
|
* Load the host-side watermark.
|
|
482
507
|
*
|
|
@@ -585,6 +610,35 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
585
610
|
return { ...this.running }
|
|
586
611
|
}
|
|
587
612
|
|
|
613
|
+
/**
|
|
614
|
+
* typert endpoint: read the per-workspace preferences (tag / color / pinned /
|
|
615
|
+
* hidden). A copy, not the live object: a caller must not be able to mutate the
|
|
616
|
+
* Host's state by editing the response.
|
|
617
|
+
* @returns `{ version: 1, workspaces: { [workspaceId]: { tag, color, pinned, hidden, pinAnchor } } }`.
|
|
618
|
+
*/
|
|
619
|
+
async getWorkspacePrefs() {
|
|
620
|
+
return normalizeWorkspacePrefs(this.workspacePrefs)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* typert endpoint: replace the whole workspace-preferences document.
|
|
625
|
+
*
|
|
626
|
+
* Full replacement, not a patch: the client owns the document and sends it
|
|
627
|
+
* whole, so a workspace the client dropped disappears here too. Dirty input is
|
|
628
|
+
* normalized (invalid ids/values degrade to defaults) and never throws; the
|
|
629
|
+
* write is best effort, like the other advisory preference files.
|
|
630
|
+
* @param request - `{ version: 1, workspaces: { ... } }`.
|
|
631
|
+
* @returns the stored (normalized) document.
|
|
632
|
+
*/
|
|
633
|
+
async setWorkspacePrefs(request) {
|
|
634
|
+
if (request === null || typeof request !== 'object' || Array.isArray(request)) {
|
|
635
|
+
throw new Error('缺少 workspaces 参数')
|
|
636
|
+
}
|
|
637
|
+
this.workspacePrefs = normalizeWorkspacePrefs(request)
|
|
638
|
+
await this._saveWorkspacePrefs().catch(() => {}) // preference file is advisory
|
|
639
|
+
return normalizeWorkspacePrefs(this.workspacePrefs)
|
|
640
|
+
}
|
|
641
|
+
|
|
588
642
|
/**
|
|
589
643
|
* typert endpoint: merge this browser's "seen" watermarks into the Host's copy.
|
|
590
644
|
*
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-lookout · 工作区偏好(标签/隐藏/置顶)的宿主半
|
|
3
|
+
*
|
|
4
|
+
* 只干两件事:
|
|
5
|
+
* 1. 把任意脏输入夹成一份**完整、可安全落盘**的偏好文档;
|
|
6
|
+
* 2. 在 $DSH_HOME/custom-favicon/workspace-prefs.json 上读/写这份文档。
|
|
7
|
+
*
|
|
8
|
+
* 设计约束(与 unread.js 的规则开关同一条思路):
|
|
9
|
+
* * normalize 永不抛错 —— 文件被手改坏、客户端版本对不上,都只降级成默认值,
|
|
10
|
+
* 绝不因为一条偏好把图标/红点这些主功能一起带崩;
|
|
11
|
+
* * 写盘 best effort —— 调用方(index.js)吞掉落盘错误,内存里的偏好照常生效;
|
|
12
|
+
* * 文档整份覆盖 —— 客户端每次提交完整文档,宿主不保留任何"上一版残留"。
|
|
13
|
+
*
|
|
14
|
+
* 文档形状(version 固定为 1):
|
|
15
|
+
* { "version": 1, "workspaces": { "<workspaceId>": { "tag", "color",
|
|
16
|
+
* "pinned", "hidden", "pinAnchor" } } }
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync } from 'node:fs'
|
|
19
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises'
|
|
20
|
+
import { dirname, join } from 'node:path'
|
|
21
|
+
|
|
22
|
+
/** 文档版本。未来改结构时靠它整份丢弃旧文档,而不是猜字段。 */
|
|
23
|
+
export const WORKSPACE_PREFS_VERSION = 1
|
|
24
|
+
|
|
25
|
+
/** 与 library.json / unread.json 同目录:custom-favicon/。 */
|
|
26
|
+
export const WORKSPACE_PREFS_FILE = 'workspace-prefs.json'
|
|
27
|
+
|
|
28
|
+
/** 标签颜色白名单。非法颜色一律落到 gray,不猜"最接近的颜色"。 */
|
|
29
|
+
export const WORKSPACE_COLORS = ['red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'purple', 'gray']
|
|
30
|
+
|
|
31
|
+
/** 非法/缺省颜色。 */
|
|
32
|
+
export const WORKSPACE_COLOR_DEFAULT = 'gray'
|
|
33
|
+
|
|
34
|
+
/** 标签最多 6 个 Unicode 字符(不是 UTF-16 单元:emoji 也只算一个)。 */
|
|
35
|
+
export const WORKSPACE_TAG_MAX = 6
|
|
36
|
+
|
|
37
|
+
/** 工作区 id 长度上限(与客户端约定一致)。 */
|
|
38
|
+
export const WORKSPACE_ID_MAX = 200
|
|
39
|
+
|
|
40
|
+
/** 危险对象键:绝不能通过 JSON 里的 `__proto__` 改到原型。 */
|
|
41
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
42
|
+
|
|
43
|
+
/** 空文档。文件缺失/损坏/版本未知时都用它。 */
|
|
44
|
+
export function emptyWorkspacePrefs() {
|
|
45
|
+
return { version: WORKSPACE_PREFS_VERSION, workspaces: {} }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 只留可打印字符:去掉控制字符(Cc),再 trim。 */
|
|
49
|
+
export function normalizeTag(input) {
|
|
50
|
+
if (typeof input !== 'string') return ''
|
|
51
|
+
const cleaned = input.replace(/\p{Cc}/gu, '').trim()
|
|
52
|
+
return [...cleaned].slice(0, WORKSPACE_TAG_MAX).join('')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 颜色白名单,非法回落 gray。 */
|
|
56
|
+
export function normalizeColor(input) {
|
|
57
|
+
return WORKSPACE_COLORS.includes(input) ? input : WORKSPACE_COLOR_DEFAULT
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 夹一个工作区条目。未知字段丢弃,坏类型一律降级:
|
|
62
|
+
* tag 非法 → '';color 非法 → 'gray';pinned/hidden 非 true → false;
|
|
63
|
+
* pinAnchor 非 string → null。
|
|
64
|
+
*/
|
|
65
|
+
export function normalizeWorkspaceEntry(input) {
|
|
66
|
+
const source = input !== null && typeof input === 'object' && !Array.isArray(input) ? input : {}
|
|
67
|
+
return {
|
|
68
|
+
tag: normalizeTag(source.tag),
|
|
69
|
+
color: normalizeColor(source.color),
|
|
70
|
+
// 只认真正的 true:字符串 'true' / 1 都不能把工作区置顶或藏起来。
|
|
71
|
+
pinned: source.pinned === true,
|
|
72
|
+
hidden: source.hidden === true,
|
|
73
|
+
pinAnchor: typeof source.pinAnchor === 'string' ? source.pinAnchor : null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 工作区 id 是否可用:非空 string,长度不超过 200。 */
|
|
78
|
+
export function isValidWorkspaceId(input) {
|
|
79
|
+
return typeof input === 'string' && input.length > 0 && input.length <= WORKSPACE_ID_MAX
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 把任意输入夹成完整文档 —— 永不抛错。
|
|
84
|
+
* @param {unknown} input - 可能是 null、数组、字符串、缺字段、字段类型全错的对象,或一份旧文档
|
|
85
|
+
* @returns {{ version: 1, workspaces: Record<string, { tag: string, color: string, pinned: boolean, hidden: boolean, pinAnchor: string | null }> }}
|
|
86
|
+
*/
|
|
87
|
+
export function normalizeWorkspacePrefs(input) {
|
|
88
|
+
const document = emptyWorkspacePrefs()
|
|
89
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) return document
|
|
90
|
+
const workspaces = input.workspaces
|
|
91
|
+
if (workspaces === null || typeof workspaces !== 'object' || Array.isArray(workspaces)) return document
|
|
92
|
+
for (const [workspaceId, entry] of Object.entries(workspaces)) {
|
|
93
|
+
if (!isValidWorkspaceId(workspaceId) || UNSAFE_KEYS.has(workspaceId)) continue
|
|
94
|
+
document.workspaces[workspaceId] = normalizeWorkspaceEntry(entry)
|
|
95
|
+
}
|
|
96
|
+
return document
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 偏好文件路径(与图标库同目录)。 */
|
|
100
|
+
export function workspacePrefsFilePath(storageDir) {
|
|
101
|
+
return join(storageDir, WORKSPACE_PREFS_FILE)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 读偏好文档。文件缺失、空文件、坏 JSON、版本/结构不对 —— 一律返回空文档,绝不抛错。
|
|
106
|
+
* 同步读:与 index.js 其余"启动时读一次"的文件保持一致,调用方不必等 Promise。
|
|
107
|
+
*/
|
|
108
|
+
export function readWorkspacePrefs(filePath) {
|
|
109
|
+
try {
|
|
110
|
+
const raw = readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')
|
|
111
|
+
return normalizeWorkspacePrefs(JSON.parse(raw))
|
|
112
|
+
} catch {
|
|
113
|
+
return emptyWorkspacePrefs()
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 原子写偏好文档:先写 `<file>.tmp`,再 rename 覆盖正式文件,
|
|
119
|
+
* 这样进程在写一半时被杀也不会留下半截 JSON(照 unread-seen.json 的写法)。
|
|
120
|
+
* 失败会 throw,由调用方决定是否吞掉 —— 偏好文件是 advisory(尽力而为)。
|
|
121
|
+
*/
|
|
122
|
+
export async function writeWorkspacePrefs(filePath, prefs) {
|
|
123
|
+
const document = normalizeWorkspacePrefs(prefs)
|
|
124
|
+
await mkdir(dirname(filePath), { recursive: true })
|
|
125
|
+
const tmp = `${filePath}.tmp`
|
|
126
|
+
await writeFile(tmp, JSON.stringify(document, null, 2))
|
|
127
|
+
await rename(tmp, filePath)
|
|
128
|
+
return document
|
|
129
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -14,15 +14,41 @@
|
|
|
14
14
|
* 合并后的 lib/typert.host.js 是唯一的 typert 清单(package.json 的
|
|
15
15
|
* exports["./typert"] 指向它),里面的服务命名空间 iconCustom / mailNotify
|
|
16
16
|
* 原样保留 —— 浏览器侧的调用串依赖这两个名字。
|
|
17
|
+
*
|
|
18
|
+
* 另外还有一件事必须在**模块顶层**做(不是接线,但只能放在这里):
|
|
19
|
+
* 导出 `Config`(见下方)。设置页读的就是入口模块的这个导出。
|
|
17
20
|
*/
|
|
18
21
|
import { FaviconCustomService } from './icon/index.js'
|
|
19
22
|
import { apply as applyNotify } from './notify/index.js'
|
|
20
23
|
import { apply as applySessionInfo } from './session/index.js'
|
|
24
|
+
import { MailSchema } from './notify/config.js'
|
|
21
25
|
|
|
22
26
|
export const name = 'dsh-lookout'
|
|
23
27
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
28
|
+
/**
|
|
29
|
+
* A 的 static inject(webServer) ∪ B 的 inject(sessions)。
|
|
30
|
+
*
|
|
31
|
+
* 这里**没有 settings**:B 的配置就是它自己的入口 config(lib/notify/config.js 的
|
|
32
|
+
* MailSchema),代码里没有任何 `ctx.settings` 调用;留着这条 inject 只会让插件
|
|
33
|
+
* 多等一个用不到的服务,还会让人误以为配置来自 settings 服务。
|
|
34
|
+
*/
|
|
35
|
+
export const inject = ['webServer', 'sessions']
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 本插件的配置 schema —— **设置页能用的前提,不能删**。
|
|
39
|
+
*
|
|
40
|
+
* DSH 0.2.x 的 dsh-settings 从 `entry.fiber.runtime.Config` 取 schema
|
|
41
|
+
* (见 dsh-settings 的 `schema(entry)`)。没有这个导出,`describe()` 直接跳过
|
|
42
|
+
* 本 entry —— 设置页只会显示「这一页读不到本插件的配置」,读不到也写不进。
|
|
43
|
+
*
|
|
44
|
+
* 两条硬约束:
|
|
45
|
+
* 1. 必须在**模块顶层**导出,settings 服务读的是模块的导出,不是 apply 的产物;
|
|
46
|
+
* 2. schema 里 15 个字段全部标了 `.volatile()`(见 lib/notify/config.js),
|
|
47
|
+
* 否则 `volatileForm()` 返回 undefined,同样被跳过。
|
|
48
|
+
*
|
|
49
|
+
* 只做转手:schema 本体仍住在 lib/notify/config.js,这里不复制、不改写。
|
|
50
|
+
*/
|
|
51
|
+
export const Config = MailSchema
|
|
26
52
|
|
|
27
53
|
// 具名再导出:合并清单里两条 service 的 exportName 指向这两个类名。
|
|
28
54
|
export { FaviconCustomService }
|
|
@@ -31,7 +57,7 @@ export { MailNotifyRemoteService } from './notify/service.js'
|
|
|
31
57
|
/**
|
|
32
58
|
* 插件入口。三段装配按 A → B → C 的顺序执行。
|
|
33
59
|
* @param {import('@deepseek-ai/cordis').Context} ctx 插件上下文
|
|
34
|
-
* @param {object} [config] cordis 行的 config(组合层默认值;B
|
|
60
|
+
* @param {object} [config] cordis 行的 config(组合层默认值;B 每次读活的它,不缓存)
|
|
35
61
|
* @returns {void}
|
|
36
62
|
*/
|
|
37
63
|
export function apply(ctx, config) {
|
package/lib/notify/config.js
CHANGED
|
@@ -1,180 +1,175 @@
|
|
|
1
1
|
// dsh-notify-p · 配置层
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 全部非机密配置都来自组合层入口:$DSH_HOME/profiles/<profile>/cordis.patch.yml
|
|
4
|
+
// 里本插件 entry 的 `config:`(运行时以 volatile 引用交给 apply,见下面的 unwrapVolatile)。
|
|
4
5
|
// 机密(SMTP 授权码)只在这里存"引用名",真值在凭据库
|
|
5
|
-
// ($DSH_HOME/.credentials.yaml
|
|
6
|
+
// ($DSH_HOME/.credentials.yaml 或同名环境变量)——配置文件里永不出现密钥。
|
|
6
7
|
//
|
|
7
8
|
// 本文件只依赖 schemastery,不碰 cordis,便于单测。
|
|
8
9
|
|
|
9
10
|
import z from '@deepseek-ai/schemastery';
|
|
10
11
|
|
|
11
|
-
/** settings namespace;客户端卡片以同名 key 注册到 settings.plugin.item。 */
|
|
12
|
-
export const NAMESPACE = 'dsh-notify-p';
|
|
13
|
-
|
|
14
12
|
/** 默认凭据引用名(与 $DSH_HOME/.credentials.yaml 的键同名)。 */
|
|
15
13
|
export const DEFAULT_SMTP_PASSWORD_REF = 'DSH_MAIL_NOTIFY_SMTP_PASSWORD';
|
|
16
14
|
|
|
17
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* 发件人显示名(固定值)。
|
|
17
|
+
*
|
|
18
|
+
* 发件地址固定用 smtpUser——发件人清单与旧发件人字段已整块删除,
|
|
19
|
+
* 这里只剩一个固定显示名,不再走任何配置字段。
|
|
20
|
+
*/
|
|
18
21
|
export const DEFAULT_SENDER_NAME = 'DSH 通知 / DSH Notification';
|
|
19
22
|
|
|
23
|
+
// ---- 节奏参数:界面不再暴露,硬编码 ----
|
|
24
|
+
//
|
|
25
|
+
// 这三个值以前是配置字段(settleMs / minDurationMs / ratePerMinute),
|
|
26
|
+
// 现在从 schema 里删掉了:设置页不再出现它们,行为与原来的默认值完全一致。
|
|
27
|
+
// 想改节奏请改这里的常量(需要重新加载插件)。
|
|
28
|
+
|
|
29
|
+
/** 空闲合并窗口:turn/end 后再等这么久,期间若同会话又开新轮则合并成一封。 */
|
|
30
|
+
export const SETTLE_MS = 1500;
|
|
31
|
+
|
|
32
|
+
/** 短任务不打扰:会话总时长低于此值不发(只对"完成"生效)。 */
|
|
33
|
+
export const MIN_DURATION_MS = 2000;
|
|
34
|
+
|
|
35
|
+
/** 全局发信速率上限(封/分钟),多会话并停时保护 SMTP。 */
|
|
36
|
+
export const RATE_PER_MINUTE = 10;
|
|
37
|
+
|
|
20
38
|
/**
|
|
21
39
|
* 本插件的完整配置 schema。
|
|
22
40
|
* 每个字段都会出现在「设置 → 插件 → 插件配置 → 邮件通知」卡片上(中英并列)。
|
|
41
|
+
*
|
|
42
|
+
* 发件人身份与「邮件直达链接」两条能力已整块删除:相关字段一条都不剩。
|
|
43
|
+
* 发件地址固定用 smtpUser,邮件正文不带任何链接。
|
|
44
|
+
*
|
|
45
|
+
* ⚠️ 两条不能删的约束(DSH 0.2.x 的 settings 契约,见 docs/adr/0005):
|
|
46
|
+
*
|
|
47
|
+
* 1. **本 schema 必须由入口模块(lib/index.js)以 `Config` 的名字导出**。
|
|
48
|
+
* dsh-settings 的 `schema(entry)` 只读 `entry.fiber.runtime.Config`;
|
|
49
|
+
* 没有这个导出,`describe()` 直接跳过本 entry —— 设置页显示
|
|
50
|
+
* 「这一页读不到本插件的配置」,读不到也写不进。
|
|
51
|
+
* 2. **每个字段都必须 `.volatile()`**。settings 的 `volatileForm()` 只把
|
|
52
|
+
* `meta.volatile` 的字段算作可编辑:一个都没有时它返回 undefined,
|
|
53
|
+
* `describe()` 同样跳过;写回还会报 `has no volatile fields`。
|
|
54
|
+
*
|
|
55
|
+
* 附带后果(很重要,别当成 bug):标了 volatile 的字段在 schemastery 校验时
|
|
56
|
+
* 会被包成 **volatile 引用**(`{ get(), [Symbol.for('cosmokit.volatile.write')] }`),
|
|
57
|
+
* 运行中的 config 对象里存的就是这种引用,见下面 `unwrapVolatile()` 的注释。
|
|
23
58
|
*/
|
|
24
59
|
export const MailSchema = z.object({
|
|
25
60
|
/** 总开关。关掉后插件只订阅不动作。 */
|
|
26
|
-
enabled: z.boolean().default(true),
|
|
61
|
+
enabled: z.boolean().default(true).volatile(),
|
|
27
62
|
|
|
28
63
|
/** 收件人:一行一个(也接受逗号/分号分隔)。 */
|
|
29
|
-
recipients: z.string().default(''),
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* 发件人显示名(**旧字段**,与 fromAddress 一起被 senders 取代)。
|
|
33
|
-
* 留着是为了老配置照常发信;新配置只写 senders,见 senderList() 的迁移约定。
|
|
34
|
-
*/
|
|
35
|
-
senderName: z.string().default(DEFAULT_SENDER_NAME),
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* 发件人地址(**旧字段**);留空时按 transport 取默认(SMTP 用 smtpUser)。
|
|
39
|
-
*
|
|
40
|
-
* 写 senders 时必须**同一批 op 里清掉它**,否则删光的清单会让它复活。
|
|
41
|
-
* @deprecated 用 senders + activeSender。
|
|
42
|
-
*/
|
|
43
|
-
fromAddress: z.string().default(''),
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* 发件人清单:保存所有发件身份,发信时用 activeSender 选中的那条。
|
|
47
|
-
*
|
|
48
|
-
* 空数组的语义是"没设置过 vs 显式清空"分不清:settings 的 describe() 返回 schema 解析后的
|
|
49
|
-
* 完整值,没写过 senders 的老配置读出来也是 `[]`,跟"用户删光了"长得一模一样。约定是
|
|
50
|
-
* **非空才作数,空了回落到旧的 senderName/fromAddress**。
|
|
51
|
-
*
|
|
52
|
-
* 这里**允许删光**:设置页写列表时会在同一批 op 里把 fromAddress/senderName 一并清掉,
|
|
53
|
-
* 刚删掉的那条不会借旧字段"复活"。
|
|
54
|
-
*/
|
|
55
|
-
senders: z.array(z.object({
|
|
56
|
-
/** 显示名;留空 = 用默认显示名,由 resolveSender 兜底(不把本地化默认值写进配置)。 */
|
|
57
|
-
name: z.string().default(''),
|
|
58
|
-
/** 邮箱地址;清单里的每条都必须是能用的 From。 */
|
|
59
|
-
address: z.string(),
|
|
60
|
-
})).default([]),
|
|
61
|
-
|
|
62
|
-
/** 当前生效的发件人。**记地址而不是序号**——删掉前面一条时序号会整体错位。 */
|
|
63
|
-
activeSender: z.string().default(''),
|
|
64
|
-
|
|
65
|
-
/** 邮件里链接的对外地址,如 http://127.0.0.1:3081 或 https://dsh.example.com;留空则读 DSH_WEB_URL。 */
|
|
66
|
-
publicBaseUrl: z.string().default(''),
|
|
67
|
-
|
|
68
|
-
/** 是否在邮件里附带"直达会话"链接(需要另装 dsh-deeplink / dsh-session-link 才能被识别)。 */
|
|
69
|
-
linkEnabled: z.boolean().default(true),
|
|
64
|
+
recipients: z.string().default('').volatile(),
|
|
70
65
|
|
|
71
66
|
/** 是否也通知子代理会话(默认关:一个 workflow 能扇出几十个子会话)。 */
|
|
72
|
-
includeSubagents: z.boolean().default(false),
|
|
73
|
-
|
|
74
|
-
/** 空闲合并窗口:turn/end 后再等这么久,期间若同会话又开新轮则合并成一封。 */
|
|
75
|
-
settleMs: z.natural().default(1500),
|
|
76
|
-
|
|
77
|
-
/** 短任务不打扰:会话总时长低于此值不发。 */
|
|
78
|
-
minDurationMs: z.natural().default(2000),
|
|
79
|
-
|
|
80
|
-
/** 全局发信速率上限(封/分钟),多会话并停时保护 SMTP。 */
|
|
81
|
-
ratePerMinute: z.natural().default(10),
|
|
82
|
-
|
|
83
|
-
/** 投递账本路径;留空 = $DSH_HOME/dsh-notify-p/state.json(排错时 cat 它)。 */
|
|
84
|
-
statePath: z.string().default(''),
|
|
67
|
+
includeSubagents: z.boolean().default(false).volatile(),
|
|
85
68
|
|
|
86
|
-
// ---- SMTP
|
|
87
|
-
smtpHost: z.string().default(''),
|
|
88
|
-
smtpPort: z.natural().max(65535).default(465),
|
|
69
|
+
// ---- SMTP(唯一的发送通道;smtpUser 同时是发件地址)----
|
|
70
|
+
smtpHost: z.string().default('').volatile(),
|
|
71
|
+
smtpPort: z.natural().max(65535).default(465).volatile(),
|
|
89
72
|
/** auto:465 走隐式 TLS,其余走 STARTTLS。 */
|
|
90
|
-
smtpSecure: z.union([z.const('auto'), z.const('tls'), z.const('starttls'), z.const('plain')]).default('auto'),
|
|
91
|
-
smtpUser: z.string().default(''),
|
|
73
|
+
smtpSecure: z.union([z.const('auto'), z.const('tls'), z.const('starttls'), z.const('plain')]).default('auto').volatile(),
|
|
74
|
+
smtpUser: z.string().default('').volatile(),
|
|
92
75
|
/** 凭据引用名,不是密码本身。 */
|
|
93
|
-
smtpPasswordRef: z.string().default(DEFAULT_SMTP_PASSWORD_REF),
|
|
76
|
+
smtpPasswordRef: z.string().default(DEFAULT_SMTP_PASSWORD_REF).volatile(),
|
|
94
77
|
|
|
95
78
|
// ---- 事件开关 ----
|
|
96
|
-
notifyCompleted: z.boolean().default(true),
|
|
97
|
-
notifyError: z.boolean().default(true),
|
|
98
|
-
notifyBlocked: z.boolean().default(true),
|
|
79
|
+
notifyCompleted: z.boolean().default(true).volatile(),
|
|
80
|
+
notifyError: z.boolean().default(true).volatile(),
|
|
81
|
+
notifyBlocked: z.boolean().default(true).volatile(),
|
|
99
82
|
/** 只有 aborted.reason.kind === 'user'(真·你主动停止)才算。 */
|
|
100
|
-
notifyAbortedByUser: z.boolean().default(false),
|
|
83
|
+
notifyAbortedByUser: z.boolean().default(false).volatile(),
|
|
101
84
|
/** 重启/热更/被父级取消 等系统中断。 */
|
|
102
|
-
notifyInterrupted: z.boolean().default(false),
|
|
103
|
-
notifyMaxTokens: z.boolean().default(false),
|
|
104
|
-
notifyApproval: z.boolean().default(true),
|
|
85
|
+
notifyInterrupted: z.boolean().default(false).volatile(),
|
|
86
|
+
notifyMaxTokens: z.boolean().default(false).volatile(),
|
|
87
|
+
notifyApproval: z.boolean().default(true).volatile(),
|
|
105
88
|
});
|
|
106
89
|
|
|
90
|
+
// ---- volatile 引用:读配置前必须先拆掉这层壳 ----
|
|
91
|
+
//
|
|
92
|
+
// 字段标了 .volatile() 之后,schemastery 在校验时把每个值包成 cosmokit 的
|
|
93
|
+
// **volatile 引用**(`Object.freeze({ get, [Symbol.for('cosmokit.volatile.write')] })`)。
|
|
94
|
+
// 这不是本插件的发明,是 DSH 0.2.x 的运行态真相:
|
|
95
|
+
//
|
|
96
|
+
// * 插件拿到 config 时,标了 volatile 的字段已经是引用对象(不是裸值);
|
|
97
|
+
// * 设置页保存走 cordis-plugin-loader 的 `_commitVolatile`:它**原地**把新值写进
|
|
98
|
+
// 这些引用(`updateVolatile(ref, source)` → `ref[write](source.get())`),
|
|
99
|
+
// **不会重跑 apply**。所以"每次读活的 config"= 每次都从引用里 `.get()`,
|
|
100
|
+
// 而不是把启动时解析出来的那份对象当真相。
|
|
101
|
+
// * 反过来,把引用对象喂回 schema 会直接校验失败
|
|
102
|
+
// (`$.smtpHost expected string but got [object Object]`),所以入参也要先拆壳。
|
|
103
|
+
//
|
|
104
|
+
// 用 `Symbol.for()` 取协议键:全局符号表是进程级的,不需要 import cosmokit
|
|
105
|
+
// (本插件的模块图刻意不出现 DSH 裸包名,理由见 lib/deps.js 那类注释)。
|
|
106
|
+
|
|
107
|
+
/** cosmokit volatile 引用协议键(与 cordis 用的是同一个全局符号)。 */
|
|
108
|
+
const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write');
|
|
109
|
+
|
|
107
110
|
/**
|
|
108
|
-
*
|
|
109
|
-
* @
|
|
111
|
+
* 判断一个值是不是 cosmokit 的 volatile 引用。
|
|
112
|
+
* @param {unknown} value 候选值
|
|
113
|
+
* @returns {boolean}
|
|
110
114
|
*/
|
|
111
|
-
export function
|
|
112
|
-
return
|
|
115
|
+
export function isVolatileRef(value) {
|
|
116
|
+
return value !== null
|
|
117
|
+
&& typeof value === 'object'
|
|
118
|
+
&& typeof value.get === 'function'
|
|
119
|
+
&& VOLATILE_WRITE in value;
|
|
113
120
|
}
|
|
114
121
|
|
|
115
122
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* @
|
|
123
|
+
* 递归拆掉 volatile 引用,返回**全新**的普通对象(绝不改动传进来的对象——
|
|
124
|
+
* 它可能是 loader 正在用的那份 config)。
|
|
125
|
+
* @template T
|
|
126
|
+
* @param {T} value 可能含引用的值
|
|
127
|
+
* @returns {T} 拆壳后的普通值
|
|
119
128
|
*/
|
|
120
|
-
export function
|
|
121
|
-
|
|
129
|
+
export function unwrapVolatile(value) {
|
|
130
|
+
if (isVolatileRef(value)) return unwrapVolatile(value.get());
|
|
131
|
+
if (Array.isArray(value)) return value.map((item) => unwrapVolatile(item));
|
|
132
|
+
if (value !== null && typeof value === 'object') {
|
|
133
|
+
const proto = Object.getPrototypeOf(value);
|
|
134
|
+
// 只递归普通对象:Date / Map / 类实例原样带过,免得把别的东西拆坏
|
|
135
|
+
if (proto !== Object.prototype && proto !== null) return value;
|
|
136
|
+
const out = {};
|
|
137
|
+
for (const [key, child] of Object.entries(value)) out[key] = unwrapVolatile(child);
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
return value;
|
|
122
141
|
}
|
|
123
142
|
|
|
124
143
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* 迁移规则:`senders` 非空就作数;为空且旧 `fromAddress` 有值时,合成唯一一条
|
|
128
|
-
* `{ name: senderName, address: fromAddress }`。旧地址为空时**不迁移**——
|
|
129
|
-
* 那时旧行为本来就是"回退到默认",与"清单为空"完全等价,凭空造一条反而会改变行为。
|
|
130
|
-
* @param {object} [config] 已解析配置(或任何带 senders / fromAddress 的对象)
|
|
131
|
-
* @returns {{ name: string, address: string }[]} 去空、去重后的清单(可能为空)
|
|
144
|
+
* schema 默认值(也给 cordis 行 config 缺省时兜底)。
|
|
145
|
+
* @returns {object} 完整默认配置
|
|
132
146
|
*/
|
|
133
|
-
export function
|
|
134
|
-
|
|
135
|
-
const list = [];
|
|
136
|
-
const seen = new Set();
|
|
137
|
-
for (const entry of raw) {
|
|
138
|
-
const address = String(entry?.address ?? '').trim();
|
|
139
|
-
if (address === '') continue; // 地址是唯一必填项:没有地址的条目等于没配置
|
|
140
|
-
const key = address.toLowerCase();
|
|
141
|
-
if (seen.has(key)) continue;
|
|
142
|
-
seen.add(key);
|
|
143
|
-
list.push({ name: String(entry?.name ?? '').trim(), address });
|
|
144
|
-
}
|
|
145
|
-
if (list.length > 0) return list;
|
|
146
|
-
|
|
147
|
-
const address = String(config?.fromAddress ?? '').trim();
|
|
148
|
-
if (address === '') return [];
|
|
149
|
-
return [{ name: String(config?.senderName ?? '').trim(), address }];
|
|
147
|
+
export function defaultConfig() {
|
|
148
|
+
return resolveConfig({});
|
|
150
149
|
}
|
|
151
150
|
|
|
152
151
|
/**
|
|
153
|
-
*
|
|
152
|
+
* 把(可选)组合层入口配置解析成完整配置。
|
|
154
153
|
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* @param {object} [
|
|
159
|
-
* @returns {
|
|
154
|
+
* 入参可以是:cordis 行 config(可能含 volatile 引用)、settings 读到的值、
|
|
155
|
+
* 或者上一层的解析结果。出参**永远是裸值的普通对象**——调用方(发信判定、
|
|
156
|
+
* 渲染、测试邮件)都按裸值用,谁也不必再关心 volatile 这层壳。
|
|
157
|
+
* @param {object} [entry] cordis 行的 config(活的、可原地更新的那个对象)
|
|
158
|
+
* @returns {object} 补齐默认值后的配置
|
|
160
159
|
*/
|
|
161
|
-
export function
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const picked = list.find((entry) => entry.address.toLowerCase() === wanted) ?? list[0];
|
|
165
|
-
|
|
166
|
-
if (picked !== undefined) {
|
|
167
|
-
const name = picked.name !== '' ? picked.name : String(config?.senderName ?? '').trim();
|
|
168
|
-
return { name: name !== '' ? name : DEFAULT_SENDER_NAME, address: picked.address };
|
|
169
|
-
}
|
|
170
|
-
return { name: String(config?.senderName ?? '').trim() || DEFAULT_SENDER_NAME, address: '' };
|
|
160
|
+
export function resolveConfig(entry) {
|
|
161
|
+
// 先拆入参的壳(引用喂回 schema 会校验失败),再拆出参的壳(schema 会重新包上)
|
|
162
|
+
return unwrapVolatile(MailSchema(unwrapVolatile(entry ?? {})));
|
|
171
163
|
}
|
|
172
164
|
|
|
173
165
|
/**
|
|
174
166
|
* SMTP 缺什么(发信前能查出来的)。
|
|
175
167
|
*
|
|
176
|
-
* 只查"SMTP 特有"
|
|
168
|
+
* 只查"SMTP 特有"的必填项;收件人这类跟发送方式无关的,由调用方查一次,
|
|
177
169
|
* 免得同一句话被重复报。
|
|
170
|
+
*
|
|
171
|
+
* 发件地址固定取 smtpUser(见 send/index.js 的 resolveFrom),所以"发件人地址为空"
|
|
172
|
+
* 这件事不会单独出现:用户名查过就够了。
|
|
178
173
|
* @param {object} config 已解析配置
|
|
179
174
|
* @returns {string[]} 问题列表(空数组表示没发现明显问题)
|
|
180
175
|
*/
|
|
@@ -187,7 +182,6 @@ export function smtpIssues(config) {
|
|
|
187
182
|
if (String(config?.smtpUser ?? '').trim() === '') {
|
|
188
183
|
issues.push('SMTP 用户名为空(通常是你的完整邮箱地址)/ smtpUser is empty');
|
|
189
184
|
}
|
|
190
|
-
// 发件人地址不在这里报:拿不到时 SMTP 会退回 smtpUser(见 resolveFrom),那是合法的。
|
|
191
185
|
return issues;
|
|
192
186
|
}
|
|
193
187
|
|
|
@@ -253,31 +247,3 @@ export function parseRecipients(raw) {
|
|
|
253
247
|
}
|
|
254
248
|
return { valid, invalid };
|
|
255
249
|
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* 解析邮件里链接的基地址:优先用户配置,其次 DSH_WEB_URL,最后回退回环地址。
|
|
259
|
-
* @param {object} config 已解析配置
|
|
260
|
-
* @param {string} [webUrl] 运行时探测到的 Web GUI 地址
|
|
261
|
-
* @returns {string} 不带结尾斜杠的基地址;都拿不到时返回空串
|
|
262
|
-
*/
|
|
263
|
-
export function resolveBaseUrl(config, webUrl) {
|
|
264
|
-
const raw = typeof config?.publicBaseUrl === 'string' && config.publicBaseUrl.trim() !== ''
|
|
265
|
-
? config.publicBaseUrl
|
|
266
|
-
: (typeof webUrl === 'string' ? webUrl : '');
|
|
267
|
-
return String(raw).trim().replace(/\/+$/, '');
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* 拼一条"直达会话"链接。
|
|
272
|
-
*
|
|
273
|
-
* 本插件**不实现** `?session=` 的接收端——那由 dsh-deeplink(或 dsh-session-link)负责。
|
|
274
|
-
* 这里只负责把链接拼对放进正文。
|
|
275
|
-
* @param {string} baseUrl 基地址(已去掉结尾斜杠)
|
|
276
|
-
* @param {string} sessionId 会话 ID
|
|
277
|
-
* @returns {string} 形如 http://127.0.0.1:3081/?session=session-xxxx;baseUrl 为空时返回空串
|
|
278
|
-
*/
|
|
279
|
-
export function sessionLink(baseUrl, sessionId) {
|
|
280
|
-
if (typeof baseUrl !== 'string' || baseUrl === '') return '';
|
|
281
|
-
if (typeof sessionId !== 'string' || sessionId === '') return '';
|
|
282
|
-
return `${baseUrl}/?session=${encodeURIComponent(sessionId)}`;
|
|
283
|
-
}
|