@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,39 @@
1
+ /**
2
+ * Adapter-owned active Turn tracking (design.md §6, task 9.4). Discord owns a
3
+ * Turn only through its durable submitted request ID — a session that is
4
+ * merely running (DSH Web, an external submitter) grants no control. The
5
+ * tracker keeps one active turn per session: later submissions queue without
6
+ * taking ownership, and a completed turn stops being controllable until the
7
+ * next adapter submission registers.
8
+ */
9
+ export interface RegisteredTurn {
10
+ sessionId: string;
11
+ requestId: string;
12
+ threadId: string;
13
+ }
14
+ export type RegisterResult = {
15
+ ok: true;
16
+ } | {
17
+ ok: false;
18
+ error: 'turn-already-active';
19
+ };
20
+ export type ControlDecision = {
21
+ allowed: true;
22
+ requestId: string;
23
+ } | {
24
+ allowed: false;
25
+ reason: 'not-adapter-owned' | 'no-active-turn';
26
+ };
27
+ export interface TurnTracker {
28
+ register(turn: RegisteredTurn): RegisterResult;
29
+ complete(requestId: string): void;
30
+ authorizeControl(input: {
31
+ sessionId: string;
32
+ threadId: string;
33
+ /** Whether DSH reports the session as running; running alone grants nothing. */
34
+ sessionRunning?: boolean | undefined;
35
+ }): ControlDecision;
36
+ /** The active turn for a session, if the adapter owns one. */
37
+ active(sessionId: string): RegisteredTurn | undefined;
38
+ }
39
+ export declare function createTurnTracker(): TurnTracker;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Adapter-owned active Turn tracking (design.md §6, task 9.4). Discord owns a
3
+ * Turn only through its durable submitted request ID — a session that is
4
+ * merely running (DSH Web, an external submitter) grants no control. The
5
+ * tracker keeps one active turn per session: later submissions queue without
6
+ * taking ownership, and a completed turn stops being controllable until the
7
+ * next adapter submission registers.
8
+ */
9
+ export function createTurnTracker() {
10
+ const activeTurns = new Map();
11
+ return {
12
+ register(turn) {
13
+ const existing = activeTurns.get(turn.sessionId);
14
+ if (existing !== undefined)
15
+ return { ok: false, error: 'turn-already-active' };
16
+ activeTurns.set(turn.sessionId, turn);
17
+ return { ok: true };
18
+ },
19
+ complete(requestId) {
20
+ for (const [sessionId, turn] of activeTurns) {
21
+ if (turn.requestId === requestId)
22
+ activeTurns.delete(sessionId);
23
+ }
24
+ },
25
+ authorizeControl({ sessionId, threadId, sessionRunning }) {
26
+ const turn = activeTurns.get(sessionId);
27
+ if (turn === undefined) {
28
+ // A running session the adapter never submitted belongs to someone
29
+ // else (DSH Web); with no turn at all there is simply nothing to control.
30
+ return sessionRunning === true
31
+ ? { allowed: false, reason: 'not-adapter-owned' }
32
+ : { allowed: false, reason: 'no-active-turn' };
33
+ }
34
+ if (turn.threadId !== threadId)
35
+ return { allowed: false, reason: 'not-adapter-owned' };
36
+ return { allowed: true, requestId: turn.requestId };
37
+ },
38
+ active: sessionId => activeTurns.get(sessionId),
39
+ };
40
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Unbound-channel mentions and Workspace creation (design.md §4, Non-Goals).
3
+ * A mention in an unbound channel never creates a Session: it answers with an
4
+ * ephemeral bind affordance whose guidance depends on the actor's authority.
5
+ * Workspace creation is a Milestone-1 non-goal, so the request is refused
6
+ * explicitly — the filesystem port exists only to prove no mutation can
7
+ * happen, and the refusal is ephemeral like every other denial.
8
+ */
9
+ import type { AccessDecision } from '../policy/authorization.js';
10
+ /** What the unbound-mention handler should render. */
11
+ export type UnboundMentionPlan = {
12
+ outcome: 'bind-affordance';
13
+ audience: 'administrator' | 'member';
14
+ } | {
15
+ outcome: 'none';
16
+ };
17
+ /**
18
+ * Decide the response for a mention arriving on an unbound channel. Denied
19
+ * and non-granted actors were already refused upstream; for them (and for
20
+ * bound channels) there is nothing to add.
21
+ */
22
+ export declare function planUnboundMention(input: {
23
+ decision: AccessDecision;
24
+ isBound: boolean;
25
+ }): UnboundMentionPlan;
26
+ /**
27
+ * The filesystem-shaped surface the creation flow WOULD use. Declared only so
28
+ * the refusal can be tested against it: an implementation reaching for the
29
+ * filesystem is a spec violation.
30
+ */
31
+ export interface FilesystemPort {
32
+ stat(path: string): Promise<unknown>;
33
+ mkdir(path: string): Promise<unknown>;
34
+ }
35
+ export type WorkspaceCreationResult = {
36
+ outcome: 'refused';
37
+ reason: 'workspace-creation-unavailable';
38
+ response: 'ephemeral';
39
+ };
40
+ /**
41
+ * Milestone 1 never creates directories or registers new Workspaces: refuse
42
+ * explicitly, touch nothing, and answer ephemerally.
43
+ */
44
+ export declare function planWorkspaceCreation(_request: {
45
+ requestedPath: string;
46
+ actorId: string;
47
+ }, deps: {
48
+ filesystem: FilesystemPort;
49
+ }): Promise<WorkspaceCreationResult>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Unbound-channel mentions and Workspace creation (design.md §4, Non-Goals).
3
+ * A mention in an unbound channel never creates a Session: it answers with an
4
+ * ephemeral bind affordance whose guidance depends on the actor's authority.
5
+ * Workspace creation is a Milestone-1 non-goal, so the request is refused
6
+ * explicitly — the filesystem port exists only to prove no mutation can
7
+ * happen, and the refusal is ephemeral like every other denial.
8
+ */
9
+ /**
10
+ * Decide the response for a mention arriving on an unbound channel. Denied
11
+ * and non-granted actors were already refused upstream; for them (and for
12
+ * bound channels) there is nothing to add.
13
+ */
14
+ export function planUnboundMention(input) {
15
+ if (input.isBound)
16
+ return { outcome: 'none' };
17
+ if (!input.decision.allowed)
18
+ return { outcome: 'none' };
19
+ return {
20
+ outcome: 'bind-affordance',
21
+ audience: input.decision.level === 'member' ? 'member' : 'administrator',
22
+ };
23
+ }
24
+ /**
25
+ * Milestone 1 never creates directories or registers new Workspaces: refuse
26
+ * explicitly, touch nothing, and answer ephemerally.
27
+ */
28
+ export function planWorkspaceCreation(_request, deps) {
29
+ void deps.filesystem;
30
+ return Promise.resolve({ outcome: 'refused', reason: 'workspace-creation-unavailable', response: 'ephemeral' });
31
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Per-workspace Discord channel naming and placement (design.md §4). A
3
+ * successful bind may provision the workspace's home channel under the
4
+ * adapter's category. The name is a Discord-safe slug of the Workspace
5
+ * display title (never a path); placement reuses a same-name channel only
6
+ * when it is unbound or already serves this Workspace — a channel bound to
7
+ * another Workspace is never stolen, a suffixed sibling is created instead.
8
+ */
9
+ /** Discord text-channel name: lowercase letters, digits, hyphen, underscore. */
10
+ export declare function workspaceChannelName(title: string): string;
11
+ /** How the existing binding relates to the Workspace being bound. */
12
+ export type ChannelBindingState = 'unbound' | 'this-workspace' | 'other-workspace';
13
+ export type WorkspaceChannelPlacement = {
14
+ outcome: 'reuse';
15
+ channelId: string;
16
+ needsBind: boolean;
17
+ } | {
18
+ outcome: 'create';
19
+ name: string;
20
+ };
21
+ export interface WorkspaceChannelCandidate {
22
+ id: string;
23
+ name: string;
24
+ parentId: string | undefined;
25
+ }
26
+ /**
27
+ * Decide create-vs-reuse for the workspace channel, add-project
28
+ * style. The Workspace's existing home channel (any channel of this guild
29
+ * already bound to this Workspace) wins outright: reuse without rebinding —
30
+ * one Workspace, one channel. Otherwise a same-name channel under the
31
+ * category is reused only when unbound; a channel serving another Workspace
32
+ * is never stolen, and a `-2` sibling is created instead.
33
+ */
34
+ export declare function planWorkspaceChannel(options: {
35
+ channels: ReadonlyArray<WorkspaceChannelCandidate>;
36
+ categoryId: string;
37
+ desiredName: string;
38
+ bindingOf: (channelId: string) => ChannelBindingState;
39
+ /** A channel of this guild already bound to this Workspace, when known. */
40
+ existingForWorkspace?: string | undefined;
41
+ }): WorkspaceChannelPlacement;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Per-workspace Discord channel naming and placement (design.md §4). A
3
+ * successful bind may provision the workspace's home channel under the
4
+ * adapter's category. The name is a Discord-safe slug of the Workspace
5
+ * display title (never a path); placement reuses a same-name channel only
6
+ * when it is unbound or already serves this Workspace — a channel bound to
7
+ * another Workspace is never stolen, a suffixed sibling is created instead.
8
+ */
9
+ /** Discord text-channel name: lowercase letters, digits, hyphen, underscore. */
10
+ export function workspaceChannelName(title) {
11
+ const trimmed = title.trim();
12
+ const slug = trimmed
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9_-]+/g, '-')
15
+ .replace(/-{2,}/g, '-')
16
+ .replace(/^[-_]+|[-_]+$/g, '')
17
+ .slice(0, 90)
18
+ .replace(/[-_]+$/g, '');
19
+ // Non-ASCII titles slugify to nothing; the raw title is Discord-legal and
20
+ // preserves the name a user actually chose.
21
+ return slug === '' ? trimmed.slice(0, 90) : slug;
22
+ }
23
+ /**
24
+ * Decide create-vs-reuse for the workspace channel, add-project
25
+ * style. The Workspace's existing home channel (any channel of this guild
26
+ * already bound to this Workspace) wins outright: reuse without rebinding —
27
+ * one Workspace, one channel. Otherwise a same-name channel under the
28
+ * category is reused only when unbound; a channel serving another Workspace
29
+ * is never stolen, and a `-2` sibling is created instead.
30
+ */
31
+ export function planWorkspaceChannel(options) {
32
+ if (options.existingForWorkspace !== undefined) {
33
+ return { outcome: 'reuse', channelId: options.existingForWorkspace, needsBind: false };
34
+ }
35
+ const sameName = options.channels.filter(channel => channel.parentId === options.categoryId && channel.name === options.desiredName);
36
+ const reusable = sameName.find(channel => options.bindingOf(channel.id) !== 'other-workspace');
37
+ if (reusable !== undefined) {
38
+ return {
39
+ outcome: 'reuse',
40
+ channelId: reusable.id,
41
+ needsBind: options.bindingOf(reusable.id) === 'unbound',
42
+ };
43
+ }
44
+ if (sameName.length === 0)
45
+ return { outcome: 'create', name: options.desiredName };
46
+ return { outcome: 'create', name: `${options.desiredName}-2` };
47
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The Discord Gateway state machine, driven entirely through an injected
3
+ * socket factory so every behavior is testable with fake sockets and fake
4
+ * clocks. One instance owns at most one live socket; every generation change
5
+ * (reconnect or replacement) invalidates all callbacks captured by the
6
+ * previous socket, so a stale close can never schedule duplicate reconnects
7
+ * or deliver events after disposal.
8
+ */
9
+ /** The subset of the WebSocket face the machine uses. */
10
+ export interface GatewaySocket {
11
+ readonly url: string;
12
+ send(data: string): void;
13
+ close(code?: number, reason?: string): void;
14
+ terminate(): void;
15
+ onopen: (() => void) | null;
16
+ onmessage: ((data: string) => void) | null;
17
+ onclose: ((code: number) => void) | null;
18
+ onerror: ((error: Error) => void) | null;
19
+ }
20
+ export type GatewaySocketFactory = (url: string) => GatewaySocket;
21
+ /** One forwarded Gateway dispatch frame (opcode 0). */
22
+ export interface GatewayDispatch {
23
+ t: string;
24
+ s: number | null;
25
+ op: number;
26
+ d: unknown;
27
+ }
28
+ export interface GatewayOptions {
29
+ url: string;
30
+ /** Resolved per connection attempt; never retained between attempts. */
31
+ tokenProvider: () => Promise<string>;
32
+ /** Gateway intent bitmask to identify with. */
33
+ intents: number;
34
+ socketFactory: GatewaySocketFactory;
35
+ /** Every validated opcode-0 dispatch, including READY. */
36
+ onDispatch(event: GatewayDispatch): void;
37
+ /** A terminal close code was received; the machine will not reconnect. */
38
+ onTerminalClose(code: number): void;
39
+ /** A reconnect was scheduled; attempt is 1-based. */
40
+ onBackoffScheduled?(attempt: number, delayMs: number): void;
41
+ /** First retry delay; doubles per attempt up to `backoffCapMs`. */
42
+ backoffBaseMs?: number;
43
+ backoffCapMs?: number;
44
+ }
45
+ /** Close codes Discord defines as non-recoverable for this session shape. */
46
+ export declare const TERMINAL_CLOSE_CODES: readonly number[];
47
+ export interface GatewayHandle {
48
+ dispose(): void;
49
+ }
50
+ export declare function startGateway(options: GatewayOptions): GatewayHandle;
@@ -0,0 +1,229 @@
1
+ /**
2
+ * The Discord Gateway state machine, driven entirely through an injected
3
+ * socket factory so every behavior is testable with fake sockets and fake
4
+ * clocks. One instance owns at most one live socket; every generation change
5
+ * (reconnect or replacement) invalidates all callbacks captured by the
6
+ * previous socket, so a stale close can never schedule duplicate reconnects
7
+ * or deliver events after disposal.
8
+ */
9
+ /** Close codes Discord defines as non-recoverable for this session shape. */
10
+ export const TERMINAL_CLOSE_CODES = [4004, 4010, 4011, 4012, 4013, 4014];
11
+ const OPCODE_DISPATCH = 0;
12
+ const OPCODE_HEARTBEAT = 1;
13
+ const OPCODE_IDENTIFY = 2;
14
+ const OPCODE_RESUME = 6;
15
+ const OPCODE_RECONNECT = 7;
16
+ const OPCODE_INVALID_SESSION = 9;
17
+ const OPCODE_HELLO = 10;
18
+ const OPCODE_HEARTBEAT_ACK = 11;
19
+ /** A recoverable close code: Discord allows session resume. */
20
+ const RECOVERABLE_CLOSE = 4000;
21
+ /**
22
+ * Grace period after a heartbeat-miss `terminate()`. The production socket
23
+ * adapter terminates via a graceful close handshake, which a partitioned
24
+ * peer never answers — without this fallback the machine would sit silently
25
+ * dead with no onclose and no reconnect.
26
+ */
27
+ const TERMINATE_FALLBACK_MS = 5_000;
28
+ export function startGateway(options) {
29
+ const backoffBaseMs = options.backoffBaseMs ?? 1_000;
30
+ const backoffCapMs = options.backoffCapMs ?? 60_000;
31
+ let disposed = false;
32
+ let generation = 0;
33
+ let heartbeatTimer;
34
+ let reconnectTimer;
35
+ let terminateFallbackTimer;
36
+ let heartbeatAcked = true;
37
+ let lastSeq = null;
38
+ let sessionId;
39
+ let backoffAttempt = 0;
40
+ let currentSocket;
41
+ function clearTimers() {
42
+ if (heartbeatTimer !== undefined) {
43
+ clearInterval(heartbeatTimer);
44
+ heartbeatTimer = undefined;
45
+ }
46
+ if (reconnectTimer !== undefined) {
47
+ clearTimeout(reconnectTimer);
48
+ reconnectTimer = undefined;
49
+ }
50
+ if (terminateFallbackTimer !== undefined) {
51
+ clearTimeout(terminateFallbackTimer);
52
+ terminateFallbackTimer = undefined;
53
+ }
54
+ }
55
+ function send(socket, op, d) {
56
+ socket.send(JSON.stringify({ op, d }));
57
+ }
58
+ function connect() {
59
+ if (disposed)
60
+ return;
61
+ generation += 1;
62
+ const liveGeneration = generation;
63
+ heartbeatAcked = true;
64
+ void options.tokenProvider().then((token) => {
65
+ if (disposed || liveGeneration !== generation)
66
+ return;
67
+ const socket = options.socketFactory(options.url);
68
+ currentSocket = socket;
69
+ socket.onopen = () => {
70
+ if (disposed || liveGeneration !== generation)
71
+ return;
72
+ };
73
+ socket.onmessage = (data) => {
74
+ if (disposed || liveGeneration !== generation)
75
+ return;
76
+ handleFrame(socket, data, token, liveGeneration);
77
+ };
78
+ socket.onclose = (code) => {
79
+ if (disposed || liveGeneration !== generation)
80
+ return;
81
+ handleClose(code);
82
+ };
83
+ socket.onerror = () => {
84
+ if (disposed || liveGeneration !== generation)
85
+ return;
86
+ // The close frame follows with the real code; nothing to do here.
87
+ };
88
+ }).catch(() => {
89
+ // Token resolution failed: recoverable, retry through the same backoff.
90
+ if (disposed || liveGeneration !== generation)
91
+ return;
92
+ handleClose(RECOVERABLE_CLOSE);
93
+ });
94
+ }
95
+ function handleFrame(socket, data, token, liveGeneration) {
96
+ let parsed;
97
+ try {
98
+ parsed = JSON.parse(data);
99
+ }
100
+ catch {
101
+ return;
102
+ }
103
+ if (typeof parsed !== 'object' || parsed === null)
104
+ return;
105
+ const frame = parsed;
106
+ if (typeof frame['op'] !== 'number')
107
+ return;
108
+ switch (frame['op']) {
109
+ case OPCODE_HELLO: {
110
+ const hello = frame['d'];
111
+ if (typeof hello !== 'object' || hello === null)
112
+ return;
113
+ const interval = hello['heartbeat_interval'];
114
+ if (typeof interval !== 'number' || interval <= 0)
115
+ return;
116
+ heartbeatAcked = true;
117
+ // A resumed session re-receives HELLO: retire the old timer first so
118
+ // intervals never stack.
119
+ if (heartbeatTimer !== undefined)
120
+ clearInterval(heartbeatTimer);
121
+ heartbeatTimer = setInterval(() => {
122
+ if (disposed || liveGeneration !== generation)
123
+ return;
124
+ if (!heartbeatAcked) {
125
+ currentSocket?.terminate();
126
+ // The adapter's terminate may be a graceful close, which a dead
127
+ // peer never answers — onclose can then stay silent for minutes.
128
+ // Arm a one-shot fallback so the reconnect machinery runs
129
+ // regardless of socket-adapter behavior.
130
+ if (terminateFallbackTimer === undefined) {
131
+ terminateFallbackTimer = setTimeout(() => {
132
+ terminateFallbackTimer = undefined;
133
+ if (disposed || liveGeneration !== generation)
134
+ return;
135
+ if (currentSocket === socket)
136
+ handleClose(RECOVERABLE_CLOSE);
137
+ }, TERMINATE_FALLBACK_MS);
138
+ }
139
+ return;
140
+ }
141
+ heartbeatAcked = false;
142
+ send(socket, OPCODE_HEARTBEAT, lastSeq);
143
+ }, interval);
144
+ if (sessionId !== undefined) {
145
+ send(socket, OPCODE_RESUME, { token, session_id: sessionId, seq: lastSeq });
146
+ }
147
+ else {
148
+ // Discord rejects an IDENTIFY without the properties block (op 9
149
+ // invalid session → close 4000): os/browser/device are required.
150
+ send(socket, OPCODE_IDENTIFY, {
151
+ token,
152
+ intents: options.intents,
153
+ properties: { os: 'linux', browser: 'dsh-discord', device: 'dsh-discord' },
154
+ });
155
+ }
156
+ return;
157
+ }
158
+ case OPCODE_HEARTBEAT_ACK: {
159
+ heartbeatAcked = true;
160
+ return;
161
+ }
162
+ case OPCODE_DISPATCH: {
163
+ const seq = frame['s'];
164
+ if (typeof seq === 'number')
165
+ lastSeq = seq;
166
+ const type = frame['t'];
167
+ const payload = frame['d'];
168
+ if (type === 'READY' && typeof payload === 'object' && payload !== null) {
169
+ const id = payload['session_id'];
170
+ if (typeof id === 'string')
171
+ sessionId = id;
172
+ backoffAttempt = 0;
173
+ }
174
+ options.onDispatch({
175
+ t: typeof type === 'string' ? type : '',
176
+ s: typeof seq === 'number' ? seq : null,
177
+ op: OPCODE_DISPATCH,
178
+ d: payload,
179
+ });
180
+ return;
181
+ }
182
+ case OPCODE_RECONNECT: {
183
+ // Server-requested recycle: closing drives the resume path.
184
+ currentSocket?.close(RECOVERABLE_CLOSE);
185
+ return;
186
+ }
187
+ case OPCODE_INVALID_SESSION: {
188
+ if (frame['d'] !== true) {
189
+ // Not resumable: forget the session so the next HELLO identifies fresh.
190
+ sessionId = undefined;
191
+ lastSeq = null;
192
+ }
193
+ // Discord closes the socket right after op 9; that close reconnects.
194
+ currentSocket?.close(RECOVERABLE_CLOSE);
195
+ return;
196
+ }
197
+ default:
198
+ }
199
+ }
200
+ function handleClose(code) {
201
+ console.error('[dsh-discord] gateway close:', code);
202
+ clearTimers();
203
+ if (disposed)
204
+ return;
205
+ if (TERMINAL_CLOSE_CODES.includes(code)) {
206
+ options.onTerminalClose(code);
207
+ return;
208
+ }
209
+ backoffAttempt += 1;
210
+ const delayMs = Math.min(backoffBaseMs * 2 ** (backoffAttempt - 1), backoffCapMs);
211
+ options.onBackoffScheduled?.(backoffAttempt, delayMs);
212
+ reconnectTimer = setTimeout(() => {
213
+ reconnectTimer = undefined;
214
+ connect();
215
+ }, delayMs);
216
+ }
217
+ connect();
218
+ return {
219
+ dispose() {
220
+ if (disposed)
221
+ return;
222
+ disposed = true;
223
+ clearTimers();
224
+ generation += 1;
225
+ currentSocket?.close(1000, 'plugin disposal');
226
+ currentSocket = undefined;
227
+ },
228
+ };
229
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Normalized inbound Gateway events. Every Discord dispatch this module
3
+ * accepts is untrusted wire data: validation failures are values, never
4
+ * throws, so one malformed payload can never break the dispatch loop. Only
5
+ * guild-scoped events the adapter understands survive this boundary; DMs and
6
+ * foreign event shapes are rejected here, before any business logic runs.
7
+ */
8
+ /** A Discord snowflake-shaped string. */
9
+ export type DiscordSnowflake = string;
10
+ /** Validate the snowflake shape of an untrusted string. */
11
+ export declare function isDiscordSnowflake(value: unknown): value is DiscordSnowflake;
12
+ /** Raw untrusted dispatch: an event name plus its unvalidated payload. */
13
+ export interface GatewayDispatch {
14
+ t: string;
15
+ d?: unknown;
16
+ }
17
+ /** Why a dispatch was rejected; discrimination keeps rejection handling total. */
18
+ export type IngestRejectReason = 'unsupported-event' | 'malformed-payload' | 'bot-authored' | 'non-guild-event';
19
+ export type IngestResult = {
20
+ accepted: true;
21
+ event: NormalizedMessage | NormalizedInteraction;
22
+ } | {
23
+ accepted: false;
24
+ reason: IngestRejectReason;
25
+ };
26
+ /** A validated guild message with its mention-stripped content. */
27
+ export interface NormalizedMessage {
28
+ kind: 'message';
29
+ messageId: DiscordSnowflake;
30
+ guildId: DiscordSnowflake;
31
+ channelId: DiscordSnowflake;
32
+ authorId: DiscordSnowflake;
33
+ /** Role ids the wire attached to the member (empty when absent). */
34
+ roleIds: string[];
35
+ /** Permission bitmask string from the member, when the wire carried one. */
36
+ memberPermissions: string | undefined;
37
+ /** Content after stripping every bot-mention token and trimming. */
38
+ content: string;
39
+ /** Whether the message explicitly mentioned the adapter's bot user. */
40
+ mentionedBot: boolean;
41
+ /** The snowflake of the message this one replies to, when present and valid. */
42
+ repliedToId: DiscordSnowflake | undefined;
43
+ }
44
+ /** A validated guild interaction with its actor identity. */
45
+ export interface NormalizedInteraction {
46
+ kind: 'interaction';
47
+ interactionId: DiscordSnowflake;
48
+ /** Discord interaction type (2=command, 3=component, 4=autocomplete, 5=modal). */
49
+ interactionType: number;
50
+ guildId: DiscordSnowflake;
51
+ channelId: DiscordSnowflake;
52
+ actorId: DiscordSnowflake;
53
+ roleIds: string[];
54
+ /** Permission bitmask string from the member, when the wire carried one. */
55
+ memberPermissions: string | undefined;
56
+ /** The component interaction's parent message (control editing). */
57
+ componentMessageId: string | undefined;
58
+ /** Select-menu values (string selects only). */
59
+ selectValues: string[];
60
+ /** Modal text fields, flattened from the action rows (custom_id + value). */
61
+ modalFields: Array<{
62
+ customId: string;
63
+ value: string;
64
+ }>;
65
+ /** Whether the invoking user is itself a bot; such invocations are denied. */
66
+ isBot: boolean;
67
+ /** Slash-command name for command/autocomplete interactions, when present. */
68
+ commandName: string | undefined;
69
+ /** The opaque interaction data table, normalized for downstream routing. */
70
+ data: Record<string, unknown>;
71
+ }
72
+ /**
73
+ * Detect and strip the adapter's own mention (`<@id>` or `<@!id>`) from
74
+ * message content. Other users' mentions are left intact; a mention-only
75
+ * message yields empty text.
76
+ */
77
+ export declare function extractBotMention(content: string, selfUserId: DiscordSnowflake): {
78
+ mentioned: boolean;
79
+ text: string;
80
+ };
81
+ /**
82
+ * Validate and normalize one untrusted Gateway dispatch. Returns a rejection
83
+ * value for every unsupported or malformed input and never throws.
84
+ */
85
+ export declare function parseGatewayDispatch(dispatch: GatewayDispatch, selfUserId: DiscordSnowflake): IngestResult;