@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
@@ -0,0 +1,162 @@
1
+ import { cancel, intro, isCancel, log, note, outro, password, select } from '@clack/prompts';
2
+ import type { ChannelSubcommandContext } from '@moxxy/sdk';
3
+ import type { VaultStore } from '@moxxy/plugin-vault';
4
+ import {
5
+ DISCORD_ALLOWED_CHANNELS_KEY,
6
+ DISCORD_AUTHORIZED_USER_KEY,
7
+ DISCORD_TOKEN_ENV,
8
+ DISCORD_TOKEN_KEY,
9
+ DISCORD_TOKEN_RE,
10
+ parseAllowedChannels,
11
+ parseAuthorizedUser,
12
+ } from './keys.js';
13
+ import { runPairFlow } from './pair-flow.js';
14
+
15
+ // Tiny zero-dep ANSI helpers (bold + dim) so this wizard stays inside the
16
+ // plugin without depending on the CLI's colors module.
17
+ const ANSI = process.stdout.isTTY && !process.env.NO_COLOR;
18
+ const bold = (s: string): string => (ANSI ? `\x1b[1m${s}\x1b[22m` : s);
19
+ const dim = (s: string): string => (ANSI ? `\x1b[2m${s}\x1b[22m` : s);
20
+
21
+ interface State {
22
+ readonly hasToken: boolean;
23
+ readonly authorizedUserId: string | null;
24
+ readonly allowedChannelCount: number;
25
+ }
26
+
27
+ type Action = 'set-token' | 'pair' | 'unpair' | 'start' | 'quit';
28
+
29
+ /**
30
+ * Interactive Discord setup menu — invoked as the channel's
31
+ * `interactiveCommand` when the user runs `moxxy discord` with no subcommand
32
+ * in a TTY. Headless invocations bypass it and start the bot directly.
33
+ *
34
+ * Menu offers actions appropriate to the current state:
35
+ * - no token -> "Set bot token" + "Quit"
36
+ * - token, not paired -> "Pair a Discord account" + "Change token" + "Quit"
37
+ * - token + paired -> "Start bot" + "Unpair" + "Change token" + "Quit"
38
+ */
39
+ export async function runDiscordWizard(ctx: ChannelSubcommandContext): Promise<number> {
40
+ const vault = ctx.deps.vault as VaultStore;
41
+
42
+ intro(bold('moxxy discord setup'));
43
+
44
+ while (true) {
45
+ const state = await readState(vault);
46
+ printStatus(state);
47
+ const action = await pickAction(state);
48
+ if (action === null) {
49
+ cancel('cancelled.');
50
+ return 0;
51
+ }
52
+ if (action === 'quit') {
53
+ outro(dim('done.'));
54
+ return 0;
55
+ }
56
+ if (action === 'set-token') {
57
+ await actionSetToken(vault);
58
+ continue;
59
+ }
60
+ if (action === 'pair') {
61
+ return await runPairFlow(ctx);
62
+ }
63
+ if (action === 'unpair') {
64
+ await vault.delete(DISCORD_AUTHORIZED_USER_KEY);
65
+ log.success('Unpaired. Run pairing again to authorize an account.');
66
+ continue;
67
+ }
68
+ if (action === 'start') {
69
+ log.info('Starting the bot. Press Ctrl+C to stop.');
70
+ outro(dim('handing off to bot...'));
71
+ return ctx.startChannel();
72
+ }
73
+ }
74
+ }
75
+
76
+ async function readState(vault: VaultStore): Promise<State> {
77
+ // env beats vault for the token (matches the channel's own precedence) so
78
+ // the wizard reflects what the bot would actually see at start time.
79
+ const envToken = process.env[DISCORD_TOKEN_ENV];
80
+ const vaultToken = envToken ?? (await vault.get(DISCORD_TOKEN_KEY));
81
+ const authorized = await vault.get(DISCORD_AUTHORIZED_USER_KEY);
82
+ const allowed = parseAllowedChannels(await vault.get(DISCORD_ALLOWED_CHANNELS_KEY));
83
+ return {
84
+ hasToken: !!vaultToken,
85
+ authorizedUserId: parseAuthorizedUser(authorized),
86
+ allowedChannelCount: allowed.length,
87
+ };
88
+ }
89
+
90
+ function printStatus(state: State): void {
91
+ const lines: string[] = [];
92
+ lines.push(`Token ${state.hasToken ? bold('set') : dim('not set')}`);
93
+ lines.push(
94
+ `Paired account ${state.authorizedUserId != null ? bold(state.authorizedUserId) : dim('none')}`,
95
+ );
96
+ lines.push(
97
+ `Guild channels ${state.allowedChannelCount > 0 ? bold(String(state.allowedChannelCount)) : dim('none allow-listed (DM works once paired)')}`,
98
+ );
99
+ note(lines.join('\n'), 'status');
100
+ }
101
+
102
+ async function pickAction(state: State): Promise<Action | null> {
103
+ const options: Array<{ value: Action; label: string; hint?: string }> = [];
104
+ if (state.hasToken && state.authorizedUserId != null) {
105
+ options.push({
106
+ value: 'start',
107
+ label: 'Start the bot',
108
+ hint: 'runs forever - Ctrl+C to stop',
109
+ });
110
+ options.push({
111
+ value: 'unpair',
112
+ label: 'Unpair this account',
113
+ hint: 'pairing must be run again before the bot answers anyone',
114
+ });
115
+ } else if (state.hasToken) {
116
+ options.push({
117
+ value: 'pair',
118
+ label: 'Pair a Discord account',
119
+ hint: 'DM the bot; it replies with a code you paste here',
120
+ });
121
+ }
122
+ options.push({
123
+ value: 'set-token',
124
+ label: state.hasToken ? 'Change the bot token' : 'Set the bot token',
125
+ hint: state.hasToken ? undefined : 'create an app + bot at discord.com/developers',
126
+ });
127
+ options.push({ value: 'quit', label: 'Quit' });
128
+
129
+ const choice = await select<Action>({ message: 'What do you want to do?', options });
130
+ if (isCancel(choice)) return null;
131
+ return choice as Action;
132
+ }
133
+
134
+ async function actionSetToken(vault: VaultStore): Promise<boolean> {
135
+ note(
136
+ 'Open https://discord.com/developers/applications, create an application, add a Bot,\n' +
137
+ 'and copy its token (Bot → Reset Token). IMPORTANT: on the same Bot page, under\n' +
138
+ '"Privileged Gateway Intents", enable ' +
139
+ bold('MESSAGE CONTENT INTENT') +
140
+ ' — without it the bot\n' +
141
+ 'receives empty messages. The token goes straight into the moxxy vault under\n' +
142
+ "'" +
143
+ DISCORD_TOKEN_KEY +
144
+ "' - no env var needed.",
145
+ 'get a bot token',
146
+ );
147
+ const token = await password({
148
+ message: 'Paste the Discord bot token',
149
+ mask: '•',
150
+ validate: (v) => {
151
+ if (!v || v.trim().length === 0) return 'required';
152
+ if (!DISCORD_TOKEN_RE.test(v.trim())) {
153
+ return 'doesn\'t look like a Discord bot token - expected three dot-separated segments';
154
+ }
155
+ return undefined;
156
+ },
157
+ });
158
+ if (isCancel(token)) return false;
159
+ await vault.set(DISCORD_TOKEN_KEY, String(token).trim(), ['discord']);
160
+ log.success('Token stored in vault.');
161
+ return true;
162
+ }
@@ -0,0 +1,182 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { VaultStore, createStaticKeySource, deriveKey, generateSalt } from '@moxxy/plugin-vault';
6
+ import type { ChannelDef } from '@moxxy/sdk';
7
+ import { buildDiscordPlugin } from './index.js';
8
+ import {
9
+ DISCORD_ALLOWED_CHANNELS_KEY,
10
+ DISCORD_AUTHORIZED_USER_KEY,
11
+ DISCORD_TOKEN_KEY,
12
+ serializeAllowedChannels,
13
+ } from './keys.js';
14
+
15
+ let tmp: string;
16
+ let vault: VaultStore;
17
+ let discordDef: ChannelDef;
18
+ let writeOut: string[];
19
+ let writeErr: string[];
20
+ let origStdoutWrite: typeof process.stdout.write;
21
+ let origStderrWrite: typeof process.stderr.write;
22
+
23
+ beforeEach(async () => {
24
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-dc-sub-'));
25
+ vault = new VaultStore({
26
+ filePath: path.join(tmp, 'vault.json'),
27
+ keySource: createStaticKeySource(deriveKey('test', generateSalt())),
28
+ });
29
+ const plugin = buildDiscordPlugin({ vault });
30
+ discordDef = plugin.channels![0]!;
31
+ writeOut = [];
32
+ writeErr = [];
33
+ origStdoutWrite = process.stdout.write.bind(process.stdout);
34
+ origStderrWrite = process.stderr.write.bind(process.stderr);
35
+ process.stdout.write = ((chunk: string | Uint8Array) => {
36
+ writeOut.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString());
37
+ return true;
38
+ }) as typeof process.stdout.write;
39
+ process.stderr.write = ((chunk: string | Uint8Array) => {
40
+ writeErr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString());
41
+ return true;
42
+ }) as typeof process.stderr.write;
43
+ });
44
+
45
+ afterEach(async () => {
46
+ await fs.rm(tmp, { recursive: true, force: true });
47
+ process.stdout.write = origStdoutWrite;
48
+ process.stderr.write = origStderrWrite;
49
+ });
50
+
51
+ function ctx(
52
+ overrides: {
53
+ startChannel?: () => Promise<number>;
54
+ session?: { setPermissionResolver: (r: unknown) => void };
55
+ } = {},
56
+ ) {
57
+ return {
58
+ deps: { cwd: tmp, vault, logger: undefined, options: {} },
59
+ args: { positional: [], flags: {} },
60
+ startChannel: overrides.startChannel ?? (async () => 0),
61
+ session: overrides.session ?? { setPermissionResolver: () => {} },
62
+ } as never;
63
+ }
64
+
65
+ describe('discord ChannelDef', () => {
66
+ it('declares the dedicated-runner + session-source contract', () => {
67
+ expect(discordDef.name).toBe('discord');
68
+ expect(discordDef.dedicatedRunner).toBe(true);
69
+ expect(discordDef.sessionSource).toBe('discord');
70
+ expect(discordDef.interactiveCommand).toBe('setup');
71
+ expect(discordDef.config?.fields[0]?.vaultKey).toBe(DISCORD_TOKEN_KEY);
72
+ });
73
+
74
+ it('is unavailable without a token, available with one in the vault', async () => {
75
+ const unavailable = await discordDef.isAvailable!({ cwd: tmp, vault });
76
+ expect(unavailable.ok).toBe(false);
77
+ expect(unavailable.reason).toMatch(/MOXXY_DISCORD_TOKEN/);
78
+ await vault.set(DISCORD_TOKEN_KEY, 'x'.repeat(24) + '.abcdef.' + 'y'.repeat(28));
79
+ const available = await discordDef.isAvailable!({ cwd: tmp, vault });
80
+ expect(available.ok).toBe(true);
81
+ });
82
+ });
83
+
84
+ describe('discord channel subcommands (registered on ChannelDef)', () => {
85
+ it('exposes setup, pair, unpair, status', () => {
86
+ expect(Object.keys(discordDef.subcommands!)).toEqual(
87
+ expect.arrayContaining(['setup', 'pair', 'unpair', 'status']),
88
+ );
89
+ });
90
+
91
+ it('`status` reports unconfigured vault state as JSON', async () => {
92
+ const code = await discordDef.subcommands!.status!.run(ctx());
93
+ expect(code).toBe(0);
94
+ const parsed = JSON.parse(writeOut.join(''));
95
+ expect(parsed).toEqual({
96
+ tokenConfigured: false,
97
+ authorizedUserId: null,
98
+ allowedChannelIds: [],
99
+ });
100
+ });
101
+
102
+ it('`status` surfaces stored token + authorized user + allow-list', async () => {
103
+ await vault.set(DISCORD_TOKEN_KEY, 'x'.repeat(24) + '.abcdef.' + 'y'.repeat(28));
104
+ await vault.set(DISCORD_AUTHORIZED_USER_KEY, '987654321');
105
+ await vault.set(DISCORD_ALLOWED_CHANNELS_KEY, serializeAllowedChannels(['123456789']));
106
+ const code = await discordDef.subcommands!.status!.run(ctx());
107
+ expect(code).toBe(0);
108
+ const parsed = JSON.parse(writeOut.join(''));
109
+ expect(parsed).toEqual({
110
+ tokenConfigured: true,
111
+ authorizedUserId: '987654321',
112
+ allowedChannelIds: ['123456789'],
113
+ });
114
+ });
115
+
116
+ it('`status` reports null (not junk) for a corrupt stored user id', async () => {
117
+ await vault.set(DISCORD_AUTHORIZED_USER_KEY, 'not-a-snowflake');
118
+ const code = await discordDef.subcommands!.status!.run(ctx());
119
+ expect(code).toBe(0);
120
+ const parsed = JSON.parse(writeOut.join(''));
121
+ expect(parsed.authorizedUserId).toBeNull();
122
+ });
123
+
124
+ it('`unpair` clears the authorized user and reports', async () => {
125
+ await vault.set(DISCORD_AUTHORIZED_USER_KEY, '111');
126
+ const code = await discordDef.subcommands!.unpair!.run(ctx());
127
+ expect(code).toBe(0);
128
+ expect(writeOut.join('')).toContain('unpaired');
129
+ expect(await vault.get(DISCORD_AUTHORIZED_USER_KEY)).toBeNull();
130
+ });
131
+
132
+ it('`unpair` is a no-op when nothing is paired', async () => {
133
+ const code = await discordDef.subcommands!.unpair!.run(ctx());
134
+ expect(code).toBe(0);
135
+ expect(writeOut.join('')).toContain('no pairing was active');
136
+ });
137
+
138
+ it('`pair` drives the pairing flow on the session in an interactive TTY', async () => {
139
+ const originalIsTTY = process.stdin.isTTY;
140
+ Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
141
+ try {
142
+ const startChannel = vi.fn(async () => 0);
143
+ const setPermissionResolver = vi.fn();
144
+ // No token in the vault -> the channel throws before it can open a real
145
+ // gateway connection, so the flow fails fast (no network). We only
146
+ // assert that `pair` drives the in-process pairing flow (wires the
147
+ // session's permission resolver) instead of delegating to startChannel.
148
+ await expect(
149
+ discordDef.subcommands!.pair!.run(ctx({ startChannel, session: { setPermissionResolver } })),
150
+ ).rejects.toThrow(/token/i);
151
+ expect(setPermissionResolver).toHaveBeenCalledTimes(1);
152
+ expect(startChannel).not.toHaveBeenCalled();
153
+ } finally {
154
+ Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
155
+ }
156
+ });
157
+
158
+ it('`pair` refuses to start without a TTY (interactive-only flow)', async () => {
159
+ const originalIsTTY = process.stdin.isTTY;
160
+ Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
161
+ try {
162
+ const startChannel = vi.fn(async () => 0);
163
+ const code = await discordDef.subcommands!.pair!.run(ctx({ startChannel }));
164
+ expect(code).toBe(1);
165
+ expect(startChannel).not.toHaveBeenCalled();
166
+ expect(writeErr.join('')).toMatch(/TTY/);
167
+ } finally {
168
+ Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
169
+ }
170
+ });
171
+
172
+ it('subcommands return 1 when vault is unavailable', async () => {
173
+ const badCtx = {
174
+ deps: { cwd: tmp, vault: undefined, logger: undefined, options: {} },
175
+ args: { positional: [], flags: {} },
176
+ startChannel: async () => 0,
177
+ };
178
+ const code = await discordDef.subcommands!.status!.run(badCtx as never);
179
+ expect(code).toBe(1);
180
+ expect(writeErr.join('')).toContain('vault unavailable');
181
+ });
182
+ });