@addozhang/dsh-discord 0.6.4-rc.2 → 0.6.4
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 +5 -4
- package/README.zh.md +5 -4
- package/lib/dsh/host-asks.d.ts +20 -0
- package/lib/dsh/host-asks.js +103 -0
- package/lib/features/reconcile-bindings.d.ts +29 -0
- package/lib/features/reconcile-bindings.js +27 -0
- package/lib/index.js +23 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ No extra process: the adapter is a DSH plugin that mounts straight onto your `ds
|
|
|
20
20
|
|
|
21
21
|
- **Mention-driven sessions** — an authorized `@bot <task>` in a bound channel anchors a thread (your message becomes the first post), creates the DSH session, and submits the prompt at most once. Follow-ups inside the thread queue without a mention. Attached images ride along: they are downloaded from the Discord CDN within strict size/host bounds and submitted as image parts for multimodal models.
|
|
22
22
|
- **Stream rendering** — typing indicator, one head message edited in place, per-tool activity rows, fenced long-answer splitting, one-time finalize; the activity message is deleted when the turn ends.
|
|
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.
|
|
23
|
+
- **Approvals & questions (dual surface)** — DSH asks become buttons, select menus, and a free-text modal, mirrored to the web/desktop panel at the same time: the first surface to answer wins and the other auto-retires (Discord carries the ask alone when no web client is connected). 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
26
|
- **Adapter status** — `/info` shows the adapter version, connection state, and project links (feedback, docs). Works in any channel.
|
|
@@ -40,11 +40,12 @@ Two channels ship in parallel, each pinned to the dsh host surface it was verifi
|
|
|
40
40
|
|
|
41
41
|
| Channel | Adapter | dsh CLI | Install |
|
|
42
42
|
|---|---|---|---|
|
|
43
|
-
| **
|
|
44
|
-
| **
|
|
43
|
+
| **stable (0.2.1 line)** | `0.6.4` — npm `latest` | `0.2.1-rc.3`+ — npm `latest`/`next` | `dsh plugin --profile web add @addozhang/dsh-discord` |
|
|
44
|
+
| **next (0.2.1 line)** | `>=0.6.1-rc.1` — npm `next` | `0.2.1-rc.3`+ — npm `latest`/`next` | `dsh plugin --profile web add @addozhang/dsh-discord@next` |
|
|
45
|
+
| Legacy (0.1.7 line) | `0.5.0` — exact version | `0.1.7-rc.1` — exact version | `dsh plugin --profile web add @addozhang/dsh-discord@0.5.0` |
|
|
45
46
|
|
|
46
47
|
- **The rows do not mix.** Adapter `0.6.1+` carries runtime dependencies (`dsh-storage-domain`/`dsh-credentials`) pinned to the dsh `0.2.1` line — on any other host line the exact-version compatibility gate disables them (the storageDomain service never arrives and the plugin stays inactive); adapter `0.6.0-rc.1` is the `0.2.0-rc.2` pairing (both sides are exact-version installs now that the dist-tags moved to 0.2.1), and adapter `0.5.0` pins the `0.1.7` line symmetrically. Adapter `>=0.5.0-rc.4` (including `0.5.0`) speaks the 0.1.7 profile-backed settings forms and controller services and will not load on older hosts (the startup probe refuses with one actionable diagnostic); `0.5.0-rc.1`–`rc.3` target dsh `0.1.6-alpha`; adapter `0.4.x` talks to the former `apiProxy` surface, which `0.1.6-alpha` removed — it remains the pairing for the dsh `0.1.5` stable line, reachable by exact version (e.g. `@addozhang/dsh-discord@0.4.0`). A mismatched pair fails at startup.
|
|
47
|
-
- To switch the dsh CLI line: `npm install -g @deepseek-ai/dsh@latest` (0.2.1-rc.
|
|
48
|
+
- To switch the dsh CLI line: `npm install -g @deepseek-ai/dsh@latest` (0.2.1-rc.x, pairs with adapter 0.6.1+) or `npm install -g @deepseek-ai/dsh@0.1.7-rc.1` (pairs with adapter 0.5.0).
|
|
48
49
|
- Right after a `next` release, the `@next` spec can resolve to a stale cached version. If the installed copy looks old, install by exact version instead (e.g. `@addozhang/dsh-discord@0.5.0-rc.4`) and verify the `version` field inside the profile's `node_modules`.
|
|
49
50
|
|
|
50
51
|
## Install
|
package/README.zh.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
- **@提及驱动会话** — 在已绑定的频道中,被授权的 `@机器人 <任务>` 会锚定一个线程(你的消息成为首帖)、创建 DSH 会话,并且至多提交一次。线程内的后续消息无需 @ 即可排队。消息附带的图片会一并送达:在严格的大小与主机白名单约束下从 Discord CDN 下载,并作为 image parts 提交给多模态模型。
|
|
22
22
|
- **流式渲染** — typing 指示、单条头消息原地编辑、逐工具活动行、代码围栏感知的长文分段、一次性收尾;Turn 结束时活动消息会被删除。
|
|
23
|
-
-
|
|
23
|
+
- **审批与提问(双面)** — DSH 的 ask 渲染为按钮、下拉菜单与自由文本弹窗,并同时镜像到 web/桌面端:两端先答先得,另一端的控件与卡片自动失效(无 web 客户端时 Discord 单独承载)。所有权强制校验(提问者——或后续 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
26
|
- **适配器状态** — `/info` 显示适配器版本、连接状态与项目链接(反馈/文档)。任意频道可用。
|
|
@@ -40,11 +40,12 @@
|
|
|
40
40
|
|
|
41
41
|
| 通道 | 适配器 | dsh CLI | 安装命令 |
|
|
42
42
|
|---|---|---|---|
|
|
43
|
-
| **
|
|
44
|
-
| **
|
|
43
|
+
| **stable(0.2.1 线)** | `0.6.4` — npm `latest` | `0.2.1-rc.3`+ — npm `latest`/`next` | `dsh plugin --profile web add @addozhang/dsh-discord` |
|
|
44
|
+
| **next(0.2.1 线)** | `>=0.6.1-rc.1` — npm `next` | `0.2.1-rc.3`+ — npm `latest`/`next` | `dsh plugin --profile web add @addozhang/dsh-discord@next` |
|
|
45
|
+
| 旧线(0.1.7) | `0.5.0` — 精确版本号 | `0.1.7-rc.1` — 精确版本号 | `dsh plugin --profile web add @addozhang/dsh-discord@0.5.0` |
|
|
45
46
|
|
|
46
47
|
- **两行不可混搭。** 适配器 `0.6.1+` 携带的运行时依赖(`dsh-storage-domain`/`dsh-credentials`)钉在 dsh `0.2.1` 线——装到其它宿主线会被宿主的精确版本兼容门禁用(storageDomain 服务缺失,插件保持未激活);适配器 `0.6.0-rc.1` 是 `0.2.0-rc.2` 的配对(dist-tags 已移到 0.2.1,两侧都只能精确版本号安装),适配器 `0.5.0` 同理反向钉在 `0.1.7` 线。适配器 `>=0.5.0-rc.4`(含 `0.5.0`)使用 0.1.7 的 profile-backed settings forms 与控制器服务,在更旧的宿主上无法加载(startup 探针会给出一条可操作的诊断后拒绝激活);`0.5.0-rc.1`–`rc.3` 对应 dsh `0.1.6-alpha`;适配器 `0.4.x` 使用旧版 `apiProxy` 宿主面(该面已在 `0.1.6-alpha` 中移除)——它仍是 dsh `0.1.5` stable 线的配对,可用精确版本号安装(如 `@addozhang/dsh-discord@0.4.0`)。错配会在启动时失败。
|
|
47
|
-
- 切换 dsh CLI 通道:`npm install -g @deepseek-ai/dsh@latest`(0.2.1-rc.
|
|
48
|
+
- 切换 dsh CLI 通道:`npm install -g @deepseek-ai/dsh@latest`(0.2.1-rc.x,配适配器 0.6.1+)或 `npm install -g @deepseek-ai/dsh@0.1.7-rc.1`(配适配器 0.5.0)。
|
|
48
49
|
- `next` 刚发版后,`@next` 规格可能解析到过期的缓存版本。若装到的版本偏旧,请改用精确版本号安装(如 `@addozhang/dsh-discord@0.5.0-rc.4`),并核对 profile `node_modules` 中的 `version` 字段。
|
|
49
50
|
|
|
50
51
|
## 安装
|
package/lib/dsh/host-asks.d.ts
CHANGED
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
* renders the ask through the adapter's ask-wiring (buttons, ownership,
|
|
12
12
|
* expiry) and resolves with the user's answer; every other ask passes
|
|
13
13
|
* through to the original path (the web UI keeps working unchanged).
|
|
14
|
+
*
|
|
15
|
+
* Dual surface (16.105, 2026-10-30): a claimed ask is ALSO forwarded to
|
|
16
|
+
* the original entry under a relayed abort signal, so a connected web
|
|
17
|
+
* client renders the same card. The two surfaces race and the first
|
|
18
|
+
* decisive answer wins: a web answer settles the claimed ask and retires
|
|
19
|
+
* the Discord controls; a Discord answer aborts the relay and the web wait
|
|
20
|
+
* closes. The pre-binding passthrough direction stays single-surface (the
|
|
21
|
+
* 2026-10-26 decision on issue #11 — status quo).
|
|
14
22
|
*/
|
|
15
23
|
/** Narrow approval-request shape the service receives. */
|
|
16
24
|
export interface HostApprovalRequest {
|
|
@@ -68,6 +76,18 @@ export interface HostAskDeps {
|
|
|
68
76
|
questions: ReadonlyArray<Record<string, unknown>>;
|
|
69
77
|
}): void;
|
|
70
78
|
disableControl(key: string): Promise<void>;
|
|
79
|
+
/** Record a web-surface resolution and retire the Discord controls. */
|
|
80
|
+
onApprovalResolved(input: {
|
|
81
|
+
sessionId: string;
|
|
82
|
+
approvalId: string;
|
|
83
|
+
outcome?: string | undefined;
|
|
84
|
+
}): void;
|
|
85
|
+
/** Record a web-surface resolution and retire the Discord controls. */
|
|
86
|
+
onQuestionResolved(input: {
|
|
87
|
+
sessionId: string;
|
|
88
|
+
questionRpcId: string;
|
|
89
|
+
outcome: 'answered' | 'cancelled';
|
|
90
|
+
}): void;
|
|
71
91
|
};
|
|
72
92
|
approvalTimeoutMs(): number;
|
|
73
93
|
questionTimeoutMs(): number;
|
package/lib/dsh/host-asks.js
CHANGED
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
* renders the ask through the adapter's ask-wiring (buttons, ownership,
|
|
12
12
|
* expiry) and resolves with the user's answer; every other ask passes
|
|
13
13
|
* through to the original path (the web UI keeps working unchanged).
|
|
14
|
+
*
|
|
15
|
+
* Dual surface (16.105, 2026-10-30): a claimed ask is ALSO forwarded to
|
|
16
|
+
* the original entry under a relayed abort signal, so a connected web
|
|
17
|
+
* client renders the same card. The two surfaces race and the first
|
|
18
|
+
* decisive answer wins: a web answer settles the claimed ask and retires
|
|
19
|
+
* the Discord controls; a Discord answer aborts the relay and the web wait
|
|
20
|
+
* closes. The pre-binding passthrough direction stays single-surface (the
|
|
21
|
+
* 2026-10-26 decision on issue #11 — status quo).
|
|
14
22
|
*/
|
|
15
23
|
/** One claimed ask: a settle promise raced against the ask's abort signal. */
|
|
16
24
|
class PendingAsk {
|
|
@@ -48,6 +56,36 @@ class PendingAsk {
|
|
|
48
56
|
this.cancel();
|
|
49
57
|
}
|
|
50
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Relays the host's abort signal into an owned controller so the losing
|
|
61
|
+
* ask surface's wait can be closed independently: the web leg receives
|
|
62
|
+
* `relay.signal` instead of the host's own signal, and a Discord answer
|
|
63
|
+
* aborts it to settle the web card without touching the host's signal.
|
|
64
|
+
*/
|
|
65
|
+
class SignalRelay {
|
|
66
|
+
controller = new AbortController();
|
|
67
|
+
constructor(source) {
|
|
68
|
+
if (source === undefined)
|
|
69
|
+
return;
|
|
70
|
+
if (source.aborted) {
|
|
71
|
+
this.controller.abort(source.reason);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
source.addEventListener('abort', () => { this.controller.abort(source.reason); }, { once: true });
|
|
75
|
+
}
|
|
76
|
+
get signal() {
|
|
77
|
+
return this.controller.signal;
|
|
78
|
+
}
|
|
79
|
+
/** Close the relayed wait (a no-op when the source signal already did). */
|
|
80
|
+
abort(reason) {
|
|
81
|
+
this.controller.abort(reason);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** The question waterfall's "no connected answerer" rejection shape. */
|
|
85
|
+
function isNoProvider(cause) {
|
|
86
|
+
return typeof cause === 'object' && cause !== null
|
|
87
|
+
&& cause.code === 'NO_PROVIDER';
|
|
88
|
+
}
|
|
51
89
|
/**
|
|
52
90
|
* Wrap both ask services. Thread-bound sessions get the Discord ask flow;
|
|
53
91
|
* everything else passes through untouched. Returns the settle ports the
|
|
@@ -57,6 +95,8 @@ class PendingAsk {
|
|
|
57
95
|
export function installAskServicePatches(approval, questions, deps) {
|
|
58
96
|
const pendingApprovals = new Map();
|
|
59
97
|
const pendingQuestions = new Map();
|
|
98
|
+
/** Live web-surface waits for claimed asks, keyed by ask id. */
|
|
99
|
+
const webRelays = new Map();
|
|
60
100
|
// Capture the current entries (own or prototype) before patching; dispose
|
|
61
101
|
// restores exactly these.
|
|
62
102
|
const originalRequest = approval.request.bind(approval);
|
|
@@ -73,6 +113,11 @@ export function installAskServicePatches(approval, questions, deps) {
|
|
|
73
113
|
if (questions !== undefined && originalAsk !== undefined) {
|
|
74
114
|
questions.ask = originalAsk;
|
|
75
115
|
}
|
|
116
|
+
// Close every live web wait before cancelling the Discord surface:
|
|
117
|
+
// the relayed abort lets the original entries settle their cards.
|
|
118
|
+
for (const [, relay] of [...webRelays])
|
|
119
|
+
relay.abort();
|
|
120
|
+
webRelays.clear();
|
|
76
121
|
for (const [, pending] of [...pendingApprovals])
|
|
77
122
|
pending.dispose();
|
|
78
123
|
pendingApprovals.clear();
|
|
@@ -105,9 +150,40 @@ export function installAskServicePatches(approval, questions, deps) {
|
|
|
105
150
|
});
|
|
106
151
|
const pending = new PendingAsk(pendingApprovals, approvalId, req.signal, () => 'cancelled');
|
|
107
152
|
pendingApprovals.set(approvalId, pending);
|
|
153
|
+
// Dual surface (16.105): the web surface races the Discord buttons
|
|
154
|
+
// through the original entry under a relayed signal. The first
|
|
155
|
+
// decisive outcome wins; 'unavailable' (no connected answerer, or an
|
|
156
|
+
// answerer failure the host normalizes fail-closed) and 'cancelled'
|
|
157
|
+
// (an abort racing the decision) never decide for the user.
|
|
158
|
+
const relay = new SignalRelay(req.signal);
|
|
159
|
+
webRelays.set(approvalId, relay);
|
|
160
|
+
void Promise.resolve()
|
|
161
|
+
.then(() => originalRequest({ ...req, signal: relay.signal }))
|
|
162
|
+
.then(outcome => {
|
|
163
|
+
if (outcome !== 'allowed-once' && outcome !== 'rejected') {
|
|
164
|
+
deps.log('discord_ask_dual_web_ignored', { kind: 'approval', approvalId, outcome });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!pending.resolve(outcome))
|
|
168
|
+
return;
|
|
169
|
+
deps.log('discord_ask_dual_web_won', { kind: 'approval', approvalId, outcome });
|
|
170
|
+
deps.askWiring.onApprovalResolved({ sessionId, approvalId, outcome });
|
|
171
|
+
})
|
|
172
|
+
.catch((cause) => {
|
|
173
|
+
// The real entry normalizes answerer failures to 'unavailable';
|
|
174
|
+
// a rejection here means the relay closed the wait (the Discord
|
|
175
|
+
// surface won) or the host aborted — both already settled.
|
|
176
|
+
if (relay.signal.aborted)
|
|
177
|
+
return;
|
|
178
|
+
deps.log('discord_ask_dual_web_failed', { kind: 'approval', approvalId, cause: String(cause) });
|
|
179
|
+
});
|
|
108
180
|
if (req.signal?.aborted === true)
|
|
109
181
|
pending.dispose();
|
|
110
182
|
void pending.promise.then(() => {
|
|
183
|
+
webRelays.delete(approvalId);
|
|
184
|
+
// Close the web wait when the Discord surface settled first (a
|
|
185
|
+
// no-op when the web answer already won or the host aborted).
|
|
186
|
+
relay.abort();
|
|
111
187
|
deps.onWaitState?.(threadId, false);
|
|
112
188
|
void deps.askWiring.disableControl(approvalId).catch((cause) => {
|
|
113
189
|
deps.log('discord_ask_control_disable_failed', { key: approvalId, cause: String(cause) });
|
|
@@ -144,9 +220,36 @@ export function installAskServicePatches(approval, questions, deps) {
|
|
|
144
220
|
const cancelledAnswer = { answers: [] };
|
|
145
221
|
const pending = new PendingAsk(pendingQuestions, rpcId, req.signal, () => cancelledAnswer);
|
|
146
222
|
pendingQuestions.set(rpcId, pending);
|
|
223
|
+
// Dual surface (16.105): mirror the batch to the web surface under
|
|
224
|
+
// the relayed signal; the first complete answer batch wins.
|
|
225
|
+
const relay = new SignalRelay(req.signal);
|
|
226
|
+
webRelays.set(rpcId, relay);
|
|
227
|
+
void Promise.resolve()
|
|
228
|
+
.then(() => originalAsk({ ...req, signal: relay.signal }))
|
|
229
|
+
.then(answer => {
|
|
230
|
+
if (!pending.resolve(answer))
|
|
231
|
+
return;
|
|
232
|
+
deps.log('discord_ask_dual_web_won', { kind: 'question', rpcId });
|
|
233
|
+
deps.askWiring.onQuestionResolved({ sessionId, questionRpcId: rpcId, outcome: 'answered' });
|
|
234
|
+
})
|
|
235
|
+
.catch((cause) => {
|
|
236
|
+
// NO_PROVIDER = no connected web client: the Discord surface
|
|
237
|
+
// carries the ask alone (the pre-dual-surface behavior).
|
|
238
|
+
if (isNoProvider(cause)) {
|
|
239
|
+
deps.log('discord_ask_dual_web_absent', { kind: 'question', rpcId });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (relay.signal.aborted)
|
|
243
|
+
return;
|
|
244
|
+
deps.log('discord_ask_dual_web_failed', { kind: 'question', rpcId, cause: String(cause) });
|
|
245
|
+
});
|
|
147
246
|
if (req.signal?.aborted === true)
|
|
148
247
|
pending.dispose();
|
|
149
248
|
void pending.promise.then(() => {
|
|
249
|
+
webRelays.delete(rpcId);
|
|
250
|
+
// Close the web wait when the Discord surface settled first (a
|
|
251
|
+
// no-op when the web answer already won or the host aborted).
|
|
252
|
+
relay.abort();
|
|
150
253
|
deps.onWaitState?.(threadId, false);
|
|
151
254
|
void deps.askWiring.disableControl(rpcId).catch((cause) => {
|
|
152
255
|
deps.log('discord_ask_control_disable_failed', { key: rpcId, cause: String(cause) });
|
|
@@ -73,3 +73,32 @@ export interface BindingReconciliationPlan {
|
|
|
73
73
|
}
|
|
74
74
|
/** Plan one reconciliation pass. Pure: callers decide how actions are applied. */
|
|
75
75
|
export declare function planBindingReconciliation(input: BindingReconciliationInput): BindingReconciliationPlan;
|
|
76
|
+
/**
|
|
77
|
+
* One direct channel probe for a thread binding the guild's active-thread
|
|
78
|
+
* listing did not account for (16.104). The shapes mirror the REST client's
|
|
79
|
+
* outcome vocabulary; `completed` needs no body — existence is the verdict.
|
|
80
|
+
*/
|
|
81
|
+
export type ThreadChannelProbeOutcome = {
|
|
82
|
+
outcome: 'completed';
|
|
83
|
+
} | {
|
|
84
|
+
outcome: 'rejected';
|
|
85
|
+
status: number;
|
|
86
|
+
} | {
|
|
87
|
+
outcome: 'unknown';
|
|
88
|
+
};
|
|
89
|
+
export type ThreadChannelProbe = (threadId: string) => Promise<ThreadChannelProbeOutcome>;
|
|
90
|
+
/**
|
|
91
|
+
* Refine facts for thread bindings absent from `/guilds/{id}/threads/active`.
|
|
92
|
+
* That listing drops both deleted threads and Discord-archived ones, so
|
|
93
|
+
* absence alone proves nothing (the pre-16.104 planner kept such threads
|
|
94
|
+
* forever — deleting a bound channel orphans every thread binding under it,
|
|
95
|
+
* permanently excluding those sessions from `/session resume`). One
|
|
96
|
+
* `GET /channels/{threadId}` each settles it: 200 → `ok` (alive, including
|
|
97
|
+
* Discord-archived — the thread remains the binding's live surface and a
|
|
98
|
+
* post un-archives it), 404 → `missing` (verified deletion → retire),
|
|
99
|
+
* anything else → `unknown` (keep-blocked, exactly the inconclusive
|
|
100
|
+
* posture the reconciliation spec mandates). Probes run sequentially; a
|
|
101
|
+
* retired binding leaves the table, so the probe set only ever shrinks to
|
|
102
|
+
* the genuinely alive or persistently unreachable.
|
|
103
|
+
*/
|
|
104
|
+
export declare function refineUnlistedThreadFacts(threadIds: readonly string[], probe: ThreadChannelProbe): Promise<Record<string, 'ok' | 'missing' | 'unknown'>>;
|
|
@@ -52,3 +52,30 @@ export function planBindingReconciliation(input) {
|
|
|
52
52
|
});
|
|
53
53
|
return { channelActions, threadActions };
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Refine facts for thread bindings absent from `/guilds/{id}/threads/active`.
|
|
57
|
+
* That listing drops both deleted threads and Discord-archived ones, so
|
|
58
|
+
* absence alone proves nothing (the pre-16.104 planner kept such threads
|
|
59
|
+
* forever — deleting a bound channel orphans every thread binding under it,
|
|
60
|
+
* permanently excluding those sessions from `/session resume`). One
|
|
61
|
+
* `GET /channels/{threadId}` each settles it: 200 → `ok` (alive, including
|
|
62
|
+
* Discord-archived — the thread remains the binding's live surface and a
|
|
63
|
+
* post un-archives it), 404 → `missing` (verified deletion → retire),
|
|
64
|
+
* anything else → `unknown` (keep-blocked, exactly the inconclusive
|
|
65
|
+
* posture the reconciliation spec mandates). Probes run sequentially; a
|
|
66
|
+
* retired binding leaves the table, so the probe set only ever shrinks to
|
|
67
|
+
* the genuinely alive or persistently unreachable.
|
|
68
|
+
*/
|
|
69
|
+
export async function refineUnlistedThreadFacts(threadIds, probe) {
|
|
70
|
+
const facts = {};
|
|
71
|
+
for (const threadId of threadIds) {
|
|
72
|
+
const probed = await probe(threadId);
|
|
73
|
+
if (probed.outcome === 'completed')
|
|
74
|
+
facts[threadId] = 'ok';
|
|
75
|
+
else if (probed.outcome === 'rejected' && probed.status === 404)
|
|
76
|
+
facts[threadId] = 'missing';
|
|
77
|
+
else
|
|
78
|
+
facts[threadId] = 'unknown';
|
|
79
|
+
}
|
|
80
|
+
return facts;
|
|
81
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -21,7 +21,7 @@ import { handleSelectInput, handleModalSubmit } from './features/question-routin
|
|
|
21
21
|
import { createCopy, permissionPresetLabel } from './i18n.js';
|
|
22
22
|
import { createInteractionRouter } from './features/interaction-router.js';
|
|
23
23
|
import { channelBindingKey, parseChannelBindingKey, threadBindingKey, parseThreadBindingKey, discordDomainSpec, CHANNEL_BINDINGS_TABLE, THREAD_BINDINGS_TABLE, INTENTS_TABLE } from './state/domain.js';
|
|
24
|
-
import { planBindingReconciliation } from './features/reconcile-bindings.js';
|
|
24
|
+
import { planBindingReconciliation, refineUnlistedThreadFacts } from './features/reconcile-bindings.js';
|
|
25
25
|
import { guildKeysToForget, sweepExpired } from './state/retention.js';
|
|
26
26
|
import { staleUncertainIntents } from './features/reconcile-intents.js';
|
|
27
27
|
import { createHostEventRouter } from './dsh/host-events.js';
|
|
@@ -673,6 +673,28 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
673
673
|
if (facts[channelId] === undefined)
|
|
674
674
|
facts[channelId] = 'missing';
|
|
675
675
|
}
|
|
676
|
+
// The active-thread listing drops Discord-archived threads too,
|
|
677
|
+
// so an unlisted thread binding is *absent evidence*, not
|
|
678
|
+
// deletion — one direct channel probe each settles it (16.104):
|
|
679
|
+
// 404 retires the orphan (deleting a bound channel kills every
|
|
680
|
+
// thread under it), 200 keeps it (archived threads are still the
|
|
681
|
+
// binding's live surface), anything else stays unverified.
|
|
682
|
+
const unlistedThreads = [...threadKeyById.keys()].filter(threadId => facts[threadId] === undefined);
|
|
683
|
+
if (unlistedThreads.length > 0) {
|
|
684
|
+
const refined = await refineUnlistedThreadFacts(unlistedThreads, async (threadId) => {
|
|
685
|
+
const probed = await rest.request('GET', `/channels/${threadId}`);
|
|
686
|
+
rpcLog('discord_reconcile_thread_probe', {
|
|
687
|
+
threadId,
|
|
688
|
+
outcome: probed.outcome === 'rejected' ? `http-${String(probed.status)}` : probed.outcome,
|
|
689
|
+
});
|
|
690
|
+
if (probed.outcome === 'completed')
|
|
691
|
+
return { outcome: 'completed' };
|
|
692
|
+
if (probed.outcome === 'rejected')
|
|
693
|
+
return { outcome: 'rejected', status: probed.status };
|
|
694
|
+
return { outcome: 'unknown' };
|
|
695
|
+
});
|
|
696
|
+
Object.assign(facts, refined);
|
|
697
|
+
}
|
|
676
698
|
}
|
|
677
699
|
// Default surface (16.34/16.37): the adapter's category and the
|
|
678
700
|
// general control channel are ensured on every READY. Bound home
|