dsh-conversation-recycle-bin 0.2.2 → 0.3.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/CHANGELOG.md +15 -0
- package/README.md +87 -2
- package/client.js +306 -4
- package/config.schema.json +12 -0
- package/cordis.patch.yml +16 -0
- package/lib/core/config.js +34 -0
- package/lib/core/errors.js +6 -0
- package/lib/host/api.js +24 -3
- package/lib/host/feedback.js +400 -0
- package/lib/version.js +1 -1
- package/package.json +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
All notable changes to `dsh-conversation-recycle-bin` are documented here.
|
|
4
4
|
The format follows Keep a Changelog; the project uses Semantic Versioning.
|
|
5
5
|
|
|
6
|
+
## [0.3.0] — 2026-10-18
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **设置页「反馈」横幅(`feedback` 配置段 + `POST /conversation-recycle-bin/api/feedback`)**:设置页最上方新增一条「反馈」横幅(主色按钮,位于标题之上;`dc-feedback-bar`),表单含类型(问题 / 建议 / 其他)、标题、正文、可选联系方式,并自动附带插件版本、平台与语言(不含任何对话内容)。提交时按钮进入「正在发送…」禁用态,成功后弹 toast 并关闭对话框,失败按错误码给出可读原因。Host 侧 `lib/host/feedback.js` 负责校验、渲染与投递:标题 4–120 字、正文 10–4000 字、联系方式 ≤200 字(Host 复核,客户端只是提前禁用按钮);每条反馈每小时默认最多 5 条(`feedback.rateLimitPerHour`),全部从操作者自己的邮箱发出。
|
|
11
|
+
- **两种投递通道,均为环境变量注入凭证**:`http`(`DSH_CRB_MAIL_API_URL` + `DSH_CRB_MAIL_API_TOKEN`,POST JSON)与 `smtp`(`DSH_CRB_SMTP_URL`,按需 `import('nodemailer')`)。`nodemailer` 只声明为 `optionalDependencies`(`^10.1.2`),装不上不影响插件其余功能,缺失时明确返回 `reason: nodemailer-missing`。收件人/发件人由配置项 `feedback.to` / `feedback.from` 给出,**环境变量 `DSH_CRB_FEEDBACK_TO` / `DSH_CRB_FEEDBACK_FROM` 优先于它们**(同一份包被多人/多机使用时不必改配置文件);`feedback.to` 默认即维护者收件箱,以 `b64:<base64>` 形式随包分发(避免公开 tarball 中出现可被正则抓取的 `name@domain`;属混淆而非加密,解码器在 `lib/host/feedback.js`,明文地址同样可用),`feedback.from` 默认留空并复用 SMTP 连接串里的用户名(服务商要求 From 与登录账号一致)。**任何凭证都不进配置文件、不进代码库**。
|
|
12
|
+
- **反馈通道与删除开关解耦**:`enabled:false` 只关闭删除;`/state.feedback` 单独报告 `enabled`、`transport`、`toConfigured`。通道未配置时设置页不渲染按钮,直接请求也返回 `503 E_FEEDBACK_DISABLED`(带 `reason: not-configured | disabled | off`),而不是在点击后才失败。
|
|
13
|
+
- 新增错误码 `E_FEEDBACK_DISABLED`(503) / `E_FEEDBACK_INVALID`(400) / `E_FEEDBACK_RATE_LIMITED`(429) / `E_FEEDBACK_TRANSPORT`(502);投递失败只记错误码与 HTTP 状态,**正文、标题、联系方式不进 Host 日志**。
|
|
14
|
+
- **收件地址以 `b64:<base64>` 打包**:默认收件箱不再以明文 `name@domain` 出现在 `cordis.patch.yml` / `config.schema.json` / README / CHANGELOG 中,避免公开 tarball 被正则抓取。这是**混淆而非加密**——解码器 `decodeAddress()` 就在 `lib/host/feedback.js` 里,拿到包的人总能还原;明文地址与环境变量照旧可用。
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- `npm test` 增加 `test/feedback.test.mjs`(15 个用例:配置归一化、通道判定、校验/渲染、HTTP 通路、失败状态、限流窗口、未配置、SMTP 缺依赖、两条路由用例),用例总数 106 → 121;`npm run check` 增加 `lib/host/feedback.js` 语法检查。
|
|
19
|
+
- `cordis.patch.yml` / `config.schema.json` 增加 `feedback` 默认段(`to` 默认为维护者公开反馈邮箱、`from` 留空、`transport: auto`、`rateLimitPerHour: 5`)。
|
|
20
|
+
|
|
6
21
|
## [0.2.2] — 2026-10-18
|
|
7
22
|
|
|
8
23
|
对 0.2.1 的双环境(桌面/网页)全面审计修复轮。问题编号对应 `AUDIT-2026-10-17-desktop-web.md`;每个问题在该文档中都有平台、复现条件、原因与修复方向的完整记录。
|
package/README.md
CHANGED
|
@@ -188,8 +188,9 @@ dsh-conversation-recycle-bin/
|
|
|
188
188
|
│ ├─ catalog.js # archive/detach/pin 与日志移动
|
|
189
189
|
│ ├─ lifecycle.js # live 会话停机:cancel + archive + drain
|
|
190
190
|
│ ├─ memory.js # 适配器装配
|
|
191
|
+
│ ├─ feedback.js # 反馈校验/渲染/投递(http 或 smtp,凭证只读环境变量)
|
|
191
192
|
│ └─ api.js # 同源 HTTP API、确认 token、限流
|
|
192
|
-
└─ test/ #
|
|
193
|
+
└─ test/ # 121 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
|
|
193
194
|
```
|
|
194
195
|
|
|
195
196
|
---
|
|
@@ -198,7 +199,7 @@ dsh-conversation-recycle-bin/
|
|
|
198
199
|
|
|
199
200
|
```powershell
|
|
200
201
|
cd D:\Users\34332\AI\dsh-conversation-recycle-bin
|
|
201
|
-
npm test # = manifest && client-module && core && cascade && memory && trash-audit && api(
|
|
202
|
+
npm test # = manifest && client-module && core && cascade && memory && trash-audit && feedback && api(121 个用例)
|
|
202
203
|
npm run check # 语法 + manifest
|
|
203
204
|
```
|
|
204
205
|
|
|
@@ -226,3 +227,87 @@ npm run check # 语法 + manifest
|
|
|
226
227
|
5. UI 依赖当前版本的 slot 约定;宿主升级若改 slot props,入口会失效但不会破坏宿主。0.1.4 的原地刷新还会调用 `uiWorkspace.clearArchivedCurrent/clearMain`(宿主 `.d.ts` 中标为 private);两者都不存在时会退回整页刷新,不会留下过期视图,但属于对私有方法的依赖,宿主升级需复核。
|
|
227
228
|
6. 回收站 manifest 的并发写由 `<dataDir>/mutation.lock` 跨进程锁保护(抢不到锁返回 `E_LOCK_BUSY`);但**会话日志内容**的跨进程写保护仍是 best-effort:`foreignWriterGraceMs` 只能发现"窗口内仍在增长"的日志,另一个 Host 的空闲 live 会话(两次写入之间)无法被本进程识别。桌面端与 `dsh web` 同时运行时,仍应先关闭另一端的会话再彻底删除。
|
|
228
229
|
7. 未完成条目(failed/pending/restoring/purging)若仍持有已移动的会话日志或记忆文件,出于数据安全不允许 `discard`(会报 `E_TRASH_CONFLICT`);设置页对这类条目提供「恢复」按钮,可直接恢复回会话列表,不再出现"失败了却没有出口"的死条目。日志与记忆都已不在磁盘上的条目才可以 discard。
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## 9. 反馈通道(设置页「反馈」按钮)
|
|
234
|
+
|
|
235
|
+
设置页**最上方**新增一条「反馈」横幅(主色按钮,位于标题之上):把问题或建议写成一条消息,由**宿主进程**通过邮件发到维护者邮箱。凭证只在宿主侧读取,页面拿不到、也发不出任何邮件。
|
|
236
|
+
|
|
237
|
+
### 9.1 为什么是「页面 → 宿主路由 → 邮件」三段
|
|
238
|
+
|
|
239
|
+
浏览器既不能开 SMTP 连接,也不该持有邮件服务凭证。因此 `client.js` 只负责表单与校验提示,`POST /conversation-recycle-bin/api/feedback` 由 `lib/host/feedback.js` 校验、渲染并投递。表单里的「类型 / 标题 / 正文 / 联系方式」之外,只额外附带**对话框里已展示给用户**的上下文(插件版本、平台、语言);对话内容、会话 id、工作区路径都不会进入邮件。
|
|
240
|
+
|
|
241
|
+
### 9.2 配置项(`feedback` 段,非机密)
|
|
242
|
+
|
|
243
|
+
| 键 | 默认 | 说明 |
|
|
244
|
+
|---|---|---|
|
|
245
|
+
| `feedback.enabled` | `true` | 是否允许该通道。通道本身没配好时按钮依旧不显示 |
|
|
246
|
+
| `feedback.to` | `b64:…`(维护者收件箱) | 收件人。默认值以 `b64:<base64>` 形式携带,公开 tarball 里没有可直接抓取的 `name@domain` 串(这是**混淆,不是加密**:解码器就在 `lib/host/feedback.js`,拿到包的人总能解出来)。也接受明文地址;`DSH_CRB_FEEDBACK_TO` 优先于它 |
|
|
247
|
+
| `feedback.from` | `''` | 发件人。留空时 SMTP 通道复用连接串里的用户名(服务商通常要求 From 与登录账号一致);`DSH_CRB_FEEDBACK_FROM` 优先于它 |
|
|
248
|
+
| `feedback.transport` | `auto` | `auto` = 有 `DSH_CRB_MAIL_API_URL` 走 http,否则有 `DSH_CRB_SMTP_URL` 走 smtp;`http`/`smtp` 强制单通道(配不全就报未配置,不会静默换道);`off` 关闭 |
|
|
249
|
+
| `feedback.rateLimitPerHour` | `5` | 每宿主每小时发送上限(1–100)。邮件从操作者自己的账号发出,默认值刻意压低 |
|
|
250
|
+
|
|
251
|
+
### 9.3 环境变量(优先级高于配置项;凭证只走这里,不进配置文件)
|
|
252
|
+
|
|
253
|
+
> 冲突时**环境变量赢**:`DSH_CRB_FEEDBACK_TO` 覆盖 `feedback.to`,`DSH_CRB_FEEDBACK_FROM` 覆盖 `feedback.from`。这样多台机器/多人共用一个 package 时,各自改环境变量即可,不必改配置。
|
|
254
|
+
|
|
255
|
+
| 变量 | 用途 |
|
|
256
|
+
|---|---|
|
|
257
|
+
| `DSH_CRB_FEEDBACK_TO` | 收件人(优先于 `feedback.to`) |
|
|
258
|
+
| `DSH_CRB_FEEDBACK_FROM` | 发件人(优先于 `feedback.from`) |
|
|
259
|
+
| `DSH_CRB_MAIL_API_URL` | 邮件 HTTP API 地址(Resend 兼容形状,也可指向自建转发网关) |
|
|
260
|
+
| `DSH_CRB_MAIL_API_TOKEN` | 该 API 的 Bearer token |
|
|
261
|
+
| `DSH_CRB_MAIL_API_AUTH_HEADER` | 可选,鉴权头名,默认 `authorization`,值为 `Bearer <token>` |
|
|
262
|
+
| `DSH_CRB_SMTP_URL` | SMTP 连接串,如 `smtps://user:pass@smtp.example.com:465` |
|
|
263
|
+
|
|
264
|
+
HTTP 通道发出的 JSON 固定为 `{from, to: [收件人], subject, text, reply_to}`,`text` 是纯文本(不生成 HTML);`reply_to` 只在联系方式看起来可用时填写。SMTP 通道按需 `import('nodemailer')`:它已声明为 `optionalDependencies`(`^10.1.2`),正常安装会随包就位;以 `link:` 方式开发安装时插件从自身真实目录解析,需在**本仓库**跑一次 `npm install`。仍未安装时返回 `E_FEEDBACK_TRANSPORT` + `reason: nodemailer-missing`。
|
|
265
|
+
|
|
266
|
+
#### 9.3.1 QQ 邮箱开通示例
|
|
267
|
+
|
|
268
|
+
QQ 邮箱不能直接用登录密码发信,要先取**授权码**(设置 → 账号 → POP3/SMTP 服务 → 开启 → 生成授权码),再把两件事写进环境变量(`@` 在连接串里必须写成 `%40`;`<你的QQ号>` 换成实际号码):
|
|
269
|
+
|
|
270
|
+
```powershell
|
|
271
|
+
setx DSH_CRB_SMTP_URL 'smtps://<你的QQ号>%40qq.com:你的授权码@smtp.qq.com:465'
|
|
272
|
+
# 可选:换收件人/发件人(不设就用包内默认的维护者收件箱)
|
|
273
|
+
setx DSH_CRB_FEEDBACK_TO 'your-feedback@example.com'
|
|
274
|
+
# 设置后重启 DSH 桌面端(setx 只对新进程生效)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
需要自己生成一个 `b64:` 地址(放进 profile patch 而不是环境变量时):
|
|
278
|
+
|
|
279
|
+
```powershell
|
|
280
|
+
node -e "console.log('b64:' + Buffer.from('your-feedback@example.com','utf8').toString('base64'))"
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
### 9.4 本地验证
|
|
285
|
+
|
|
286
|
+
```powershell
|
|
287
|
+
cd D:\Users\34332\AI\dsh-conversation-recycle-bin
|
|
288
|
+
npm run check # 语法 + manifest(含 feedback.js)
|
|
289
|
+
node test/feedback.test.mjs # 15 个反馈用例
|
|
290
|
+
npm test # 全量 121 个用例
|
|
291
|
+
|
|
292
|
+
# 端到端:用本机收件桩观察真实投递路径,不外发邮件
|
|
293
|
+
$env:DSH_CRB_MAIL_API_URL = 'http://127.0.0.1:9099/emails'
|
|
294
|
+
$env:DSH_CRB_MAIL_API_TOKEN = 'dev-token'
|
|
295
|
+
$env:DSH_CRB_FEEDBACK_TO = 'feedback@example.test'
|
|
296
|
+
# 另一个终端起桩:
|
|
297
|
+
# node -e "require('http').createServer((q,s)=>{let b='';q.on('data',c=>b+=c);q.on('end',()=>{console.log(q.method,q.url,b);s.end('{}')})}).listen(9099)"
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
起桩后打开设置页点「反馈」,填表发送:桩终端应打印 `POST /emails {...}`,页面出现「反馈已发送,谢谢!」。测 SMTP 通路时改设 `DSH_CRB_SMTP_URL`(本仓库已按 `optionalDependencies` 声明 `nodemailer`,先在本仓库 `npm install` 一次;未安装时返回 `E_FEEDBACK_TRANSPORT` + `reason: nodemailer-missing`,这是预期行为)。
|
|
301
|
+
|
|
302
|
+
### 9.5 失败排查
|
|
303
|
+
|
|
304
|
+
| 现象 | 原因 | 处理 |
|
|
305
|
+
|---|---|---|
|
|
306
|
+
| 设置页没有「反馈」按钮 | 通道未配置(`/state.feedback.enabled === false`) | 设好 `DSH_CRB_MAIL_API_URL` + token,或 `DSH_CRB_SMTP_URL`;确认 `feedback.transport` 不是 `off`、`enabled` 不是 `false` |
|
|
307
|
+
| 点击后 503 `E_FEEDBACK_DISABLED` | 同上一行(按钮来自旧页面渲染) | 刷新页面;看 `/state.feedback.reason`:`not-configured` / `disabled` / `off` |
|
|
308
|
+
| 400 `E_FEEDBACK_INVALID` | 标题 4–120 字、正文 10–4000 字、联系方式 ≤200 字不满足 | `details.field` 指明字段,`details.min`/`details.max` 给出边界 |
|
|
309
|
+
| 429 `E_FEEDBACK_RATE_LIMITED` | 超过 `feedback.rateLimitPerHour` | 等 `details.retryAfterMs` 后重试,或调高上限 |
|
|
310
|
+
| 502 `E_FEEDBACK_TRANSPORT` | 邮件服务返回非 2xx / 网络失败 / 未装 nodemailer / 暴露不出 `createTransport` | 看宿主日志 `[conversation-recycle-bin] feedback delivery failed (<transport>): <code> <message>`(只含错误码与通道,不含正文);核对 URL、token、SMTP 授权码 |
|
|
311
|
+
| 接口 200 但收件箱没有 | 服务端已受理,但被对方退信或进了垃圾箱 | 查发件账号退信;`feedback.from` 用与 SMTP 账号一致的地址,并检查收件人域名 |
|
|
312
|
+
|
|
313
|
+
> 隐私边界:邮件正文 = 用户输入 + 对话框里已展示的上下文;宿主日志只记错误码、通道与耗时。删除开关(`enabled`)与反馈通道互相独立——关掉删除不影响反馈,反之亦然。
|
package/client.js
CHANGED
|
@@ -147,6 +147,36 @@ window.__ModuleLoader__.load({
|
|
|
147
147
|
'state.unavailable': 'unavailable',
|
|
148
148
|
'state.rate': '{used}/{limit} deletions this minute',
|
|
149
149
|
'state.batch': 'Effective batch limit: {limit}',
|
|
150
|
+
'feedback.button': 'Feedback',
|
|
151
|
+
'feedback.bannerTitle': 'Ran into a problem or have an idea?',
|
|
152
|
+
'feedback.bannerHint': 'Send one email to the maintainer. It carries your text plus the plugin version, platform and language — no conversation content.',
|
|
153
|
+
'feedback.title': 'Send feedback',
|
|
154
|
+
'feedback.intro': 'Tell the maintainer what went wrong or what you would like to see. The message is sent by email from this Host; nothing is uploaded anywhere else.',
|
|
155
|
+
'feedback.categoryLabel': 'Type',
|
|
156
|
+
'feedback.categoryBug': 'Problem / bug',
|
|
157
|
+
'feedback.categorySuggestion': 'Suggestion',
|
|
158
|
+
'feedback.categoryOther': 'Other',
|
|
159
|
+
'feedback.titleLabel': 'Title',
|
|
160
|
+
'feedback.titlePlaceholder': 'One line that summarises it',
|
|
161
|
+
'feedback.bodyLabel': 'Message',
|
|
162
|
+
'feedback.bodyPlaceholder': 'What happened, what you expected, and the steps that led to it.',
|
|
163
|
+
'feedback.contactLabel': 'Contact (optional)',
|
|
164
|
+
'feedback.contactPlaceholder': 'An email address or another way to reach you',
|
|
165
|
+
'feedback.contextLabel': 'Attached automatically',
|
|
166
|
+
'feedback.contextHint': 'Plugin version, platform and language. Conversation content is never attached.',
|
|
167
|
+
'feedback.send': 'Send',
|
|
168
|
+
'feedback.sending': 'Sending…',
|
|
169
|
+
'feedback.sent': 'Feedback sent. Thank you!',
|
|
170
|
+
'feedback.tooShortTitle': 'The title needs at least {min} characters.',
|
|
171
|
+
'feedback.tooLongTitle': 'The title may be at most {max} characters.',
|
|
172
|
+
'feedback.tooShortBody': 'The message needs at least {min} characters.',
|
|
173
|
+
'feedback.tooLongBody': 'The message may be at most {max} characters.',
|
|
174
|
+
'feedback.tooLongContact': 'The contact field may be at most {max} characters.',
|
|
175
|
+
'feedback.unavailable': 'The feedback channel is not configured on this Host.',
|
|
176
|
+
'error.E_FEEDBACK_DISABLED': 'The feedback channel is not configured on this Host.',
|
|
177
|
+
'error.E_FEEDBACK_INVALID': 'The feedback form is incomplete or too long.',
|
|
178
|
+
'error.E_FEEDBACK_RATE_LIMITED': 'Several messages were sent recently; try again a little later.',
|
|
179
|
+
'error.E_FEEDBACK_TRANSPORT': 'The message could not be delivered. Check the Host mail configuration and retry.',
|
|
150
180
|
'error.E_CONFIRM_PHRASE': 'The confirmation phrase does not match.',
|
|
151
181
|
'error.E_CONFIRM_EXPIRED': 'The confirmation expired; the counts were reloaded.',
|
|
152
182
|
'error.E_CONFIRM_INVALID': 'The confirmation is stale; the plan was reloaded.',
|
|
@@ -263,6 +293,36 @@ window.__ModuleLoader__.load({
|
|
|
263
293
|
'state.unavailable': '不可用',
|
|
264
294
|
'state.rate': '本分钟已删除 {used}/{limit}',
|
|
265
295
|
'state.batch': '单次上限:{limit} 个(受每分钟额度约束)',
|
|
296
|
+
'feedback.button': '反馈',
|
|
297
|
+
'feedback.bannerTitle': '遇到问题,或有改进建议?',
|
|
298
|
+
'feedback.bannerHint': '写一条消息发给维护者,正文之外只附带插件版本、平台与语言,不含任何对话内容。',
|
|
299
|
+
'feedback.title': '发送反馈',
|
|
300
|
+
'feedback.intro': '把遇到的问题或想要的功能告诉维护者。消息由本机宿主通过邮件发送,不会上传到其他位置。',
|
|
301
|
+
'feedback.categoryLabel': '类型',
|
|
302
|
+
'feedback.categoryBug': '问题 / Bug',
|
|
303
|
+
'feedback.categorySuggestion': '建议',
|
|
304
|
+
'feedback.categoryOther': '其他',
|
|
305
|
+
'feedback.titleLabel': '标题',
|
|
306
|
+
'feedback.titlePlaceholder': '用一句话概括',
|
|
307
|
+
'feedback.bodyLabel': '正文',
|
|
308
|
+
'feedback.bodyPlaceholder': '发生了什么、预期是什么、如何复现。',
|
|
309
|
+
'feedback.contactLabel': '联系方式(选填)',
|
|
310
|
+
'feedback.contactPlaceholder': '邮箱或其他能找到你的方式',
|
|
311
|
+
'feedback.contextLabel': '自动附带',
|
|
312
|
+
'feedback.contextHint': '插件版本、平台与语言;不会附带任何对话内容。',
|
|
313
|
+
'feedback.send': '发送',
|
|
314
|
+
'feedback.sending': '正在发送…',
|
|
315
|
+
'feedback.sent': '反馈已发送,谢谢!',
|
|
316
|
+
'feedback.tooShortTitle': '标题至少需要 {min} 个字符。',
|
|
317
|
+
'feedback.tooLongTitle': '标题最多 {max} 个字符。',
|
|
318
|
+
'feedback.tooShortBody': '正文至少需要 {min} 个字符。',
|
|
319
|
+
'feedback.tooLongBody': '正文最多 {max} 个字符。',
|
|
320
|
+
'feedback.tooLongContact': '联系方式最多 {max} 个字符。',
|
|
321
|
+
'feedback.unavailable': '当前宿主未配置反馈通道。',
|
|
322
|
+
'error.E_FEEDBACK_DISABLED': '当前宿主未配置反馈通道。',
|
|
323
|
+
'error.E_FEEDBACK_INVALID': '反馈内容不完整或超出长度限制。',
|
|
324
|
+
'error.E_FEEDBACK_RATE_LIMITED': '最近已发送多条反馈,请稍后再试。',
|
|
325
|
+
'error.E_FEEDBACK_TRANSPORT': '邮件发送失败,请检查宿主的邮件配置后重试。',
|
|
266
326
|
'error.E_CONFIRM_PHRASE': '确认短语不匹配。',
|
|
267
327
|
'error.E_CONFIRM_EXPIRED': '确认已过期;计数已重新加载。',
|
|
268
328
|
'error.E_CONFIRM_INVALID': '确认已失效;计划已重新加载。',
|
|
@@ -696,6 +756,16 @@ window.__ModuleLoader__.load({
|
|
|
696
756
|
h('path', { d: 'M20 4v5h-5' }))
|
|
697
757
|
}
|
|
698
758
|
|
|
759
|
+
function GlyphFeedback({ size = 14 }) {
|
|
760
|
+
return h('svg', {
|
|
761
|
+
width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
|
|
762
|
+
stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
763
|
+
'aria-hidden': 'true',
|
|
764
|
+
},
|
|
765
|
+
h('path', { d: 'M4 6h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1Z' }),
|
|
766
|
+
h('path', { d: 'm3.5 7 8.5 6 8.5-6' }))
|
|
767
|
+
}
|
|
768
|
+
|
|
699
769
|
function Button({ children, onClick, variant = 'secondary', disabled = false, type = 'button', title }) {
|
|
700
770
|
return h('button', {
|
|
701
771
|
type,
|
|
@@ -746,6 +816,48 @@ window.__ModuleLoader__.load({
|
|
|
746
816
|
}
|
|
747
817
|
}
|
|
748
818
|
|
|
819
|
+
/* ------------------------------------------------------------------ *
|
|
820
|
+
* Feedback form (pure helpers, shared with the Host's validation)
|
|
821
|
+
* ------------------------------------------------------------------ */
|
|
822
|
+
|
|
823
|
+
// Mirrors FEEDBACK_LIMITS in `lib/host/feedback.js`. The Host re-validates
|
|
824
|
+
// every field; this copy only exists to disable the submit button early and
|
|
825
|
+
// to point at the offending input.
|
|
826
|
+
const FEEDBACK_LIMITS = Object.freeze({
|
|
827
|
+
titleMin: 4,
|
|
828
|
+
titleMax: 120,
|
|
829
|
+
bodyMin: 10,
|
|
830
|
+
bodyMax: 4_000,
|
|
831
|
+
contactMax: 200,
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* @param {{title?: string, body?: string, contact?: string}} input
|
|
836
|
+
* @returns {{ok: true} | {ok: false, key: string, vars: Record<string, number>}}
|
|
837
|
+
*/
|
|
838
|
+
function validateFeedbackInput(input) {
|
|
839
|
+
const title = String(input?.title ?? '').trim()
|
|
840
|
+
const body = String(input?.body ?? '').trim()
|
|
841
|
+
const contact = String(input?.contact ?? '').trim()
|
|
842
|
+
if (title.length < FEEDBACK_LIMITS.titleMin) return { ok: false, key: 'feedback.tooShortTitle', vars: { min: FEEDBACK_LIMITS.titleMin } }
|
|
843
|
+
if (title.length > FEEDBACK_LIMITS.titleMax) return { ok: false, key: 'feedback.tooLongTitle', vars: { max: FEEDBACK_LIMITS.titleMax } }
|
|
844
|
+
if (body.length < FEEDBACK_LIMITS.bodyMin) return { ok: false, key: 'feedback.tooShortBody', vars: { min: FEEDBACK_LIMITS.bodyMin } }
|
|
845
|
+
if (body.length > FEEDBACK_LIMITS.bodyMax) return { ok: false, key: 'feedback.tooLongBody', vars: { max: FEEDBACK_LIMITS.bodyMax } }
|
|
846
|
+
if (contact.length > FEEDBACK_LIMITS.contactMax) return { ok: false, key: 'feedback.tooLongContact', vars: { max: FEEDBACK_LIMITS.contactMax } }
|
|
847
|
+
return { ok: true }
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Context the dialog shows before sending; nothing here is user content. */
|
|
851
|
+
function collectFeedbackContext() {
|
|
852
|
+
const userAgent = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
|
|
853
|
+
? navigator.userAgent
|
|
854
|
+
: ''
|
|
855
|
+
return {
|
|
856
|
+
platform: userAgent.slice(0, 160),
|
|
857
|
+
locale: currentLocaleId(),
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
749
861
|
/* ------------------------------------------------------------------ *
|
|
750
862
|
* Confirmation dialog
|
|
751
863
|
* ------------------------------------------------------------------ */
|
|
@@ -994,6 +1106,155 @@ window.__ModuleLoader__.load({
|
|
|
994
1106
|
return store === undefined ? 'not detected' : adapterReason(store.reason)
|
|
995
1107
|
}
|
|
996
1108
|
|
|
1109
|
+
/* ------------------------------------------------------------------ *
|
|
1110
|
+
* Feedback dialog
|
|
1111
|
+
* ------------------------------------------------------------------ */
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Settings page "反馈" button.
|
|
1115
|
+
*
|
|
1116
|
+
* The Host performs the delivery, so the page never holds a mail
|
|
1117
|
+
* credential. The auto-attached context is rendered BEFORE the user
|
|
1118
|
+
* commits, so nothing leaves the machine that they did not see.
|
|
1119
|
+
*/
|
|
1120
|
+
function FeedbackDialog({ payload }) {
|
|
1121
|
+
useLocaleTick()
|
|
1122
|
+
const feedback = payload?.feedback ?? null
|
|
1123
|
+
const version = typeof payload?.version === 'string' ? payload.version : ''
|
|
1124
|
+
const [category, setCategory] = useState('bug')
|
|
1125
|
+
const [title, setTitle] = useState('')
|
|
1126
|
+
const [body, setBody] = useState('')
|
|
1127
|
+
const [contact, setContact] = useState('')
|
|
1128
|
+
const [busy, setBusy] = useState(false)
|
|
1129
|
+
const [error, setError] = useState(null)
|
|
1130
|
+
const validation = validateFeedbackInput({ title, body, contact })
|
|
1131
|
+
const context = collectFeedbackContext()
|
|
1132
|
+
// A Host without a mail transport advertises `enabled: false`; the form is
|
|
1133
|
+
// still rendered read-only so the explanation is visible instead of a
|
|
1134
|
+
// button that fails on click.
|
|
1135
|
+
const unavailable = feedback?.enabled !== true
|
|
1136
|
+
|
|
1137
|
+
useEffect(() => {
|
|
1138
|
+
function onKey(event) {
|
|
1139
|
+
if (event.key === 'Escape' && busy !== true) dialogStore.close()
|
|
1140
|
+
}
|
|
1141
|
+
window.addEventListener('keydown', onKey)
|
|
1142
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
1143
|
+
}, [busy])
|
|
1144
|
+
|
|
1145
|
+
async function submit() {
|
|
1146
|
+
if (busy || unavailable || validation.ok !== true) return
|
|
1147
|
+
setBusy(true)
|
|
1148
|
+
setError(null)
|
|
1149
|
+
try {
|
|
1150
|
+
await api('/feedback', {
|
|
1151
|
+
method: 'POST',
|
|
1152
|
+
timeoutMs: 30_000,
|
|
1153
|
+
body: {
|
|
1154
|
+
category,
|
|
1155
|
+
title: title.trim(),
|
|
1156
|
+
body: body.trim(),
|
|
1157
|
+
contact: contact.trim(),
|
|
1158
|
+
context: {
|
|
1159
|
+
pluginVersion: version,
|
|
1160
|
+
platform: context.platform,
|
|
1161
|
+
locale: context.locale,
|
|
1162
|
+
},
|
|
1163
|
+
},
|
|
1164
|
+
})
|
|
1165
|
+
flashStore.show(t('feedback.sent'))
|
|
1166
|
+
dialogStore.close()
|
|
1167
|
+
} catch (caught) {
|
|
1168
|
+
// The Host log already records the stable code; this keeps the form
|
|
1169
|
+
// path debuggable from the browser console without a network tab.
|
|
1170
|
+
console.error('[conversation-recycle-bin] feedback submission failed:', caught?.code ?? caught)
|
|
1171
|
+
setError(caught)
|
|
1172
|
+
} finally {
|
|
1173
|
+
setBusy(false)
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
return h('div', {
|
|
1178
|
+
className: 'dc-backdrop',
|
|
1179
|
+
onMouseDown: (event) => {
|
|
1180
|
+
if (event.target === event.currentTarget && busy !== true) dialogStore.close()
|
|
1181
|
+
},
|
|
1182
|
+
},
|
|
1183
|
+
h('div', { className: 'dc-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dc-feedback-title' },
|
|
1184
|
+
h('h2', { id: 'dc-feedback-title', className: 'dc-dialog-title' }, t('feedback.title')),
|
|
1185
|
+
h('p', { className: 'dc-dialog-body' }, t('feedback.intro')),
|
|
1186
|
+
unavailable ? h('div', { className: 'dc-error', role: 'alert' }, t('feedback.unavailable')) : null,
|
|
1187
|
+
h(ErrorBanner, { error }),
|
|
1188
|
+
|
|
1189
|
+
h('label', { className: 'dc-field' },
|
|
1190
|
+
h('span', { className: 'dc-field-label' }, t('feedback.categoryLabel')),
|
|
1191
|
+
h('select', {
|
|
1192
|
+
className: 'dc-input',
|
|
1193
|
+
value: category,
|
|
1194
|
+
disabled: busy,
|
|
1195
|
+
onChange: (event) => setCategory(event.target.value),
|
|
1196
|
+
},
|
|
1197
|
+
h('option', { value: 'bug' }, t('feedback.categoryBug')),
|
|
1198
|
+
h('option', { value: 'suggestion' }, t('feedback.categorySuggestion')),
|
|
1199
|
+
h('option', { value: 'other' }, t('feedback.categoryOther')))),
|
|
1200
|
+
|
|
1201
|
+
h('label', { className: 'dc-field' },
|
|
1202
|
+
h('span', { className: 'dc-field-label' }, t('feedback.titleLabel')),
|
|
1203
|
+
h('input', {
|
|
1204
|
+
type: 'text',
|
|
1205
|
+
className: 'dc-input',
|
|
1206
|
+
value: title,
|
|
1207
|
+
maxLength: FEEDBACK_LIMITS.titleMax,
|
|
1208
|
+
placeholder: t('feedback.titlePlaceholder'),
|
|
1209
|
+
disabled: busy,
|
|
1210
|
+
autoFocus: true,
|
|
1211
|
+
onChange: (event) => setTitle(event.target.value),
|
|
1212
|
+
})),
|
|
1213
|
+
|
|
1214
|
+
h('label', { className: 'dc-field' },
|
|
1215
|
+
h('span', { className: 'dc-field-label' }, t('feedback.bodyLabel')),
|
|
1216
|
+
h('textarea', {
|
|
1217
|
+
className: 'dc-input dc-textarea',
|
|
1218
|
+
value: body,
|
|
1219
|
+
rows: 6,
|
|
1220
|
+
maxLength: FEEDBACK_LIMITS.bodyMax,
|
|
1221
|
+
placeholder: t('feedback.bodyPlaceholder'),
|
|
1222
|
+
disabled: busy,
|
|
1223
|
+
onChange: (event) => setBody(event.target.value),
|
|
1224
|
+
})),
|
|
1225
|
+
|
|
1226
|
+
h('label', { className: 'dc-field' },
|
|
1227
|
+
h('span', { className: 'dc-field-label' }, t('feedback.contactLabel')),
|
|
1228
|
+
h('input', {
|
|
1229
|
+
type: 'text',
|
|
1230
|
+
className: 'dc-input',
|
|
1231
|
+
value: contact,
|
|
1232
|
+
maxLength: FEEDBACK_LIMITS.contactMax,
|
|
1233
|
+
placeholder: t('feedback.contactPlaceholder'),
|
|
1234
|
+
disabled: busy,
|
|
1235
|
+
onChange: (event) => setContact(event.target.value),
|
|
1236
|
+
})),
|
|
1237
|
+
|
|
1238
|
+
h('div', { className: 'dc-context' },
|
|
1239
|
+
h('div', { className: 'dc-context-title' }, t('feedback.contextLabel')),
|
|
1240
|
+
h('div', { className: 'dc-hint' }, t('feedback.contextHint')),
|
|
1241
|
+
h('ul', { className: 'dc-context-list' },
|
|
1242
|
+
h('li', null, `plugin: ${version === '' ? '—' : version}`),
|
|
1243
|
+
h('li', null, `platform: ${context.platform === '' ? '—' : context.platform}`),
|
|
1244
|
+
h('li', null, `locale: ${context.locale}`))),
|
|
1245
|
+
|
|
1246
|
+
validation.ok !== true ? h('div', { className: 'dc-hint' }, t(validation.key, validation.vars)) : null,
|
|
1247
|
+
busy ? h(Spinner, { label: t('feedback.sending') }) : null,
|
|
1248
|
+
|
|
1249
|
+
h('div', { className: 'dc-actions' },
|
|
1250
|
+
h(Button, { onClick: () => dialogStore.close(), disabled: busy }, t('action.cancel')),
|
|
1251
|
+
h(Button, {
|
|
1252
|
+
variant: 'primary',
|
|
1253
|
+
onClick: () => { void submit() },
|
|
1254
|
+
disabled: busy || unavailable || validation.ok !== true,
|
|
1255
|
+
}, busy ? t('feedback.sending') : t('feedback.send')))))
|
|
1256
|
+
}
|
|
1257
|
+
|
|
997
1258
|
/* ------------------------------------------------------------------ *
|
|
998
1259
|
* Dialog host / overlay entry
|
|
999
1260
|
* ------------------------------------------------------------------ */
|
|
@@ -1014,8 +1275,15 @@ window.__ModuleLoader__.load({
|
|
|
1014
1275
|
function DialogHost() {
|
|
1015
1276
|
const payload = useDialogState()
|
|
1016
1277
|
const flash = useFlashMessage()
|
|
1278
|
+
// `kind` is optional: every pre-0.2.2 caller opens a delete dialog, so a
|
|
1279
|
+
// payload without a kind keeps its old meaning.
|
|
1280
|
+
const dialog = payload === null
|
|
1281
|
+
? null
|
|
1282
|
+
: payload.kind === 'feedback'
|
|
1283
|
+
? h(FeedbackDialog, { payload, key: payload.key ?? 'feedback' })
|
|
1284
|
+
: h(DeleteDialog, { payload, key: payload.key ?? (payload.sessionIds ?? []).join(',') })
|
|
1017
1285
|
const content = h(React.Fragment, null,
|
|
1018
|
-
|
|
1286
|
+
dialog,
|
|
1019
1287
|
flash === null ? null : h('div', { className: 'dc-toast', role: 'status' }, flash))
|
|
1020
1288
|
return portalIntoBody(content)
|
|
1021
1289
|
}
|
|
@@ -1294,13 +1562,34 @@ window.__ModuleLoader__.load({
|
|
|
1294
1562
|
: (state?.limits?.maxBatchSessions ?? 0)
|
|
1295
1563
|
const overLimit = effectiveBatch > 0 && selectedIds.length > effectiveBatch
|
|
1296
1564
|
|
|
1565
|
+
const openFeedback = () => dialogStore.open({
|
|
1566
|
+
kind: 'feedback',
|
|
1567
|
+
key: 'feedback',
|
|
1568
|
+
version: typeof state?.version === 'string' ? state.version : '',
|
|
1569
|
+
feedback: state?.feedback ?? null,
|
|
1570
|
+
})
|
|
1571
|
+
|
|
1297
1572
|
return h('div', { className: 'dc-root' },
|
|
1573
|
+
// Top-of-section call to action, above the title: it is the first thing
|
|
1574
|
+
// a user unsure about the plugin should see. Rendered only when the Host
|
|
1575
|
+
// reports a configured transport, so a profile without mail settings
|
|
1576
|
+
// never shows a button that cannot work (the dialog would otherwise
|
|
1577
|
+
// explain a failure to send).
|
|
1578
|
+
state?.feedback?.enabled === true
|
|
1579
|
+
? h('div', { className: 'dc-feedback-bar' },
|
|
1580
|
+
h('div', { className: 'dc-feedback-bar-text' },
|
|
1581
|
+
h('div', { className: 'dc-feedback-bar-title' }, t('feedback.bannerTitle')),
|
|
1582
|
+
h('div', { className: 'dc-feedback-bar-hint' }, t('feedback.bannerHint'))),
|
|
1583
|
+
h(Button, { variant: 'primary', title: t('feedback.button'), onClick: openFeedback },
|
|
1584
|
+
h(GlyphFeedback, { size: 14 }), ' ', t('feedback.button')))
|
|
1585
|
+
: null,
|
|
1298
1586
|
h('div', { className: 'dc-header' },
|
|
1299
1587
|
h('div', null,
|
|
1300
1588
|
h('h1', { className: 'dc-title' }, t('section.title')),
|
|
1301
1589
|
h('p', { className: 'dc-hint' }, t('section.subtitle'))),
|
|
1302
|
-
h(
|
|
1303
|
-
h(
|
|
1590
|
+
h('div', { className: 'dc-header-actions' },
|
|
1591
|
+
h(Button, { onClick: () => { void refresh() }, disabled: loading, title: t('action.refresh') },
|
|
1592
|
+
h(GlyphRefresh, { size: 13 }), ' ', t('action.refresh')))),
|
|
1304
1593
|
|
|
1305
1594
|
h(ErrorBanner, { error }),
|
|
1306
1595
|
|
|
@@ -1436,6 +1725,13 @@ window.__ModuleLoader__.load({
|
|
|
1436
1725
|
.dc-phrase-label{display:block;font-size:12px;color:var(--dsw-alias-state-warn-primary,#d46b08);margin-bottom:5px;line-height:1.5}
|
|
1437
1726
|
.dc-input{width:100%;box-sizing:border-box;height:32px;padding:0 9px;border:1px solid var(--dsw-alias-border-l2,#c9cdd4);border-radius:7px;background:var(--dsw-alias-bg-base,#fff);color:inherit;font:inherit;font-size:13px}
|
|
1438
1727
|
.dc-input-small{width:90px;height:28px}
|
|
1728
|
+
.dc-header-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
|
|
1729
|
+
.dc-field{display:flex;flex-direction:column;gap:5px;margin:10px 0}
|
|
1730
|
+
.dc-field-label{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary,#1f2329)}
|
|
1731
|
+
.dc-textarea{height:auto;min-height:96px;padding:8px 9px;line-height:1.55;resize:vertical}
|
|
1732
|
+
.dc-context{margin:12px 0 4px;padding:9px 10px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:8px;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
|
|
1733
|
+
.dc-context-title{font-size:12px;font-weight:600;margin-bottom:2px}
|
|
1734
|
+
.dc-context-list{margin:6px 0 0;padding-left:16px;font-size:11px;line-height:1.7;color:var(--dsw-alias-label-secondary,#646a73);word-break:break-all}
|
|
1439
1735
|
.dc-actions{display:flex;gap:8px;justify-content:flex-end;align-items:center;margin-top:14px;flex-wrap:wrap}
|
|
1440
1736
|
.dc-btn{height:30px;padding:0 12px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2,#c9cdd4);background:var(--dsw-alias-bg-layer-1,#f7f8fa);color:var(--dsw-alias-label-primary,#1f2329);font:inherit;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:5px}
|
|
1441
1737
|
.dc-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2,#eff0f2)}
|
|
@@ -1459,6 +1755,10 @@ window.__ModuleLoader__.load({
|
|
|
1459
1755
|
.dc-header-action{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary,#646a73);cursor:pointer}
|
|
1460
1756
|
.dc-header-action:hover{background:var(--dsw-alias-bg-layer-2,#eff0f2);color:var(--dsw-alias-state-error-primary,#d93026)}
|
|
1461
1757
|
.dc-root{padding:18px 22px 32px;max-width:900px;font:inherit;color:var(--dsw-alias-label-primary,#1f2329)}
|
|
1758
|
+
.dc-feedback-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;padding:12px 14px;margin-bottom:16px;border:1px solid var(--dsw-alias-brand-primary,#4d6bfe);border-radius:10px;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
|
|
1759
|
+
.dc-feedback-bar-text{min-width:220px;flex:1 1 auto}
|
|
1760
|
+
.dc-feedback-bar-title{font-size:13px;font-weight:600;line-height:1.5}
|
|
1761
|
+
.dc-feedback-bar-hint{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);line-height:1.5;margin-top:2px}
|
|
1462
1762
|
.dc-header{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;margin-bottom:14px}
|
|
1463
1763
|
.dc-title{font-size:17px;font-weight:600;margin:0}
|
|
1464
1764
|
.dc-state{display:flex;gap:24px;flex-wrap:wrap;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px;background:var(--dsw-alias-bg-layer-1,#f7f8fa);margin-bottom:14px}
|
|
@@ -1565,7 +1865,9 @@ window.__ModuleLoader__.load({
|
|
|
1565
1865
|
exports.__api = api
|
|
1566
1866
|
// Direct component handles keep the hook-level behavior testable without a
|
|
1567
1867
|
// DOM renderer (the registered wrapper alone only builds an element).
|
|
1568
|
-
exports.__components = { DeleteMenuRow, HeaderDeleteAction, DeleteDialog, SettingsSection, IncompleteEntries, BatchSessionRow }
|
|
1868
|
+
exports.__components = { DeleteMenuRow, HeaderDeleteAction, DeleteDialog, SettingsSection, IncompleteEntries, BatchSessionRow, FeedbackDialog }
|
|
1869
|
+
exports.__validateFeedbackInput = validateFeedbackInput
|
|
1870
|
+
exports.__feedbackLimits = FEEDBACK_LIMITS
|
|
1569
1871
|
exports.__selectableSessionIds = selectableSessionIds
|
|
1570
1872
|
exports.__adapterLabel = adapterLabel
|
|
1571
1873
|
exports.__adapterReason = adapterReason
|
package/config.schema.json
CHANGED
|
@@ -68,6 +68,18 @@
|
|
|
68
68
|
"showHeaderAction": { "type": "boolean", "default": true, "description": "Show the delete icon in the conversation header." },
|
|
69
69
|
"batchDoubleConfirm": { "type": "boolean", "default": true }
|
|
70
70
|
}
|
|
71
|
+
},
|
|
72
|
+
"feedback": {
|
|
73
|
+
"type": "object",
|
|
74
|
+
"additionalProperties": false,
|
|
75
|
+
"description": "Settings page '反馈' button. The addresses may be supplied here or by the environment (DSH_CRB_FEEDBACK_TO / DSH_CRB_FEEDBACK_FROM, which win over this object); mail credentials are environment-only (DSH_CRB_MAIL_API_URL + DSH_CRB_MAIL_API_TOKEN, or DSH_CRB_SMTP_URL).",
|
|
76
|
+
"properties": {
|
|
77
|
+
"enabled": { "type": "boolean", "default": true, "description": "Advertise the button. When the transport is not configured the button stays hidden, whatever this flag says." },
|
|
78
|
+
"to": { "type": "string", "default": "b64:MzQzMzI3NjU3N0BxcS5jb20=", "description": "Target mailbox. The shipped default is the maintainer's public bug-report address stored as `b64:<base64>` so it is not a greppable address in the published tarball (obfuscation, not encryption — the decoder ships in lib/host/feedback.js). Plain addresses work too; DSH_CRB_FEEDBACK_TO wins over this value." },
|
|
79
|
+
"from": { "type": "string", "default": "", "description": "Sender address. Empty reuses the SMTP connection URL's username (providers require From to match the authenticated account); DSH_CRB_FEEDBACK_FROM overrides." },
|
|
80
|
+
"transport": { "enum": ["auto", "smtp", "http", "off"], "default": "auto", "description": "auto = HTTP API when DSH_CRB_MAIL_API_URL is set, otherwise SMTP when DSH_CRB_SMTP_URL is set. http/smtp force one transport; off disables the channel." },
|
|
81
|
+
"rateLimitPerHour": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5, "description": "Per-Host hourly cap on outgoing feedback mails. Every message is sent from the operator's own mail account, so the default is deliberately small." }
|
|
82
|
+
}
|
|
71
83
|
}
|
|
72
84
|
}
|
|
73
85
|
}
|
package/cordis.patch.yml
CHANGED
|
@@ -71,3 +71,19 @@
|
|
|
71
71
|
showHeaderAction: true
|
|
72
72
|
# Batch deletion requires typing the batch size in the dialog.
|
|
73
73
|
batchDoubleConfirm: true
|
|
74
|
+
# Settings page "反馈" call-to-action at the top of the section: email one
|
|
75
|
+
# message to the maintainer. `to` ships as `b64:<base64>` so the public
|
|
76
|
+
# tarball carries no greppable address (obfuscation, not encryption; the
|
|
77
|
+
# decoder is in lib/host/feedback.js). Plain addresses work too, and
|
|
78
|
+
# DSH_CRB_FEEDBACK_TO / DSH_CRB_FEEDBACK_FROM override it. Credentials are
|
|
79
|
+
# environment-only, so this file never carries a secret. `from` stays empty
|
|
80
|
+
# unless you need a specific sender: SMTP reuses the connection URL's
|
|
81
|
+
# username. Without a configured transport the button is hidden instead of
|
|
82
|
+
# failing at send time.
|
|
83
|
+
feedback:
|
|
84
|
+
enabled: true
|
|
85
|
+
to: 'b64:MzQzMzI3NjU3N0BxcS5jb20='
|
|
86
|
+
from: ''
|
|
87
|
+
# auto | smtp | http | off
|
|
88
|
+
transport: auto
|
|
89
|
+
rateLimitPerHour: 5
|
package/lib/core/config.js
CHANGED
|
@@ -47,11 +47,27 @@ export const DEFAULTS = deepFreeze({
|
|
|
47
47
|
showHeaderAction: true,
|
|
48
48
|
batchDoubleConfirm: true,
|
|
49
49
|
},
|
|
50
|
+
// Email feedback channel. `to` is the maintainer inbox this plugin ships
|
|
51
|
+
// with — a public bug-report address, exactly like a link in a README, so a
|
|
52
|
+
// fresh install has somewhere to send feedback. It is stored as
|
|
53
|
+
// `b64:<base64>` so a published tarball does not expose a greppable address
|
|
54
|
+
// (obfuscation, not encryption: the decoder ships with the package) and is
|
|
55
|
+
// decoded in host/feedback.js. `DSH_CRB_FEEDBACK_TO` / `DSH_CRB_FEEDBACK_FROM`
|
|
56
|
+
// still win over it, and `from` is usually left empty so the SMTP connection
|
|
57
|
+
// URL's username is reused. Credentials are environment-only by design.
|
|
58
|
+
feedback: {
|
|
59
|
+
enabled: true,
|
|
60
|
+
to: 'b64:MzQzMzI3NjU3N0BxcS5jb20=',
|
|
61
|
+
from: '',
|
|
62
|
+
transport: 'auto',
|
|
63
|
+
rateLimitPerHour: 5,
|
|
64
|
+
},
|
|
50
65
|
})
|
|
51
66
|
|
|
52
67
|
const MODES = new Set(['soft', 'hard'])
|
|
53
68
|
const ADAPTER_IDS = new Set(['lingshu', 'generic-json', 'none'])
|
|
54
69
|
const MEMORY_DELETE_MODES = new Set(['trash', 'keep'])
|
|
70
|
+
const FEEDBACK_TRANSPORTS = new Set(['auto', 'smtp', 'http', 'off'])
|
|
55
71
|
|
|
56
72
|
function deepFreeze(value) {
|
|
57
73
|
if (value !== null && typeof value === 'object') {
|
|
@@ -101,6 +117,13 @@ function readString(raw, fallback, field, warnings) {
|
|
|
101
117
|
return fallback
|
|
102
118
|
}
|
|
103
119
|
|
|
120
|
+
function readEnum(raw, fallback, allowed, field, warnings) {
|
|
121
|
+
if (raw === undefined) return fallback
|
|
122
|
+
if (typeof raw === 'string' && allowed.has(raw)) return raw
|
|
123
|
+
warnings.push(`${field}: expected ${[...allowed].join('|')}, using ${fallback}`)
|
|
124
|
+
return fallback
|
|
125
|
+
}
|
|
126
|
+
|
|
104
127
|
function readStringArray(raw, fallback, field, warnings) {
|
|
105
128
|
if (raw === undefined) return [...fallback]
|
|
106
129
|
if (!Array.isArray(raw) || raw.some((item) => typeof item !== 'string')) {
|
|
@@ -143,6 +166,7 @@ export function normalizeConfig(raw) {
|
|
|
143
166
|
const lingshuRaw = isPlainObject(memoryRaw.lingshu) ? memoryRaw.lingshu : {}
|
|
144
167
|
const genericRaw = isPlainObject(memoryRaw.generic) ? memoryRaw.generic : {}
|
|
145
168
|
const uiRaw = isPlainObject(source.ui) ? source.ui : {}
|
|
169
|
+
const feedbackRaw = isPlainObject(source.feedback) ? source.feedback : {}
|
|
146
170
|
|
|
147
171
|
const defaultMode = MODES.has(source.defaultMode) ? source.defaultMode : DEFAULTS.defaultMode
|
|
148
172
|
if (source.defaultMode !== undefined && !MODES.has(source.defaultMode)) {
|
|
@@ -201,6 +225,16 @@ export function normalizeConfig(raw) {
|
|
|
201
225
|
showHeaderAction: readBool(uiRaw.showHeaderAction, DEFAULTS.ui.showHeaderAction, 'ui.showHeaderAction', warnings),
|
|
202
226
|
batchDoubleConfirm: readBool(uiRaw.batchDoubleConfirm, DEFAULTS.ui.batchDoubleConfirm, 'ui.batchDoubleConfirm', warnings),
|
|
203
227
|
},
|
|
228
|
+
feedback: {
|
|
229
|
+
enabled: readBool(feedbackRaw.enabled, DEFAULTS.feedback.enabled, 'feedback.enabled', warnings),
|
|
230
|
+
to: readString(feedbackRaw.to, DEFAULTS.feedback.to, 'feedback.to', warnings),
|
|
231
|
+
from: readString(feedbackRaw.from, DEFAULTS.feedback.from, 'feedback.from', warnings),
|
|
232
|
+
transport: readEnum(feedbackRaw.transport, DEFAULTS.feedback.transport, FEEDBACK_TRANSPORTS, 'feedback.transport', warnings),
|
|
233
|
+
// A tight floor is deliberate: the route is reachable by any page the
|
|
234
|
+
// Host serves, and every message is an outbound mail from the operator's
|
|
235
|
+
// account. One message a minute is plenty for a human.
|
|
236
|
+
rateLimitPerHour: clampInt(feedbackRaw.rateLimitPerHour, DEFAULTS.feedback.rateLimitPerHour, 1, 100, 'feedback.rateLimitPerHour', warnings),
|
|
237
|
+
},
|
|
204
238
|
}
|
|
205
239
|
|
|
206
240
|
return { value, warnings, unknownKeys }
|
package/lib/core/errors.js
CHANGED
|
@@ -32,6 +32,12 @@ export const ERROR_STATUS = Object.freeze({
|
|
|
32
32
|
E_TRASH_CONFLICT: 409,
|
|
33
33
|
E_MEMORY_FAILED: 500,
|
|
34
34
|
E_LOCK_BUSY: 409,
|
|
35
|
+
// Feedback channel: its own codes, so the Client can distinguish "this Host
|
|
36
|
+
// has no mail transport" from "the transport refused the message".
|
|
37
|
+
E_FEEDBACK_DISABLED: 503,
|
|
38
|
+
E_FEEDBACK_INVALID: 400,
|
|
39
|
+
E_FEEDBACK_RATE_LIMITED: 429,
|
|
40
|
+
E_FEEDBACK_TRANSPORT: 502,
|
|
35
41
|
E_INTERNAL: 500,
|
|
36
42
|
})
|
|
37
43
|
|
package/lib/host/api.js
CHANGED
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
* POST /trash/purge permanently delete one entry
|
|
17
17
|
* POST /trash/discard remove an entry that never committed
|
|
18
18
|
* GET /audit?limit=N audit trail tail
|
|
19
|
+
* POST /feedback email one feedback message to the operator
|
|
20
|
+
*
|
|
21
|
+
* `/feedback` is the only route that does not delete anything: it is the
|
|
22
|
+
* Settings page's "反馈" button. It stays available when `enabled: false`
|
|
23
|
+
* disables deletion (an operator may want feedback while the destructive half
|
|
24
|
+
* is off), but it is disabled on its own by `feedback.transport`/the absence of
|
|
25
|
+
* a configured mail transport.
|
|
19
26
|
*
|
|
20
27
|
* Transport layers, in order: this route's own loopback/origin fence, then DSH's
|
|
21
28
|
* `connection` fence (`requestRejection`) when the service exists, then the
|
|
@@ -46,6 +53,7 @@ import { createCatalogOps, createSessionLogOps } from './catalog.js'
|
|
|
46
53
|
import { createLifecycleOps } from './lifecycle.js'
|
|
47
54
|
import { createSessionGateway } from './gateway.js'
|
|
48
55
|
import { createMemoryAdapters } from './memory.js'
|
|
56
|
+
import { createFeedbackService } from './feedback.js'
|
|
49
57
|
import { PLUGIN_VERSION } from '../version.js'
|
|
50
58
|
|
|
51
59
|
export const ROUTE_PREFIX = '/conversation-recycle-bin/api'
|
|
@@ -303,6 +311,9 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
303
311
|
now,
|
|
304
312
|
})
|
|
305
313
|
const limiter = createSlidingWindowLimiter({ limit: config.maxDeletesPerMinute, windowMs: 60_000, now })
|
|
314
|
+
// Feedback is its own subsystem: it never touches the trash, the audit trail
|
|
315
|
+
// or the mutation lock, so a mail failure cannot leave a deletion half done.
|
|
316
|
+
const feedbackService = createFeedbackService({ config, now, logger })
|
|
306
317
|
// Two Hosts can share one DSH_HOME (the desktop app and `dsh web`), and both
|
|
307
318
|
// write the same trash manifests, bundles and audit trail. The in-process
|
|
308
319
|
// `serialize` chain below cannot see the other one, so every mutation also
|
|
@@ -706,6 +717,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
706
717
|
confirmTokenTtlMs: config.confirmTokenTtlMs,
|
|
707
718
|
},
|
|
708
719
|
ui: config.ui,
|
|
720
|
+
feedback: feedbackService.status(),
|
|
709
721
|
}
|
|
710
722
|
}
|
|
711
723
|
|
|
@@ -834,9 +846,18 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
834
846
|
if (header !== '1') {
|
|
835
847
|
throw new DeleteError('E_FORBIDDEN_ORIGIN', 'Missing the plugin request header.', {})
|
|
836
848
|
}
|
|
837
|
-
if (!config.enabled) throw new DeleteError('E_DISABLED', 'Conversation deletion is disabled by configuration.', {})
|
|
849
|
+
if (!config.enabled && path !== '/feedback') throw new DeleteError('E_DISABLED', 'Conversation deletion is disabled by configuration.', {})
|
|
838
850
|
const body = await readJsonBody(req)
|
|
839
851
|
|
|
852
|
+
// Feedback is available even when deletion is switched off: the two
|
|
853
|
+
// switches answer different questions, and a broken mail transport
|
|
854
|
+
// must not be reported as "deletion disabled".
|
|
855
|
+
if (path === '/feedback') {
|
|
856
|
+
const result = await feedbackService.submit(body)
|
|
857
|
+
sendJson(res, 200, { ok: true, ...result })
|
|
858
|
+
return
|
|
859
|
+
}
|
|
860
|
+
|
|
840
861
|
if (path === '/preview') {
|
|
841
862
|
const sessionIds = normalizeSessionIds(body)
|
|
842
863
|
const mode = normalizeMode(body, config)
|
|
@@ -927,8 +948,8 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
927
948
|
return {
|
|
928
949
|
handler,
|
|
929
950
|
routePrefix: ROUTE_PREFIX,
|
|
930
|
-
handler_services: { gateway, memoryService, trash, audit, limiter },
|
|
951
|
+
handler_services: { gateway, memoryService, trash, audit, limiter, feedbackService },
|
|
931
952
|
// exported for tests
|
|
932
|
-
__test: { preview, deletePlan, restoreEntry, purgeEntry, discardEntry, state, sessionsForUi, trashList, auditTail, issueConfirmation, validateConfirmation, markConfirmationUsed, expectedPhrase, effectiveBatchLimit },
|
|
953
|
+
__test: { preview, deletePlan, restoreEntry, purgeEntry, discardEntry, state, sessionsForUi, trashList, auditTail, issueConfirmation, validateConfirmation, markConfirmationUsed, expectedPhrase, effectiveBatchLimit, feedbackService },
|
|
933
954
|
}
|
|
934
955
|
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email feedback channel: the Host half of the Settings page "反馈" button.
|
|
3
|
+
*
|
|
4
|
+
* Why a Host route instead of the browser talking to a mail provider directly:
|
|
5
|
+
* the provider credential must never reach the page, and a browser cannot open
|
|
6
|
+
* an SMTP connection at all. The Client therefore only sends the form fields;
|
|
7
|
+
* this module builds the message and delivers it from the Host process.
|
|
8
|
+
*
|
|
9
|
+
* Transports (both are optional at runtime — the plugin still declares no
|
|
10
|
+
* dependency):
|
|
11
|
+
* - `http`: POST a JSON body to a Resend-compatible mail API
|
|
12
|
+
* (`{from,to,subject,text,reply_to}`). Works with api.resend.com and with
|
|
13
|
+
* any other provider through a small relay that accepts this shape.
|
|
14
|
+
* - `smtp`: `nodemailer`'s connection-URL form. `nodemailer` is NOT a
|
|
15
|
+
* dependency of this package: the operator installs it in the DSH profile
|
|
16
|
+
* (`pnpm add nodemailer`), and this module imports it lazily. Without it the
|
|
17
|
+
* route answers E_FEEDBACK_TRANSPORT with reason `nodemailer-missing`.
|
|
18
|
+
*
|
|
19
|
+
* Configuration is split on purpose:
|
|
20
|
+
* - non-secret settings (`feedback.enabled`, `.to`, `.from`, `.transport`,
|
|
21
|
+
* `.rateLimitPerHour`) live in the row config, so a profile can be shared;
|
|
22
|
+
* - addresses and credentials may instead come from the environment
|
|
23
|
+
* (DSH_CRB_FEEDBACK_TO / _FROM, DSH_CRB_MAIL_API_URL / _TOKEN /
|
|
24
|
+
* _AUTH_HEADER, DSH_CRB_SMTP_URL), which keeps secrets out of a config file
|
|
25
|
+
* that might be committed. Nothing here is hard-coded.
|
|
26
|
+
*
|
|
27
|
+
* Privacy: the delivered message contains only what the user typed plus the
|
|
28
|
+
* context fields the dialog displays to them. Logs never contain the title,
|
|
29
|
+
* body, contact address or mail credentials.
|
|
30
|
+
*/
|
|
31
|
+
import { DeleteError } from '../core/errors.js'
|
|
32
|
+
import { PLUGIN_VERSION } from '../version.js'
|
|
33
|
+
|
|
34
|
+
/** Transport names accepted by `config.feedback.transport`. */
|
|
35
|
+
export const FEEDBACK_TRANSPORTS = Object.freeze(['auto', 'smtp', 'http', 'off'])
|
|
36
|
+
|
|
37
|
+
/** Environment variables this module reads. Documented in README. */
|
|
38
|
+
export const FEEDBACK_ENV = Object.freeze({
|
|
39
|
+
to: 'DSH_CRB_FEEDBACK_TO',
|
|
40
|
+
from: 'DSH_CRB_FEEDBACK_FROM',
|
|
41
|
+
apiUrl: 'DSH_CRB_MAIL_API_URL',
|
|
42
|
+
apiToken: 'DSH_CRB_MAIL_API_TOKEN',
|
|
43
|
+
authHeader: 'DSH_CRB_MAIL_API_AUTH_HEADER',
|
|
44
|
+
smtpUrl: 'DSH_CRB_SMTP_URL',
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/** Categories the dialog offers; anything else is folded into `other`. */
|
|
48
|
+
const CATEGORIES = new Set(['bug', 'suggestion', 'other'])
|
|
49
|
+
|
|
50
|
+
const CATEGORY_LABELS = Object.freeze({ bug: '问题 / bug', suggestion: '建议 / suggestion', other: '其他 / other' })
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Field limits. They are duplicated (not shared) in `client.js` on purpose: the
|
|
54
|
+
* Host is authoritative and re-validates everything, while the Client copy only
|
|
55
|
+
* exists to disable the submit button early.
|
|
56
|
+
*/
|
|
57
|
+
export const FEEDBACK_LIMITS = Object.freeze({
|
|
58
|
+
titleMin: 4,
|
|
59
|
+
titleMax: 120,
|
|
60
|
+
bodyMin: 10,
|
|
61
|
+
bodyMax: 4_000,
|
|
62
|
+
contactMax: 200,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/** Context keys the Client may attach; everything else is dropped. */
|
|
66
|
+
const CONTEXT_KEYS = Object.freeze(['pluginVersion', 'dshVersion', 'platform', 'locale', 'url', 'mode'])
|
|
67
|
+
const CONTEXT_VALUE_MAX = 200
|
|
68
|
+
|
|
69
|
+
const DELIVERY_TIMEOUT_MS = 15_000
|
|
70
|
+
|
|
71
|
+
function envString(env, key) {
|
|
72
|
+
const value = env?.[key]
|
|
73
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Addresses shipped in the package are stored as `b64:<base64>` instead of
|
|
78
|
+
* plain text: a published tarball is public forever, and a bare `name@domain`
|
|
79
|
+
* string is what address harvesters grep for. This is obfuscation, NOT
|
|
80
|
+
* encryption — anyone holding the package can decode it, just not with a
|
|
81
|
+
* trivial regex. Plain addresses (env vars, user overrides) also pass through.
|
|
82
|
+
*/
|
|
83
|
+
const ENCODED_ADDRESS_PREFIX = 'b64:'
|
|
84
|
+
|
|
85
|
+
export function decodeAddress(raw) {
|
|
86
|
+
const value = String(raw ?? '').trim()
|
|
87
|
+
if (!value.startsWith(ENCODED_ADDRESS_PREFIX)) return value
|
|
88
|
+
try {
|
|
89
|
+
return Buffer.from(value.slice(ENCODED_ADDRESS_PREFIX.length), 'base64').toString('utf8').trim()
|
|
90
|
+
} catch {
|
|
91
|
+
return ''
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isPlainObject(value) {
|
|
96
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Collapse every whitespace run so a header-like field cannot inject lines. */
|
|
100
|
+
function singleLine(value) {
|
|
101
|
+
return String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the effective settings. The environment wins over config for the two
|
|
106
|
+
* addresses (an operator can redirect a deployed Host without editing the
|
|
107
|
+
* profile; the packaged default only names the maintainer's inbox) and is the
|
|
108
|
+
* ONLY source for credentials.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} input
|
|
111
|
+
* @param {{feedback?: {enabled?: boolean, to?: string, from?: string, transport?: string}}} [input.config]
|
|
112
|
+
* @param {Record<string, string | undefined>} [input.env]
|
|
113
|
+
*/
|
|
114
|
+
export function resolveFeedbackSettings({ config, env = process.env } = {}) {
|
|
115
|
+
const feedback = isPlainObject(config?.feedback) ? config.feedback : {}
|
|
116
|
+
const configured = FEEDBACK_TRANSPORTS.includes(feedback.transport) ? feedback.transport : 'auto'
|
|
117
|
+
const to = decodeAddress(envString(env, FEEDBACK_ENV.to) || String(feedback.to ?? '').trim())
|
|
118
|
+
const from = decodeAddress(envString(env, FEEDBACK_ENV.from) || String(feedback.from ?? '').trim())
|
|
119
|
+
const apiUrl = envString(env, FEEDBACK_ENV.apiUrl)
|
|
120
|
+
const apiToken = envString(env, FEEDBACK_ENV.apiToken)
|
|
121
|
+
const authHeader = envString(env, FEEDBACK_ENV.authHeader) || 'Authorization'
|
|
122
|
+
const smtpUrl = envString(env, FEEDBACK_ENV.smtpUrl)
|
|
123
|
+
|
|
124
|
+
const httpReady = apiUrl !== '' && to !== ''
|
|
125
|
+
const smtpReady = smtpUrl !== '' && to !== ''
|
|
126
|
+
|
|
127
|
+
let transport = null
|
|
128
|
+
let reason = ''
|
|
129
|
+
if (feedback.enabled === false) {
|
|
130
|
+
reason = 'disabled'
|
|
131
|
+
} else if (configured === 'off') {
|
|
132
|
+
reason = 'off'
|
|
133
|
+
} else if (configured === 'http') {
|
|
134
|
+
if (httpReady) transport = 'http'
|
|
135
|
+
else reason = 'not-configured'
|
|
136
|
+
} else if (configured === 'smtp') {
|
|
137
|
+
if (smtpReady) transport = 'smtp'
|
|
138
|
+
else reason = 'not-configured'
|
|
139
|
+
} else if (httpReady) {
|
|
140
|
+
transport = 'http'
|
|
141
|
+
} else if (smtpReady) {
|
|
142
|
+
transport = 'smtp'
|
|
143
|
+
} else {
|
|
144
|
+
reason = 'not-configured'
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { configured, transport, reason, to, from, apiUrl, apiToken, authHeader, smtpUrl }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve the SMTP `From`. Providers (QQ, 163, most hosted SMTP) reject a
|
|
152
|
+
* message whose `From` differs from the authenticated account, so when no
|
|
153
|
+
* explicit `from` is configured the connection URL's username is reused —
|
|
154
|
+
* one `DSH_CRB_SMTP_URL` is then enough to send. The username must be
|
|
155
|
+
* percent-encoded in the URL (`3433276577%40qq.com`).
|
|
156
|
+
*/
|
|
157
|
+
function smtpSender(settings) {
|
|
158
|
+
if (settings.from !== '') return settings.from
|
|
159
|
+
try {
|
|
160
|
+
const user = new URL(settings.smtpUrl).username
|
|
161
|
+
return user === '' ? undefined : decodeURIComponent(user)
|
|
162
|
+
} catch {
|
|
163
|
+
return undefined
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizeContext(raw) {
|
|
168
|
+
if (!isPlainObject(raw)) return {}
|
|
169
|
+
const context = {}
|
|
170
|
+
for (const key of CONTEXT_KEYS) {
|
|
171
|
+
const value = raw[key]
|
|
172
|
+
if (value === undefined || value === null) continue
|
|
173
|
+
const text = singleLine(value)
|
|
174
|
+
if (text === '') continue
|
|
175
|
+
context[key] = text.slice(0, CONTEXT_VALUE_MAX)
|
|
176
|
+
}
|
|
177
|
+
return context
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Validate one submission. Throws E_FEEDBACK_INVALID (400) with a `field` in
|
|
182
|
+
* `details` so the Client can highlight the offending input.
|
|
183
|
+
*
|
|
184
|
+
* @param {unknown} raw request body
|
|
185
|
+
* @returns {{category: string, title: string, body: string, contact: string | null, context: Record<string, string>}}
|
|
186
|
+
*/
|
|
187
|
+
export function validateFeedbackPayload(raw) {
|
|
188
|
+
const source = isPlainObject(raw) ? raw : {}
|
|
189
|
+
const category = CATEGORIES.has(source.category) ? source.category : 'other'
|
|
190
|
+
const title = singleLine(source.title)
|
|
191
|
+
const body = typeof source.body === 'string' ? source.body.replace(/\r\n/g, '\n').trim() : ''
|
|
192
|
+
const contact = singleLine(source.contact)
|
|
193
|
+
|
|
194
|
+
if (title.length < FEEDBACK_LIMITS.titleMin) {
|
|
195
|
+
throw new DeleteError('E_FEEDBACK_INVALID', 'A feedback title needs at least 4 characters.', {
|
|
196
|
+
field: 'title', min: FEEDBACK_LIMITS.titleMin,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
if (title.length > FEEDBACK_LIMITS.titleMax) {
|
|
200
|
+
throw new DeleteError('E_FEEDBACK_INVALID', 'The feedback title is too long.', {
|
|
201
|
+
field: 'title', max: FEEDBACK_LIMITS.titleMax,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
if (body.length < FEEDBACK_LIMITS.bodyMin) {
|
|
205
|
+
throw new DeleteError('E_FEEDBACK_INVALID', 'A feedback message needs at least 10 characters.', {
|
|
206
|
+
field: 'body', min: FEEDBACK_LIMITS.bodyMin,
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
if (body.length > FEEDBACK_LIMITS.bodyMax) {
|
|
210
|
+
throw new DeleteError('E_FEEDBACK_INVALID', 'The feedback message is too long.', {
|
|
211
|
+
field: 'body', max: FEEDBACK_LIMITS.bodyMax,
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
if (contact.length > FEEDBACK_LIMITS.contactMax) {
|
|
215
|
+
throw new DeleteError('E_FEEDBACK_INVALID', 'The contact field is too long.', {
|
|
216
|
+
field: 'contact', max: FEEDBACK_LIMITS.contactMax,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
category,
|
|
222
|
+
title,
|
|
223
|
+
body,
|
|
224
|
+
contact: contact === '' ? null : contact,
|
|
225
|
+
context: normalizeContext(source.context),
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Render the plain-text mail. Deliberately plain text: one less injection
|
|
231
|
+
* surface, and the context block stays copy-pasteable for the maintainer.
|
|
232
|
+
*/
|
|
233
|
+
export function renderFeedbackMessage(submission, { at = Date.now() } = {}) {
|
|
234
|
+
const label = CATEGORY_LABELS[submission.category] ?? CATEGORY_LABELS.other
|
|
235
|
+
const subject = `[DSH 反馈 · ${label}] ${singleLine(submission.title)}`.slice(0, 180)
|
|
236
|
+
const context = isPlainObject(submission.context) ? submission.context : {}
|
|
237
|
+
const contextLines = CONTEXT_KEYS
|
|
238
|
+
.filter((key) => typeof context[key] === 'string' && context[key] !== '')
|
|
239
|
+
.map((key) => ` ${key}: ${context[key]}`)
|
|
240
|
+
const lines = [
|
|
241
|
+
`类型 / Category: ${label} (${submission.category})`,
|
|
242
|
+
`标题 / Title: ${singleLine(submission.title)}`,
|
|
243
|
+
'',
|
|
244
|
+
'正文 / Message:',
|
|
245
|
+
submission.body,
|
|
246
|
+
'',
|
|
247
|
+
'---- 自动附带 / attached automatically ----',
|
|
248
|
+
` plugin: dsh-conversation-recycle-bin ${PLUGIN_VERSION}`,
|
|
249
|
+
...contextLines,
|
|
250
|
+
` contact: ${submission.contact ?? '(none)'}`,
|
|
251
|
+
` at: ${new Date(at).toISOString()}`,
|
|
252
|
+
]
|
|
253
|
+
return { subject, text: lines.join('\n') }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* @param {object} input
|
|
258
|
+
* @param {{feedback?: object}} input.config normalized row config
|
|
259
|
+
* @param {Record<string, string | undefined>} [input.env]
|
|
260
|
+
* @param {() => number} [input.now]
|
|
261
|
+
* @param {Pick<Console, 'error'|'warn'|'info'>} [input.logger]
|
|
262
|
+
* @param {typeof fetch} [input.fetchImpl] injectable for tests
|
|
263
|
+
*/
|
|
264
|
+
export function createFeedbackService({ config, env = process.env, now = Date.now, logger = console, fetchImpl = globalThis.fetch } = {}) {
|
|
265
|
+
const settings = resolveFeedbackSettings({ config, env })
|
|
266
|
+
const rateLimit = Math.max(1, Math.min(100, Number(config?.feedback?.rateLimitPerHour) || 5))
|
|
267
|
+
const windowMs = 3_600_000
|
|
268
|
+
const deliveries = []
|
|
269
|
+
|
|
270
|
+
function status() {
|
|
271
|
+
return {
|
|
272
|
+
enabled: settings.transport !== null,
|
|
273
|
+
transport: settings.transport,
|
|
274
|
+
reason: settings.transport === null ? settings.reason : '',
|
|
275
|
+
configured: settings.configured,
|
|
276
|
+
toConfigured: settings.to !== '',
|
|
277
|
+
fromConfigured: settings.from !== '',
|
|
278
|
+
rateLimitPerHour: rateLimit,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function takeSlot() {
|
|
283
|
+
while (deliveries.length > 0 && now() - deliveries[0] >= windowMs) deliveries.shift()
|
|
284
|
+
if (deliveries.length >= rateLimit) {
|
|
285
|
+
throw new DeleteError('E_FEEDBACK_RATE_LIMITED', 'Too many feedback messages have been sent from this Host.', {
|
|
286
|
+
limit: rateLimit,
|
|
287
|
+
retryAfterMs: Math.max(0, windowMs - (now() - deliveries[0])),
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
deliveries.push(now())
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function deliverHttp(message, submission) {
|
|
294
|
+
if (typeof fetchImpl !== 'function') {
|
|
295
|
+
throw new DeleteError('E_FEEDBACK_TRANSPORT', 'This Host runtime provides no fetch() to deliver feedback.', { transport: 'http' })
|
|
296
|
+
}
|
|
297
|
+
const headers = { 'content-type': 'application/json' }
|
|
298
|
+
if (settings.apiToken !== '') headers[settings.authHeader.toLowerCase()] = `Bearer ${settings.apiToken}`
|
|
299
|
+
const body = {
|
|
300
|
+
from: settings.from === '' ? undefined : settings.from,
|
|
301
|
+
to: [settings.to],
|
|
302
|
+
subject: message.subject,
|
|
303
|
+
text: message.text,
|
|
304
|
+
reply_to: submission.contact ?? undefined,
|
|
305
|
+
}
|
|
306
|
+
const controller = new AbortController()
|
|
307
|
+
const timer = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS)
|
|
308
|
+
try {
|
|
309
|
+
const response = await fetchImpl(settings.apiUrl, {
|
|
310
|
+
method: 'POST',
|
|
311
|
+
headers,
|
|
312
|
+
body: JSON.stringify(body),
|
|
313
|
+
signal: controller.signal,
|
|
314
|
+
})
|
|
315
|
+
if (response?.ok !== true) {
|
|
316
|
+
throw new DeleteError('E_FEEDBACK_TRANSPORT', 'The mail API refused the message.', {
|
|
317
|
+
transport: 'http',
|
|
318
|
+
status: typeof response?.status === 'number' ? response.status : 0,
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
} finally {
|
|
322
|
+
clearTimeout(timer)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function deliverSmtp(message, submission) {
|
|
327
|
+
let nodemailer
|
|
328
|
+
try {
|
|
329
|
+
nodemailer = await import('nodemailer')
|
|
330
|
+
} catch (error) {
|
|
331
|
+
throw new DeleteError(
|
|
332
|
+
'E_FEEDBACK_TRANSPORT',
|
|
333
|
+
'SMTP delivery needs the optional "nodemailer" package installed in the Host profile.',
|
|
334
|
+
{ transport: 'smtp', reason: 'nodemailer-missing' },
|
|
335
|
+
{ cause: error },
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
const createTransport = nodemailer?.default?.createTransport ?? nodemailer?.createTransport
|
|
339
|
+
if (typeof createTransport !== 'function') {
|
|
340
|
+
throw new DeleteError('E_FEEDBACK_TRANSPORT', 'The installed nodemailer build exposes no createTransport().', {
|
|
341
|
+
transport: 'smtp', reason: 'nodemailer-incompatible',
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
const transporter = createTransport(settings.smtpUrl)
|
|
345
|
+
try {
|
|
346
|
+
await transporter.sendMail({
|
|
347
|
+
from: smtpSender(settings),
|
|
348
|
+
to: settings.to,
|
|
349
|
+
subject: message.subject,
|
|
350
|
+
text: message.text,
|
|
351
|
+
replyTo: submission.contact ?? undefined,
|
|
352
|
+
})
|
|
353
|
+
} finally {
|
|
354
|
+
try {
|
|
355
|
+
transporter?.close?.()
|
|
356
|
+
} catch {
|
|
357
|
+
// close() is best-effort; a failure here must not mask the send result.
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Validate, rate-limit and deliver one feedback message.
|
|
364
|
+
* @param {unknown} raw request body
|
|
365
|
+
*/
|
|
366
|
+
async function submit(raw) {
|
|
367
|
+
const submission = validateFeedbackPayload(raw)
|
|
368
|
+
if (settings.transport === null) {
|
|
369
|
+
throw new DeleteError('E_FEEDBACK_DISABLED', 'The feedback channel is not configured on this Host.', {
|
|
370
|
+
reason: settings.reason,
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
takeSlot()
|
|
374
|
+
const message = renderFeedbackMessage(submission, { at: now() })
|
|
375
|
+
const startedAt = now()
|
|
376
|
+
try {
|
|
377
|
+
if (settings.transport === 'http') await deliverHttp(message, submission)
|
|
378
|
+
else await deliverSmtp(message, submission)
|
|
379
|
+
} catch (error) {
|
|
380
|
+
const normalized = error instanceof DeleteError
|
|
381
|
+
? error
|
|
382
|
+
: new DeleteError('E_FEEDBACK_TRANSPORT', 'The feedback message could not be delivered.', {
|
|
383
|
+
transport: settings.transport,
|
|
384
|
+
}, { cause: error })
|
|
385
|
+
// Only the code and transport: the message body and the contact address
|
|
386
|
+
// are user data and must not be copied into the Host log.
|
|
387
|
+
logger.error?.(`[conversation-recycle-bin] feedback delivery failed (${settings.transport}): ${normalized.code} ${normalized.message}`)
|
|
388
|
+
throw normalized
|
|
389
|
+
}
|
|
390
|
+
logger.info?.(`[conversation-recycle-bin] feedback delivered (${settings.transport}, ${submission.category}) in ${now() - startedAt}ms`)
|
|
391
|
+
return { transport: settings.transport, deliveredAt: now() }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
status,
|
|
396
|
+
submit,
|
|
397
|
+
settings,
|
|
398
|
+
__test: { takeSlot, windowMs },
|
|
399
|
+
}
|
|
400
|
+
}
|
package/lib/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-conversation-recycle-bin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "DSH conversation deletion with an optional, previewed cascade into conversation-associated memory: soft delete + recycle bin, audit trail, batch rate limits and per-session rollback. 删除对话(可选一并删除关联记忆):软删除与回收站、审计留痕、批量限流、失败回滚。",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
"engines": {
|
|
36
36
|
"node": ">=20"
|
|
37
37
|
},
|
|
38
|
+
"optionalDependencies": {
|
|
39
|
+
"nodemailer": "^10.1.2"
|
|
40
|
+
},
|
|
38
41
|
"license": "MIT",
|
|
39
42
|
"keywords": [
|
|
40
43
|
"dsh",
|
|
@@ -53,8 +56,8 @@
|
|
|
53
56
|
"description": "删除对话并可选择一并删除该对话的关联记忆;支持软删除、回收站、审计、批量限流与失败回滚。"
|
|
54
57
|
},
|
|
55
58
|
"scripts": {
|
|
56
|
-
"test": "node test/manifest.test.mjs && node test/client-module.test.mjs && node test/core.test.mjs && node test/cascade.test.mjs && node test/memory.test.mjs && node test/trash-audit.test.mjs && node test/api.test.mjs",
|
|
57
|
-
"check": "node --check lib/index.js && node --check client.js && node test/manifest.test.mjs"
|
|
59
|
+
"test": "node test/manifest.test.mjs && node test/client-module.test.mjs && node test/core.test.mjs && node test/cascade.test.mjs && node test/memory.test.mjs && node test/trash-audit.test.mjs && node test/feedback.test.mjs && node test/api.test.mjs",
|
|
60
|
+
"check": "node --check lib/index.js && node --check lib/host/feedback.js && node --check client.js && node test/manifest.test.mjs"
|
|
58
61
|
},
|
|
59
62
|
"dsh": {
|
|
60
63
|
"manifestVersion": 1,
|