@addozhang/dsh-discord 0.1.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.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -0
  3. package/cordis.patch.yml +14 -0
  4. package/lib/client/DiscordSettingsCard.d.ts +6 -0
  5. package/lib/client/PluginCard.d.ts +43 -0
  6. package/lib/client/card-controller.d.ts +22 -0
  7. package/lib/client/card-form.d.ts +111 -0
  8. package/lib/client/fields.d.ts +50 -0
  9. package/lib/client/index.d.ts +12 -0
  10. package/lib/client/nav-icon.d.ts +21 -0
  11. package/lib/client/settings-model.d.ts +49 -0
  12. package/lib/client/slot-contract.d.ts +57 -0
  13. package/lib/client/snapshot-store.d.ts +9 -0
  14. package/lib/client.js +1622 -0
  15. package/lib/compose.d.ts +158 -0
  16. package/lib/compose.js +227 -0
  17. package/lib/credential.d.ts +20 -0
  18. package/lib/credential.js +24 -0
  19. package/lib/discord/commands.d.ts +45 -0
  20. package/lib/discord/commands.js +87 -0
  21. package/lib/discord/components.d.ts +29 -0
  22. package/lib/discord/components.js +57 -0
  23. package/lib/discord/delivery.d.ts +21 -0
  24. package/lib/discord/delivery.js +31 -0
  25. package/lib/discord/interaction-lifecycle.d.ts +42 -0
  26. package/lib/discord/interaction-lifecycle.js +78 -0
  27. package/lib/discord/rest.d.ts +74 -0
  28. package/lib/discord/rest.js +149 -0
  29. package/lib/discord/selector.d.ts +39 -0
  30. package/lib/discord/selector.js +70 -0
  31. package/lib/discord/thread-port.d.ts +37 -0
  32. package/lib/discord/thread-port.js +58 -0
  33. package/lib/dsh/api-proxy-face.d.ts +253 -0
  34. package/lib/dsh/api-proxy-face.js +370 -0
  35. package/lib/features/adapter-status.d.ts +111 -0
  36. package/lib/features/adapter-status.js +103 -0
  37. package/lib/features/approval-expiry.d.ts +27 -0
  38. package/lib/features/approval-expiry.js +36 -0
  39. package/lib/features/approval-routing.d.ts +50 -0
  40. package/lib/features/approval-routing.js +57 -0
  41. package/lib/features/approval-store.d.ts +86 -0
  42. package/lib/features/approval-store.js +119 -0
  43. package/lib/features/approval-view.d.ts +47 -0
  44. package/lib/features/approval-view.js +48 -0
  45. package/lib/features/ask-wiring.d.ts +74 -0
  46. package/lib/features/ask-wiring.js +151 -0
  47. package/lib/features/host-status.d.ts +41 -0
  48. package/lib/features/host-status.js +24 -0
  49. package/lib/features/image-collection.d.ts +56 -0
  50. package/lib/features/image-collection.js +79 -0
  51. package/lib/features/image-download.d.ts +48 -0
  52. package/lib/features/image-download.js +77 -0
  53. package/lib/features/image-submission.d.ts +67 -0
  54. package/lib/features/image-submission.js +57 -0
  55. package/lib/features/interaction-router.d.ts +84 -0
  56. package/lib/features/interaction-router.js +655 -0
  57. package/lib/features/model-control.d.ts +79 -0
  58. package/lib/features/model-control.js +51 -0
  59. package/lib/features/preset-control.d.ts +84 -0
  60. package/lib/features/preset-control.js +43 -0
  61. package/lib/features/project-bind.d.ts +69 -0
  62. package/lib/features/project-bind.js +57 -0
  63. package/lib/features/project-info.d.ts +25 -0
  64. package/lib/features/project-info.js +22 -0
  65. package/lib/features/project-list.d.ts +79 -0
  66. package/lib/features/project-list.js +93 -0
  67. package/lib/features/prompt-submission.d.ts +53 -0
  68. package/lib/features/prompt-submission.js +47 -0
  69. package/lib/features/question-expiry.d.ts +53 -0
  70. package/lib/features/question-expiry.js +56 -0
  71. package/lib/features/question-routing.d.ts +108 -0
  72. package/lib/features/question-routing.js +162 -0
  73. package/lib/features/question-store.d.ts +124 -0
  74. package/lib/features/question-store.js +186 -0
  75. package/lib/features/question-view.d.ts +58 -0
  76. package/lib/features/question-view.js +81 -0
  77. package/lib/features/queue-control.d.ts +63 -0
  78. package/lib/features/queue-control.js +31 -0
  79. package/lib/features/reconcile-bindings.d.ts +75 -0
  80. package/lib/features/reconcile-bindings.js +54 -0
  81. package/lib/features/reconcile-deliveries.d.ts +29 -0
  82. package/lib/features/reconcile-deliveries.js +28 -0
  83. package/lib/features/reconcile-events.d.ts +49 -0
  84. package/lib/features/reconcile-events.js +50 -0
  85. package/lib/features/reconcile-intents.d.ts +32 -0
  86. package/lib/features/reconcile-intents.js +32 -0
  87. package/lib/features/reconcile-interactions.d.ts +59 -0
  88. package/lib/features/reconcile-interactions.js +54 -0
  89. package/lib/features/session-adopt.d.ts +89 -0
  90. package/lib/features/session-adopt.js +77 -0
  91. package/lib/features/session-creation.d.ts +49 -0
  92. package/lib/features/session-creation.js +43 -0
  93. package/lib/features/session-mainline.d.ts +75 -0
  94. package/lib/features/session-mainline.js +96 -0
  95. package/lib/features/session-resume.d.ts +44 -0
  96. package/lib/features/session-resume.js +40 -0
  97. package/lib/features/skill-run.d.ts +48 -0
  98. package/lib/features/skill-run.js +25 -0
  99. package/lib/features/steer-control.d.ts +37 -0
  100. package/lib/features/steer-control.js +28 -0
  101. package/lib/features/stop-control.d.ts +38 -0
  102. package/lib/features/stop-control.js +31 -0
  103. package/lib/features/task-admission.d.ts +29 -0
  104. package/lib/features/task-admission.js +21 -0
  105. package/lib/features/thread-continuation.d.ts +36 -0
  106. package/lib/features/thread-continuation.js +18 -0
  107. package/lib/features/thread-creation.d.ts +81 -0
  108. package/lib/features/thread-creation.js +81 -0
  109. package/lib/features/thread-routing.d.ts +49 -0
  110. package/lib/features/thread-routing.js +34 -0
  111. package/lib/features/turn-ownership.d.ts +39 -0
  112. package/lib/features/turn-ownership.js +40 -0
  113. package/lib/features/unbound-mention.d.ts +49 -0
  114. package/lib/features/unbound-mention.js +31 -0
  115. package/lib/features/workspace-channel.d.ts +41 -0
  116. package/lib/features/workspace-channel.js +47 -0
  117. package/lib/gateway/gateway.d.ts +50 -0
  118. package/lib/gateway/gateway.js +229 -0
  119. package/lib/gateway/inbound.d.ts +85 -0
  120. package/lib/gateway/inbound.js +186 -0
  121. package/lib/gateway/ingress.d.ts +15 -0
  122. package/lib/gateway/ingress.js +22 -0
  123. package/lib/i18n.d.ts +92 -0
  124. package/lib/i18n.js +180 -0
  125. package/lib/index.d.ts +17 -0
  126. package/lib/index.js +869 -0
  127. package/lib/lifecycle.d.ts +44 -0
  128. package/lib/lifecycle.js +84 -0
  129. package/lib/policy/authorization.d.ts +41 -0
  130. package/lib/policy/authorization.js +64 -0
  131. package/lib/policy/disclosure.d.ts +93 -0
  132. package/lib/policy/disclosure.js +153 -0
  133. package/lib/policy/guard.d.ts +31 -0
  134. package/lib/policy/guard.js +46 -0
  135. package/lib/policy/suppress.d.ts +13 -0
  136. package/lib/policy/suppress.js +20 -0
  137. package/lib/settings-namespace.d.ts +7 -0
  138. package/lib/settings-namespace.js +6 -0
  139. package/lib/settings.d.ts +37 -0
  140. package/lib/settings.js +102 -0
  141. package/lib/startup.d.ts +16 -0
  142. package/lib/startup.js +60 -0
  143. package/lib/state/bindings.d.ts +46 -0
  144. package/lib/state/bindings.js +55 -0
  145. package/lib/state/channel-bindings.d.ts +37 -0
  146. package/lib/state/channel-bindings.js +43 -0
  147. package/lib/state/domain.d.ts +82 -0
  148. package/lib/state/domain.js +86 -0
  149. package/lib/state/effect-machine.d.ts +37 -0
  150. package/lib/state/effect-machine.js +52 -0
  151. package/lib/state/fail-closed.d.ts +44 -0
  152. package/lib/state/fail-closed.js +57 -0
  153. package/lib/state/intents.d.ts +51 -0
  154. package/lib/state/intents.js +73 -0
  155. package/lib/state/records.d.ts +37 -0
  156. package/lib/state/records.js +30 -0
  157. package/lib/state/retention.d.ts +74 -0
  158. package/lib/state/retention.js +68 -0
  159. package/lib/state/session-owners.d.ts +50 -0
  160. package/lib/state/session-owners.js +50 -0
  161. package/lib/stream/finalizer.d.ts +49 -0
  162. package/lib/stream/finalizer.js +67 -0
  163. package/lib/stream/icons.d.ts +15 -0
  164. package/lib/stream/icons.js +36 -0
  165. package/lib/stream/live.d.ts +140 -0
  166. package/lib/stream/live.js +515 -0
  167. package/lib/stream/markdown.d.ts +24 -0
  168. package/lib/stream/markdown.js +95 -0
  169. package/lib/stream/outbound.d.ts +27 -0
  170. package/lib/stream/outbound.js +25 -0
  171. package/lib/stream/render-fence.d.ts +27 -0
  172. package/lib/stream/render-fence.js +37 -0
  173. package/lib/stream/render-model.d.ts +48 -0
  174. package/lib/stream/render-model.js +68 -0
  175. package/lib/stream/splitter.d.ts +8 -0
  176. package/lib/stream/splitter.js +58 -0
  177. package/lib/stream/tool-view.d.ts +40 -0
  178. package/lib/stream/tool-view.js +39 -0
  179. package/lib/stream/typing.d.ts +22 -0
  180. package/lib/stream/typing.js +58 -0
  181. package/lib/stream/update-scheduler.d.ts +20 -0
  182. package/lib/stream/update-scheduler.js +65 -0
  183. package/package.json +86 -0
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Per-route delivery queues with nonce delivery identity. Writes to one
3
+ * Discord route (a channel, an interaction token) serialize FIFO so edits can
4
+ * never reorder terminal output; distinct routes proceed concurrently. A
5
+ * nonce identifies one in-process delivery claim and cannot be reused while
6
+ * known, which turns a double-send race into a loud local failure instead of
7
+ * duplicate Discord output.
8
+ */
9
+ export function createRouteQueues() {
10
+ const activeNonces = new Map();
11
+ const tails = new Map();
12
+ function enqueue(route, op, identity) {
13
+ if (activeNonces.has(identity.nonce)) {
14
+ throw new TypeError(`delivery nonce '${identity.nonce}' is still known; duplicate claims are refused`);
15
+ }
16
+ const tail = tails.get(route) ?? Promise.resolve();
17
+ const run = tail.then(op, op);
18
+ // A failed op must not poison its route's tail.
19
+ tails.set(route, run.then(() => undefined, () => undefined));
20
+ // The release is registered before the caller can observe `run`, so a
21
+ // nonce is always forgettable by the time the caller's await resumes.
22
+ const release = () => {
23
+ if (activeNonces.get(identity.nonce) === run)
24
+ activeNonces.delete(identity.nonce);
25
+ };
26
+ void run.then(release, release);
27
+ activeNonces.set(identity.nonce, run);
28
+ return run;
29
+ }
30
+ return { enqueue };
31
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Interaction response lifecycle. Discord grants exactly one callback per
3
+ * interaction inside a 3-second ack deadline and a webhook token with a
4
+ * bounded lifetime. This module models that budget as a state machine whose
5
+ * every rule violation is a plain value: double acknowledgements,
6
+ * past-deadline callbacks, and expired tokens refuse locally without a wire
7
+ * call, and wire-side unknown outcomes propagate untouched — the adapter
8
+ * never invents success it did not observe.
9
+ */
10
+ import type { RestResult } from './rest.js';
11
+ /** The interaction identity Discord delivered. */
12
+ export interface InteractionHandle {
13
+ id: string;
14
+ token: string;
15
+ applicationId: string;
16
+ }
17
+ /** The two wire faces an interaction can use. */
18
+ export interface InteractionWire {
19
+ callback(body: Record<string, unknown>): Promise<RestResult<unknown>>;
20
+ followUp(body: Record<string, unknown>): Promise<RestResult<unknown>>;
21
+ }
22
+ export interface LifecycleOptions {
23
+ /** Callback budget in ms; Discord's contract is 3000. */
24
+ ackDeadlineMs?: number;
25
+ /** Webhook token budget in ms; Discord's contract is 15 minutes. */
26
+ tokenLifetimeMs?: number;
27
+ /** Injectable clock for deterministic tests. */
28
+ now?: () => number;
29
+ }
30
+ export type LifecycleOutcome = {
31
+ ok: true;
32
+ } | {
33
+ ok: false;
34
+ error: 'already-acknowledged' | 'not-acknowledged' | 'past-ack-deadline' | 'token-expired' | 'wire-rejected' | 'wire-unknown';
35
+ detail?: string;
36
+ };
37
+ export declare function createInteractionSession(wire: InteractionWire, options?: LifecycleOptions): {
38
+ readonly state: 'fresh' | 'responded' | 'deferred';
39
+ respond(data: Record<string, unknown>): Promise<LifecycleOutcome>;
40
+ defer(): Promise<LifecycleOutcome>;
41
+ followUp(data: Record<string, unknown>): Promise<LifecycleOutcome>;
42
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Interaction response lifecycle. Discord grants exactly one callback per
3
+ * interaction inside a 3-second ack deadline and a webhook token with a
4
+ * bounded lifetime. This module models that budget as a state machine whose
5
+ * every rule violation is a plain value: double acknowledgements,
6
+ * past-deadline callbacks, and expired tokens refuse locally without a wire
7
+ * call, and wire-side unknown outcomes propagate untouched — the adapter
8
+ * never invents success it did not observe.
9
+ */
10
+ const DEFAULT_ACK_DEADLINE_MS = 3_000;
11
+ const DEFAULT_TOKEN_LIFETIME_MS = 15 * 60_000;
12
+ /** Discord's "Unknown interaction" code: the token no longer resolves. */
13
+ const UNKNOWN_INTERACTION_CODE = 10062;
14
+ /** Ephemeral message flag: only the invoking user sees the response. */
15
+ const EPHEMERAL_FLAG = 64;
16
+ const DISCORD_CALLBACK_RESPOND = 4;
17
+ const DISCORD_CALLBACK_DEFER = 5;
18
+ export function createInteractionSession(wire, options = {}) {
19
+ const ackDeadlineMs = options.ackDeadlineMs ?? DEFAULT_ACK_DEADLINE_MS;
20
+ const tokenLifetimeMs = options.tokenLifetimeMs ?? DEFAULT_TOKEN_LIFETIME_MS;
21
+ const now = options.now ?? Date.now;
22
+ const startedAt = now();
23
+ let state = 'fresh';
24
+ function withinAckDeadline() {
25
+ return now() - startedAt <= ackDeadlineMs;
26
+ }
27
+ function acknowledge(body) {
28
+ if (state !== 'fresh') {
29
+ return Promise.resolve({ ok: false, error: 'already-acknowledged' });
30
+ }
31
+ if (!withinAckDeadline()) {
32
+ return Promise.resolve({ ok: false, error: 'past-ack-deadline' });
33
+ }
34
+ return wire.callback(body).then((result) => {
35
+ if (result.outcome === 'completed') {
36
+ state = body['type'] === DISCORD_CALLBACK_DEFER ? 'deferred' : 'responded';
37
+ return { ok: true };
38
+ }
39
+ if (result.outcome === 'rejected') {
40
+ if (result.error.code === UNKNOWN_INTERACTION_CODE) {
41
+ return { ok: false, error: 'token-expired' };
42
+ }
43
+ return { ok: false, error: 'wire-rejected', detail: result.error.message };
44
+ }
45
+ return { ok: false, error: 'wire-unknown', detail: result.reason };
46
+ });
47
+ }
48
+ return {
49
+ get state() {
50
+ return state;
51
+ },
52
+ respond(data) {
53
+ return acknowledge({ type: DISCORD_CALLBACK_RESPOND, data: { flags: EPHEMERAL_FLAG, ...data } });
54
+ },
55
+ defer() {
56
+ return acknowledge({ type: DISCORD_CALLBACK_DEFER, data: { flags: EPHEMERAL_FLAG } });
57
+ },
58
+ followUp(data) {
59
+ if (state === 'fresh') {
60
+ return Promise.resolve({ ok: false, error: 'not-acknowledged' });
61
+ }
62
+ if (now() - startedAt > tokenLifetimeMs) {
63
+ return Promise.resolve({ ok: false, error: 'token-expired' });
64
+ }
65
+ return wire.followUp({ flags: EPHEMERAL_FLAG, ...data }).then((result) => {
66
+ if (result.outcome === 'completed')
67
+ return { ok: true };
68
+ if (result.outcome === 'rejected') {
69
+ if (result.error.code === UNKNOWN_INTERACTION_CODE) {
70
+ return { ok: false, error: 'token-expired' };
71
+ }
72
+ return { ok: false, error: 'wire-rejected', detail: result.error.message };
73
+ }
74
+ return { ok: false, error: 'wire-unknown', detail: result.reason };
75
+ });
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The typed Discord REST adapter. Every request resolves to exactly one of
3
+ * three outcomes — `completed`, `rejected`, or `unknown` — because cross-system
4
+ * delivery is never provably once: a refused request (4xx, exhausted 429) is
5
+ * definitive, while an unobservable outcome (network failure, abort,
6
+ * exhausted 5xx) may or may not have been applied and must be reconciled,
7
+ * never blindly retried by callers.
8
+ */
9
+ export type RestMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
10
+ /** The untyped wire face injected for testability (fetch-shaped). */
11
+ export interface FetchRequest {
12
+ readonly method: RestMethod;
13
+ readonly url: string;
14
+ readonly path: string;
15
+ readonly headers: Record<string, string>;
16
+ readonly body?: string;
17
+ readonly signal?: AbortSignal;
18
+ }
19
+ export type FetchLike = (request: FetchRequest) => Promise<Response>;
20
+ /** Discord's structured error body (subset the adapter reasons about). */
21
+ export interface DiscordError {
22
+ code: number | string;
23
+ message: string;
24
+ }
25
+ export type RestResult<T> = {
26
+ outcome: 'completed';
27
+ status: number;
28
+ body: T;
29
+ } | {
30
+ outcome: 'rejected';
31
+ status: number;
32
+ error: DiscordError;
33
+ } | {
34
+ outcome: 'unknown';
35
+ reason: 'network-unreachable' | 'aborted';
36
+ };
37
+ export interface RestClientConfig {
38
+ token: string;
39
+ /** REST base; production points at https://discord.com/api/v10. */
40
+ apiBase?: string;
41
+ }
42
+ export interface RestClientOptions {
43
+ /** Extra attempts after the first; each 429/5xx retry consumes one. */
44
+ maxRetries?: number;
45
+ /** Base delay for 5xx backoff (doubles per attempt). */
46
+ backoffBaseMs?: number;
47
+ /** Cap for any single wait, so a hostile Retry-After cannot stall the adapter. */
48
+ maxDelayMs?: number;
49
+ }
50
+ export declare function createRestClient(config: RestClientConfig, options?: RestClientOptions, fetchLike?: FetchLike): {
51
+ request<T>(method: RestMethod, path: string, body?: unknown, init?: {
52
+ signal?: AbortSignal;
53
+ }): Promise<RestResult<T>>;
54
+ };
55
+ /**
56
+ * The route bucket a request counts against (discord-transport spec,
57
+ * "Rate-limit-aware delivery"): the first two path segments carry Discord's
58
+ * major parameters — `channels/:id`, `guilds/:id`, `webhooks/:id` — so every
59
+ * write into one channel shares one bucket and serializes behind it.
60
+ */
61
+ export declare function routeBucketOf(path: string): string;
62
+ export interface SharedRestClient {
63
+ request<T>(method: RestMethod, path: string, body?: unknown, init?: {
64
+ signal?: AbortSignal;
65
+ }): Promise<RestResult<T>>;
66
+ }
67
+ /**
68
+ * A process-wide REST client whose requests serialize per route bucket.
69
+ * Composition creates ONE shared instance (instead of a client per
70
+ * interaction) so concurrent sends, edits, and typing refreshes into the
71
+ * same channel never race the bucket's rate budget; distinct buckets run
72
+ * concurrently. Retries and outcome mapping stay the underlying client's.
73
+ */
74
+ export declare function createSharedRestClient(config: RestClientConfig, options?: RestClientOptions, fetchLike?: FetchLike): SharedRestClient;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The typed Discord REST adapter. Every request resolves to exactly one of
3
+ * three outcomes — `completed`, `rejected`, or `unknown` — because cross-system
4
+ * delivery is never provably once: a refused request (4xx, exhausted 429) is
5
+ * definitive, while an unobservable outcome (network failure, abort,
6
+ * exhausted 5xx) may or may not have been applied and must be reconciled,
7
+ * never blindly retried by callers.
8
+ */
9
+ const DEFAULT_API_BASE = 'https://discord.com/api/v10';
10
+ const DEFAULT_MAX_RETRIES = 3;
11
+ const DEFAULT_BACKOFF_BASE_MS = 1_000;
12
+ const DEFAULT_MAX_DELAY_MS = 30_000;
13
+ const sleep = (ms) => {
14
+ return new Promise(resolve => { setTimeout(resolve, ms); });
15
+ };
16
+ function parseDelayMs(response, fallbackMs, capMs) {
17
+ const header = response.headers.get('retry-after');
18
+ const seconds = header === null ? Number.NaN : Number.parseFloat(header);
19
+ if (!Number.isFinite(seconds) || seconds < 0)
20
+ return fallbackMs;
21
+ return Math.min(seconds * 1_000, capMs);
22
+ }
23
+ function readError(body) {
24
+ if (typeof body === 'object' && body !== null) {
25
+ const record = body;
26
+ const code = record['code'];
27
+ const message = record['message'];
28
+ if ((typeof code === 'number' || typeof code === 'string') && typeof message === 'string') {
29
+ return { code, message };
30
+ }
31
+ }
32
+ return { code: 'unknown-discord-error', message: 'unparseable error body' };
33
+ }
34
+ export function createRestClient(config, options = {}, fetchLike) {
35
+ const apiBase = config.apiBase ?? DEFAULT_API_BASE;
36
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
37
+ const backoffBaseMs = options.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
38
+ const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
39
+ const doFetch = fetchLike ?? (async (request) => {
40
+ const init = { method: request.method, headers: request.headers };
41
+ if (request.body !== undefined)
42
+ init.body = request.body;
43
+ if (request.signal !== undefined)
44
+ init.signal = request.signal;
45
+ return fetch(request.url, init);
46
+ });
47
+ async function attempt(request) {
48
+ try {
49
+ const response = await doFetch(request);
50
+ const text = await response.text();
51
+ let body;
52
+ try {
53
+ body = text === '' ? undefined : JSON.parse(text);
54
+ }
55
+ catch {
56
+ body = text;
57
+ }
58
+ if (response.ok) {
59
+ return { outcome: 'completed', status: response.status, body: body };
60
+ }
61
+ const error = readError(body);
62
+ if (response.status === 429) {
63
+ // Discord did not apply the request; honor its Retry-After (bounded).
64
+ return { retry: '429', delayMs: parseDelayMs(response, backoffBaseMs, maxDelayMs) };
65
+ }
66
+ if (response.status >= 500) {
67
+ return { retry: '5xx' };
68
+ }
69
+ return { outcome: 'rejected', status: response.status, error };
70
+ }
71
+ catch (error) {
72
+ if (error instanceof Error && error.name === 'AbortError') {
73
+ return { outcome: 'unknown', reason: 'aborted' };
74
+ }
75
+ return { retry: 'network' };
76
+ }
77
+ }
78
+ async function request(method, path, body, init) {
79
+ for (let attemptNo = 0; attemptNo <= maxRetries; attemptNo += 1) {
80
+ if (init?.signal?.aborted === true) {
81
+ return { outcome: 'unknown', reason: 'aborted' };
82
+ }
83
+ const fetchRequest = {
84
+ method,
85
+ url: `${apiBase}${path}`,
86
+ path,
87
+ headers: {
88
+ 'authorization': `Bot ${config.token}`,
89
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
90
+ },
91
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
92
+ ...(init?.signal === undefined ? {} : { signal: init.signal }),
93
+ };
94
+ const outcome = await attempt(fetchRequest);
95
+ if ('retry' in outcome) {
96
+ const isFinalAttempt = attemptNo === maxRetries;
97
+ if (outcome.retry === '429') {
98
+ // A 429 on the last attempt is a definitive rejection, not unknown:
99
+ // Discord answered and refused, so the request was never applied.
100
+ if (isFinalAttempt) {
101
+ return { outcome: 'rejected', status: 429, error: { code: 'rate-limited', message: 'rate limit retries exhausted' } };
102
+ }
103
+ await sleep(outcome.delayMs);
104
+ continue;
105
+ }
106
+ if (isFinalAttempt) {
107
+ return { outcome: 'unknown', reason: 'network-unreachable' };
108
+ }
109
+ await sleep(Math.min(backoffBaseMs * 2 ** attemptNo, maxDelayMs));
110
+ continue;
111
+ }
112
+ return outcome;
113
+ }
114
+ return { outcome: 'unknown', reason: 'network-unreachable' };
115
+ }
116
+ return { request };
117
+ }
118
+ /**
119
+ * The route bucket a request counts against (discord-transport spec,
120
+ * "Rate-limit-aware delivery"): the first two path segments carry Discord's
121
+ * major parameters — `channels/:id`, `guilds/:id`, `webhooks/:id` — so every
122
+ * write into one channel shares one bucket and serializes behind it.
123
+ */
124
+ export function routeBucketOf(path) {
125
+ const segments = path.split('/').filter(segment => segment.length > 0);
126
+ return segments.slice(0, 2).join('/');
127
+ }
128
+ /**
129
+ * A process-wide REST client whose requests serialize per route bucket.
130
+ * Composition creates ONE shared instance (instead of a client per
131
+ * interaction) so concurrent sends, edits, and typing refreshes into the
132
+ * same channel never race the bucket's rate budget; distinct buckets run
133
+ * concurrently. Retries and outcome mapping stay the underlying client's.
134
+ */
135
+ export function createSharedRestClient(config, options = {}, fetchLike) {
136
+ const client = createRestClient(config, options, fetchLike);
137
+ const chains = new Map();
138
+ return {
139
+ request(method, path, body, init) {
140
+ const bucket = routeBucketOf(path);
141
+ const tail = chains.get(bucket) ?? Promise.resolve();
142
+ const run = tail.then(() => client.request(method, path, body, init));
143
+ // The bucket chain never rejects: a failed request must not wedge or
144
+ // discard the requests queued behind it.
145
+ chains.set(bucket, run.then(() => undefined, () => undefined));
146
+ return run;
147
+ },
148
+ };
149
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Autocomplete filtering and select-menu pagination as pure models. Discord
3
+ * caps autocomplete and select components at 25 entries; filtering ranks
4
+ * prefix matches above substring matches case-insensitively, and the pager
5
+ * budgets navigation entries into the same 25-slot limit so a page is always
6
+ * directly renderable as one component.
7
+ */
8
+ /** A selectable entry as Discord renders it. */
9
+ export interface SelectorOption {
10
+ label: string;
11
+ value: string;
12
+ description?: string;
13
+ }
14
+ /** Discord's hard cap for autocomplete choices and select-menu options. */
15
+ export declare const DISCORD_SELECT_LIMIT = 25;
16
+ /** Sentinel value shape for navigation entries; wrapped into opaque ids later. */
17
+ export interface SelectorPage<T extends SelectorOption = SelectorOption> {
18
+ items: T[];
19
+ pageIndex: number;
20
+ pageCount: number;
21
+ hasPrev: boolean;
22
+ hasNext: boolean;
23
+ /** Value of the prev/next navigation entries present on this page. */
24
+ navValues: {
25
+ prev?: string;
26
+ next?: string;
27
+ };
28
+ }
29
+ /**
30
+ * Rank and cut options for an autocomplete response: empty queries keep the
31
+ * original order; otherwise prefix matches outrank substring matches and
32
+ * non-matches are dropped, ties keep input order.
33
+ */
34
+ export declare function filterAutocomplete(options: readonly SelectorOption[], query: string, limit?: number): SelectorOption[];
35
+ /**
36
+ * Slice options into one renderable page. Navigation entries spend slots of
37
+ * the same 25-entry budget. Out-of-range indices clamp into range.
38
+ */
39
+ export declare function paginateSelector<T extends SelectorOption>(options: readonly T[], requestedIndex: number, sentinel?: string): SelectorPage<T>;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Autocomplete filtering and select-menu pagination as pure models. Discord
3
+ * caps autocomplete and select components at 25 entries; filtering ranks
4
+ * prefix matches above substring matches case-insensitively, and the pager
5
+ * budgets navigation entries into the same 25-slot limit so a page is always
6
+ * directly renderable as one component.
7
+ */
8
+ /** Discord's hard cap for autocomplete choices and select-menu options. */
9
+ export const DISCORD_SELECT_LIMIT = 25;
10
+ /**
11
+ * Rank and cut options for an autocomplete response: empty queries keep the
12
+ * original order; otherwise prefix matches outrank substring matches and
13
+ * non-matches are dropped, ties keep input order.
14
+ */
15
+ export function filterAutocomplete(options, query, limit = DISCORD_SELECT_LIMIT) {
16
+ const needle = query.trim().toLowerCase();
17
+ if (needle === '')
18
+ return options.slice(0, limit);
19
+ const prefix = [];
20
+ const substring = [];
21
+ for (const option of options) {
22
+ const haystack = option.label.toLowerCase();
23
+ if (haystack.startsWith(needle))
24
+ prefix.push(option);
25
+ else if (haystack.includes(needle))
26
+ substring.push(option);
27
+ }
28
+ return [...prefix, ...substring].slice(0, limit);
29
+ }
30
+ /**
31
+ * Derive a self-consistent page budget: a single page needs no navigation and
32
+ * holds 25 entries; two pages hold 24 data entries each (one nav slot);
33
+ * three or more pages hold 23 (middle pages need prev and next). The chosen
34
+ * page count always re-derives from its own per-page budget.
35
+ */
36
+ function computeBudget(total) {
37
+ if (total <= DISCORD_SELECT_LIMIT)
38
+ return { pageCount: 1, perPage: DISCORD_SELECT_LIMIT };
39
+ for (const perPage of [DISCORD_SELECT_LIMIT - 1, DISCORD_SELECT_LIMIT - 2]) {
40
+ const pageCount = Math.ceil(total / perPage);
41
+ const requiredPerPage = pageCount === 2 ? DISCORD_SELECT_LIMIT - 1 : DISCORD_SELECT_LIMIT - 2;
42
+ if (perPage === requiredPerPage)
43
+ return { pageCount, perPage };
44
+ }
45
+ const perPage = DISCORD_SELECT_LIMIT - 2;
46
+ return { pageCount: Math.ceil(total / perPage), perPage };
47
+ }
48
+ /**
49
+ * Slice options into one renderable page. Navigation entries spend slots of
50
+ * the same 25-entry budget. Out-of-range indices clamp into range.
51
+ */
52
+ export function paginateSelector(options, requestedIndex, sentinel = '__page__') {
53
+ const { pageCount, perPage } = computeBudget(options.length);
54
+ const pageIndex = Math.min(Math.max(requestedIndex, 0), pageCount - 1);
55
+ const hasPrev = pageIndex > 0;
56
+ const hasNext = pageIndex < pageCount - 1;
57
+ const navValues = {};
58
+ if (hasPrev)
59
+ navValues.prev = `${sentinel}:${String(pageIndex - 1)}`;
60
+ if (hasNext)
61
+ navValues.next = `${sentinel}:${String(pageIndex + 1)}`;
62
+ return {
63
+ items: options.slice(pageIndex * perPage, (pageIndex + 1) * perPage),
64
+ pageIndex,
65
+ pageCount,
66
+ hasPrev,
67
+ hasNext,
68
+ navValues,
69
+ };
70
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The REST-backed DiscordThreadPort (anchored source-message thread model). Thread creation
3
+ * is anchored to the source message (`message_id`): Discord moves the user's
4
+ * task message into the thread as its durable first post, so the thread
5
+ * opens with what reads exactly like the user's own task post. Crash-window
6
+ * recovery matches a thread by its anchored first message (the source
7
+ * message id). The author is joined to the thread so it appears in their
8
+ * sidebar. Outcomes map one-to-one onto the port's contract; retry policy
9
+ * belongs to the shared REST client.
10
+ */
11
+ import type { DiscordThreadPort } from '../features/thread-creation.js';
12
+ import type { RestMethod, RestResult } from './rest.js';
13
+ /** Scripted route outcome for tests (shape mirrors RestClient outcomes). */
14
+ export type ScriptedRoute = {
15
+ outcome: 'completed';
16
+ body: unknown;
17
+ status?: number;
18
+ } | {
19
+ outcome: 'rejected';
20
+ status?: number;
21
+ error?: {
22
+ code: number | string;
23
+ message: string;
24
+ };
25
+ } | {
26
+ outcome: 'unknown';
27
+ reason?: 'network-unreachable' | 'aborted';
28
+ };
29
+ /** The narrow request face this port needs (SharedRestClient-compatible). */
30
+ export interface ThreadPortRest {
31
+ request<T>(method: RestMethod, path: string, body?: unknown): Promise<RestResult<T>>;
32
+ }
33
+ export interface ThreadPortOptions {
34
+ /** Live archive-duration reader (Discord accepts 60/1440/4320/10080). */
35
+ autoArchiveMinutes?: () => number | undefined;
36
+ }
37
+ export declare function createRestThreadPort(rest: ThreadPortRest, options?: ThreadPortOptions): DiscordThreadPort;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The REST-backed DiscordThreadPort (anchored source-message thread model). Thread creation
3
+ * is anchored to the source message (`message_id`): Discord moves the user's
4
+ * task message into the thread as its durable first post, so the thread
5
+ * opens with what reads exactly like the user's own task post. Crash-window
6
+ * recovery matches a thread by its anchored first message (the source
7
+ * message id). The author is joined to the thread so it appears in their
8
+ * sidebar. Outcomes map one-to-one onto the port's contract; retry policy
9
+ * belongs to the shared REST client.
10
+ */
11
+ /** Bound the crash-recovery scan: never walk an unbounded thread list. */
12
+ const MAX_THREADS_SCANNED = 25;
13
+ /** OneDay auto-archive is the default; the deployment can widen or narrow it. */
14
+ const DEFAULT_AUTO_ARCHIVE_MINUTES = 1440;
15
+ export function createRestThreadPort(rest, options = {}) {
16
+ return {
17
+ async createThread(request) {
18
+ // Anchored creation uses the documented message-scoped route: Discord
19
+ // moves the source message into the thread as its first post. The
20
+ // channel-scoped route ignores a `message_id` body field, so anchoring
21
+ // silently no-ops there (seen live).
22
+ const made = await rest.request('POST', `/channels/${request.parentChannelId}/messages/${request.sourceMessageId}/threads`, {
23
+ name: request.name,
24
+ type: 11,
25
+ auto_archive_duration: options.autoArchiveMinutes?.() ?? DEFAULT_AUTO_ARCHIVE_MINUTES,
26
+ });
27
+ if (made.outcome === 'completed' && typeof made.body?.id === 'string') {
28
+ return { outcome: 'completed', threadId: made.body.id };
29
+ }
30
+ return made.outcome === 'rejected' ? { outcome: 'failed' } : { outcome: 'unknown' };
31
+ },
32
+ async findThreadBySource(request) {
33
+ // Active-thread listing is GUILD-scoped only: the channel-scoped
34
+ // `threads/active` route does not exist in the Discord API (archived
35
+ // listing is the only channel-level one), so recovery would 404 forever.
36
+ const listed = await rest.request('GET', `/guilds/${request.guildId}/threads/active`);
37
+ if (listed.outcome !== 'completed')
38
+ return { outcome: 'not-found' };
39
+ const threads = Array.isArray(listed.body?.threads) ? listed.body.threads : [];
40
+ for (const thread of threads.slice(0, MAX_THREADS_SCANNED)) {
41
+ const oldest = await rest.request('GET', `/channels/${thread.id}/messages?after=0&limit=1`);
42
+ if (oldest.outcome !== 'completed')
43
+ continue;
44
+ const first = Array.isArray(oldest.body) ? oldest.body[0] : undefined;
45
+ if (first?.id === request.sourceMessageId) {
46
+ return { outcome: 'found', threadId: thread.id };
47
+ }
48
+ }
49
+ return { outcome: 'not-found' };
50
+ },
51
+ async joinThread(request) {
52
+ // Idempotent per Discord (joining an existing member answers 2xx);
53
+ // callers treat this as best-effort and never fail the task on it.
54
+ const joined = await rest.request('PUT', `/channels/${request.threadId}/thread-members/${request.userId}`);
55
+ return joined.outcome === 'completed' ? { outcome: 'completed' } : { outcome: 'failed' };
56
+ },
57
+ };
58
+ }