@moxxy/plugin-channel-discord 0.27.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 (126) hide show
  1. package/LICENSE +21 -0
  2. package/dist/allow-list.d.ts +20 -0
  3. package/dist/allow-list.d.ts.map +1 -0
  4. package/dist/allow-list.js +12 -0
  5. package/dist/allow-list.js.map +1 -0
  6. package/dist/approval.d.ts +29 -0
  7. package/dist/approval.d.ts.map +1 -0
  8. package/dist/approval.js +65 -0
  9. package/dist/approval.js.map +1 -0
  10. package/dist/channel/allow-list-store.d.ts +22 -0
  11. package/dist/channel/allow-list-store.d.ts.map +1 -0
  12. package/dist/channel/allow-list-store.js +45 -0
  13. package/dist/channel/allow-list-store.js.map +1 -0
  14. package/dist/channel/approval-prompt.d.ts +16 -0
  15. package/dist/channel/approval-prompt.d.ts.map +1 -0
  16. package/dist/channel/approval-prompt.js +27 -0
  17. package/dist/channel/approval-prompt.js.map +1 -0
  18. package/dist/channel/components.d.ts +27 -0
  19. package/dist/channel/components.d.ts.map +1 -0
  20. package/dist/channel/components.js +34 -0
  21. package/dist/channel/components.js.map +1 -0
  22. package/dist/channel/discord-like.d.ts +31 -0
  23. package/dist/channel/discord-like.d.ts.map +1 -0
  24. package/dist/channel/discord-like.js +9 -0
  25. package/dist/channel/discord-like.js.map +1 -0
  26. package/dist/channel/interaction-handler.d.ts +67 -0
  27. package/dist/channel/interaction-handler.d.ts.map +1 -0
  28. package/dist/channel/interaction-handler.js +155 -0
  29. package/dist/channel/interaction-handler.js.map +1 -0
  30. package/dist/channel/message-handler.d.ts +60 -0
  31. package/dist/channel/message-handler.d.ts.map +1 -0
  32. package/dist/channel/message-handler.js +135 -0
  33. package/dist/channel/message-handler.js.map +1 -0
  34. package/dist/channel/pairing-handler.d.ts +58 -0
  35. package/dist/channel/pairing-handler.d.ts.map +1 -0
  36. package/dist/channel/pairing-handler.js +109 -0
  37. package/dist/channel/pairing-handler.js.map +1 -0
  38. package/dist/channel/permission-prompt.d.ts +19 -0
  39. package/dist/channel/permission-prompt.d.ts.map +1 -0
  40. package/dist/channel/permission-prompt.js +38 -0
  41. package/dist/channel/permission-prompt.js.map +1 -0
  42. package/dist/channel/slash-handler.d.ts +45 -0
  43. package/dist/channel/slash-handler.d.ts.map +1 -0
  44. package/dist/channel/slash-handler.js +107 -0
  45. package/dist/channel/slash-handler.js.map +1 -0
  46. package/dist/channel/turn-runner.d.ts +49 -0
  47. package/dist/channel/turn-runner.d.ts.map +1 -0
  48. package/dist/channel/turn-runner.js +116 -0
  49. package/dist/channel/turn-runner.js.map +1 -0
  50. package/dist/channel/typing-indicator.d.ts +12 -0
  51. package/dist/channel/typing-indicator.d.ts.map +1 -0
  52. package/dist/channel/typing-indicator.js +28 -0
  53. package/dist/channel/typing-indicator.js.map +1 -0
  54. package/dist/channel/voice-handler.d.ts +41 -0
  55. package/dist/channel/voice-handler.d.ts.map +1 -0
  56. package/dist/channel/voice-handler.js +105 -0
  57. package/dist/channel/voice-handler.js.map +1 -0
  58. package/dist/channel.d.ts +107 -0
  59. package/dist/channel.d.ts.map +1 -0
  60. package/dist/channel.js +464 -0
  61. package/dist/channel.js.map +1 -0
  62. package/dist/index.d.ts +29 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +179 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/keys.d.ts +64 -0
  67. package/dist/keys.d.ts.map +1 -0
  68. package/dist/keys.js +85 -0
  69. package/dist/keys.js.map +1 -0
  70. package/dist/pair-flow.d.ts +16 -0
  71. package/dist/pair-flow.d.ts.map +1 -0
  72. package/dist/pair-flow.js +109 -0
  73. package/dist/pair-flow.js.map +1 -0
  74. package/dist/pairing.d.ts +83 -0
  75. package/dist/pairing.d.ts.map +1 -0
  76. package/dist/pairing.js +98 -0
  77. package/dist/pairing.js.map +1 -0
  78. package/dist/permission.d.ts +30 -0
  79. package/dist/permission.d.ts.map +1 -0
  80. package/dist/permission.js +54 -0
  81. package/dist/permission.js.map +1 -0
  82. package/dist/render.d.ts +34 -0
  83. package/dist/render.d.ts.map +1 -0
  84. package/dist/render.js +204 -0
  85. package/dist/render.js.map +1 -0
  86. package/dist/schema.d.ts +123 -0
  87. package/dist/schema.d.ts.map +1 -0
  88. package/dist/schema.js +70 -0
  89. package/dist/schema.js.map +1 -0
  90. package/dist/setup-wizard.d.ts +13 -0
  91. package/dist/setup-wizard.d.ts.map +1 -0
  92. package/dist/setup-wizard.js +133 -0
  93. package/dist/setup-wizard.js.map +1 -0
  94. package/package.json +89 -0
  95. package/src/allow-list.test.ts +64 -0
  96. package/src/allow-list.ts +30 -0
  97. package/src/approval.ts +76 -0
  98. package/src/channel/allow-list-store.ts +52 -0
  99. package/src/channel/approval-prompt.ts +43 -0
  100. package/src/channel/components.ts +49 -0
  101. package/src/channel/discord-like.ts +34 -0
  102. package/src/channel/interaction-handler.test.ts +212 -0
  103. package/src/channel/interaction-handler.ts +237 -0
  104. package/src/channel/message-handler.test.ts +295 -0
  105. package/src/channel/message-handler.ts +195 -0
  106. package/src/channel/pairing-handler.ts +147 -0
  107. package/src/channel/permission-prompt.ts +54 -0
  108. package/src/channel/slash-handler.ts +136 -0
  109. package/src/channel/turn-runner.test.ts +177 -0
  110. package/src/channel/turn-runner.ts +139 -0
  111. package/src/channel/typing-indicator.ts +31 -0
  112. package/src/channel/voice-handler.test.ts +177 -0
  113. package/src/channel/voice-handler.ts +150 -0
  114. package/src/channel/voice-reply.test.ts +142 -0
  115. package/src/channel.ts +582 -0
  116. package/src/index.ts +247 -0
  117. package/src/keys.ts +100 -0
  118. package/src/pair-flow.ts +122 -0
  119. package/src/pairing.test.ts +99 -0
  120. package/src/pairing.ts +178 -0
  121. package/src/permission.ts +70 -0
  122. package/src/render.test.ts +82 -0
  123. package/src/render.ts +228 -0
  124. package/src/schema.ts +94 -0
  125. package/src/setup-wizard.ts +162 -0
  126. package/src/subcommands.test.ts +182 -0
package/src/index.ts ADDED
@@ -0,0 +1,247 @@
1
+ import { defineChannel, definePlugin, type LifecycleHooks, type Plugin } from '@moxxy/sdk';
2
+ import type { VaultStore } from '@moxxy/plugin-vault';
3
+ import { DiscordChannel } from './channel.js';
4
+ import {
5
+ DISCORD_ALLOWED_CHANNELS_KEY,
6
+ DISCORD_AUTHORIZED_USER_KEY,
7
+ DISCORD_TOKEN_ENV,
8
+ DISCORD_TOKEN_KEY,
9
+ parseAllowedChannels,
10
+ parseAuthorizedUser,
11
+ resolveBotToken,
12
+ } from './keys.js';
13
+ import { runDiscordWizard } from './setup-wizard.js';
14
+ import { runPairFlow } from './pair-flow.js';
15
+
16
+ export {
17
+ DiscordChannel,
18
+ type DiscordChannelOptions,
19
+ type DiscordStartOpts,
20
+ type PairingConfirmResult,
21
+ } from './channel.js';
22
+ export { DiscordPermissionResolver, type PendingPermission } from './permission.js';
23
+ export { DiscordApprovalResolver, type PendingApproval } from './approval.js';
24
+ export {
25
+ armPairing,
26
+ clearDiscordPairing,
27
+ confirmPendingCode,
28
+ createDiscordPairingState,
29
+ isUserAuthorized,
30
+ mintCodeForPeer,
31
+ pairingPhase,
32
+ type DiscordPairingDecision,
33
+ type DiscordPairingPhase,
34
+ type DiscordPairingState,
35
+ } from './pairing.js';
36
+ export { gateInbound, type GateVerdict } from './allow-list.js';
37
+ export { DiscordTurnRenderer, splitForDiscord, DISCORD_MESSAGE_LIMIT } from './render.js';
38
+ export {
39
+ extractInboundMessage,
40
+ inboundMessageSchema,
41
+ MAX_AUDIO_BYTES,
42
+ MAX_CONTENT_CHARS,
43
+ type InboundAttachment,
44
+ type InboundMessage,
45
+ } from './schema.js';
46
+ export {
47
+ DISCORD_TOKEN_KEY,
48
+ DISCORD_AUTHORIZED_USER_KEY,
49
+ DISCORD_ALLOWED_CHANNELS_KEY,
50
+ DISCORD_TOKEN_ENV,
51
+ DISCORD_TOKEN_RE,
52
+ parseAuthorizedUser,
53
+ parseAllowedChannels,
54
+ serializeAllowedChannels,
55
+ resolveBotToken,
56
+ } from './keys.js';
57
+
58
+ export interface BuildDiscordPluginOptions {
59
+ /** Host-injected encrypted secret store (available immediately). */
60
+ readonly vault: VaultStore;
61
+ }
62
+
63
+ /**
64
+ * Build the Discord channel plugin with a host-injected vault (mirroring the
65
+ * Telegram plugin's vault injection).
66
+ */
67
+ export function buildDiscordPlugin(opts: BuildDiscordPluginOptions): Plugin {
68
+ return makeDiscordPlugin(() => opts.vault);
69
+ }
70
+
71
+ /**
72
+ * Discovery-loadable default export: resolves the vault from the inter-plugin
73
+ * service registry in `onInit` (the vault plugin publishes `'vault'`). Requires
74
+ * `@moxxy/plugin-vault` to load first (declared in `package.json`
75
+ * `moxxy.requirements`). The channel + subcommands read the vault via
76
+ * `getVault()`, so resolution is deferred to call time — after `onInit` wired it.
77
+ */
78
+ export const discordPlugin: Plugin = (() => {
79
+ let resolved: VaultStore | null = null;
80
+ const getVault = (): VaultStore => {
81
+ if (!resolved) {
82
+ throw new Error(
83
+ '@moxxy/plugin-channel-discord: the "vault" service is unavailable — @moxxy/plugin-vault must load first',
84
+ );
85
+ }
86
+ return resolved;
87
+ };
88
+ const hooks: LifecycleHooks = {
89
+ onInit: (ctx) => {
90
+ resolved = ctx.services.require<VaultStore>('vault');
91
+ },
92
+ };
93
+ return makeDiscordPlugin(getVault, hooks);
94
+ })();
95
+
96
+ // Discovery entry: `createPluginLoader` requires a default Plugin export.
97
+ export default discordPlugin;
98
+
99
+ function makeDiscordPlugin(getVault: () => VaultStore, hooks?: LifecycleHooks): Plugin {
100
+ return definePlugin({
101
+ name: '@moxxy/plugin-channel-discord',
102
+ version: '0.0.0',
103
+ ...(hooks ? { hooks } : {}),
104
+ channels: [
105
+ defineChannel({
106
+ name: 'discord',
107
+ description:
108
+ 'Discord bot channel via discord.js (gateway). DM code pairing; streamed replies via message edits; button-based permission prompts.',
109
+ // Like Slack/Telegram, run on a dedicated, isolated runner (separate
110
+ // socket + sticky session) so the bot keeps its own persistent history
111
+ // apart from the user's desktop/TUI work. The gateway is an outbound
112
+ // WebSocket — no tunnel needed.
113
+ dedicatedRunner: true,
114
+ sessionSource: 'discord',
115
+ // Self-described config so a control surface (TUI `/channels`, `moxxy
116
+ // channels start`) can configure + run Discord without a hardcoded table.
117
+ config: {
118
+ fields: [
119
+ {
120
+ name: 'botToken',
121
+ label: 'Bot token',
122
+ vaultKey: DISCORD_TOKEN_KEY,
123
+ required: true,
124
+ secret: true,
125
+ placeholder: 'MTIz…abc.def…',
126
+ help: 'discord.com/developers → your app → Bot → Reset Token. Enable the MESSAGE CONTENT privileged intent on the same page.',
127
+ },
128
+ ],
129
+ hasRequestUrl: false,
130
+ runHint:
131
+ 'Invite the bot to a server via the link shown, then DM it — it replies with a one-time code; finish with `moxxy discord pair` in a terminal.',
132
+ connect: {
133
+ kind: 'url',
134
+ title: 'Invite the bot',
135
+ hint: 'Open the link to invite the bot to your server, then DM it to receive a pairing code and finish with `moxxy discord pair`.',
136
+ openable: true,
137
+ openLabel: 'Open Discord authorization',
138
+ },
139
+ },
140
+ create: (deps) =>
141
+ new DiscordChannel({
142
+ vault: getVault(),
143
+ token: (deps.options?.['token'] as string | undefined) ?? undefined,
144
+ logger: deps.logger as never,
145
+ ...(typeof deps.options?.['editFrameMs'] === 'number'
146
+ ? { editFrameMs: deps.options['editFrameMs'] as number }
147
+ : {}),
148
+ }),
149
+ isAvailable: async () => {
150
+ // Env-first: a fully env-configured bot is available even in a probe
151
+ // context (e.g. the `moxxy channels` listing) where onInit has not
152
+ // yet wired the vault service, so `getVault()` would throw.
153
+ if (process.env[DISCORD_TOKEN_ENV]?.trim()) return { ok: true };
154
+ try {
155
+ if ((await resolveBotToken(getVault())) != null) return { ok: true };
156
+ return {
157
+ ok: false,
158
+ reason:
159
+ "No bot token. Set MOXXY_DISCORD_TOKEN, or store one in the vault as '" +
160
+ DISCORD_TOKEN_KEY +
161
+ "' via `moxxy discord setup`.",
162
+ };
163
+ } catch {
164
+ return {
165
+ ok: false,
166
+ reason: 'Set MOXXY_DISCORD_TOKEN to skip the vault, or unlock the vault first.',
167
+ };
168
+ }
169
+ },
170
+ interactiveCommand: 'setup',
171
+ subcommands: {
172
+ setup: {
173
+ description:
174
+ 'Interactive setup: store a bot token (with privileged-intent guidance), pair an account, then start the bot. Shown by default for `moxxy discord` on a TTY.',
175
+ run: async (ctx) => {
176
+ // The wizard drives token entry + pairing through clack prompts,
177
+ // so it needs an interactive terminal. In a headless invocation
178
+ // we just start the bot directly.
179
+ if (process.stdin.isTTY !== true) {
180
+ return ctx.startChannel();
181
+ }
182
+ return runDiscordWizard(ctx);
183
+ },
184
+ },
185
+ pair: {
186
+ description:
187
+ 'DM code pairing: start the bot with a pairing window armed, DM it from your account, then paste the code it replies with.',
188
+ run: async (ctx) => {
189
+ // Pairing needs the operator to paste a code, so it needs an
190
+ // interactive terminal. In a headless invocation we bail with a
191
+ // clear message instead of starting a bot nobody can pair.
192
+ if (process.stdin.isTTY !== true) {
193
+ process.stderr.write(
194
+ 'Pairing needs a TTY (you paste the code the bot DMs back). Run `moxxy channels discord pair` on a workstation.\n',
195
+ );
196
+ return 1;
197
+ }
198
+ return runPairFlow(ctx);
199
+ },
200
+ },
201
+ unpair: {
202
+ description: 'Forget the currently authorized Discord account.',
203
+ run: async (ctx) => {
204
+ const vault = ctx.deps.vault as VaultStore | undefined;
205
+ if (!vault) {
206
+ process.stderr.write('vault unavailable\n');
207
+ return 1;
208
+ }
209
+ const removed = await vault.delete(DISCORD_AUTHORIZED_USER_KEY);
210
+ process.stdout.write(removed ? 'unpaired\n' : 'no pairing was active\n');
211
+ return 0;
212
+ },
213
+ },
214
+ status: {
215
+ description:
216
+ 'Report whether a Discord token + an authorized account + allow-listed channels are configured.',
217
+ run: async (ctx) => {
218
+ const vault = ctx.deps.vault as VaultStore | undefined;
219
+ if (!vault) {
220
+ process.stderr.write('vault unavailable\n');
221
+ return 1;
222
+ }
223
+ const hasToken =
224
+ !!process.env[DISCORD_TOKEN_ENV]?.trim() || (await vault.has(DISCORD_TOKEN_KEY));
225
+ const authorized = await vault.get(DISCORD_AUTHORIZED_USER_KEY);
226
+ const allowed = parseAllowedChannels(
227
+ await vault.get(DISCORD_ALLOWED_CHANNELS_KEY),
228
+ );
229
+ process.stdout.write(
230
+ JSON.stringify(
231
+ {
232
+ tokenConfigured: hasToken,
233
+ authorizedUserId: parseAuthorizedUser(authorized),
234
+ allowedChannelIds: allowed,
235
+ },
236
+ null,
237
+ 2,
238
+ ) + '\n',
239
+ );
240
+ return 0;
241
+ },
242
+ },
243
+ },
244
+ }),
245
+ ],
246
+ });
247
+ }
package/src/keys.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Vault keys + env overrides + validators shared by the channel, its
3
+ * subcommands, and the interactive setup wizard. Kept in their own module so
4
+ * the wizard / pair-flow helpers can import them without pulling in the
5
+ * plugin's full index. The names here are the single source of truth for what
6
+ * the channel reads at boot (mirrored by the desktop channel catalog).
7
+ */
8
+ import { z } from '@moxxy/sdk';
9
+ import { resolveSecret } from '@moxxy/channel-kit';
10
+
11
+ /** Vault key for the Discord bot token. */
12
+ export const DISCORD_TOKEN_KEY = 'discord_bot_token';
13
+ /** Vault key for the paired (authorized) Discord user id — the sole principal
14
+ * allowed to drive the session. */
15
+ export const DISCORD_AUTHORIZED_USER_KEY = 'discord_authorized_user_id';
16
+ /** Vault key for the guild-channel allow-list (JSON array of channel ids the
17
+ * paired user may drive the bot from, beyond their own DM). */
18
+ export const DISCORD_ALLOWED_CHANNELS_KEY = 'discord_allowed_channel_ids';
19
+ /**
20
+ * Vault key for the voice-replies preference. Single-paired-account model, so a
21
+ * present-flag (`'1'` = on, absent = off) toggled from a DM / allow-listed
22
+ * channel via `/voice`.
23
+ */
24
+ export const DISCORD_VOICE_REPLIES_KEY = 'discord_voice_replies';
25
+
26
+ /** Env override for the bot token (beats the vault, matching every other channel). */
27
+ export const DISCORD_TOKEN_ENV = 'MOXXY_DISCORD_TOKEN';
28
+
29
+ /**
30
+ * Shape-only validation of a Discord bot token: three dot-separated url-safe
31
+ * base64 segments (id.timestamp.hmac). Connectivity is proven by the gateway
32
+ * login, not here.
33
+ */
34
+ export const DISCORD_TOKEN_RE = /^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{20,}$/;
35
+
36
+ /** A Discord snowflake id — numeric string, 5..25 digits. */
37
+ export const snowflakeSchema = z.string().regex(/^\d{5,25}$/, 'expected a Discord snowflake id');
38
+
39
+ /**
40
+ * Resolve the bot token: env override first, then the vault (the shared
41
+ * env→vault resolution in @moxxy/channel-kit). Returns null when neither is
42
+ * set. Trimmed; never returns an empty string.
43
+ */
44
+ export async function resolveBotToken(vault: {
45
+ get(name: string): Promise<string | null>;
46
+ }): Promise<string | null> {
47
+ return resolveSecret(vault, { envVar: DISCORD_TOKEN_ENV, vaultKey: DISCORD_TOKEN_KEY });
48
+ }
49
+
50
+ /**
51
+ * Parse the stored authorized-user id. Returns null for a missing OR corrupt
52
+ * value (anything that isn't a snowflake) so a bad vault write reads as
53
+ * "unpaired" instead of silently authorizing garbage.
54
+ */
55
+ export function parseAuthorizedUser(raw: string | null | undefined): string | null {
56
+ if (!raw) return null;
57
+ const trimmed = raw.trim();
58
+ return snowflakeSchema.safeParse(trimmed).success ? trimmed : null;
59
+ }
60
+
61
+ const allowedChannelsSchema = z.array(snowflakeSchema);
62
+
63
+ /**
64
+ * Parse the stored guild-channel allow-list (a JSON array of snowflakes).
65
+ * Corrupt/missing values read as the empty list — DENY every guild channel —
66
+ * rather than throwing or letting junk ids through.
67
+ */
68
+ export function parseAllowedChannels(raw: string | null | undefined): ReadonlyArray<string> {
69
+ if (!raw) return [];
70
+ try {
71
+ const parsed = allowedChannelsSchema.safeParse(JSON.parse(raw));
72
+ return parsed.success ? parsed.data : [];
73
+ } catch {
74
+ return [];
75
+ }
76
+ }
77
+
78
+ /** Serialize the allow-list for the vault (deduplicated, stable order). */
79
+ export function serializeAllowedChannels(ids: Iterable<string>): string {
80
+ return JSON.stringify([...new Set(ids)]);
81
+ }
82
+
83
+ /** Read the persisted voice-replies preference (`'1'` → on). */
84
+ export async function loadVoiceReplies(vault: {
85
+ get(name: string): Promise<string | null>;
86
+ }): Promise<boolean> {
87
+ return (await vault.get(DISCORD_VOICE_REPLIES_KEY)) === '1';
88
+ }
89
+
90
+ /** Persist the voice-replies preference: store `'1'` when on, remove it when off. */
91
+ export async function saveVoiceReplies(
92
+ vault: {
93
+ set(name: string, value: string): Promise<void>;
94
+ delete(name: string): Promise<boolean>;
95
+ },
96
+ on: boolean,
97
+ ): Promise<void> {
98
+ if (on) await vault.set(DISCORD_VOICE_REPLIES_KEY, '1');
99
+ else await vault.delete(DISCORD_VOICE_REPLIES_KEY);
100
+ }
@@ -0,0 +1,122 @@
1
+ import { isCancel, log, outro, spinner, text } from '@clack/prompts';
2
+ import { exitAfterPairRequested, type ChannelSubcommandContext } from '@moxxy/sdk';
3
+ import type { VaultStore } from '@moxxy/plugin-vault';
4
+ import { DiscordChannel } from './channel.js';
5
+
6
+ // Tiny zero-dep ANSI dim helper, so this flow stays inside the plugin.
7
+ const ANSI = process.stdout.isTTY && !process.env.NO_COLOR;
8
+ const dim = (s: string): string => (ANSI ? `\x1b[2m${s}\x1b[22m` : s);
9
+
10
+ /** How many wrong codes the operator may paste before we give up. */
11
+ const MAX_CODE_ATTEMPTS = 5;
12
+
13
+ /**
14
+ * Drive the DM code pairing flow end-to-end in a terminal.
15
+ *
16
+ * Steps:
17
+ * 1. Build a DiscordChannel from the subcommand ctx and wire the session's
18
+ * permission resolver.
19
+ * 2. Start the bot with the pairing window armed (`pair: true`).
20
+ * 3. Tell the user to DM the bot (from the account that should own it) —
21
+ * the bot replies to that DM with a one-time code.
22
+ * 4. Prompt for the code here; on match that account is authorized and
23
+ * persisted.
24
+ * 5. Keep the bot running until Ctrl+C (mirrors the Telegram pair flow).
25
+ */
26
+ export async function runPairFlow(ctx: ChannelSubcommandContext): Promise<number> {
27
+ const session = ctx.session;
28
+ const channel = new DiscordChannel({
29
+ vault: ctx.deps.vault as VaultStore,
30
+ token: (ctx.deps.options?.['token'] as string | undefined) ?? undefined,
31
+ logger: ctx.deps.logger as never,
32
+ });
33
+ session.setPermissionResolver(channel.permissionResolver);
34
+
35
+ outro(dim('starting the bot with a pairing window armed...'));
36
+
37
+ const handle = await channel.start({ session, pair: true });
38
+
39
+ const stopBot = async (): Promise<void> => {
40
+ try {
41
+ await handle.stop('wizard');
42
+ } catch {
43
+ /* ignore */
44
+ }
45
+ };
46
+
47
+ // Already paired (no window armed): nothing to confirm. Tell the user how
48
+ // to re-pair rather than prompting for a code that can never arrive.
49
+ if (channel.connected) {
50
+ log.info(
51
+ 'This bot is already paired. Run `moxxy channels discord unpair` first to pair a different account.',
52
+ );
53
+ await stopBot();
54
+ return 0;
55
+ }
56
+
57
+ if (channel.requestUrl) {
58
+ log.info(`Invite the bot to a server (needed once so you can DM it):\n ${channel.requestUrl}`);
59
+ }
60
+ log.info(
61
+ 'Now DM the bot from YOUR Discord account (any message). It replies with a one-time code — paste that code below.',
62
+ );
63
+
64
+ // Graceful Ctrl-C while waiting (or once running): stop the bot and exit.
65
+ let stopping = false;
66
+ const shutdown = async (): Promise<void> => {
67
+ if (stopping) return;
68
+ stopping = true;
69
+ await stopBot();
70
+ await session.close('SIGINT').catch(() => undefined);
71
+ process.exit(0);
72
+ };
73
+ const onSignal = (): void => void shutdown();
74
+ process.once('SIGINT', onSignal);
75
+ process.once('SIGTERM', onSignal);
76
+
77
+ try {
78
+ let paired = false;
79
+ for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS && !paired; attempt++) {
80
+ const code = await text({
81
+ message: 'Paste the pairing code from the bot\'s DM reply',
82
+ placeholder: '6-digit code',
83
+ validate: (v) => (!v || !v.trim() ? 'required' : undefined),
84
+ });
85
+ if (isCancel(code)) {
86
+ log.info('Pairing cancelled.');
87
+ await stopBot();
88
+ return 0;
89
+ }
90
+ const result = await channel.confirmPairingCode(String(code));
91
+ if (result.ok) {
92
+ paired = true;
93
+ log.success(`Paired ✓ — Discord user ${result.userId} is authorized.`);
94
+ break;
95
+ }
96
+ log.warn(result.message);
97
+ }
98
+ if (!paired) {
99
+ log.error('Too many failed attempts — pairing aborted. Run `moxxy discord pair` to retry.');
100
+ await stopBot();
101
+ return 1;
102
+ }
103
+
104
+ if (exitAfterPairRequested(ctx)) {
105
+ // Orchestrated pairing (`moxxy onboard`): hand control back — the
106
+ // caller starts the bot under its own service afterwards.
107
+ await stopBot();
108
+ return 0;
109
+ }
110
+
111
+ const spin = spinner();
112
+ spin.start('Bot is running. Press Ctrl+C to stop.');
113
+ // Only reached if the bot stops on its own (a signal path exits via
114
+ // shutdown()).
115
+ await handle.running;
116
+ spin.stop('bot stopped.');
117
+ return 0;
118
+ } finally {
119
+ process.removeListener('SIGINT', onSignal);
120
+ process.removeListener('SIGTERM', onSignal);
121
+ }
122
+ }
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ armPairing,
4
+ clearDiscordPairing,
5
+ confirmPendingCode,
6
+ createDiscordPairingState,
7
+ isUserAuthorized,
8
+ mintCodeForPeer,
9
+ pairingPhase,
10
+ } from './pairing.js';
11
+
12
+ const USER_A = '111111111111';
13
+ const USER_B = '222222222222';
14
+
15
+ describe('discord pairing state machine (DM code flow)', () => {
16
+ it('starts idle and unarmed; DMs get a "no window" rejection', () => {
17
+ const state = createDiscordPairingState();
18
+ expect(pairingPhase(state)).toBe('idle');
19
+ const decision = mintCodeForPeer(state, USER_A);
20
+ expect(decision.action.kind).toBe('reject');
21
+ expect(isUserAuthorized(decision.state, USER_A)).toBe(false);
22
+ });
23
+
24
+ it('mints a code for a DM-ing user once armed, then pairs on the pasted code', async () => {
25
+ let state = armPairing(createDiscordPairingState());
26
+ expect(pairingPhase(state)).toBe('armed');
27
+
28
+ const minted = mintCodeForPeer(state, USER_A, '123456');
29
+ expect(minted.action).toEqual({ kind: 'code-minted', userId: USER_A, code: '123456' });
30
+ state = minted.state;
31
+ expect(pairingPhase(state)).toBe('awaiting-host-code');
32
+
33
+ const confirmed = confirmPendingCode(state, ' 123 456 '); // whitespace tolerated
34
+ expect(confirmed.action).toEqual({ kind: 'paired', userId: USER_A });
35
+ expect(isUserAuthorized(confirmed.state, USER_A)).toBe(true);
36
+ expect(isUserAuthorized(confirmed.state, USER_B)).toBe(false);
37
+ expect(pairingPhase(confirmed.state)).toBe('paired');
38
+ });
39
+
40
+ it('rejects a wrong pasted code without transitioning', () => {
41
+ let state = armPairing(createDiscordPairingState());
42
+ state = mintCodeForPeer(state, USER_A, '123456').state;
43
+ const confirmed = confirmPendingCode(state, '654321');
44
+ expect(confirmed.action.kind).toBe('mismatch');
45
+ expect(isUserAuthorized(confirmed.state, USER_A)).toBe(false);
46
+ // The window stays open — the right code still pairs.
47
+ const retry = confirmPendingCode(confirmed.state, '123456');
48
+ expect(retry.action.kind).toBe('paired');
49
+ });
50
+
51
+ it('a second DM-ing user re-mints the window: the FIRST code goes stale (fails safe)', () => {
52
+ let state = armPairing(createDiscordPairingState());
53
+ const first = mintCodeForPeer(state, USER_A, '111111');
54
+ state = first.state;
55
+ const second = mintCodeForPeer(state, USER_B, '222222');
56
+ expect(second.action).toEqual({ kind: 'code-minted', userId: USER_B, code: '222222' });
57
+ state = second.state;
58
+ // Pasting USER_A's (stale) code must NOT pair anyone.
59
+ const stale = confirmPendingCode(state, '111111');
60
+ expect(stale.action.kind).toBe('mismatch');
61
+ expect(isUserAuthorized(stale.state, USER_A)).toBe(false);
62
+ expect(isUserAuthorized(stale.state, USER_B)).toBe(false);
63
+ // Pasting the CURRENT code pairs the user it was minted for.
64
+ const paired = confirmPendingCode(state, '222222');
65
+ expect(paired.action).toEqual({ kind: 'paired', userId: USER_B });
66
+ });
67
+
68
+ it('confirm without any pending DM is not-pending', () => {
69
+ const state = armPairing(createDiscordPairingState());
70
+ const confirmed = confirmPendingCode(state, '123456');
71
+ expect(confirmed.action.kind).toBe('not-pending');
72
+ });
73
+
74
+ it('a foreign user DM-ing a PAIRED bot is rejected; the paired user is greeted', () => {
75
+ const state = createDiscordPairingState({ authorizedUserId: USER_A });
76
+ expect(pairingPhase(state)).toBe('paired');
77
+ expect(mintCodeForPeer(state, USER_B).action.kind).toBe('reject');
78
+ expect(mintCodeForPeer(state, USER_A).action.kind).toBe('still-paired');
79
+ });
80
+
81
+ it('an expired code reports expired and clears the pending window', () => {
82
+ let state = armPairing(createDiscordPairingState());
83
+ // Arm with an explicit ttl through the kit path by minting then aging the
84
+ // confirm clock beyond a synthetic expiry: mint has no ttl by default, so
85
+ // simulate via a stale `now` only when expiresAt is set — the default is
86
+ // no-TTL, so this confirms the no-TTL behavior instead.
87
+ state = mintCodeForPeer(state, USER_A, '123456').state;
88
+ const decision = confirmPendingCode(state, '123456', Date.now() + 10 * 60_000);
89
+ // No TTL by default → still pairs even much later.
90
+ expect(decision.action.kind).toBe('paired');
91
+ });
92
+
93
+ it('clearDiscordPairing forgets the principal but keeps the armed flag', () => {
94
+ let state = armPairing(createDiscordPairingState({ authorizedUserId: USER_A }));
95
+ state = clearDiscordPairing(state);
96
+ expect(isUserAuthorized(state, USER_A)).toBe(false);
97
+ expect(pairingPhase(state)).toBe('armed');
98
+ });
99
+ });