@addozhang/dsh-discord 0.6.2-rc.1 → 0.6.4-rc.1
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 +4 -2
- package/README.zh.md +4 -2
- package/lib/client/DiscordSettingsCard.d.ts +3 -0
- package/lib/client/card-form.d.ts +2 -0
- package/lib/client.js +20 -9
- package/lib/credential.d.ts +6 -1
- package/lib/credential.js +5 -1
- package/lib/discord/commands.d.ts +9 -4
- package/lib/discord/commands.js +10 -5
- package/lib/discord/rest.d.ts +16 -3
- package/lib/discord/rest.js +46 -7
- package/lib/discord/thread-port.js +5 -1
- package/lib/dsh/host-asks.js +12 -2
- package/lib/dsh/host-events.d.ts +11 -0
- package/lib/dsh/host-events.js +36 -8
- package/lib/dsh/host-face.js +11 -2
- package/lib/features/interaction-router.d.ts +5 -1
- package/lib/features/interaction-router.js +37 -2
- package/lib/features/prompt-submission.d.ts +8 -5
- package/lib/features/prompt-submission.js +19 -6
- package/lib/features/question-routing.d.ts +1 -1
- package/lib/features/question-routing.js +62 -40
- package/lib/features/question-store.d.ts +6 -0
- package/lib/features/question-store.js +12 -0
- package/lib/features/question-view.d.ts +8 -0
- package/lib/features/question-view.js +11 -1
- package/lib/features/reconcile-intents.d.ts +15 -0
- package/lib/features/reconcile-intents.js +20 -0
- package/lib/features/session-mainline.js +10 -1
- package/lib/features/single-flight.d.ts +13 -0
- package/lib/features/single-flight.js +36 -0
- package/lib/features/thread-creation.d.ts +4 -1
- package/lib/features/thread-creation.js +4 -0
- package/lib/gateway/gateway.js +27 -1
- package/lib/i18n.d.ts +1 -0
- package/lib/i18n.js +4 -2
- package/lib/index.js +205 -104
- package/lib/startup.js +5 -2
- package/lib/stream/finalizer.js +18 -3
- package/lib/stream/live.d.ts +8 -0
- package/lib/stream/live.js +100 -18
- package/lib/stream/markdown.d.ts +4 -1
- package/lib/stream/markdown.js +39 -4
- package/lib/stream/splitter.js +5 -0
- package/lib/stream/typing.d.ts +4 -2
- package/lib/stream/typing.js +15 -5
- package/lib/version.d.ts +2 -0
- package/lib/version.js +11 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@ No extra process: the adapter is a DSH plugin that mounts straight onto your `ds
|
|
|
23
23
|
- **Approvals & questions** — DSH asks become 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 settled controls grey out in place.
|
|
24
24
|
- **Session control** — `/steer`, `/stop`, and `/queue list|remove` with turn-ownership checks; `/project bind|list|info` and `/session resume` for guild↔workspace binding and history; `/guild forget` for operator cleanup.
|
|
25
25
|
- **Model selection** — `/model show` reads the session's live model directory; `/model select` walks an interactive provider → model → reasoning cascade, or applies a typed `provider/model` directly. Open to any authorized member by default; restrictable to Host operators.
|
|
26
|
+
- **Adapter status** — `/info` shows the adapter version, connection state, and project links (feedback, docs). Works in any channel.
|
|
26
27
|
- **Permission switching** — `/permission show` / `set` read and switch the session's permission preset (bundled sandbox + approval tiers) through the Host's own `/permission` command path, inheriting its journal audit pair; `danger-full-access` requires a button confirmation.
|
|
27
28
|
- **Settings card, bilingual out of the box** — token onboarding and connect/disconnect (stored in the Host credential service, never in settings or logs), guild allowlist, auto-archive, and language. Every Discord-visible string ships in Chinese and English; the bot follows the DSH language preference or a pinned choice.
|
|
28
29
|
- **Hardened by design** — deny-first authorization inside an explicit guild allowlist. Mentions are suppressed twice: `allowed_mentions` on every request, plus byte-level neutralization of the wire body. DSH submission is at-most-once with unknown-preserving reconciliation — an ambiguous delivery is never blindly resent. Bindings survive restarts, and the READY sweep rebuilds deleted category/control channels while treating a deleted workspace channel as user intent (the mapping retires; the workspace stays bindable).
|
|
@@ -121,7 +122,8 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
|
|
|
121
122
|
| `/project bind` | any channel | bind the guild to a workspace (admin; provisions the home channel) |
|
|
122
123
|
| `/project list` / `info` | any channel | list workspaces / inspect this channel's binding |
|
|
123
124
|
| `/queue list`, `/queue remove` | session thread | inspect and trim the pending queue |
|
|
124
|
-
| `/steer`, `/stop` | session thread | steer or cancel the running turn (owner only) |
|
|
125
|
+
| `/steer`, `/stop` | session thread | steer or cancel the running turn — any member of the thread may act on the current turn (the thread is a deliberately shared surface: single-user and small-team deployments; approvals/questions stay stricter — asker or thread owner only) |
|
|
126
|
+
| `/info` | any channel | show the adapter version, connection state, and project links |
|
|
125
127
|
| `/model show` / `select` | session thread | show the live model directory; `select` without arguments walks the interactive provider → model → reasoning cascade (any authorized member by default) |
|
|
126
128
|
| `/session resume` | project channel | pick one of this workspace's past sessions (autocomplete: title and age, newest first) and resume it into a new thread of this channel; blank, already-bound, subagent, and archived sessions are never offered. The thread renders the session's history — answers and tool summaries plus the user's inputs as quoted echo lines (plugin/system injections are never echoed). After an adapter restart, threads catch up only what they missed: a persisted render watermark suppresses already-delivered history, and bound sessions re-subscribe on reconnect so turns prompted from the web UI still land in their thread |
|
|
127
129
|
| `/guild forget` | any channel | operator-only removal of adapter records |
|
|
@@ -148,7 +150,7 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
|
|
|
148
150
|
|
|
149
151
|
```sh
|
|
150
152
|
pnpm install --ignore-scripts
|
|
151
|
-
pnpm test #
|
|
153
|
+
pnpm test # 782 tests incl. gateway/REST twin E2E
|
|
152
154
|
pnpm typecheck
|
|
153
155
|
pnpm lint
|
|
154
156
|
pnpm build # lib + client bundle
|
package/README.zh.md
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
- **审批与提问** — DSH 的 ask 渲染为按钮、下拉菜单与自由文本弹窗。所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,结算后的控件原地置灰。
|
|
24
24
|
- **会话控制** — `/steer`、`/stop`、`/queue list|remove` 带运行所有权校验;`/project bind|list|info` 与 `/session resume` 管理 Guild↔工作区绑定与历史会话;`/guild forget` 供操作员清理。
|
|
25
25
|
- **模型切换** — `/model show` 读取会话的实时模型目录;`/model select` 走交互式 provider → 模型 → 推理强度级联,也可直接填写 `provider/model` 应用。默认对所有授权成员开放,可收紧为仅 Host 操作员。
|
|
26
|
+
- **适配器状态** — `/info` 显示适配器版本、连接状态与项目链接(反馈/文档)。任意频道可用。
|
|
26
27
|
- **权限切换** — `/permission show` / `set` 读写会话权限预设(沙箱 + 审批捆绑档位),走宿主自己的 `/permission` 命令路径,journal 自动记审计;`danger-full-access` 需按钮二次确认。
|
|
27
28
|
- **设置卡片,开箱双语** — Token 引导与连接/断开(存入 Host 凭据服务,绝不写入设置或日志)、服务器白名单、自动归档与语言。所有 Discord 可见文案提供中英双语;Bot 默认跟随 DSH 语言偏好,也可从卡片固定。
|
|
28
29
|
- **安全设计** — 显式服务器白名单内的 deny-first 授权。提及抑制双保险:每条请求携带 `allowed_mentions`,外加 wire body 的字节级提及中和。DSH 提交至多一次,并做保留 unknown 的对账——结果不明的投递绝不盲目重发。绑定重启后持久;READY 扫描会重建被删除的 category/控制频道,而把被删除的工作区频道视为用户意图(解除映射,workspace 保持可重新绑定)。
|
|
@@ -121,7 +122,8 @@ dsh-discord:
|
|
|
121
122
|
| `/project bind` | 任意频道 | 将 Guild 绑定到工作区(管理员;会创建主频道) |
|
|
122
123
|
| `/project list` / `info` | 任意频道 | 列出工作区 / 查看当前频道绑定 |
|
|
123
124
|
| `/queue list`, `/queue remove` | 会话线程 | 查看与移除待处理队列 |
|
|
124
|
-
| `/steer`, `/stop` | 会话线程 | 插话或取消运行中的 Turn
|
|
125
|
+
| `/steer`, `/stop` | 会话线程 | 插话或取消运行中的 Turn——线程内任意成员可操作当前 Turn(有意为之的共享驾驶舱模型,面向单人与小团队场景;审批/提问保持更严的提问人-or-线程主人规则) |
|
|
126
|
+
| `/info` | 任意频道 | 显示适配器版本、连接状态与项目链接 |
|
|
125
127
|
| `/model show` / `select` | 会话线程 | 查看实时模型目录;`select` 不带参数时走交互式 provider → 模型 → 推理强度级联(默认对所有授权成员开放) |
|
|
126
128
|
| `/session resume` | 项目频道 | 自动补全选择本工作区的历史会话(显示标题与时间,最新优先),恢复为当前频道的新线程;空白、已挂线程、subagent、已归档的会话不会出现。线程会渲染会话历史——回答与工具摘要之外,用户输入以引用行回显(插件/系统注入永不回显)。适配器重启后线程只补齐错过部分:持久化的渲染水位抑制已投递过的历史,已绑定会话在重连时自动重订阅,因此从 web UI 发起的回合也会落进对应线程 |
|
|
127
129
|
| `/guild forget` | 任意频道 | 仅操作员:移除适配器记录 |
|
|
@@ -149,7 +151,7 @@ dsh-discord:
|
|
|
149
151
|
|
|
150
152
|
```sh
|
|
151
153
|
pnpm install --ignore-scripts
|
|
152
|
-
pnpm test #
|
|
154
|
+
pnpm test # 782 tests incl. gateway/REST twin E2E
|
|
153
155
|
pnpm typecheck
|
|
154
156
|
pnpm lint
|
|
155
157
|
pnpm build # lib + client bundle
|
|
@@ -3,4 +3,7 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli
|
|
|
3
3
|
import type { DiscordCardFace } from './card-controller.js';
|
|
4
4
|
/** Props the runtime binds for the Discord settings card. */
|
|
5
5
|
export type DiscordSettingsCardProps = PropsRuntime<"settings.section"> & PropsLocale<'dsh-discord'> & InjectFace<DiscordCardFace>;
|
|
6
|
+
/** Scoped stylesheet, injected once per card mount. Neutral translucent
|
|
7
|
+
* tones keep the controls readable on both the Host's dark and light themes. */
|
|
8
|
+
export declare const CARD_CSS = "\n[data-dsh-discord-card] { display: flex; flex-direction: column; gap: 24px; padding: 4px 2px 0; }\n[data-dsh-discord-card-head] { display: flex; flex-direction: column; gap: 2px; text-align: left; }\n[data-dsh-discord-card-title] { font-size: 14px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }\n[data-dsh-discord-card-title] svg { opacity: .85; }\n[data-dsh-discord-card-description] { font-size: 12px; opacity: .55; }\n[data-dsh-discord-card] button[aria-expanded] { all: unset; cursor: pointer; display: block; width: 100%; box-sizing: border-box; }\n[data-dsh-discord-card] button[aria-expanded]:focus-visible { outline: 2px solid rgba(88, 101, 242, .8); border-radius: 8px; }\n[data-dsh-discord-badge] { display: inline-block; font-size: 11px; line-height: 18px; padding: 0 8px; border-radius: 999px; }\n[data-dsh-discord-badge=\"unsaved\"] { background: rgba(250, 204, 21, .16); color: #facc15; }\n[data-dsh-discord-status] { display: flex; align-items: center; gap: 8px; font-size: 13px; }\n[data-dsh-discord-status-dot] { width: 10px; height: 10px; border-radius: 50%; flex: none; box-shadow: 0 0 6px currentColor; }\n[data-dsh-discord-field] { display: flex; flex-direction: column; gap: 6px; }\n[data-dsh-discord-field-help] + [data-dsh-discord-field], [data-dsh-discord-token] { margin-top: 8px; }\n[data-dsh-discord-field-head] { display: flex; align-items: center; gap: 8px; }\n[data-dsh-discord-field-head] label { font-size: 13px; font-weight: 500; }\n[data-dsh-discord-badges] { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; }\n[data-dsh-discord-badge=\"overridden\"] { background: rgba(250, 204, 21, .16); color: #facc15; }\n[data-dsh-discord-field-head] button { all: unset; cursor: pointer; font-size: 12px; padding: 1px 8px; border-radius: 6px; background: rgba(127, 127, 127, .18); }\n[data-dsh-discord-field-head] button:hover { background: rgba(127, 127, 127, .3); }\n[data-dsh-discord-field] textarea, [data-dsh-discord-field] select {\n width: 100%; box-sizing: border-box; color: inherit; font: inherit; font-size: 13px;\n background: rgba(127, 127, 127, .12); border: 1px solid rgba(127, 127, 127, .28);\n border-radius: 8px; padding: 8px 10px; resize: vertical;\n}\n[data-dsh-discord-field] textarea:focus-visible, [data-dsh-discord-field] select:focus-visible {\n outline: none; border-color: rgba(88, 101, 242, .9);\n}\n[data-dsh-discord-field] textarea[aria-invalid=\"true\"], [data-dsh-discord-field] select[aria-invalid=\"true\"] {\n border-color: rgba(242, 63, 67, .8);\n}\n[data-dsh-discord-field-note] { margin: 0; font-size: 12px; opacity: .55; }\n[data-dsh-discord-field-note=\"invalid\"] { opacity: 1; color: #f23f43; }\n[data-dsh-discord-field-help] { margin: -2px 0 0; font-size: 12px; opacity: .55; line-height: 1.6; }\n[data-dsh-discord-disconnect] { all: unset; cursor: pointer; margin-left: auto; font-size: 12px; padding: 2px 10px; border-radius: 6px; background: rgba(127, 127, 127, .18); }\n[data-dsh-discord-disconnect]:hover { background: rgba(242, 63, 67, .18); color: #f23f43; }\n[data-dsh-discord-token] { display: flex; flex-direction: column; gap: 8px; padding: 14px; border: 1px solid rgba(127, 127, 127, .28); border-radius: 10px; }\n[data-dsh-discord-token-row] { display: flex; gap: 10px; }\n[data-dsh-discord-token-row] input {\n flex: 1; min-width: 0; box-sizing: border-box; color: inherit; font: inherit; font-size: 13px;\n background: rgba(127, 127, 127, .12); border: 1px solid rgba(127, 127, 127, .28); border-radius: 8px; padding: 8px 10px;\n}\n[data-dsh-discord-token-row] input:focus-visible { outline: none; border-color: rgba(88, 101, 242, .9); }\n[data-dsh-discord-token-row] button { all: unset; cursor: pointer; font-size: 13px; padding: 7px 16px; border-radius: 8px; background: rgba(88, 101, 242, .9); color: #fff; white-space: nowrap; }\n[data-dsh-discord-token-row] button:disabled { opacity: .45; cursor: default; }\n[data-dsh-discord-token-error] { margin: 0; font-size: 12px; color: #f23f43; }\n[data-dsh-discord-card-footer] { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; }\n[data-dsh-discord-card-footer] button { all: unset; cursor: pointer; font-size: 13px; padding: 6px 16px; border-radius: 8px; }\n[data-dsh-discord-card-footer] button[type=\"button\"]:first-child { background: rgba(127, 127, 127, .18); }\n[data-dsh-discord-card-footer] button:not(:disabled) { background: rgba(88, 101, 242, .9); color: #fff; }\n[data-dsh-discord-card-footer] button[type=\"button\"]:first-child:not(:disabled) { background: rgba(127, 127, 127, .18); color: inherit; }\n[data-dsh-discord-card-footer] button:disabled { opacity: .4; cursor: default; }\n[data-dsh-discord-card-footer] button:not(:disabled):hover { filter: brightness(1.12); }\n";
|
|
6
9
|
export declare function DiscordSettingsCard(props: DiscordSettingsCardProps): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -88,6 +88,8 @@ export declare class DiscordCardForm {
|
|
|
88
88
|
private saving;
|
|
89
89
|
private failed;
|
|
90
90
|
private status;
|
|
91
|
+
/** The one bound store every face shares (lazily created). */
|
|
92
|
+
private bound;
|
|
91
93
|
constructor(scope: ConfigForm<DiscordSettings>);
|
|
92
94
|
/**
|
|
93
95
|
* Publish the Host's sanitized status view. Edits, saves, and failures
|
package/lib/client.js
CHANGED
|
@@ -870,6 +870,8 @@ var DiscordCardForm = class {
|
|
|
870
870
|
saving = false;
|
|
871
871
|
failed = false;
|
|
872
872
|
status = void 0;
|
|
873
|
+
/** The one bound store every face shares (lazily created). */
|
|
874
|
+
bound;
|
|
873
875
|
/**
|
|
874
876
|
* Publish the Host's sanitized status view. Edits, saves, and failures
|
|
875
877
|
* never touch it — connection state is the Host's report, not form state.
|
|
@@ -880,11 +882,13 @@ var DiscordCardForm = class {
|
|
|
880
882
|
}
|
|
881
883
|
/** Publish the card state the renderer reads through its bound selector. */
|
|
882
884
|
bind() {
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
885
|
+
if (this.bound === void 0) {
|
|
886
|
+
this.bound = createLocalSnapshotStore(this.project());
|
|
887
|
+
this.listeners.add(() => {
|
|
888
|
+
this.bound?.set(this.project());
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
return this.bound;
|
|
888
892
|
}
|
|
889
893
|
/** Build the edit, reset, save, and discard actions bound to this form. */
|
|
890
894
|
actions() {
|
|
@@ -1004,11 +1008,17 @@ var DiscordCardForm = class {
|
|
|
1004
1008
|
this.saving = true;
|
|
1005
1009
|
this.failed = false;
|
|
1006
1010
|
this.publish();
|
|
1011
|
+
const plannedDrafts = /* @__PURE__ */ new Map();
|
|
1012
|
+
for (const item of plan) plannedDrafts.set(item.field, this.staged.get(item.field));
|
|
1007
1013
|
let landed = true;
|
|
1008
1014
|
for (const write of writes) {
|
|
1009
1015
|
landed = await write().catch(() => false) && landed;
|
|
1010
1016
|
}
|
|
1011
|
-
if (landed)
|
|
1017
|
+
if (landed) {
|
|
1018
|
+
for (const [field, snapshot] of plannedDrafts) {
|
|
1019
|
+
if (this.staged.get(field) === snapshot) this.staged.delete(field);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1012
1022
|
this.saving = false;
|
|
1013
1023
|
this.failed = !landed;
|
|
1014
1024
|
this.publish();
|
|
@@ -1246,8 +1256,8 @@ var CARD_CSS = `
|
|
|
1246
1256
|
[data-dsh-discord-card-title] { font-size: 14px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
|
|
1247
1257
|
[data-dsh-discord-card-title] svg { opacity: .85; }
|
|
1248
1258
|
[data-dsh-discord-card-description] { font-size: 12px; opacity: .55; }
|
|
1249
|
-
button[aria-expanded] { all: unset; cursor: pointer; display: block; width: 100%; box-sizing: border-box; }
|
|
1250
|
-
button[aria-expanded]:focus-visible { outline: 2px solid rgba(88, 101, 242, .8); border-radius: 8px; }
|
|
1259
|
+
[data-dsh-discord-card] button[aria-expanded] { all: unset; cursor: pointer; display: block; width: 100%; box-sizing: border-box; }
|
|
1260
|
+
[data-dsh-discord-card] button[aria-expanded]:focus-visible { outline: 2px solid rgba(88, 101, 242, .8); border-radius: 8px; }
|
|
1251
1261
|
[data-dsh-discord-badge] { display: inline-block; font-size: 11px; line-height: 18px; padding: 0 8px; border-radius: 999px; }
|
|
1252
1262
|
[data-dsh-discord-badge="unsaved"] { background: rgba(250, 204, 21, .16); color: #facc15; }
|
|
1253
1263
|
[data-dsh-discord-status] { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
|
@@ -1342,7 +1352,8 @@ function TokenSetup(props) {
|
|
|
1342
1352
|
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "button", disabled: connecting || !props.tokenConfigured && token.trim() === "", onClick: submit, children: connecting ? t("discordTokenConnecting") : t("discordTokenConnect") })
|
|
1343
1353
|
] }),
|
|
1344
1354
|
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { "data-dsh-discord-field-help": "", children: props.tokenConfigured ? t("discordTokenReconnectHint") : t("discordTokenHelp") }),
|
|
1345
|
-
error !== "" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { "data-dsh-discord-token-error": "", children: error })
|
|
1355
|
+
error !== "" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { "data-dsh-discord-token-error": "", children: error }),
|
|
1356
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { "data-dsh-discord-card-footer-link": "", style: { margin: "8px 0 0", fontSize: 12, opacity: 0.6 }, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("a", { href: "https://github.com/addozhang/dsh-discord", target: "_blank", rel: "noopener noreferrer", children: "\u2B50 Feedback & docs" }) })
|
|
1346
1357
|
] });
|
|
1347
1358
|
}
|
|
1348
1359
|
function DiscordSettingsCard(props) {
|
package/lib/credential.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
/** Branded ref, inlined from @deepseek-ai/dsh-credentials (runtime value
|
|
2
|
+
* is the plain string — the brand is compile-time only; see domain.ts). */
|
|
3
|
+
export type DiscordCredentialRef = string & {
|
|
4
|
+
readonly __brand: 'DiscordCredentialRef';
|
|
5
|
+
};
|
|
6
|
+
export declare const DISCORD_BOT_TOKEN_REF: DiscordCredentialRef;
|
|
2
7
|
export interface DiscordCredentialProvider {
|
|
3
8
|
describe(ref: typeof DISCORD_BOT_TOKEN_REF): Promise<{
|
|
4
9
|
configured: boolean;
|
package/lib/credential.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
function credentialRef(value) {
|
|
2
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(value))
|
|
3
|
+
throw new TypeError(`credential ref "${value}" must be an uppercase identifier`);
|
|
4
|
+
return value;
|
|
5
|
+
}
|
|
2
6
|
export const DISCORD_BOT_TOKEN_REF = credentialRef('DSH_DISCORD_BOT_TOKEN');
|
|
3
7
|
export async function describeDiscordCredential(provider) {
|
|
4
8
|
const result = await provider.describe(DISCORD_BOT_TOKEN_REF);
|
|
@@ -42,9 +42,14 @@ export declare const MILESTONE_ONE_COMMANDS: readonly AdapterCommand[];
|
|
|
42
42
|
/**
|
|
43
43
|
* Build the bulk-overwrite registration payload for
|
|
44
44
|
* `PUT /applications/{application.id}/commands`.
|
|
45
|
-
* `default_member_permissions: '0'`
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
45
|
+
* `default_member_permissions` is deliberately OMITTED: `'0'` is Discord's
|
|
46
|
+
* "disabled for everyone except server admins" sentinel, which hid the
|
|
47
|
+
* commands from ordinary members entirely — the opposite of the adapter's
|
|
48
|
+
* model, where every command is visible to the guild and the deny-first
|
|
49
|
+
* RBAC gate authorizes at interaction time (Discord roles cannot express
|
|
50
|
+
* Workspace-administrator or Host-operator authority anyway). Omitted
|
|
51
|
+
* means @everyone may invoke, subject to the guild's own role overrides.
|
|
52
|
+
* Wire change: verify once on a real machine with a plain member account
|
|
53
|
+
* (the digital twin does not model permission gating).
|
|
49
54
|
*/
|
|
50
55
|
export declare function buildCommandRegistrations(): Array<Record<string, unknown>>;
|
package/lib/discord/commands.js
CHANGED
|
@@ -27,6 +27,7 @@ export const MILESTONE_ONE_COMMANDS = [
|
|
|
27
27
|
{ name: 'list' },
|
|
28
28
|
{ name: 'remove', options: [{ name: 'item', required: true }] },
|
|
29
29
|
]),
|
|
30
|
+
leaf('info', 'Show the adapter version, connection state, and project links'),
|
|
30
31
|
leaf('steer', 'Steer the running turn of this thread', [{ name: 'prompt', required: true }]),
|
|
31
32
|
leaf('stop', 'Cancel the running turn of this thread'),
|
|
32
33
|
grouped('model', 'Show or select the session model', [
|
|
@@ -68,10 +69,15 @@ function argumentToWire(option) {
|
|
|
68
69
|
/**
|
|
69
70
|
* Build the bulk-overwrite registration payload for
|
|
70
71
|
* `PUT /applications/{application.id}/commands`.
|
|
71
|
-
* `default_member_permissions: '0'`
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
72
|
+
* `default_member_permissions` is deliberately OMITTED: `'0'` is Discord's
|
|
73
|
+
* "disabled for everyone except server admins" sentinel, which hid the
|
|
74
|
+
* commands from ordinary members entirely — the opposite of the adapter's
|
|
75
|
+
* model, where every command is visible to the guild and the deny-first
|
|
76
|
+
* RBAC gate authorizes at interaction time (Discord roles cannot express
|
|
77
|
+
* Workspace-administrator or Host-operator authority anyway). Omitted
|
|
78
|
+
* means @everyone may invoke, subject to the guild's own role overrides.
|
|
79
|
+
* Wire change: verify once on a real machine with a plain member account
|
|
80
|
+
* (the digital twin does not model permission gating).
|
|
75
81
|
*/
|
|
76
82
|
export function buildCommandRegistrations() {
|
|
77
83
|
return MILESTONE_ONE_COMMANDS.map((command) => {
|
|
@@ -88,7 +94,6 @@ export function buildCommandRegistrations() {
|
|
|
88
94
|
name: command.name,
|
|
89
95
|
description: command.description,
|
|
90
96
|
contexts: [DISCORD_GUILD_CONTEXT],
|
|
91
|
-
default_member_permissions: '0',
|
|
92
97
|
...(options === undefined || options.length === 0 ? {} : { options }),
|
|
93
98
|
};
|
|
94
99
|
});
|
package/lib/discord/rest.d.ts
CHANGED
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
* The typed Discord REST adapter. Every request resolves to exactly one of
|
|
3
3
|
* three outcomes — `completed`, `rejected`, or `unknown` — because cross-system
|
|
4
4
|
* delivery is never provably once: a refused request (4xx, exhausted 429) is
|
|
5
|
-
* definitive, while an unobservable outcome (network failure, abort,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* definitive, while an unobservable outcome (network failure, abort, 5xx)
|
|
6
|
+
* may or may not have been applied and must be reconciled, never blindly
|
|
7
|
+
* retried by callers. That holds inside the adapter too: transient failures
|
|
8
|
+
* are auto-retried only for idempotent methods (GET/PUT/DELETE/PATCH); a
|
|
9
|
+
* POST that hits 5xx or the network fails returns `unknown` on the first
|
|
10
|
+
* failure — the retry decision belongs to the caller's nonce
|
|
11
|
+
* reconciliation, because a re-sent POST whose first attempt landed would
|
|
12
|
+
* double-deliver with no error to show for it.
|
|
8
13
|
*/
|
|
9
14
|
export type RestMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
|
10
15
|
/** The untyped wire face injected for testability (fetch-shaped). */
|
|
@@ -46,6 +51,14 @@ export interface RestClientOptions {
|
|
|
46
51
|
backoffBaseMs?: number;
|
|
47
52
|
/** Cap for any single wait, so a hostile Retry-After cannot stall the adapter. */
|
|
48
53
|
maxDelayMs?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Ack budget applied automatically to `/interactions/` callback paths:
|
|
56
|
+
* Discord grants one callback per interaction inside a 3-second deadline,
|
|
57
|
+
* so a request parked behind a Retry-After sleep (or a 5xx backoff)
|
|
58
|
+
* would deliver an ack that can no longer matter — the budget aborts it
|
|
59
|
+
* to unknown early instead. 0 disables the budget.
|
|
60
|
+
*/
|
|
61
|
+
interactionAckBudgetMs?: number;
|
|
49
62
|
}
|
|
50
63
|
export declare function createRestClient(config: RestClientConfig, options?: RestClientOptions, fetchLike?: FetchLike): {
|
|
51
64
|
request<T>(method: RestMethod, path: string, body?: unknown, init?: {
|
package/lib/discord/rest.js
CHANGED
|
@@ -2,18 +2,39 @@
|
|
|
2
2
|
* The typed Discord REST adapter. Every request resolves to exactly one of
|
|
3
3
|
* three outcomes — `completed`, `rejected`, or `unknown` — because cross-system
|
|
4
4
|
* delivery is never provably once: a refused request (4xx, exhausted 429) is
|
|
5
|
-
* definitive, while an unobservable outcome (network failure, abort,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* definitive, while an unobservable outcome (network failure, abort, 5xx)
|
|
6
|
+
* may or may not have been applied and must be reconciled, never blindly
|
|
7
|
+
* retried by callers. That holds inside the adapter too: transient failures
|
|
8
|
+
* are auto-retried only for idempotent methods (GET/PUT/DELETE/PATCH); a
|
|
9
|
+
* POST that hits 5xx or the network fails returns `unknown` on the first
|
|
10
|
+
* failure — the retry decision belongs to the caller's nonce
|
|
11
|
+
* reconciliation, because a re-sent POST whose first attempt landed would
|
|
12
|
+
* double-deliver with no error to show for it.
|
|
8
13
|
*/
|
|
9
14
|
import { randomUUID } from 'node:crypto';
|
|
10
15
|
const DEFAULT_API_BASE = 'https://discord.com/api/v10';
|
|
11
16
|
const DEFAULT_MAX_RETRIES = 3;
|
|
12
17
|
const DEFAULT_BACKOFF_BASE_MS = 1_000;
|
|
13
18
|
const DEFAULT_MAX_DELAY_MS = 30_000;
|
|
19
|
+
/** Discord's interaction ack deadline is 3s; leave margin for the call itself. */
|
|
20
|
+
const DEFAULT_INTERACTION_ACK_BUDGET_MS = 2_500;
|
|
14
21
|
const sleep = (ms) => {
|
|
15
22
|
return new Promise(resolve => { setTimeout(resolve, ms); });
|
|
16
23
|
};
|
|
24
|
+
/** sleep() that also wakes when the signal aborts, so a deadline can cut a pending Retry-After wait. */
|
|
25
|
+
function sleepAbortable(ms, signal) {
|
|
26
|
+
if (signal === undefined)
|
|
27
|
+
return sleep(ms);
|
|
28
|
+
return new Promise(resolve => {
|
|
29
|
+
const done = () => {
|
|
30
|
+
signal.removeEventListener('abort', done);
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
resolve();
|
|
33
|
+
};
|
|
34
|
+
const timer = setTimeout(done, ms);
|
|
35
|
+
signal.addEventListener('abort', done, { once: true });
|
|
36
|
+
});
|
|
37
|
+
}
|
|
17
38
|
function parseDelayMs(response, fallbackMs, capMs) {
|
|
18
39
|
const header = response.headers.get('retry-after');
|
|
19
40
|
const seconds = header === null ? Number.NaN : Number.parseFloat(header);
|
|
@@ -37,6 +58,7 @@ export function createRestClient(config, options = {}, fetchLike) {
|
|
|
37
58
|
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
38
59
|
const backoffBaseMs = options.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
|
|
39
60
|
const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
61
|
+
const interactionAckBudgetMs = options.interactionAckBudgetMs ?? DEFAULT_INTERACTION_ACK_BUDGET_MS;
|
|
40
62
|
const doFetch = fetchLike ?? (async (request) => {
|
|
41
63
|
const init = { method: request.method, headers: request.headers };
|
|
42
64
|
if (request.body !== undefined)
|
|
@@ -77,8 +99,17 @@ export function createRestClient(config, options = {}, fetchLike) {
|
|
|
77
99
|
}
|
|
78
100
|
}
|
|
79
101
|
async function request(method, path, body, init) {
|
|
102
|
+
// Interaction callbacks are deadline-bound: fold the ack budget into
|
|
103
|
+
// the effective signal so the retry loop's checks, the in-flight
|
|
104
|
+
// fetch, AND the Retry-After sleeps all honor it.
|
|
105
|
+
let signal = init?.signal;
|
|
106
|
+
if (interactionAckBudgetMs > 0 && path.startsWith('/interactions/')) {
|
|
107
|
+
signal = signal === undefined
|
|
108
|
+
? AbortSignal.timeout(interactionAckBudgetMs)
|
|
109
|
+
: AbortSignal.any([signal, AbortSignal.timeout(interactionAckBudgetMs)]);
|
|
110
|
+
}
|
|
80
111
|
for (let attemptNo = 0; attemptNo <= maxRetries; attemptNo += 1) {
|
|
81
|
-
if (
|
|
112
|
+
if (signal?.aborted === true) {
|
|
82
113
|
return { outcome: 'unknown', reason: 'aborted' };
|
|
83
114
|
}
|
|
84
115
|
const fetchRequest = {
|
|
@@ -90,7 +121,7 @@ export function createRestClient(config, options = {}, fetchLike) {
|
|
|
90
121
|
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
91
122
|
},
|
|
92
123
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
93
|
-
...(
|
|
124
|
+
...(signal === undefined ? {} : { signal }),
|
|
94
125
|
};
|
|
95
126
|
const outcome = await attempt(fetchRequest);
|
|
96
127
|
if ('retry' in outcome) {
|
|
@@ -101,13 +132,21 @@ export function createRestClient(config, options = {}, fetchLike) {
|
|
|
101
132
|
if (isFinalAttempt) {
|
|
102
133
|
return { outcome: 'rejected', status: 429, error: { code: 'rate-limited', message: 'rate limit retries exhausted' } };
|
|
103
134
|
}
|
|
104
|
-
await
|
|
135
|
+
await sleepAbortable(outcome.delayMs, signal);
|
|
105
136
|
continue;
|
|
106
137
|
}
|
|
138
|
+
// A 5xx or network failure may mean the request was already applied
|
|
139
|
+
// upstream. Auto-retrying a non-idempotent POST could then deliver
|
|
140
|
+
// the message twice and the retry's 200 would mask it — so POST
|
|
141
|
+
// surfaces unknown immediately and the caller's nonce reconciliation
|
|
142
|
+
// decides; only idempotent methods ride the bounded retry loop.
|
|
143
|
+
if (method !== 'GET' && method !== 'PUT' && method !== 'DELETE' && method !== 'PATCH') {
|
|
144
|
+
return { outcome: 'unknown', reason: 'network-unreachable' };
|
|
145
|
+
}
|
|
107
146
|
if (isFinalAttempt) {
|
|
108
147
|
return { outcome: 'unknown', reason: 'network-unreachable' };
|
|
109
148
|
}
|
|
110
|
-
await
|
|
149
|
+
await sleepAbortable(Math.min(backoffBaseMs * 2 ** attemptNo, maxDelayMs), signal);
|
|
111
150
|
continue;
|
|
112
151
|
}
|
|
113
152
|
return outcome;
|
|
@@ -34,8 +34,12 @@ export function createRestThreadPort(rest, options = {}) {
|
|
|
34
34
|
// `threads/active` route does not exist in the Discord API (archived
|
|
35
35
|
// listing is the only channel-level one), so recovery would 404 forever.
|
|
36
36
|
const listed = await rest.request('GET', `/guilds/${request.guildId}/threads/active`);
|
|
37
|
+
// An unobservable listing is NOT a definitive absence (review A4):
|
|
38
|
+
// conflating the two lets crash-window recovery conclude a thread
|
|
39
|
+
// that exists was never created. Only a completed listing that
|
|
40
|
+
// scanned past the source message proves 'not-found'.
|
|
37
41
|
if (listed.outcome !== 'completed')
|
|
38
|
-
return { outcome: '
|
|
42
|
+
return { outcome: 'unknown' };
|
|
39
43
|
const threads = Array.isArray(listed.body?.threads) ? listed.body.threads : [];
|
|
40
44
|
for (const thread of threads.slice(0, MAX_THREADS_SCANNED)) {
|
|
41
45
|
const oldest = await rest.request('GET', `/channels/${thread.id}/messages?after=0&limit=1`);
|
package/lib/dsh/host-asks.js
CHANGED
|
@@ -109,7 +109,13 @@ export function installAskServicePatches(approval, questions, deps) {
|
|
|
109
109
|
pending.dispose();
|
|
110
110
|
void pending.promise.then(() => {
|
|
111
111
|
deps.onWaitState?.(threadId, false);
|
|
112
|
-
void deps.askWiring.disableControl(approvalId)
|
|
112
|
+
void deps.askWiring.disableControl(approvalId).catch((cause) => {
|
|
113
|
+
deps.log('discord_ask_control_disable_failed', { key: approvalId, cause: String(cause) });
|
|
114
|
+
});
|
|
115
|
+
}).catch((cause) => {
|
|
116
|
+
// The ask itself has settled; a failing wait-state clear must not
|
|
117
|
+
// escape as an unhandled rejection.
|
|
118
|
+
deps.log('discord_ask_wait_clear_failed', { threadId, cause: String(cause) });
|
|
113
119
|
});
|
|
114
120
|
return pending.promise;
|
|
115
121
|
};
|
|
@@ -142,7 +148,11 @@ export function installAskServicePatches(approval, questions, deps) {
|
|
|
142
148
|
pending.dispose();
|
|
143
149
|
void pending.promise.then(() => {
|
|
144
150
|
deps.onWaitState?.(threadId, false);
|
|
145
|
-
void deps.askWiring.disableControl(rpcId)
|
|
151
|
+
void deps.askWiring.disableControl(rpcId).catch((cause) => {
|
|
152
|
+
deps.log('discord_ask_control_disable_failed', { key: rpcId, cause: String(cause) });
|
|
153
|
+
});
|
|
154
|
+
}).catch((cause) => {
|
|
155
|
+
deps.log('discord_ask_wait_clear_failed', { threadId, cause: String(cause) });
|
|
146
156
|
});
|
|
147
157
|
return pending.promise;
|
|
148
158
|
};
|
package/lib/dsh/host-events.d.ts
CHANGED
|
@@ -43,6 +43,14 @@ export interface TrackSeedOptions {
|
|
|
43
43
|
export interface HostEventRouterOptions {
|
|
44
44
|
log?: (event: string, detail?: unknown) => void;
|
|
45
45
|
}
|
|
46
|
+
interface FrameQueue {
|
|
47
|
+
push(frame: unknown): void;
|
|
48
|
+
close(): void;
|
|
49
|
+
}
|
|
50
|
+
/** Build the push-side of the single consumer queue. */
|
|
51
|
+
export declare function createFrameQueue(): FrameQueue & {
|
|
52
|
+
iterate(signal: AbortSignal): AsyncIterable<unknown>;
|
|
53
|
+
};
|
|
46
54
|
/**
|
|
47
55
|
* The fan-in router. `track(sessionId)` is idempotent and safe to call from
|
|
48
56
|
* every session-acquisition site (create, adopt, resume, prompt); each
|
|
@@ -51,4 +59,7 @@ export interface HostEventRouterOptions {
|
|
|
51
59
|
export declare function createHostEventRouter(services: HostSessionFollowFace, options?: HostEventRouterOptions): {
|
|
52
60
|
track(sessionId: string, seed?: TrackSeedOptions): void;
|
|
53
61
|
stream(signal: AbortSignal): AsyncIterable<unknown>;
|
|
62
|
+
/** Abort every tracked follow loop, cancel pending re-arms, stop the router. */
|
|
63
|
+
dispose(): void;
|
|
54
64
|
};
|
|
65
|
+
export {};
|
package/lib/dsh/host-events.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* ask-wiring migration wires separately.
|
|
19
19
|
*/
|
|
20
20
|
/** Build the push-side of the single consumer queue. */
|
|
21
|
-
function createFrameQueue() {
|
|
21
|
+
export function createFrameQueue() {
|
|
22
22
|
const buffered = [];
|
|
23
23
|
let notify;
|
|
24
24
|
let closed = false;
|
|
@@ -54,12 +54,18 @@ function createFrameQueue() {
|
|
|
54
54
|
if (closed)
|
|
55
55
|
return;
|
|
56
56
|
await new Promise(resolve => {
|
|
57
|
-
notify = resolve;
|
|
58
57
|
const abort = () => {
|
|
59
58
|
notify = undefined;
|
|
60
59
|
resolve();
|
|
61
60
|
};
|
|
62
61
|
signal.addEventListener('abort', abort, { once: true });
|
|
62
|
+
notify = () => {
|
|
63
|
+
// The wake path must drop its abort listener: a streaming
|
|
64
|
+
// session parks here per drain, and a leaked listener per
|
|
65
|
+
// park accumulates into MaxListenersExceededWarning.
|
|
66
|
+
signal.removeEventListener('abort', abort);
|
|
67
|
+
resolve();
|
|
68
|
+
};
|
|
63
69
|
});
|
|
64
70
|
}
|
|
65
71
|
}
|
|
@@ -112,8 +118,11 @@ export function createHostEventRouter(services, options = {}) {
|
|
|
112
118
|
const watermark = new Map();
|
|
113
119
|
/** Sessions whose NEXT opening snapshot is swallowed whole (legacy bindings). */
|
|
114
120
|
const suppressOnce = new Set();
|
|
121
|
+
/** Pending re-arm timers: disposed routers must stop resubscribing. */
|
|
122
|
+
const rearmTimers = new Set();
|
|
115
123
|
let consumer;
|
|
116
124
|
let rootSignal;
|
|
125
|
+
let disposed = false;
|
|
117
126
|
const startLoop = (sessionId, attempt = 0) => {
|
|
118
127
|
const per = new AbortController();
|
|
119
128
|
tracked.set(sessionId, per);
|
|
@@ -244,12 +253,16 @@ export function createHostEventRouter(services, options = {}) {
|
|
|
244
253
|
// process restart replays the journal. Re-arm with backoff.
|
|
245
254
|
if (!per.signal.aborted) {
|
|
246
255
|
const delayMs = Math.min(30_000, 1_000 * 2 ** attempt);
|
|
247
|
-
setTimeout(() => {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
256
|
+
const timer = setTimeout(() => {
|
|
257
|
+
rearmTimers.delete(timer);
|
|
258
|
+
if (disposed || per.signal.aborted || tracked.has(sessionId))
|
|
259
|
+
return;
|
|
260
|
+
log?.('discord_host_follow_resubscribed', { sessionId, delayMs });
|
|
261
|
+
startLoop(sessionId, attempt + 1);
|
|
252
262
|
}, delayMs);
|
|
263
|
+
// A parked re-arm must never pin the host's event loop open.
|
|
264
|
+
timer.unref();
|
|
265
|
+
rearmTimers.add(timer);
|
|
253
266
|
}
|
|
254
267
|
}
|
|
255
268
|
})();
|
|
@@ -293,7 +306,7 @@ export function createHostEventRouter(services, options = {}) {
|
|
|
293
306
|
track(sessionId, seed) {
|
|
294
307
|
if (sessionId === '' || tracked.has(sessionId))
|
|
295
308
|
return;
|
|
296
|
-
if (rootSignal?.aborted)
|
|
309
|
+
if (disposed || rootSignal?.aborted)
|
|
297
310
|
return;
|
|
298
311
|
if (seed !== undefined) {
|
|
299
312
|
// Seed BEFORE the loop opens: the opening snapshot races the very
|
|
@@ -321,5 +334,20 @@ export function createHostEventRouter(services, options = {}) {
|
|
|
321
334
|
startControlLoop(signal);
|
|
322
335
|
return queue.iterate(signal);
|
|
323
336
|
},
|
|
337
|
+
dispose() {
|
|
338
|
+
// Plugin teardown: the per-session follow loops run on their own
|
|
339
|
+
// controllers (the consumer signal only reaches the control loop),
|
|
340
|
+
// so nothing else stops them — without this, disposal leaves zombie
|
|
341
|
+
// subscriptions and a re-arm chain that resubscribes forever.
|
|
342
|
+
disposed = true;
|
|
343
|
+
rootSignal = undefined;
|
|
344
|
+
for (const [, per] of [...tracked.entries()])
|
|
345
|
+
per.abort();
|
|
346
|
+
tracked.clear();
|
|
347
|
+
for (const timer of rearmTimers)
|
|
348
|
+
clearTimeout(timer);
|
|
349
|
+
rearmTimers.clear();
|
|
350
|
+
consumer?.close();
|
|
351
|
+
},
|
|
324
352
|
};
|
|
325
353
|
}
|
package/lib/dsh/host-face.js
CHANGED
|
@@ -489,8 +489,13 @@ async function readSessionSelection(dsh, sessionId, options) {
|
|
|
489
489
|
const dispose = observation?.[Symbol.asyncDispose]
|
|
490
490
|
?? observation?.dispose;
|
|
491
491
|
if (typeof dispose === 'function') {
|
|
492
|
+
// The asyncDispose convention returns a promise: a rejection there
|
|
493
|
+
// must be caught, or it escapes as an unhandled rejection that kills
|
|
494
|
+
// the host process.
|
|
492
495
|
try {
|
|
493
|
-
|
|
496
|
+
Promise.resolve(dispose.call(observation)).catch((cause) => {
|
|
497
|
+
log?.('discord_session_selection_dispose_threw', { cause: String(cause) });
|
|
498
|
+
});
|
|
494
499
|
}
|
|
495
500
|
catch (cause) {
|
|
496
501
|
log?.('discord_session_selection_dispose_threw', { cause: String(cause) });
|
|
@@ -656,8 +661,12 @@ async function readSessionPermission(dsh, sessionId, options) {
|
|
|
656
661
|
const dispose = observation?.[Symbol.asyncDispose]
|
|
657
662
|
?? observation?.dispose;
|
|
658
663
|
if (typeof dispose === 'function') {
|
|
664
|
+
// See readSessionSelection: the disposal promise's rejection is
|
|
665
|
+
// caught, never unhandled.
|
|
659
666
|
try {
|
|
660
|
-
|
|
667
|
+
Promise.resolve(dispose.call(observation)).catch((cause) => {
|
|
668
|
+
log?.('discord_session_permission_dispose_threw', { cause: String(cause) });
|
|
669
|
+
});
|
|
661
670
|
}
|
|
662
671
|
catch (cause) {
|
|
663
672
|
log?.('discord_session_permission_dispose_threw', { cause: String(cause) });
|
|
@@ -60,6 +60,10 @@ export interface InteractionRouterDeps {
|
|
|
60
60
|
} | undefined>;
|
|
61
61
|
/** Delete every adapter-owned record for one guild (DSH untouched). */
|
|
62
62
|
forgetGuild: (guildId: string) => Promise<void>;
|
|
63
|
+
/** The adapter's own package version (from package.json at load). */
|
|
64
|
+
adapterVersion: () => string;
|
|
65
|
+
/** One-word connection condition for /info. */
|
|
66
|
+
adapterStatus: () => string;
|
|
63
67
|
/** Ephemeral failure/guidance followup on a component interaction. */
|
|
64
68
|
componentFollowUp: (interactionId: string, interactionToken: string, content: string) => Promise<void>;
|
|
65
69
|
/** Retire a rendered control (remove its components on the source message). */
|
|
@@ -76,7 +80,7 @@ export interface InteractionRouterDeps {
|
|
|
76
80
|
userId: string;
|
|
77
81
|
threadId: string;
|
|
78
82
|
text: string;
|
|
79
|
-
}) => QuestionInteractionOutcome
|
|
83
|
+
}) => Promise<QuestionInteractionOutcome>;
|
|
80
84
|
rest: () => Promise<SharedRestClient | undefined>;
|
|
81
85
|
/**
|
|
82
86
|
* Delete one channel→Workspace binding row (bind precheck: a bound
|