@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,102 @@
1
+ import { installSettingsSection } from '@deepseek-ai/dsh-settings';
2
+ import z from '@deepseek-ai/schemastery';
3
+ import { DISCORD_SETTINGS_NAMESPACE } from './settings-namespace.js';
4
+ export { DISCORD_SETTINGS_NAMESPACE } from './settings-namespace.js';
5
+ export const THREAD_AUTO_ARCHIVE_OPTIONS = [60, 1440, 4320, 10080];
6
+ export const DEFAULT_DISCORD_SETTINGS = Object.freeze({
7
+ enabled: false,
8
+ allowedGuildIds: [],
9
+ memberUserIds: [],
10
+ memberRoleIds: [],
11
+ administratorUserIds: [],
12
+ administratorRoleIds: [],
13
+ deniedUserIds: [],
14
+ deniedRoleIds: [],
15
+ hostOperatorUserIds: [],
16
+ defaultVerbosity: 'essential-tools',
17
+ language: 'auto',
18
+ streamUpdateIntervalMs: 800,
19
+ typingIntervalMs: 7_000,
20
+ approvalTimeoutMs: 10 * 60_000,
21
+ questionTimeoutMs: 30 * 60_000,
22
+ threadAutoArchiveMinutes: 1440,
23
+ });
24
+ const discordIdList = z.array(z.string()).default([]);
25
+ export const DiscordSettingsSchema = z.object({
26
+ enabled: z.boolean().default(false),
27
+ allowedGuildIds: discordIdList,
28
+ memberUserIds: discordIdList,
29
+ memberRoleIds: discordIdList,
30
+ administratorUserIds: discordIdList,
31
+ administratorRoleIds: discordIdList,
32
+ deniedUserIds: discordIdList,
33
+ deniedRoleIds: discordIdList,
34
+ hostOperatorUserIds: discordIdList,
35
+ defaultVerbosity: z.union(['text-only', 'essential-tools', 'full-tools'])
36
+ .default('essential-tools'),
37
+ language: z.union(['auto', 'zh', 'en']).default('auto'),
38
+ threadAutoArchiveMinutes: z.union([60, 1440, 4320, 10080])
39
+ .default(1440),
40
+ streamUpdateIntervalMs: z.number().step(1).min(250).max(10_000).default(800),
41
+ typingIntervalMs: z.number().step(1).min(1_000).max(30_000).default(7_000),
42
+ approvalTimeoutMs: z.number().step(1).min(30_000).max(86_400_000).default(600_000),
43
+ questionTimeoutMs: z.number().step(1).min(30_000).max(86_400_000).default(1_800_000),
44
+ });
45
+ const DISCORD_SNOWFLAKE = /^\d{17,20}$/u;
46
+ const ID_FIELDS = [
47
+ 'allowedGuildIds',
48
+ 'memberUserIds',
49
+ 'memberRoleIds',
50
+ 'administratorUserIds',
51
+ 'administratorRoleIds',
52
+ 'deniedUserIds',
53
+ 'deniedRoleIds',
54
+ 'hostOperatorUserIds',
55
+ ];
56
+ function normalizeIds(values) {
57
+ return [...new Set(values.map(value => value.trim()))];
58
+ }
59
+ export function normalizeDiscordSettings(input) {
60
+ const result = { ...input };
61
+ for (const field of ID_FIELDS) {
62
+ result[field] = normalizeIds(input[field]);
63
+ }
64
+ return result;
65
+ }
66
+ export function validateDiscordSettings(input) {
67
+ for (const field of ID_FIELDS) {
68
+ if (input[field].some(value => !DISCORD_SNOWFLAKE.test(value.trim()))) {
69
+ throw new TypeError(`${field} must contain Discord snowflake IDs`);
70
+ }
71
+ }
72
+ if (!Number.isSafeInteger(input.streamUpdateIntervalMs)
73
+ || input.streamUpdateIntervalMs < 250
74
+ || input.streamUpdateIntervalMs > 10_000) {
75
+ throw new TypeError('streamUpdateIntervalMs must be between 250 and 10000');
76
+ }
77
+ if (!Number.isSafeInteger(input.typingIntervalMs)
78
+ || input.typingIntervalMs < 1_000
79
+ || input.typingIntervalMs > 30_000) {
80
+ throw new TypeError('typingIntervalMs must be between 1000 and 30000');
81
+ }
82
+ for (const field of ['approvalTimeoutMs', 'questionTimeoutMs']) {
83
+ if (!Number.isSafeInteger(input[field]) || input[field] < 30_000 || input[field] > 86_400_000) {
84
+ throw new TypeError(`${field} must be between 30000 and 86400000`);
85
+ }
86
+ }
87
+ if (!THREAD_AUTO_ARCHIVE_OPTIONS.includes(input.threadAutoArchiveMinutes)) {
88
+ throw new TypeError('threadAutoArchiveMinutes must be one of 60, 1440, 4320, 10080');
89
+ }
90
+ }
91
+ export function installDiscordSettings(ctx, entry, onChange) {
92
+ let source = () => entry;
93
+ installSettingsSection(ctx, DISCORD_SETTINGS_NAMESPACE, DiscordSettingsSchema, entry, {
94
+ validate: value => { validateDiscordSettings(normalizeDiscordSettings(value)); },
95
+ setSource: current => {
96
+ source = () => normalizeDiscordSettings(current());
97
+ },
98
+ onChange: () => {
99
+ onChange(source());
100
+ },
101
+ });
102
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The adapter's fail-loud startup boundary. Cordis `inject` delays activation
3
+ * until the named services exist but says nothing about contract shape, and a
4
+ * silently half-mounted Discord plugin is a security liability. This module
5
+ * probes every required DSH Host service in one pass and refuses activation
6
+ * with a single actionable diagnostic naming every gap.
7
+ */
8
+ /** The exact Host service roster the adapter requires (design decision 1). */
9
+ export declare const REQUIRED_HOST_SERVICES: readonly ["apiProxy", "credentials", "settings", "storageDomain", "connection"];
10
+ export type HostServiceName = (typeof REQUIRED_HOST_SERVICES)[number];
11
+ /**
12
+ * Validate every required Host capability through `resolveService`, which
13
+ * returns the service value or `undefined` when absent. Throws one aggregated
14
+ * `TypeError` naming every missing service and every missing contract member.
15
+ */
16
+ export declare function validateHostCapabilities(resolveService: (name: HostServiceName) => unknown): void;
package/lib/startup.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The adapter's fail-loud startup boundary. Cordis `inject` delays activation
3
+ * until the named services exist but says nothing about contract shape, and a
4
+ * silently half-mounted Discord plugin is a security liability. This module
5
+ * probes every required DSH Host service in one pass and refuses activation
6
+ * with a single actionable diagnostic naming every gap.
7
+ */
8
+ /** The exact Host service roster the adapter requires (design decision 1). */
9
+ export const REQUIRED_HOST_SERVICES = [
10
+ 'apiProxy',
11
+ 'credentials',
12
+ 'settings',
13
+ 'storageDomain',
14
+ 'connection',
15
+ ];
16
+ /**
17
+ * Minimum contract members per service, confirmed against the installed DSH
18
+ * `0.1.1-rc.2` types. A service lacking any member means a version drift the
19
+ * exact peer pin should have prevented; refuse rather than probe at runtime.
20
+ */
21
+ const REQUIRED_CONTRACT_MEMBERS = {
22
+ apiProxy: ['sessions', 'workspace', 'events', 'host'],
23
+ credentials: ['resolve', 'describe', 'set', 'unset'],
24
+ settings: ['register'],
25
+ storageDomain: ['open'],
26
+ connection: ['rpc'],
27
+ };
28
+ /** Actionable remediation hint appended to every activation failure. */
29
+ const REMEDIATION = 'install @addozhang/dsh-discord into a dsh web profile providing the pinned DSH 0.1.1-rc.2 contracts';
30
+ function missingMembers(service, required) {
31
+ if (service === undefined || service === null)
32
+ return [...required];
33
+ return required.filter(member => typeof service[member] === 'undefined');
34
+ }
35
+ /**
36
+ * Validate every required Host capability through `resolveService`, which
37
+ * returns the service value or `undefined` when absent. Throws one aggregated
38
+ * `TypeError` naming every missing service and every missing contract member.
39
+ */
40
+ export function validateHostCapabilities(resolveService) {
41
+ const missing = [];
42
+ const incompatible = [];
43
+ for (const name of REQUIRED_HOST_SERVICES) {
44
+ const service = resolveService(name);
45
+ if (service === undefined || service === null) {
46
+ missing.push(name);
47
+ continue;
48
+ }
49
+ const gaps = missingMembers(service, REQUIRED_CONTRACT_MEMBERS[name]);
50
+ if (gaps.length > 0)
51
+ incompatible.push(`${name} lacks ${gaps.map(member => `'${member}'`).join(', ')}`);
52
+ }
53
+ const parts = [
54
+ missing.length > 0 ? `missing DSH host service(s) ${missing.map(name => `'${name}'`).join(', ')}` : undefined,
55
+ incompatible.length > 0 ? `incompatible service(s): ${incompatible.join('; ')}` : undefined,
56
+ ].filter(Boolean);
57
+ if (parts.length > 0) {
58
+ throw new TypeError(`dsh-discord cannot activate (${parts.join('; ')}). ${REMEDIATION}`);
59
+ }
60
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Revision-fenced bindings over one durable record each (design.md §10).
3
+ * The store serializes claims per key with a process-local chain, then
4
+ * re-checks the fence at the write slot — so an async writer that snapshotted
5
+ * an older revision always loses to the live writer, and a failed claim never
6
+ * partially mutates the record. Channel and thread bindings share this one
7
+ * mechanism; only their record shapes differ.
8
+ */
9
+ import type { ChannelBinding, ThreadBinding } from './records.js';
10
+ /** Minimal durable face the store needs (the domain's KvTable provides it). */
11
+ export interface BindingTable<V> {
12
+ get(key: string): V | undefined;
13
+ put(key: string, value: V): Promise<void>;
14
+ delete(key: string): Promise<boolean>;
15
+ }
16
+ export type BindOutcome<V> = {
17
+ ok: true;
18
+ binding: V;
19
+ } | {
20
+ ok: false;
21
+ error: 'already-bound' | 'stale-revision' | 'not-bound';
22
+ };
23
+ export interface BindOptions {
24
+ /** Fence: the caller's observed revision; undefined only for a fresh bind. */
25
+ expectedRevision?: number | undefined;
26
+ /** Test/diagnostic hook run at the claim slot before the durable write. */
27
+ beforeWrite?: () => Promise<void> | void;
28
+ }
29
+ export interface BindingStore<V extends {
30
+ revision: number;
31
+ }> {
32
+ get(key: string): V | undefined;
33
+ bind(key: string, record: Omit<V, 'revision'>, options?: BindOptions): Promise<BindOutcome<V>>;
34
+ release(key: string, options: {
35
+ expectedRevision: number;
36
+ }): Promise<BindOutcome<V>>;
37
+ /** Run one operation serialized behind this key's claim chain. */
38
+ withKey<T>(key: string, op: () => Promise<T>): Promise<T>;
39
+ }
40
+ export declare function createBindingStore<V extends {
41
+ revision: number;
42
+ }>(table: BindingTable<V>): BindingStore<V>;
43
+ /** Convenience alias for the channel-binding table's store. */
44
+ export type ChannelBindingStore = BindingStore<ChannelBinding>;
45
+ /** Convenience alias for the thread-binding table's store. */
46
+ export type ThreadBindingStore = BindingStore<ThreadBinding>;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Revision-fenced bindings over one durable record each (design.md §10).
3
+ * The store serializes claims per key with a process-local chain, then
4
+ * re-checks the fence at the write slot — so an async writer that snapshotted
5
+ * an older revision always loses to the live writer, and a failed claim never
6
+ * partially mutates the record. Channel and thread bindings share this one
7
+ * mechanism; only their record shapes differ.
8
+ */
9
+ const noBeforeWrite = () => { };
10
+ export function createBindingStore(table) {
11
+ const chains = new Map();
12
+ function enqueue(key, op) {
13
+ const tail = chains.get(key) ?? Promise.resolve();
14
+ const run = tail.then(op, op);
15
+ // A failed claim never poisons later claims on the same key.
16
+ chains.set(key, run.then(() => undefined, () => undefined));
17
+ return run;
18
+ }
19
+ async function bindAtSlot(key, record, options) {
20
+ const hook = options.beforeWrite ?? noBeforeWrite;
21
+ await Promise.resolve().then(hook);
22
+ const current = table.get(key);
23
+ if (options.expectedRevision === undefined) {
24
+ if (current !== undefined)
25
+ return { ok: false, error: 'already-bound' };
26
+ const binding = { ...record, revision: 1 };
27
+ await table.put(key, binding);
28
+ return { ok: true, binding };
29
+ }
30
+ if (current === undefined || current.revision !== options.expectedRevision) {
31
+ return { ok: false, error: 'stale-revision' };
32
+ }
33
+ const binding = { ...record, revision: options.expectedRevision + 1 };
34
+ await table.put(key, binding);
35
+ return { ok: true, binding };
36
+ }
37
+ return {
38
+ get: key => table.get(key),
39
+ bind(key, record, options = {}) {
40
+ return enqueue(key, () => bindAtSlot(key, record, options));
41
+ },
42
+ release(key, { expectedRevision }) {
43
+ return enqueue(key, async () => {
44
+ const current = table.get(key);
45
+ if (current === undefined)
46
+ return { ok: false, error: 'not-bound' };
47
+ if (current.revision !== expectedRevision)
48
+ return { ok: false, error: 'stale-revision' };
49
+ await table.delete(key);
50
+ return { ok: true, binding: current };
51
+ });
52
+ },
53
+ withKey: (key, op) => enqueue(key, op),
54
+ };
55
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The Channel→Workspace binding service: the composition of the versioned
3
+ * key codec, the revision-fenced binding store, and guild-scoped listing.
4
+ * Keys carry the full ownership scope (application+guild+channel), so
5
+ * bindings are independent across channels and guilds by construction, and
6
+ * a guild-scoped scan parses keys instead of guessing with prefixes.
7
+ */
8
+ import type { ChannelBinding } from './records.js';
9
+ import { type ChannelBindingScope } from './domain.js';
10
+ import type { BindingStore, BindOutcome } from './bindings.js';
11
+ export interface GuildBindingEntry {
12
+ scope: ChannelBindingScope;
13
+ binding: ChannelBinding;
14
+ }
15
+ export interface ChannelBindingService {
16
+ keyFor(scope: ChannelBindingScope): string;
17
+ resolve(scope: ChannelBindingScope): ChannelBinding | undefined;
18
+ bind(scope: ChannelBindingScope, request: {
19
+ workspaceId: string;
20
+ actorId: string;
21
+ nowMs: number;
22
+ }, options?: {
23
+ expectedRevision?: number | undefined;
24
+ beforeWrite?: () => Promise<void> | void;
25
+ }): Promise<BindOutcome<ChannelBinding>>;
26
+ release(scope: ChannelBindingScope, options: {
27
+ expectedRevision: number;
28
+ }): Promise<BindOutcome<ChannelBinding>>;
29
+ /** All bindings inside one guild (parsed keys, not prefix guesses). */
30
+ listForGuild(guildId: string): GuildBindingEntry[];
31
+ }
32
+ export declare function createChannelBindingService(deps: {
33
+ store: BindingStore<ChannelBinding>;
34
+ applicationId: string;
35
+ /** Raw key access for guild scans; the domain's table provides it. */
36
+ listKeys?: () => Iterable<string>;
37
+ }): ChannelBindingService;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The Channel→Workspace binding service: the composition of the versioned
3
+ * key codec, the revision-fenced binding store, and guild-scoped listing.
4
+ * Keys carry the full ownership scope (application+guild+channel), so
5
+ * bindings are independent across channels and guilds by construction, and
6
+ * a guild-scoped scan parses keys instead of guessing with prefixes.
7
+ */
8
+ import { channelBindingKey, parseChannelBindingKey } from './domain.js';
9
+ export function createChannelBindingService(deps) {
10
+ const { store, applicationId, listKeys } = deps;
11
+ function keyFor(scope) {
12
+ return channelBindingKey(scope);
13
+ }
14
+ return {
15
+ keyFor,
16
+ resolve: scope => store.get(keyFor(scope)),
17
+ bind(scope, request, options = {}) {
18
+ return store.bind(keyFor(scope), {
19
+ workspaceId: request.workspaceId,
20
+ boundBy: request.actorId,
21
+ boundAtMs: request.nowMs,
22
+ }, {
23
+ ...(options.expectedRevision === undefined ? {} : { expectedRevision: options.expectedRevision }),
24
+ ...(options.beforeWrite === undefined ? {} : { beforeWrite: options.beforeWrite }),
25
+ });
26
+ },
27
+ release: (scope, options) => store.release(keyFor(scope), options),
28
+ listForGuild(guildId) {
29
+ if (listKeys === undefined)
30
+ return [];
31
+ const entries = [];
32
+ for (const key of listKeys()) {
33
+ const scope = parseChannelBindingKey(key);
34
+ if (scope === undefined || scope.applicationId !== applicationId || scope.guildId !== guildId)
35
+ continue;
36
+ const binding = store.get(key);
37
+ if (binding !== undefined)
38
+ entries.push({ scope, binding });
39
+ }
40
+ return entries;
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The adapter's versioned DSH storage domain. Keys compose the logical
3
+ * ownership scopes — application, guild, then channel or thread — as
4
+ * parseable strings, so guild-scoped reconciliation and forget derive their
5
+ * scan space from parsed parts. The spec is declared once with
6
+ * `defineDomain`, which fails loud at module load on any naming or version
7
+ * misconfiguration.
8
+ */
9
+ /**
10
+ * Storage-legal domain name (`UNIT_NAME_RE` allows only lowercase, digits,
11
+ * and underscores) and the current format version; a medium stamped with a
12
+ * different version rejects at open, which is exactly the fail-closed
13
+ * behavior the adapter wants across upgrades.
14
+ */
15
+ export declare const DISCORD_DOMAIN_NAME = "dsh_discord";
16
+ export declare const DISCORD_DOMAIN_VERSION = 1;
17
+ export declare const CHANNEL_BINDINGS_TABLE = "channel_bindings";
18
+ export declare const THREAD_BINDINGS_TABLE = "thread_bindings";
19
+ export declare const INTENTS_TABLE = "inbound_intents";
20
+ /** Scope of one project-channel binding. */
21
+ export interface ChannelBindingScope {
22
+ applicationId: string;
23
+ guildId: string;
24
+ channelId: string;
25
+ }
26
+ /** Scope of one session-thread binding. */
27
+ export interface ThreadBindingScope {
28
+ applicationId: string;
29
+ guildId: string;
30
+ threadId: string;
31
+ }
32
+ /** Build the channel-binding key for one guild channel. */
33
+ export declare function channelBindingKey(scope: ChannelBindingScope): string;
34
+ /** Parse a channel-binding key; `undefined` when the key is another family. */
35
+ export declare function parseChannelBindingKey(key: string): ChannelBindingScope | undefined;
36
+ /** Build the thread-binding key for one guild thread. */
37
+ export declare function threadBindingKey(scope: ThreadBindingScope): string;
38
+ /** Parse a thread-binding key; `undefined` when the key is another family. */
39
+ export declare function parseThreadBindingKey(key: string): ThreadBindingScope | undefined;
40
+ /** The domain declaration: identity, format version, and record layout. */
41
+ export declare const discordDomainSpec: {
42
+ name: string;
43
+ version: number;
44
+ tables: {
45
+ channel_bindings: import("@deepseek-ai/dsh-storage-domain").DomainTableSpec<"channel", {
46
+ workspaceId: string;
47
+ revision: number;
48
+ boundBy: string;
49
+ boundAtMs: number;
50
+ }>;
51
+ thread_bindings: import("@deepseek-ai/dsh-storage-domain").DomainTableSpec<"thread", {
52
+ sessionId: string;
53
+ workspaceId: string;
54
+ revision: number;
55
+ createdBy: string;
56
+ createdAtMs: number;
57
+ }>;
58
+ inbound_intents: import("@deepseek-ai/dsh-storage-domain").DomainTableSpec<"intent", {
59
+ contentHash: string;
60
+ state: "claimed" | "unknown" | "succeeded" | "failed";
61
+ claimedAtMs: number;
62
+ resolvedAtMs?: number | undefined;
63
+ threadId?: string | undefined;
64
+ }>;
65
+ };
66
+ };
67
+ /** Minimal facility face the opener needs (the real DomainFacility matches). */
68
+ export interface DomainFacilityLike {
69
+ open(spec: typeof discordDomainSpec): Promise<{
70
+ name: string;
71
+ close(): Promise<void>;
72
+ }>;
73
+ }
74
+ /**
75
+ * Open the adapter's domain through the Host's domain facility. The caller
76
+ * owns the returned handle — close it from the plugin's cancellation root so
77
+ * shutdown drains queued writes before the unit releases.
78
+ */
79
+ export declare function openDiscordDomain(facility: DomainFacilityLike): Promise<{
80
+ name: string;
81
+ close(): Promise<void>;
82
+ }>;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The adapter's versioned DSH storage domain. Keys compose the logical
3
+ * ownership scopes — application, guild, then channel or thread — as
4
+ * parseable strings, so guild-scoped reconciliation and forget derive their
5
+ * scan space from parsed parts. The spec is declared once with
6
+ * `defineDomain`, which fails loud at module load on any naming or version
7
+ * misconfiguration.
8
+ */
9
+ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain';
10
+ import { ChannelBindingRecord, InboundIntentRecordSchema, ThreadBindingRecord } from './records.js';
11
+ /**
12
+ * Storage-legal domain name (`UNIT_NAME_RE` allows only lowercase, digits,
13
+ * and underscores) and the current format version; a medium stamped with a
14
+ * different version rejects at open, which is exactly the fail-closed
15
+ * behavior the adapter wants across upgrades.
16
+ */
17
+ export const DISCORD_DOMAIN_NAME = 'dsh_discord';
18
+ export const DISCORD_DOMAIN_VERSION = 1;
19
+ export const CHANNEL_BINDINGS_TABLE = 'channel_bindings';
20
+ export const THREAD_BINDINGS_TABLE = 'thread_bindings';
21
+ export const INTENTS_TABLE = 'inbound_intents';
22
+ const ID_SEGMENT = /^[^:\s]+$/;
23
+ function composeKey(parts) {
24
+ return parts.join(':');
25
+ }
26
+ function parseSegments(key, family) {
27
+ const segments = key.split(':');
28
+ if (segments.length !== 6)
29
+ return undefined;
30
+ const [familyPrefix, applicationId, guildLiteral, guildId, leaf, leafId] = segments;
31
+ if (familyPrefix !== 'app' || guildLiteral !== 'guild')
32
+ return undefined;
33
+ if (leaf !== family)
34
+ return undefined;
35
+ for (const id of [applicationId, guildId, leafId]) {
36
+ if (id === undefined || id === '' || !ID_SEGMENT.test(id))
37
+ return undefined;
38
+ }
39
+ return segments;
40
+ }
41
+ /** Build the channel-binding key for one guild channel. */
42
+ export function channelBindingKey(scope) {
43
+ return composeKey(['app', scope.applicationId, 'guild', scope.guildId, 'channel', scope.channelId]);
44
+ }
45
+ /** Parse a channel-binding key; `undefined` when the key is another family. */
46
+ export function parseChannelBindingKey(key) {
47
+ const segments = parseSegments(key, 'channel');
48
+ const applicationId = segments?.[1];
49
+ const guildId = segments?.[3];
50
+ const channelId = segments?.[5];
51
+ if (applicationId === undefined || guildId === undefined || channelId === undefined)
52
+ return undefined;
53
+ return { applicationId, guildId, channelId };
54
+ }
55
+ /** Build the thread-binding key for one guild thread. */
56
+ export function threadBindingKey(scope) {
57
+ return composeKey(['app', scope.applicationId, 'guild', scope.guildId, 'thread', scope.threadId]);
58
+ }
59
+ /** Parse a thread-binding key; `undefined` when the key is another family. */
60
+ export function parseThreadBindingKey(key) {
61
+ const segments = parseSegments(key, 'thread');
62
+ const applicationId = segments?.[1];
63
+ const guildId = segments?.[3];
64
+ const threadId = segments?.[5];
65
+ if (applicationId === undefined || guildId === undefined || threadId === undefined)
66
+ return undefined;
67
+ return { applicationId, guildId, threadId };
68
+ }
69
+ /** The domain declaration: identity, format version, and record layout. */
70
+ export const discordDomainSpec = defineDomain({
71
+ name: DISCORD_DOMAIN_NAME,
72
+ version: DISCORD_DOMAIN_VERSION,
73
+ tables: {
74
+ [CHANNEL_BINDINGS_TABLE]: domainTable(ChannelBindingRecord),
75
+ [THREAD_BINDINGS_TABLE]: domainTable(ThreadBindingRecord),
76
+ [INTENTS_TABLE]: domainTable(InboundIntentRecordSchema),
77
+ },
78
+ });
79
+ /**
80
+ * Open the adapter's domain through the Host's domain facility. The caller
81
+ * owns the returned handle — close it from the plugin's cancellation root so
82
+ * shutdown drains queued writes before the unit releases.
83
+ */
84
+ export async function openDiscordDomain(facility) {
85
+ return facility.open(discordDomainSpec);
86
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The external-effect state machine shared by Discord deliveries and DSH
3
+ * submissions (design.md §10):
4
+ *
5
+ * planned → executing → succeeded
6
+ * → failed
7
+ * → unknown-needs-user-resolution
8
+ *
9
+ * Effects must be marked executing before they can finish, and every
10
+ * terminal state — including the unknown one — is final: recovery always
11
+ * issues a NEW intent instead of rewriting a settled record. All decisions
12
+ * are pure values; callers decide how to persist the record.
13
+ */
14
+ /** The five effect states, in authority order. */
15
+ export declare const DELIVERY_STATES: readonly ["planned", "executing", "succeeded", "failed", "unknown-needs-user-resolution"];
16
+ export type EffectState = (typeof DELIVERY_STATES)[number];
17
+ /** Whether the edge `from → to` exists in the transition table. */
18
+ export declare function canTransition(from: EffectState, to: EffectState): boolean;
19
+ /** Terminal states can never transition again; recovery means a new intent. */
20
+ export declare function isTerminal(state: EffectState): boolean;
21
+ export interface EffectRecord {
22
+ state: EffectState;
23
+ }
24
+ export type TransitionResult = {
25
+ ok: true;
26
+ state: EffectState;
27
+ } | {
28
+ ok: false;
29
+ error: 'invalid-transition';
30
+ from: EffectState;
31
+ to: EffectState;
32
+ };
33
+ export interface EffectMachine {
34
+ create(): EffectRecord;
35
+ transition(record: EffectRecord, to: EffectState): TransitionResult;
36
+ }
37
+ export declare function createStateMachine(): EffectMachine;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The external-effect state machine shared by Discord deliveries and DSH
3
+ * submissions (design.md §10):
4
+ *
5
+ * planned → executing → succeeded
6
+ * → failed
7
+ * → unknown-needs-user-resolution
8
+ *
9
+ * Effects must be marked executing before they can finish, and every
10
+ * terminal state — including the unknown one — is final: recovery always
11
+ * issues a NEW intent instead of rewriting a settled record. All decisions
12
+ * are pure values; callers decide how to persist the record.
13
+ */
14
+ /** The five effect states, in authority order. */
15
+ export const DELIVERY_STATES = [
16
+ 'planned',
17
+ 'executing',
18
+ 'succeeded',
19
+ 'failed',
20
+ 'unknown-needs-user-resolution',
21
+ ];
22
+ const TERMINAL = new Set([
23
+ 'succeeded',
24
+ 'failed',
25
+ 'unknown-needs-user-resolution',
26
+ ]);
27
+ const VALID_EDGES = new Map([
28
+ ['planned', ['executing']],
29
+ ['executing', ['succeeded', 'failed', 'unknown-needs-user-resolution']],
30
+ ['succeeded', []],
31
+ ['failed', []],
32
+ ['unknown-needs-user-resolution', []],
33
+ ]);
34
+ /** Whether the edge `from → to` exists in the transition table. */
35
+ export function canTransition(from, to) {
36
+ return VALID_EDGES.get(from)?.includes(to) === true;
37
+ }
38
+ /** Terminal states can never transition again; recovery means a new intent. */
39
+ export function isTerminal(state) {
40
+ return TERMINAL.has(state);
41
+ }
42
+ export function createStateMachine() {
43
+ return {
44
+ create: () => ({ state: 'planned' }),
45
+ transition(record, to) {
46
+ if (!canTransition(record.state, to)) {
47
+ return { ok: false, error: 'invalid-transition', from: record.state, to };
48
+ }
49
+ return { ok: true, state: to };
50
+ },
51
+ };
52
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Fail-closed integrity gate over a binding store (design.md §10). Storage
3
+ * records are validated against the strict zod schema on every access:
4
+ * malformed or NEWER-format records make the key read as corrupt, and every
5
+ * Discord-triggered write refuses — the record is never replaced, repaired,
6
+ * or emptied by the adapter. Backend durability failures surface as plain
7
+ * values so callers can retry or alert without a crash crossing the dispatch
8
+ * loop.
9
+ */
10
+ import type { ZodType } from 'zod';
11
+ import type { BindOptions, BindingStore, BindOutcome } from './bindings.js';
12
+ export interface RawRecordAccess {
13
+ /** Raw (unvalidated) record access for integrity diagnosis. */
14
+ get(key: string): unknown;
15
+ }
16
+ export type Diagnosis = {
17
+ state: 'missing';
18
+ } | {
19
+ state: 'ok';
20
+ } | {
21
+ state: 'corrupt';
22
+ issues: string[];
23
+ };
24
+ export type FailClosedOutcome<V> = BindOutcome<V> | {
25
+ ok: false;
26
+ error: 'state-corrupt';
27
+ } | {
28
+ ok: false;
29
+ error: 'durable-write-failed';
30
+ cause: unknown;
31
+ };
32
+ export interface FailClosedBindingStore<V extends {
33
+ revision: number;
34
+ }> {
35
+ get(key: string): V | undefined;
36
+ diagnose(key: string): Diagnosis;
37
+ bind(key: string, record: Omit<V, 'revision'>, options?: BindOptions): Promise<FailClosedOutcome<V>>;
38
+ release(key: string, options: {
39
+ expectedRevision: number;
40
+ }): Promise<FailClosedOutcome<V>>;
41
+ }
42
+ export declare function createFailClosedBindingStore<V extends {
43
+ revision: number;
44
+ }>(inner: BindingStore<V>, schema: ZodType<V>, raw: RawRecordAccess): FailClosedBindingStore<V>;