@addozhang/dsh-discord 0.1.1 → 0.2.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 +92 -17
- package/README.zh.md +143 -0
- package/lib/compose.d.ts +4 -7
- package/lib/compose.js +0 -13
- package/lib/discord/commands.d.ts +8 -3
- package/lib/discord/commands.js +8 -14
- package/lib/discord/rest.d.ts +7 -0
- package/lib/discord/rest.js +10 -0
- package/lib/features/interaction-router.d.ts +6 -0
- package/lib/features/interaction-router.js +19 -3
- package/lib/index.js +81 -29
- package/lib/stream/live.js +91 -51
- package/package.json +13 -4
package/README.md
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
# @addozhang/dsh-discord
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[English](README.md) | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@addozhang/dsh-discord)
|
|
6
|
+
[](https://github.com/addozhang/dsh-discord/actions/workflows/ci.yml)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
[](./package.json)
|
|
9
|
+
|
|
10
|
+
A Discord-first adapter for [DeepSeek Harness](https://github.com/deepseek-ai): run DSH sessions from a Discord guild — mention the bot to open a task thread, steer and stop turns, answer approvals and questions inline, and watch the answer stream in.
|
|
11
|
+
|
|
12
|
+
This is a function/namespace plugin (`inject: ['apiProxy', 'credentials', 'settings', 'storageDomain', 'connection']`). It mounts the Discord Gateway, command surface, stream renderer, and the settings card onto a DSH web profile; session state lives in DSH and durable adapter bindings live in the profile's storage domain.
|
|
4
13
|
|
|
5
14
|
## Features
|
|
6
15
|
|
|
@@ -8,21 +17,34 @@ Discord-first adapter for [DeepSeek Harness](https://github.com/deepseek-ai) —
|
|
|
8
17
|
- **Stream rendering** — typing indicators, a single edited head message, per-tool activity rows, fenced long-answer splitting, one-time finalize; the activity message is deleted when the turn ends.
|
|
9
18
|
- **Approvals & questions** — DSH ask frames become Discord buttons, select menus, and a free-text modal; ownership is enforced (the asker — or the thread owner on later turns — clicks), expiry sweeps fail closed, and remote resolution retires the controls.
|
|
10
19
|
- **Session control** — `/steer`, `/stop`, `/queue list|remove` with turn-ownership checks; `/project bind|list|info` for guild↔workspace binding; `/guild forget` for operator cleanup.
|
|
11
|
-
- **Settings card** — token onboarding (paste + Connect; stored in the Host credential service, never in settings or logs), connect/disconnect, guild allowlist, thread auto-archive, and bot language
|
|
12
|
-
- **Bilingual copy** — every Discord-visible string ships in Chinese and English
|
|
13
|
-
- **Hardened by design** — deny-first authorization inside an explicit guild allowlist,
|
|
20
|
+
- **Settings card** — token onboarding (paste + Connect; stored in the Host credential service, never in settings or logs), connect/disconnect, guild allowlist, thread auto-archive, and bot language.
|
|
21
|
+
- **Bilingual copy** — every Discord-visible string ships in Chinese and English; the bot language defaults to following the DSH language preference and can be pinned from the card.
|
|
22
|
+
- **Hardened by design** — deny-first authorization inside an explicit guild allowlist, mention suppression via `allowed_mentions` plus byte-level neutralization on every wire body, at-most-once DSH submission with unknown-preserving reconciliation, durable bindings that survive restarts, and a READY sweep that rebuilds the deleted category/control channel while treating a deleted workspace channel as user intent (the mapping retires; the workspace stays bindable).
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
- The [dsh CLI](https://www.npmjs.com/package/@deepseek-ai/dsh) `0.1.1-rc.2` or newer, running a web profile
|
|
27
|
+
- Node.js `^22.19.0 || >=24`
|
|
28
|
+
- A Discord application with a bot user and the **MESSAGE CONTENT** privileged intent enabled (Developer Portal → your application → Bot → Privileged Gateway Intents)
|
|
14
29
|
|
|
15
30
|
## Install
|
|
16
31
|
|
|
17
|
-
|
|
32
|
+
Install with the dsh CLI — it installs the package into the profile and registers the bundle for you:
|
|
18
33
|
|
|
19
34
|
```sh
|
|
20
|
-
|
|
21
|
-
cd "$DSH_HOME/profiles/<your-profile>"
|
|
22
|
-
pnpm add file:/tmp/addozhang-dsh-discord-0.1.0.tgz
|
|
35
|
+
dsh plugin --profile web add @addozhang/dsh-discord
|
|
23
36
|
```
|
|
24
37
|
|
|
25
|
-
and
|
|
38
|
+
Then restart `dsh web` and refresh the browser. Under the hood `dsh plugin` is a thin pnpm forwarder into the profile directory; after installing, it reconciles the profile's `dsh.profile.bundles` layer list and appends every dependency that declares a `dsh.bundle` patch — nothing to edit by hand.
|
|
39
|
+
|
|
40
|
+
Upgrade and removal use the same command:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
dsh plugin --profile web up @addozhang/dsh-discord # upgrade; the bundle list reconciles again
|
|
44
|
+
dsh plugin --profile web rm @addozhang/dsh-discord # remove; run /guild forget first to clean adapter records
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
If you manage a profile without the CLI, the manual equivalent is to add the package with pnpm inside the profile directory and list it in `dsh.profile.bundles` yourself:
|
|
26
48
|
|
|
27
49
|
```json
|
|
28
50
|
{
|
|
@@ -30,14 +52,41 @@ and register it in the profile's `package.json`:
|
|
|
30
52
|
}
|
|
31
53
|
```
|
|
32
54
|
|
|
33
|
-
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
All keys live in the `dsh-discord` settings namespace and can be set either from the settings card or by editing the profile's user settings (`settings.yaml`):
|
|
58
|
+
|
|
59
|
+
| Key | Default | Meaning |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `enabled` | `false` | Adapter master switch; the card's Connect starts it once a token is stored. |
|
|
62
|
+
| `allowedGuildIds` | `[]` | Guild allowlist. Anything outside is ignored with zero adapter or DSH calls. |
|
|
63
|
+
| `memberUserIds` / `memberRoleIds` | `[]` | Member-level authorization inside an allowed guild. |
|
|
64
|
+
| `administratorUserIds` / `administratorRoleIds` | `[]` | Workspace-administrator level (`/project bind`, preset changes). |
|
|
65
|
+
| `deniedUserIds` / `deniedRoleIds` | `[]` | Deny entries; they win over every grant above. |
|
|
66
|
+
| `hostOperatorUserIds` | `[]` | Host operators (`/guild forget`). |
|
|
67
|
+
| `defaultVerbosity` | `essential-tools` | Tool-activity row granularity: `text-only`, `essential-tools`, or `full-tools`. |
|
|
68
|
+
| `language` | `auto` | Bot-visible copy language: `auto` follows the DSH language preference (non-Chinese renders English), or pin `zh`/`en`. |
|
|
69
|
+
| `streamUpdateIntervalMs` | `800` | Coalescing budget for stream edits (250–10000). |
|
|
70
|
+
| `typingIntervalMs` | `7000` | Typing-indicator heartbeat (1000–30000). |
|
|
71
|
+
| `approvalTimeoutMs` | `600000` | Approval ask deadline (30000–86400000); overdue asks auto-reject. |
|
|
72
|
+
| `questionTimeoutMs` | `1800000` | Question ask deadline (30000–86400000); expiry cancels the owning turn. |
|
|
73
|
+
| `threadAutoArchiveMinutes` | `1440` | Task-thread auto-archive: 60, 1440, 4320, or 10080. |
|
|
74
|
+
|
|
75
|
+
```yaml
|
|
76
|
+
dsh-discord:
|
|
77
|
+
allowedGuildIds: ["1517134847850709032"]
|
|
78
|
+
language: auto
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The settings card exposes the three high-frequency fields (guild allowlist, auto-archive, language) plus the connection and token surface; every other key is fully supported through `settings.yaml`. An invalid stored section preserves the last known-good configuration.
|
|
34
82
|
|
|
35
83
|
## Setup
|
|
36
84
|
|
|
37
|
-
1.
|
|
38
|
-
2.
|
|
39
|
-
3.
|
|
40
|
-
4.
|
|
85
|
+
1. Invite the bot to your guild with at least: View Channels, **Manage Channels** (the adapter provisions its category and workspace home channels), Send Messages, Create Public Threads, Send Messages in Threads, Attach Files, Read Message History.
|
|
86
|
+
2. Boot the profile and open the web UI.
|
|
87
|
+
3. In **Settings → Discord**, paste the bot token (Developer Portal → your application → Bot → Reset Token) and press **Connect**. The token is stored by the Host credential service — never in settings, logs, or the client.
|
|
88
|
+
4. Fill in **Allowed servers** (server IDs via Discord's Developer Mode → right-click a server → Copy Server ID). Everything outside this allowlist is ignored.
|
|
89
|
+
5. Pick the bot language and mention the bot in a bound channel to start a session.
|
|
41
90
|
|
|
42
91
|
## Commands
|
|
43
92
|
|
|
@@ -47,10 +96,27 @@ and register it in the profile's `package.json`:
|
|
|
47
96
|
| `/project list` / `info` | any channel | list workspaces / inspect this channel's binding |
|
|
48
97
|
| `/queue list`, `/queue remove` | session thread | inspect and trim the pending queue |
|
|
49
98
|
| `/steer`, `/stop` | session thread | steer or cancel the running turn (owner only) |
|
|
50
|
-
| `/model`, `/preset`, `/skill` | per context | model/preset defaults and skill runs |
|
|
51
|
-
| `/host status` | any channel | connection and version |
|
|
52
99
|
| `/guild forget` | any channel | operator-only removal of adapter records |
|
|
53
100
|
|
|
101
|
+
`/model` remains registered ahead of its interactive provider → model → thinking/reasoning cascade (next milestone); `/preset`, `/skill`, and `/host` are deregistered until the router wires them — see Known limitations.
|
|
102
|
+
|
|
103
|
+
## Design notes
|
|
104
|
+
|
|
105
|
+
- The settings card is the first-run onboarding surface: the token entry writes the credential service's `DSH_DISCORD_BOT_TOKEN` ref over the plugin management channel, then triggers the start chain. Disconnect keeps the credential; an empty reconnect uses it.
|
|
106
|
+
- The publish workflow authenticates to npm via trusted publishing (OIDC) — no publish token is stored anywhere.
|
|
107
|
+
- The adapter start chain is generation-counted, so Connect/Disconnect races with the initial boot yield exactly one gateway.
|
|
108
|
+
- A credential probe falls back to `resolve()` because the Host's `describe()` misses env-sourced values — a connected adapter never reads as unconfigured.
|
|
109
|
+
- Adapter logging is default-quiet: flow records ride the Host's debug level and failure-shaped events escalate to warn — nothing prints into the DSH process at the default level.
|
|
110
|
+
- Wire-level live-path tracing: set `DSH_DISCORD_TRACE=1` before booting to emit mux frames, drop points, and delivery outcomes to stderr (default silent). It exists because the rc.2 Host wires no plugin log exporter and exposes no log-level switch — `logger.debug` output is unobservable — and should be dropped once the Host grows one.
|
|
111
|
+
|
|
112
|
+
## Known Limitations and Deferred Work
|
|
113
|
+
|
|
114
|
+
- **`/session new|resume` is not registered** — the selector and cold-adoption modules are implemented and unit-tested, but the Host RPC face cannot back them yet (`sessions.list` v1 returns bare ids; no `session.inspect`). They return with the next milestone.
|
|
115
|
+
- **`/model` is registered but not routed yet** — its control module (model catalog with guarded selection) is implemented and unit-tested; the interactive provider → model → thinking/reasoning cascade lands with the router-wiring milestone. `/preset`, `/skill`, and `/host` stay deregistered until the same pass wires them (the `/preset` thread-context guard rides along).
|
|
116
|
+
- **Verbosity is a single global setting** (the DSH ecosystem has per-channel precedent).
|
|
117
|
+
- **Deferred after a Kimaki parity pass**: reconcile-interactions wiring, typing pause during ask waits, fail-closed binding/session-owner store wiring, and credential-rotation watching.
|
|
118
|
+
- **Known tension**: the 250ms minimum stream-edit interval against Discord's edit budget under heavy load (429s self-heal), and typing has no duration-capped watchdog.
|
|
119
|
+
|
|
54
120
|
## Development
|
|
55
121
|
|
|
56
122
|
```sh
|
|
@@ -61,7 +127,16 @@ pnpm lint
|
|
|
61
127
|
pnpm build # lib + client bundle
|
|
62
128
|
```
|
|
63
129
|
|
|
64
|
-
|
|
130
|
+
To try a local build in a profile:
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
pnpm pack --pack-destination /tmp
|
|
134
|
+
dsh plugin --profile <your-profile> add file:/tmp/addozhang-dsh-discord-<version>.tgz
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`dsh plugin` anchors relative path specs to the invoking directory, so from a checkout that has run `pnpm build`, `dsh plugin --profile <your-profile> add ../fiber` works too. Re-run pack + add to refresh the installed copy, then restart `dsh web`.
|
|
138
|
+
|
|
139
|
+
Releases are tagged (`npm version <level> && git push --follow-tags`) and published by [GitHub Actions](./.github/workflows/publish.yml) via npm trusted publishing (OIDC) — no publish token is stored anywhere. The implementation follows the OpenSpec change at `openspec/changes/build-discord-native-adapter/` (design, capability specs, verification checklist, review reports).
|
|
65
140
|
|
|
66
141
|
## License
|
|
67
142
|
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# @addozhang/dsh-discord
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@addozhang/dsh-discord)
|
|
6
|
+
[](https://github.com/addozhang/dsh-discord/actions/workflows/ci.yml)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
[](./package.json)
|
|
9
|
+
|
|
10
|
+
[DeepSeek Harness](https://github.com/deepseek-ai) 的 Discord 优先适配器:在 Discord 服务器中运行 DSH 会话——@机器人即可开启任务线程、插话与停止运行、在线审批与回答问题,并实时观看回答流式输出。
|
|
11
|
+
|
|
12
|
+
本插件为 function/namespace 插件(`inject: ['apiProxy', 'credentials', 'settings', 'storageDomain', 'connection']`):将 Discord Gateway、命令面、流式渲染器与设置卡片挂载到 DSH web profile;会话状态保存在 DSH,适配器的持久绑定保存在 profile 的存储域中。
|
|
13
|
+
|
|
14
|
+
## 功能
|
|
15
|
+
|
|
16
|
+
- **@提及驱动会话** — 在已绑定的频道中,被授权的 `@机器人 <任务>` 会锚定一个线程(你的消息成为首帖)、创建 DSH 会话,并且至多提交一次。线程内的后续消息无需 @ 即可排队。
|
|
17
|
+
- **流式渲染** — typing 指示、单条头消息编辑、逐工具活动行、代码围栏感知的长文分段、一次性收尾;Turn 结束时活动消息会被删除。
|
|
18
|
+
- **审批与提问** — DSH ask 帧渲染为 Discord 按钮、下拉菜单与自由文本弹窗;所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,远端决议自动退役控件。
|
|
19
|
+
- **会话控制** — `/steer`、`/stop`、`/queue list|remove` 带运行所有权校验;`/project bind|list|info` 管理 Guild↔工作区绑定;`/guild forget` 供操作员清理。
|
|
20
|
+
- **设置卡片** — Token 引导(粘贴 + 连接;存入 Host 凭据服务,绝不写入设置或日志)、连接/断开、服务器白名单、线程自动归档、Bot 语言。
|
|
21
|
+
- **双语文案** — 所有 Discord 可见文案提供中英双语;Bot 语言默认跟随 DSH 语言偏好,也可从卡片固定。
|
|
22
|
+
- **安全设计** — 显式服务器白名单内的 deny-first 授权、每条 wire 请求携带 `allowed_mentions` 并做字节级提及中和、至多一次的 DSH 提交与保留 unknown 的对账、重启后持久的绑定,以及 READY 扫描:被删的 category/控制频道会重建,被删的工作区频道视为用户意图(解除映射,workspace 保持可重新绑定)。
|
|
23
|
+
|
|
24
|
+
## 环境要求
|
|
25
|
+
|
|
26
|
+
- [dsh CLI](https://www.npmjs.com/package/@deepseek-ai/dsh) `0.1.1-rc.2` 或更新(web profile)
|
|
27
|
+
- Node.js `^22.19.0 || >=24`
|
|
28
|
+
- 一个 Discord 应用(含 Bot 用户),并在开发者门户启用 **MESSAGE CONTENT** 特权 intent(Developer Portal → 你的应用 → Bot → Privileged Gateway Intents)
|
|
29
|
+
|
|
30
|
+
## 安装
|
|
31
|
+
|
|
32
|
+
使用 dsh CLI 安装——它会自动把包装进 profile 并注册 bundle:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
dsh plugin --profile web add @addozhang/dsh-discord
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
然后重启 `dsh web` 并刷新浏览器。`dsh plugin` 底层是指向 profile 目录的 pnpm 薄转发;安装完成后它会自动对账 profile 的 `dsh.profile.bundles` 层列表,把所有声明了 `dsh.bundle` patch 的依赖追加进去——无需手动编辑。
|
|
39
|
+
|
|
40
|
+
升级与卸载使用同一条命令:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
dsh plugin --profile web up @addozhang/dsh-discord # 升级;bundle 列表会重新对账
|
|
44
|
+
dsh plugin --profile web rm @addozhang/dsh-discord # 卸载;先执行 /guild forget 清理适配器记录
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
如果不用 CLI、手工管理 profile,等价做法是在 profile 目录里用 pnpm 安装本包,并在 `dsh.profile.bundles` 中自行登记:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "@addozhang/dsh-discord"] } }
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 配置
|
|
56
|
+
|
|
57
|
+
全部配置项位于 `dsh-discord` 设置命名空间,既可以在设置卡片中修改,也可以直接编辑 profile 的用户设置(`settings.yaml`):
|
|
58
|
+
|
|
59
|
+
| 配置项 | 默认值 | 说明 |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `enabled` | `false` | 适配器总开关;卡片 Connect 在 Token 存入后启动。 |
|
|
62
|
+
| `allowedGuildIds` | `[]` | 服务器白名单。白名单之外零响应、零 DSH 调用。 |
|
|
63
|
+
| `memberUserIds` / `memberRoleIds` | `[]` | 白名单 Guild 内的成员级授权。 |
|
|
64
|
+
| `administratorUserIds` / `administratorRoleIds` | `[]` | 工作区管理员级(`/project bind`、预设修改)。 |
|
|
65
|
+
| `deniedUserIds` / `deniedRoleIds` | `[]` | 拒绝名单;优先级高于上述一切授权。 |
|
|
66
|
+
| `hostOperatorUserIds` | `[]` | Host 操作员(`/guild forget`)。 |
|
|
67
|
+
| `defaultVerbosity` | `essential-tools` | 工具活动行粒度:`text-only`、`essential-tools`、`full-tools`。 |
|
|
68
|
+
| `language` | `auto` | Bot 可见文案语言:`auto` 跟随 DSH 语言偏好(非中文渲染英文),或固定 `zh`/`en`。 |
|
|
69
|
+
| `streamUpdateIntervalMs` | `800` | 流式编辑合并间隔(250–10000)。 |
|
|
70
|
+
| `typingIntervalMs` | `7000` | typing 心跳(1000–30000)。 |
|
|
71
|
+
| `approvalTimeoutMs` | `600000` | 审批超时(30000–86400000);超时自动拒绝。 |
|
|
72
|
+
| `questionTimeoutMs` | `1800000` | 问题超时(30000–86400000);超时取消所属 Turn。 |
|
|
73
|
+
| `threadAutoArchiveMinutes` | `1440` | 任务线程自动归档:60、1440、4320 或 10080。 |
|
|
74
|
+
|
|
75
|
+
```yaml
|
|
76
|
+
dsh-discord:
|
|
77
|
+
allowedGuildIds: ["1517134847850709032"]
|
|
78
|
+
language: auto
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
设置卡片暴露三个高频项(服务器白名单、自动归档、语言)以及连接与 Token 面;其余键完全支持通过 `settings.yaml` 配置。非法的已存配置会保留最近一次有效配置。
|
|
82
|
+
|
|
83
|
+
## 初始设置
|
|
84
|
+
|
|
85
|
+
1. 以至少以下权限把 Bot 邀请到你的服务器:查看频道、**管理频道**(适配器要创建自己的分类和工作区频道)、发送消息、创建公共线程、在线程中发送消息、上传文件、读取消息历史。
|
|
86
|
+
2. 启动 profile 并打开 Web 界面。
|
|
87
|
+
3. 在 **Settings → Discord** 中粘贴 Bot Token(开发者门户 → 你的应用 → Bot → Reset Token)并点击 **Connect**。Token 由 Host 凭据服务保存——绝不写入设置、日志或客户端。
|
|
88
|
+
4. 填写 **允许的服务器**(服务器 ID 获取方式:Discord 开发者模式 → 右键服务器 → 复制服务器 ID)。白名单之外的一切都会被忽略。
|
|
89
|
+
5. 选择 Bot 语言,然后在已绑定的频道 @机器人 开始会话。
|
|
90
|
+
|
|
91
|
+
## 命令
|
|
92
|
+
|
|
93
|
+
| 命令 | 位置 | 说明 |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `/project bind` | 任意频道 | 将 Guild 绑定到工作区(管理员;会创建主频道) |
|
|
96
|
+
| `/project list` / `info` | 任意频道 | 列出工作区 / 查看当前频道绑定 |
|
|
97
|
+
| `/queue list`, `/queue remove` | 会话线程 | 查看与移除待处理队列 |
|
|
98
|
+
| `/steer`, `/stop` | 会话线程 | 插话或取消运行中的 Turn(仅属主) |
|
|
99
|
+
| `/guild forget` | 任意频道 | 仅操作员:移除适配器记录 |
|
|
100
|
+
|
|
101
|
+
`/model` 保持注册,等待其交互式 provider → 模型 → thinking/reasoning 级联(下个里程碑);`/preset`、`/skill`、`/host` 已取消注册,待路由接线后回归——见已知限制。
|
|
102
|
+
|
|
103
|
+
## 设计说明
|
|
104
|
+
|
|
105
|
+
- 设置卡片是首次使用的引导面:Token 输入通过插件管理通道写入凭据服务的 `DSH_DISCORD_BOT_TOKEN` 引用,然后触发启动链。断开连接保留凭据;留空重连直接使用已存 Token。
|
|
106
|
+
- 发布工作流通过 npm trusted publishing (OIDC) 认证——任何地方都不保存发布凭证。
|
|
107
|
+
- 适配器启动链带代际计数,Connect/Disconnect 与初始启动竞争时只会产生一个 Gateway。
|
|
108
|
+
- 凭据探测会回退到 `resolve()`:Host 的 `describe()` 不识别环境变量来源的值——已连接的适配器不会被误报为未配置。
|
|
109
|
+
- 适配器日志默认静默:流程记录走 Host 的 debug 级别,失败形态的事件升到 warn——默认级别下不会向 DSH 进程打印任何内容。
|
|
110
|
+
- 链路级 trace:启动前设置 `DSH_DISCORD_TRACE=1` 可将 mux 帧、丢弃点与投递结果输出到 stderr(默认静默)。存在原因:rc.2 Host 未为插件日志接线任何 exporter,也没有日志级别开关——`logger.debug` 输出不可见;Host 提供等价机制后应移除。
|
|
111
|
+
|
|
112
|
+
## 已知限制与推迟项
|
|
113
|
+
|
|
114
|
+
- **`/session new|resume` 未注册** — 选择器与冷收养模块已实现并通过单元测试,但 Host RPC 面尚不支持(`sessions.list` v1 只返回裸 id;缺少 `session.inspect`)。将在下个里程碑回归。
|
|
115
|
+
- **`/model` 已注册但尚未接线** — 其控制模块(模型目录与受控选择)已实现并通过单元测试;交互式 provider → 模型 → thinking/reasoning 级联将随路由接线里程碑落地。`/preset`、`/skill`、`/host` 保持注销状态,同一次接线时回归(`/preset` 的会话线程守卫一并处理)。
|
|
116
|
+
- **verbosity 为全局设置**(DSH 生态有按频道设置的先例)。
|
|
117
|
+
- **经 Kimaki 对齐后有意推迟**:reconcile-interactions 接线、ask 等待期暂停 typing、fail-closed 绑定/会话属主 store 接线、凭据轮换监听。
|
|
118
|
+
- **已知张力**:流式编辑 250ms 下限与高负载下 Discord 编辑预算的冲突(429 自愈),以及 typing 缺少时长上限看门狗。
|
|
119
|
+
|
|
120
|
+
## 开发
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
pnpm install --ignore-scripts
|
|
124
|
+
pnpm test # 642 tests incl. gateway/REST twin E2E
|
|
125
|
+
pnpm typecheck
|
|
126
|
+
pnpm lint
|
|
127
|
+
pnpm build # lib + client bundle
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
在 profile 中试用本地构建:
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
pnpm pack --pack-destination /tmp
|
|
134
|
+
dsh plugin --profile <your-profile> add file:/tmp/addozhang-dsh-discord-<version>.tgz
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`dsh plugin` 会把相对路径锚定到调用目录,因此在已执行 `pnpm build` 的仓库里也可以直接 `dsh plugin --profile <your-profile> add ../fiber`。重复 pack + add 即可刷新已安装副本,然后重启 `dsh web`。
|
|
138
|
+
|
|
139
|
+
发布由 [GitHub Actions](./.github/workflows/publish.yml) 经 npm trusted publishing (OIDC) 完成(`npm version <level> && git push --follow-tags`)——任何地方都不保存发布凭证。实现遵循 `openspec/changes/build-discord-native-adapter/` 中的 OpenSpec 变更(设计、能力 spec、验证清单、评审报告)。
|
|
140
|
+
|
|
141
|
+
## 许可证
|
|
142
|
+
|
|
143
|
+
MIT
|
package/lib/compose.d.ts
CHANGED
|
@@ -110,16 +110,13 @@ export interface CompositionDeps {
|
|
|
110
110
|
actorId: string;
|
|
111
111
|
audience: 'administrator' | 'member';
|
|
112
112
|
}) => void | Promise<void>;
|
|
113
|
-
/** Idempotently ensure the category + control channel exist in a guild. */
|
|
114
|
-
ensureGuildChannels?: (guildId: string) => Promise<void>;
|
|
115
113
|
/**
|
|
116
|
-
* Runs once per READY
|
|
117
|
-
*
|
|
118
|
-
*
|
|
114
|
+
* Runs once per READY: the reconciliation sweep — which also self-heals
|
|
115
|
+
* the adapter's guild surface (category, control channel, bound home
|
|
116
|
+
* channels) — and the trigger for startup mapping verification preceding
|
|
117
|
+
* accepted writes for those mappings (reconciliation spec).
|
|
119
118
|
*/
|
|
120
119
|
onReady?: () => void | Promise<void>;
|
|
121
|
-
/** Allowlist snapshot used to provision channels on READY. */
|
|
122
|
-
allowedGuildIds?: readonly string[] | undefined;
|
|
123
120
|
/**
|
|
124
121
|
* Registry override so the composition root shares ONE registry instance
|
|
125
122
|
* across the runtime and every feature handler (question controls,
|
package/lib/compose.js
CHANGED
|
@@ -98,9 +98,6 @@ export function startDiscordAdapter(deps) {
|
|
|
98
98
|
let started = false;
|
|
99
99
|
let startError;
|
|
100
100
|
let ingress;
|
|
101
|
-
// provisioning style: one category + one control channel per
|
|
102
|
-
// allowed guild, created idempotently on the first READY.
|
|
103
|
-
const provisioned = new Set();
|
|
104
101
|
function handleDispatch(dispatch) {
|
|
105
102
|
if (dispatch.t === 'READY')
|
|
106
103
|
status.setGateway('connected');
|
|
@@ -118,16 +115,6 @@ export function startDiscordAdapter(deps) {
|
|
|
118
115
|
console.error('[dsh-discord] interaction handler failed:', cause);
|
|
119
116
|
});
|
|
120
117
|
}
|
|
121
|
-
if (dispatch.t === 'READY' && deps.ensureGuildChannels !== undefined) {
|
|
122
|
-
for (const guildId of deps.allowedGuildIds ?? []) {
|
|
123
|
-
if (provisioned.has(guildId))
|
|
124
|
-
continue;
|
|
125
|
-
provisioned.add(guildId);
|
|
126
|
-
deps.ensureGuildChannels(guildId).catch((cause) => {
|
|
127
|
-
deps.logger?.warn('discord_channel_provision_failed', { guildId, cause: String(cause) });
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
118
|
if (dispatch.t === 'READY' && deps.onReady !== undefined) {
|
|
132
119
|
void Promise.resolve(deps.onReady()).catch((cause) => {
|
|
133
120
|
deps.logger?.warn('discord_ready_reconcile_failed', String(cause));
|
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
* The Milestone 1 Discord command contract (design.md §13). One declarative
|
|
3
3
|
* table feeds both the adapter's own routing metadata and the Discord
|
|
4
4
|
* registration payload, so the wire shape cannot drift from the contract:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* only commands the interaction router actually routes are registered —
|
|
6
|
+
* guild-only, ephemeral responses, no Discord-level permission gates
|
|
7
|
+
* (adapter RBAC decides at interaction time, deny-first).
|
|
8
|
+
*
|
|
9
|
+
* Deregistered: `/preset`, `/skill`, `/host` (their control modules stay
|
|
10
|
+
* implemented and unit-tested; they return when the router wires them,
|
|
11
|
+
* alongside `/session new|resume` — see design.md §13 and tasks 16.26/16.32).
|
|
7
12
|
*/
|
|
8
13
|
/** A command argument as the contract table declares it. */
|
|
9
14
|
export interface CommandArgument {
|
|
@@ -32,7 +37,7 @@ export interface AdapterCommand {
|
|
|
32
37
|
/** Milestone 1: every response is ephemeral. */
|
|
33
38
|
responseVisibility: 'ephemeral';
|
|
34
39
|
}
|
|
35
|
-
/** The
|
|
40
|
+
/** The live command set, in registration order. */
|
|
36
41
|
export declare const MILESTONE_ONE_COMMANDS: readonly AdapterCommand[];
|
|
37
42
|
/**
|
|
38
43
|
* Build the bulk-overwrite registration payload for
|
package/lib/discord/commands.js
CHANGED
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
* The Milestone 1 Discord command contract (design.md §13). One declarative
|
|
3
3
|
* table feeds both the adapter's own routing metadata and the Discord
|
|
4
4
|
* registration payload, so the wire shape cannot drift from the contract:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* only commands the interaction router actually routes are registered —
|
|
6
|
+
* guild-only, ephemeral responses, no Discord-level permission gates
|
|
7
|
+
* (adapter RBAC decides at interaction time, deny-first).
|
|
8
|
+
*
|
|
9
|
+
* Deregistered: `/preset`, `/skill`, `/host` (their control modules stay
|
|
10
|
+
* implemented and unit-tested; they return when the router wires them,
|
|
11
|
+
* alongside `/session new|resume` — see design.md §13 and tasks 16.26/16.32).
|
|
7
12
|
*/
|
|
8
13
|
function leaf(name, description, options) {
|
|
9
14
|
return { name, description, ...(options === undefined ? {} : { options }), guildOnly: true, responseVisibility: 'ephemeral' };
|
|
@@ -11,7 +16,7 @@ function leaf(name, description, options) {
|
|
|
11
16
|
function grouped(name, description, subcommands) {
|
|
12
17
|
return { name, description, subcommands, guildOnly: true, responseVisibility: 'ephemeral' };
|
|
13
18
|
}
|
|
14
|
-
/** The
|
|
19
|
+
/** The live command set, in registration order. */
|
|
15
20
|
export const MILESTONE_ONE_COMMANDS = [
|
|
16
21
|
grouped('project', 'Bind this channel to a DSH workspace', [
|
|
17
22
|
{ name: 'list', options: [{ name: 'query', required: false, autocomplete: true }] },
|
|
@@ -28,17 +33,6 @@ export const MILESTONE_ONE_COMMANDS = [
|
|
|
28
33
|
{ name: 'show' },
|
|
29
34
|
{ name: 'select', options: [{ name: 'model', required: true }, { name: 'reasoning', required: false }] },
|
|
30
35
|
]),
|
|
31
|
-
grouped('preset', 'Show, select, or reset this channel\'s agent preset', [
|
|
32
|
-
{ name: 'show' },
|
|
33
|
-
{ name: 'select', options: [{ name: 'preset', required: true }] },
|
|
34
|
-
{ name: 'reset' },
|
|
35
|
-
]),
|
|
36
|
-
grouped('skill', 'Run a DSH skill through the session queue', [
|
|
37
|
-
{ name: 'run', options: [{ name: 'skill', required: true }, { name: 'input', required: false }] },
|
|
38
|
-
]),
|
|
39
|
-
grouped('host', 'Show the connected DSH host status', [
|
|
40
|
-
{ name: 'status' },
|
|
41
|
-
]),
|
|
42
36
|
grouped('guild', 'Guild-scoped adapter operations', [
|
|
43
37
|
{ name: 'forget' },
|
|
44
38
|
]),
|
package/lib/discord/rest.d.ts
CHANGED
|
@@ -58,6 +58,13 @@ export declare function createRestClient(config: RestClientConfig, options?: Res
|
|
|
58
58
|
* major parameters — `channels/:id`, `guilds/:id`, `webhooks/:id` — so every
|
|
59
59
|
* write into one channel shares one bucket and serializes behind it.
|
|
60
60
|
*/
|
|
61
|
+
/**
|
|
62
|
+
* A fresh Discord message nonce: reconciliation identity for an
|
|
63
|
+
* unobservable send (rest.ts contract). Discord validates the wire field at
|
|
64
|
+
* **25 characters max** — a 36-char UUID is a 50035 Invalid Form Body on
|
|
65
|
+
* every send (task 16.38) — so the id is the UUID's 25 hex digits.
|
|
66
|
+
*/
|
|
67
|
+
export declare function newNonce(): string;
|
|
61
68
|
export declare function routeBucketOf(path: string): string;
|
|
62
69
|
export interface SharedRestClient {
|
|
63
70
|
request<T>(method: RestMethod, path: string, body?: unknown, init?: {
|
package/lib/discord/rest.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* exhausted 5xx) may or may not have been applied and must be reconciled,
|
|
7
7
|
* never blindly retried by callers.
|
|
8
8
|
*/
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
9
10
|
const DEFAULT_API_BASE = 'https://discord.com/api/v10';
|
|
10
11
|
const DEFAULT_MAX_RETRIES = 3;
|
|
11
12
|
const DEFAULT_BACKOFF_BASE_MS = 1_000;
|
|
@@ -121,6 +122,15 @@ export function createRestClient(config, options = {}, fetchLike) {
|
|
|
121
122
|
* major parameters — `channels/:id`, `guilds/:id`, `webhooks/:id` — so every
|
|
122
123
|
* write into one channel shares one bucket and serializes behind it.
|
|
123
124
|
*/
|
|
125
|
+
/**
|
|
126
|
+
* A fresh Discord message nonce: reconciliation identity for an
|
|
127
|
+
* unobservable send (rest.ts contract). Discord validates the wire field at
|
|
128
|
+
* **25 characters max** — a 36-char UUID is a 50035 Invalid Form Body on
|
|
129
|
+
* every send (task 16.38) — so the id is the UUID's 25 hex digits.
|
|
130
|
+
*/
|
|
131
|
+
export function newNonce() {
|
|
132
|
+
return randomUUID().replaceAll('-', '').slice(0, 25);
|
|
133
|
+
}
|
|
124
134
|
export function routeBucketOf(path) {
|
|
125
135
|
const segments = path.split('/').filter(segment => segment.length > 0);
|
|
126
136
|
return segments.slice(0, 2).join('/');
|
|
@@ -76,6 +76,12 @@ export interface InteractionRouterDeps {
|
|
|
76
76
|
text: string;
|
|
77
77
|
}) => QuestionInteractionOutcome;
|
|
78
78
|
rest: () => Promise<SharedRestClient | undefined>;
|
|
79
|
+
/**
|
|
80
|
+
* Delete one channel→Workspace binding row (bind precheck: a bound
|
|
81
|
+
* channel that Discord no longer has is user intent — purge, then bind
|
|
82
|
+
* fresh; task 16.37).
|
|
83
|
+
*/
|
|
84
|
+
purgeChannelBinding: (guildId: string, channelId: string) => Promise<void>;
|
|
79
85
|
log: (event: string, detail?: unknown) => void;
|
|
80
86
|
warn: (event: string, detail?: unknown) => void;
|
|
81
87
|
}
|
|
@@ -137,9 +137,25 @@ export function createInteractionRouter(deps) {
|
|
|
137
137
|
const { id: workspaceId, title } = resolvedWorkspace.workspace;
|
|
138
138
|
const existing = deps.findBoundChannelFor(event.guildId, workspaceId);
|
|
139
139
|
if (existing !== undefined) {
|
|
140
|
-
// Idempotent: one workspace, one channel
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
// Idempotent: one workspace, one channel — but only while the
|
|
141
|
+
// bound channel still exists. A Discord-side deletion is user
|
|
142
|
+
// intent: purge the stale mapping and bind fresh (16.37). An
|
|
143
|
+
// unverified surface keeps the idempotent reply, never a purge.
|
|
144
|
+
const stillLive = await (async () => {
|
|
145
|
+
const client = await deps.rest();
|
|
146
|
+
if (client === undefined)
|
|
147
|
+
return undefined;
|
|
148
|
+
const listed = await client.request('GET', `/guilds/${event.guildId}/channels`);
|
|
149
|
+
if (listed.outcome !== 'completed' || !Array.isArray(listed.body))
|
|
150
|
+
return undefined;
|
|
151
|
+
return listed.body.some(channel => channel.id === existing);
|
|
152
|
+
})();
|
|
153
|
+
if (stillLive === true) {
|
|
154
|
+
await followUp(deps.copy.bindChannelExists(title, existing));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (stillLive === false)
|
|
158
|
+
await deps.purgeChannelBinding(event.guildId, existing);
|
|
143
159
|
}
|
|
144
160
|
const expiresAtMs = Date.now() + 15 * 60 * 1000;
|
|
145
161
|
const confirmId = deps.registry.register({ kind: 'project-bind', action: 'confirm', workspaceId, workspaceTitle: title, guildId: event.guildId, actorId: event.actorId, expiresAtMs });
|
package/lib/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { ALLOWED_MENTIONS_NONE, DISCORD_SUPPRESS_NOTIFICATIONS_FLAG, OUTBOUND_EP
|
|
|
5
5
|
import { validateHostCapabilities } from './startup.js';
|
|
6
6
|
import { createAdapterStatusTracker, installAdapterStatusRpc, } from './features/adapter-status.js';
|
|
7
7
|
import { DISCORD_BOT_TOKEN_REF, describeDiscordCredential, resolveDiscordBotToken } from './credential.js';
|
|
8
|
-
import { createSharedRestClient } from './discord/rest.js';
|
|
8
|
+
import { createSharedRestClient, newNonce } from './discord/rest.js';
|
|
9
9
|
import { createRestThreadPort } from './discord/thread-port.js';
|
|
10
10
|
import { createComponentRegistry } from './discord/components.js';
|
|
11
11
|
import { buildCommandRegistrations } from './discord/commands.js';
|
|
@@ -120,11 +120,16 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
120
120
|
// RpcResponse and never throw business errors; boundedness and outcome
|
|
121
121
|
// logging live in src/dsh/api-proxy-face.ts.
|
|
122
122
|
const apiProxy = ctx.get('apiProxy');
|
|
123
|
-
//
|
|
124
|
-
//
|
|
123
|
+
// Default-quiet logging (16.33): flow records ride the Host's debug level
|
|
124
|
+
// only — the adapter prints nothing into the DSH process at the default
|
|
125
|
+
// level. Failure-shaped events (…failed/…threw/…blocked/…unknown) escalate
|
|
126
|
+
// to warn so a default-level Host still surfaces them.
|
|
125
127
|
const rpcLog = (event, detail) => {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
+
const serialized = typeof detail === 'string' ? detail : JSON.stringify(detail ?? null);
|
|
129
|
+
if (process.env['DSH_DISCORD_TRACE'] === '1') {
|
|
130
|
+
console.error(`[dsh-discord:trace] ${event}:`, serialized);
|
|
131
|
+
}
|
|
132
|
+
emitLog(ctx, /(?:failed|threw|blocked|unknown)/.test(event) ? 'warn' : 'debug', { event, detail: serialized });
|
|
128
133
|
};
|
|
129
134
|
void (async () => {
|
|
130
135
|
// The durable domain gates the whole composition: no adapter starts
|
|
@@ -486,8 +491,11 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
486
491
|
}
|
|
487
492
|
for (const guildId of current.allowedGuildIds) {
|
|
488
493
|
const facts = {};
|
|
494
|
+
const channelRows = [];
|
|
489
495
|
const channels = await rest.request('GET', `/guilds/${guildId}/channels`);
|
|
490
|
-
|
|
496
|
+
const listed = channels.outcome === 'completed';
|
|
497
|
+
if (listed) {
|
|
498
|
+
channelRows.push(...channels.body);
|
|
491
499
|
for (const channel of channels.body)
|
|
492
500
|
facts[channel.id] = 'ok';
|
|
493
501
|
const threads = await rest.request('GET', `/guilds/${guildId}/threads/active`);
|
|
@@ -496,15 +504,60 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
496
504
|
facts[thread.id] = 'ok';
|
|
497
505
|
}
|
|
498
506
|
}
|
|
507
|
+
// This guild's bindings only: another guild's pass must never
|
|
508
|
+
// judge (or retire) this guild's mappings against its own facts.
|
|
509
|
+
const channelKeyById = new Map();
|
|
510
|
+
const threadKeyById = new Map();
|
|
511
|
+
for (const key of channelTable.keys()) {
|
|
512
|
+
const scope = parseChannelBindingKey(key);
|
|
513
|
+
if (scope?.guildId !== guildId || scope.channelId === '')
|
|
514
|
+
continue;
|
|
515
|
+
channelKeyById.set(scope.channelId, key);
|
|
516
|
+
}
|
|
517
|
+
for (const key of threadTable.keys()) {
|
|
518
|
+
const scope = parseThreadBindingKey(key);
|
|
519
|
+
if (scope?.guildId !== guildId || scope.threadId === '')
|
|
520
|
+
continue;
|
|
521
|
+
threadKeyById.set(scope.threadId, key);
|
|
522
|
+
}
|
|
523
|
+
// Verified absence: the listing succeeded and the channel is gone.
|
|
524
|
+
if (listed) {
|
|
525
|
+
for (const channelId of channelKeyById.keys()) {
|
|
526
|
+
if (facts[channelId] === undefined)
|
|
527
|
+
facts[channelId] = 'missing';
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// Default surface (16.34/16.37): the adapter's category and the
|
|
531
|
+
// general control channel are ensured on every READY. Bound home
|
|
532
|
+
// channels are NOT recreated — a Discord-side deletion is user
|
|
533
|
+
// intent, so the verified absence marked above retires the
|
|
534
|
+
// mapping in the plan below instead.
|
|
535
|
+
const category = channelRows.find(channel => channel.type === 4 && channel.name.toLowerCase() === CATEGORY_NAME.toLowerCase())
|
|
536
|
+
?? await (async () => {
|
|
537
|
+
const made = await rest.request('POST', `/guilds/${guildId}/channels`, { name: CATEGORY_NAME, type: 4 });
|
|
538
|
+
if (made.outcome !== 'completed') {
|
|
539
|
+
// Usually HTTP 403: the bot's roles lack Manage Channels.
|
|
540
|
+
rpcLog('discord_category_ensure_failed', { guildId, cause: made.outcome === 'rejected' ? `HTTP ${String(made.status)}` : made.reason });
|
|
541
|
+
return undefined;
|
|
542
|
+
}
|
|
543
|
+
return made.body;
|
|
544
|
+
})();
|
|
545
|
+
if (category !== undefined && channelRows.find(channel => channel.type === 0 && channel.name.toLowerCase() === 'general' && channel.parent_id === category.id) === undefined) {
|
|
546
|
+
const made = await rest.request('POST', `/guilds/${guildId}/channels`, { name: 'general', type: 0, parent_id: category.id });
|
|
547
|
+
if (made.outcome === 'completed')
|
|
548
|
+
rpcLog('discord_control_channel_created', { guildId });
|
|
549
|
+
else
|
|
550
|
+
rpcLog('discord_control_channel_create_failed', { guildId, cause: made.outcome === 'rejected' ? `HTTP ${String(made.status)}` : made.reason });
|
|
551
|
+
}
|
|
499
552
|
const plan = planBindingReconciliation({
|
|
500
553
|
channelBindings: [...channelTable.entries()].map(([key, record]) => {
|
|
501
554
|
const scope = parseChannelBindingKey(key);
|
|
502
555
|
return { channelId: scope?.channelId ?? '', ...record };
|
|
503
|
-
}).filter(binding => binding.channelId !== ''),
|
|
556
|
+
}).filter(binding => binding.channelId !== '' && channelKeyById.has(binding.channelId)),
|
|
504
557
|
threadBindings: [...threadTable.entries()].map(([key, record]) => {
|
|
505
558
|
const scope = parseThreadBindingKey(key);
|
|
506
559
|
return { threadId: scope?.threadId ?? '', ...record };
|
|
507
|
-
}).filter(binding => binding.threadId !== ''),
|
|
560
|
+
}).filter(binding => binding.threadId !== '' && threadKeyById.has(binding.threadId)),
|
|
508
561
|
baseline: {
|
|
509
562
|
workspaces: catalog.workspaces.map(workspace => ({ workspaceId: workspace.id, title: workspace.title, path: '' })),
|
|
510
563
|
sessionIds: sessionIds.ids,
|
|
@@ -514,19 +567,19 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
514
567
|
for (const action of plan.channelActions) {
|
|
515
568
|
if (action.action !== 'retire')
|
|
516
569
|
continue;
|
|
517
|
-
const
|
|
518
|
-
if (
|
|
570
|
+
const key = channelKeyById.get(action.channelId);
|
|
571
|
+
if (key === undefined)
|
|
519
572
|
continue;
|
|
520
|
-
await channelTable.delete(
|
|
573
|
+
await channelTable.delete(key);
|
|
521
574
|
rpcLog('discord_reconcile_channel_retired', { channelId: action.channelId, reason: action.reason });
|
|
522
575
|
}
|
|
523
576
|
for (const action of plan.threadActions) {
|
|
524
577
|
if (action.action !== 'retire')
|
|
525
578
|
continue;
|
|
526
|
-
const
|
|
527
|
-
if (
|
|
579
|
+
const key = threadKeyById.get(action.threadId);
|
|
580
|
+
if (key === undefined)
|
|
528
581
|
continue;
|
|
529
|
-
await threadTable.delete(
|
|
582
|
+
await threadTable.delete(key);
|
|
530
583
|
rpcLog('discord_reconcile_thread_retired', { threadId: action.threadId, reason: action.reason });
|
|
531
584
|
}
|
|
532
585
|
}
|
|
@@ -628,6 +681,10 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
628
681
|
sessionForThread: (guildId, threadId) => bindings.sessionForThread(guildId, threadId),
|
|
629
682
|
ensureWorkspaceChannel,
|
|
630
683
|
rest: sharedRest,
|
|
684
|
+
purgeChannelBinding: async (guildId, channelId) => {
|
|
685
|
+
await channelTable.delete(bindChannelKey(guildId, channelId));
|
|
686
|
+
rpcLog('discord_reconcile_channel_retired', { channelId, reason: 'discord-deleted' });
|
|
687
|
+
},
|
|
631
688
|
log: rpcLog,
|
|
632
689
|
warn: (event, detail) => {
|
|
633
690
|
emitLog(ctx, 'warn', { event, detail: typeof detail === 'string' ? detail : JSON.stringify(detail ?? null) });
|
|
@@ -672,7 +729,6 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
672
729
|
...(process.env['DSH_DISCORD_GATEWAY_URL'] === undefined
|
|
673
730
|
? {}
|
|
674
731
|
: { gatewayUrl: process.env['DSH_DISCORD_GATEWAY_URL'] }),
|
|
675
|
-
allowedGuildIds: [...current.allowedGuildIds],
|
|
676
732
|
applicationId: () => applicationIdRef.current,
|
|
677
733
|
mainline,
|
|
678
734
|
bindings,
|
|
@@ -689,18 +745,6 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
689
745
|
rpcLog('discord_unbound_notice_threw', String(cause));
|
|
690
746
|
});
|
|
691
747
|
},
|
|
692
|
-
ensureGuildChannels: async (guildId) => {
|
|
693
|
-
await withRest(async (rest) => {
|
|
694
|
-
const ensured = await ensureCategory(rest, guildId);
|
|
695
|
-
if (ensured === undefined)
|
|
696
|
-
return;
|
|
697
|
-
// Never touch channels outside our category: create our own only.
|
|
698
|
-
const hasControl = ensured.channels.some((c) => c.type === 0 && c.name.toLowerCase() === 'general' && c.parent_id === ensured.categoryId);
|
|
699
|
-
if (hasControl)
|
|
700
|
-
return;
|
|
701
|
-
await rest.request('POST', `/guilds/${guildId}/channels`, { name: 'general', type: 0, parent_id: ensured.categoryId });
|
|
702
|
-
});
|
|
703
|
-
},
|
|
704
748
|
approvals: approvalsStore,
|
|
705
749
|
questions: questionsStore,
|
|
706
750
|
status: statusTracker,
|
|
@@ -782,7 +826,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
782
826
|
// The nonce lets an unobservable send be reconciled instead of
|
|
783
827
|
// blindly retried (rest.ts contract): a message that actually
|
|
784
828
|
// landed is found by its nonce and reported completed.
|
|
785
|
-
const nonce =
|
|
829
|
+
const nonce = newNonce();
|
|
786
830
|
const sent = await rest.request('POST', `/channels/${request.channelId}/messages`, {
|
|
787
831
|
content: request.content,
|
|
788
832
|
flags: DISCORD_SUPPRESS_NOTIFICATIONS_FLAG,
|
|
@@ -799,7 +843,12 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
799
843
|
rpcLog('discord_live_send_unknown', { channelId: request.channelId });
|
|
800
844
|
return { outcome: 'unknown' };
|
|
801
845
|
}
|
|
802
|
-
|
|
846
|
+
if (sent.outcome === 'rejected') {
|
|
847
|
+
rpcLog('discord_live_send_failed', { channelId: request.channelId, status: sent.status, error: sent.error });
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
rpcLog('discord_live_send_failed', { channelId: request.channelId, outcome: sent.outcome });
|
|
851
|
+
}
|
|
803
852
|
return { outcome: 'failed' };
|
|
804
853
|
},
|
|
805
854
|
edit: async (request) => {
|
|
@@ -811,6 +860,9 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
811
860
|
flags: DISCORD_SUPPRESS_NOTIFICATIONS_FLAG,
|
|
812
861
|
allowed_mentions: ALLOWED_MENTIONS_NONE,
|
|
813
862
|
});
|
|
863
|
+
if (edited.outcome === 'rejected') {
|
|
864
|
+
rpcLog('discord_live_edit_failed', { channelId: request.channelId, status: edited.status, error: edited.error });
|
|
865
|
+
}
|
|
814
866
|
return edited.outcome === 'completed' ? { outcome: 'completed' } : { outcome: 'failed' };
|
|
815
867
|
},
|
|
816
868
|
typing: async (channelId) => {
|
package/lib/stream/live.js
CHANGED
|
@@ -20,6 +20,17 @@ import { discordChannelNameKey, safeTitle } from '../policy/disclosure.js';
|
|
|
20
20
|
const DEFAULT_ACTIVITY_COALESCE_MS = 1_000;
|
|
21
21
|
/** Row budget: a presentation title is truncated before it reaches Discord. */
|
|
22
22
|
const ACTIVITY_TITLE_MAX = 80;
|
|
23
|
+
/**
|
|
24
|
+
* Wire-level live-path tracing (`DSH_DISCORD_TRACE=1` → stderr). Default
|
|
25
|
+
* silent like the rest of the adapter; the live path's drops (unrecognized
|
|
26
|
+
* frame shapes, unmatched sessions) are otherwise unobservable, which once
|
|
27
|
+
* hid a real-Host shape mismatch from every gate (16.38).
|
|
28
|
+
*/
|
|
29
|
+
const TRACE = process.env['DSH_DISCORD_TRACE'] === '1';
|
|
30
|
+
function trace(...parts) {
|
|
31
|
+
if (TRACE)
|
|
32
|
+
console.error('[dsh-discord:trace]', ...parts);
|
|
33
|
+
}
|
|
23
34
|
const ANSWER_MARKER = (interrupted, marker) => interrupted ? `\n\n${marker}` : '';
|
|
24
35
|
/** Pair-safe truncation: never split a surrogate pair at the boundary. */
|
|
25
36
|
function truncateText(text, max) {
|
|
@@ -85,6 +96,7 @@ export function startLiveRender(deps) {
|
|
|
85
96
|
finalizer: undefined,
|
|
86
97
|
headMessageId: undefined,
|
|
87
98
|
activityMessageId: undefined,
|
|
99
|
+
headFlush: undefined,
|
|
88
100
|
stepSeq: 0,
|
|
89
101
|
headAttempted: false,
|
|
90
102
|
activityAttempted: false,
|
|
@@ -99,26 +111,33 @@ export function startLiveRender(deps) {
|
|
|
99
111
|
/** Flush the current answer text: create the head once, then edit it. */
|
|
100
112
|
function flushAnswer(threadId, runtime) {
|
|
101
113
|
const stepSeq = runtime.stepSeq;
|
|
102
|
-
return
|
|
114
|
+
return (content) => {
|
|
103
115
|
if (runtime.stepSeq !== stepSeq)
|
|
104
|
-
return;
|
|
116
|
+
return Promise.resolve();
|
|
105
117
|
const payload = buildOutboundMessage({ kind: 'assistant', content });
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
+
const flush = (async () => {
|
|
119
|
+
if (runtime.headMessageId === undefined) {
|
|
120
|
+
// An earlier send whose application was unobservable must never be
|
|
121
|
+
// blind-resent (rest.ts contract): the stream pauses here and the
|
|
122
|
+
// finalizer's single fresh send carries the answer instead.
|
|
123
|
+
if (runtime.headAttempted)
|
|
124
|
+
return undefined;
|
|
125
|
+
const sent = await deps.delivery.send({ channelId: threadId, content: payload.content });
|
|
126
|
+
if (sent.outcome === 'completed') {
|
|
127
|
+
runtime.headMessageId = sent.messageId;
|
|
128
|
+
return sent.messageId;
|
|
129
|
+
}
|
|
130
|
+
if (sent.outcome === 'unknown') {
|
|
131
|
+
runtime.headAttempted = true;
|
|
132
|
+
deps.log?.('discord_live_head_send_unknown', { threadId });
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
118
135
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
136
|
+
await deps.delivery.edit({ channelId: threadId, messageId: runtime.headMessageId, content: payload.content });
|
|
137
|
+
return runtime.headMessageId;
|
|
138
|
+
})();
|
|
139
|
+
runtime.headFlush = flush;
|
|
140
|
+
return flush.then(() => undefined);
|
|
122
141
|
};
|
|
123
142
|
}
|
|
124
143
|
function beginTurn(threadId, runtime, turnId) {
|
|
@@ -126,6 +145,7 @@ export function startLiveRender(deps) {
|
|
|
126
145
|
runtime.turnId = turnId;
|
|
127
146
|
runtime.headMessageId = undefined;
|
|
128
147
|
runtime.activityMessageId = undefined;
|
|
148
|
+
runtime.headFlush = undefined;
|
|
129
149
|
runtime.headAttempted = false;
|
|
130
150
|
runtime.activityAttempted = false;
|
|
131
151
|
runtime.stepSeq += 1;
|
|
@@ -205,6 +225,7 @@ export function startLiveRender(deps) {
|
|
|
205
225
|
// A new step opens a NEW logical answer message: the previous
|
|
206
226
|
// step's completed head is never overwritten (stream-renderer spec).
|
|
207
227
|
runtime.headMessageId = undefined;
|
|
228
|
+
runtime.headFlush = undefined;
|
|
208
229
|
runtime.headAttempted = false;
|
|
209
230
|
runtime.stepSeq += 1;
|
|
210
231
|
// The previous step's finalize disposed the scheduler; a fresh one
|
|
@@ -218,11 +239,15 @@ export function startLiveRender(deps) {
|
|
|
218
239
|
return;
|
|
219
240
|
}
|
|
220
241
|
case 'assistant/chunk': {
|
|
221
|
-
if (runtime.scheduler === undefined || turnId === undefined || stepId === undefined)
|
|
242
|
+
if (runtime.scheduler === undefined || turnId === undefined || stepId === undefined) {
|
|
243
|
+
trace('chunk dropped (no turn/step/scheduler)', event.type, turnId, stepId);
|
|
222
244
|
return;
|
|
245
|
+
}
|
|
223
246
|
const chunk = data['chunk'];
|
|
224
|
-
if (chunk?.type !== 'text-delta' || typeof chunk.text !== 'string')
|
|
247
|
+
if (chunk?.type !== 'text-delta' || typeof chunk.text !== 'string') {
|
|
248
|
+
trace('chunk not text-delta:', String(chunk?.type));
|
|
225
249
|
return;
|
|
250
|
+
}
|
|
226
251
|
runtime.render.appendDelta({ turnId, stepId, text: chunk.text });
|
|
227
252
|
const snapshot = runtime.render.snapshot();
|
|
228
253
|
const current = snapshot.answers.find(answer => answer.stepId === stepId);
|
|
@@ -235,6 +260,7 @@ export function startLiveRender(deps) {
|
|
|
235
260
|
return;
|
|
236
261
|
const interrupted = data['interrupted'] === true;
|
|
237
262
|
const text = assistantText(data['message']);
|
|
263
|
+
trace('assistant/message: extracted text length', text.length, 'interrupted:', interrupted);
|
|
238
264
|
runtime.render.setAuthoritative({ turnId, stepId, text });
|
|
239
265
|
if (interrupted)
|
|
240
266
|
runtime.render.interrupt({ turnId, stepId });
|
|
@@ -243,39 +269,43 @@ export function startLiveRender(deps) {
|
|
|
243
269
|
if (text === '')
|
|
244
270
|
return;
|
|
245
271
|
// The authoritative finalize sends exactly once per turn answer. It
|
|
246
|
-
// runs detached
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
runtime.
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
272
|
+
// runs detached AFTER the in-flight head flush settles: racing the
|
|
273
|
+
// flush would read `headMessageId` before the flushed send landed
|
|
274
|
+
// and post the answer as a duplicate second message (16.39). The
|
|
275
|
+
// step fence is read post-settle for the same reason.
|
|
276
|
+
const pendingFlush = Promise.resolve(runtime.headFlush).catch(() => undefined);
|
|
277
|
+
void pendingFlush.then((inFlightHead) => {
|
|
278
|
+
const finalizedStepSeq = runtime.stepSeq;
|
|
279
|
+
const finalizer = createAnswerFinalizer({
|
|
280
|
+
delivery: {
|
|
281
|
+
editHead: async ({ messageId, content }) => {
|
|
282
|
+
const payload = buildOutboundMessage({ kind: 'assistant', content });
|
|
283
|
+
// No head exists (text arrived without flushed chunks): the
|
|
284
|
+
// first finalize send IS the head, recorded for continuations.
|
|
285
|
+
if (messageId === '') {
|
|
286
|
+
const sent = await deps.delivery.send({ channelId: threadId, content: payload.content });
|
|
287
|
+
if (sent.outcome === 'completed') {
|
|
288
|
+
if (runtime.stepSeq === finalizedStepSeq && runtime.headMessageId === undefined) {
|
|
289
|
+
runtime.headMessageId = sent.messageId;
|
|
290
|
+
}
|
|
291
|
+
return { outcome: 'completed' };
|
|
261
292
|
}
|
|
262
|
-
return { outcome: '
|
|
293
|
+
return { outcome: 'failed' };
|
|
263
294
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
return sent.outcome === 'completed' ? { outcome: 'completed' } : { outcome: 'failed' };
|
|
295
|
+
const edited = await deps.delivery.edit({ channelId: threadId, messageId, content: payload.content });
|
|
296
|
+
return edited.outcome === 'completed' ? { outcome: 'completed' } : { outcome: 'failed' };
|
|
297
|
+
},
|
|
298
|
+
sendContinuation: async ({ content }) => {
|
|
299
|
+
const payload = buildOutboundMessage({ kind: 'assistant', content });
|
|
300
|
+
const sent = await deps.delivery.send({ channelId: threadId, content: payload.content });
|
|
301
|
+
return sent.outcome === 'completed' ? { outcome: 'completed' } : { outcome: 'failed' };
|
|
302
|
+
},
|
|
273
303
|
},
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
304
|
+
headMessageId: inFlightHead ?? runtime.headMessageId ?? '',
|
|
305
|
+
});
|
|
306
|
+
const finalText = text + ANSWER_MARKER(interrupted, deps.interruptedMarker?.() ?? '*(已被中断)*');
|
|
307
|
+
return finalizer.finalize(finalText);
|
|
308
|
+
}).catch((cause) => {
|
|
279
309
|
deps.log?.('discord_live_finalize_threw', { threadId, cause: String(cause) });
|
|
280
310
|
});
|
|
281
311
|
return;
|
|
@@ -455,11 +485,15 @@ export function startLiveRender(deps) {
|
|
|
455
485
|
if (type !== 'session/event')
|
|
456
486
|
return;
|
|
457
487
|
const threadId = deps.threadForSession(sessionId);
|
|
458
|
-
if (threadId === undefined)
|
|
488
|
+
if (threadId === undefined) {
|
|
489
|
+
trace('drop: no thread for session', sessionId);
|
|
459
490
|
return;
|
|
491
|
+
}
|
|
460
492
|
const eventWrapper = frame['event'];
|
|
461
|
-
if (eventWrapper === undefined || typeof eventWrapper.type !== 'string')
|
|
493
|
+
if (eventWrapper === undefined || typeof eventWrapper.type !== 'string') {
|
|
494
|
+
trace('drop: session/event without event wrapper', JSON.stringify(frame).slice(0, 200));
|
|
462
495
|
return;
|
|
496
|
+
}
|
|
463
497
|
handleSessionEvent(sessionId, threadId, runtimeFor(threadId), { type: eventWrapper.type, data: eventWrapper.data ?? {} }, frame['view']);
|
|
464
498
|
}
|
|
465
499
|
async function runLoop() {
|
|
@@ -480,6 +514,11 @@ export function startLiveRender(deps) {
|
|
|
480
514
|
const payload = (typeof frame === 'object' && frame !== null && 'payload' in frame)
|
|
481
515
|
? frame.payload
|
|
482
516
|
: frame;
|
|
517
|
+
if (TRACE) {
|
|
518
|
+
const t = (typeof payload === 'object' && payload !== null ? payload.type : undefined);
|
|
519
|
+
const sid = (typeof payload === 'object' && payload !== null ? payload.sessionId : undefined);
|
|
520
|
+
trace('frame', String(t), String(sid));
|
|
521
|
+
}
|
|
483
522
|
handleFrame(payload, typeof envelopeRpcId === 'string' ? envelopeRpcId : undefined);
|
|
484
523
|
}
|
|
485
524
|
catch (cause) {
|
|
@@ -492,6 +531,7 @@ export function startLiveRender(deps) {
|
|
|
492
531
|
if (isDisposed())
|
|
493
532
|
return;
|
|
494
533
|
deps.log?.('discord_live_stream_error', { cause: String(cause) });
|
|
534
|
+
trace('stream closed:', String(cause));
|
|
495
535
|
}
|
|
496
536
|
if (isDisposed())
|
|
497
537
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@addozhang/dsh-discord",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Discord-first adapter for DeepSeek Harness",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,9 +24,10 @@
|
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
26
|
"lib",
|
|
27
|
-
"cordis.patch.yml",
|
|
28
27
|
"README.md",
|
|
29
|
-
"
|
|
28
|
+
"README.zh.md",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"cordis.patch.yml"
|
|
30
31
|
],
|
|
31
32
|
"publishConfig": {
|
|
32
33
|
"access": "public"
|
|
@@ -82,5 +83,13 @@
|
|
|
82
83
|
"@deepseek-ai/schemastery": "3.18.1",
|
|
83
84
|
"zod": "^4.4.3"
|
|
84
85
|
},
|
|
85
|
-
"pnpm": {}
|
|
86
|
+
"pnpm": {},
|
|
87
|
+
"repository": {
|
|
88
|
+
"type": "git",
|
|
89
|
+
"url": "git+https://github.com/addozhang/dsh-discord.git"
|
|
90
|
+
},
|
|
91
|
+
"bugs": {
|
|
92
|
+
"url": "https://github.com/addozhang/dsh-discord/issues"
|
|
93
|
+
},
|
|
94
|
+
"homepage": "https://github.com/addozhang/dsh-discord#readme"
|
|
86
95
|
}
|