@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.
- package/LICENSE +21 -0
- package/dist/allow-list.d.ts +20 -0
- package/dist/allow-list.d.ts.map +1 -0
- package/dist/allow-list.js +12 -0
- package/dist/allow-list.js.map +1 -0
- package/dist/approval.d.ts +29 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +65 -0
- package/dist/approval.js.map +1 -0
- package/dist/channel/allow-list-store.d.ts +22 -0
- package/dist/channel/allow-list-store.d.ts.map +1 -0
- package/dist/channel/allow-list-store.js +45 -0
- package/dist/channel/allow-list-store.js.map +1 -0
- package/dist/channel/approval-prompt.d.ts +16 -0
- package/dist/channel/approval-prompt.d.ts.map +1 -0
- package/dist/channel/approval-prompt.js +27 -0
- package/dist/channel/approval-prompt.js.map +1 -0
- package/dist/channel/components.d.ts +27 -0
- package/dist/channel/components.d.ts.map +1 -0
- package/dist/channel/components.js +34 -0
- package/dist/channel/components.js.map +1 -0
- package/dist/channel/discord-like.d.ts +31 -0
- package/dist/channel/discord-like.d.ts.map +1 -0
- package/dist/channel/discord-like.js +9 -0
- package/dist/channel/discord-like.js.map +1 -0
- package/dist/channel/interaction-handler.d.ts +67 -0
- package/dist/channel/interaction-handler.d.ts.map +1 -0
- package/dist/channel/interaction-handler.js +155 -0
- package/dist/channel/interaction-handler.js.map +1 -0
- package/dist/channel/message-handler.d.ts +60 -0
- package/dist/channel/message-handler.d.ts.map +1 -0
- package/dist/channel/message-handler.js +135 -0
- package/dist/channel/message-handler.js.map +1 -0
- package/dist/channel/pairing-handler.d.ts +58 -0
- package/dist/channel/pairing-handler.d.ts.map +1 -0
- package/dist/channel/pairing-handler.js +109 -0
- package/dist/channel/pairing-handler.js.map +1 -0
- package/dist/channel/permission-prompt.d.ts +19 -0
- package/dist/channel/permission-prompt.d.ts.map +1 -0
- package/dist/channel/permission-prompt.js +38 -0
- package/dist/channel/permission-prompt.js.map +1 -0
- package/dist/channel/slash-handler.d.ts +45 -0
- package/dist/channel/slash-handler.d.ts.map +1 -0
- package/dist/channel/slash-handler.js +107 -0
- package/dist/channel/slash-handler.js.map +1 -0
- package/dist/channel/turn-runner.d.ts +49 -0
- package/dist/channel/turn-runner.d.ts.map +1 -0
- package/dist/channel/turn-runner.js +116 -0
- package/dist/channel/turn-runner.js.map +1 -0
- package/dist/channel/typing-indicator.d.ts +12 -0
- package/dist/channel/typing-indicator.d.ts.map +1 -0
- package/dist/channel/typing-indicator.js +28 -0
- package/dist/channel/typing-indicator.js.map +1 -0
- package/dist/channel/voice-handler.d.ts +41 -0
- package/dist/channel/voice-handler.d.ts.map +1 -0
- package/dist/channel/voice-handler.js +105 -0
- package/dist/channel/voice-handler.js.map +1 -0
- package/dist/channel.d.ts +107 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +464 -0
- package/dist/channel.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +179 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +64 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +85 -0
- package/dist/keys.js.map +1 -0
- package/dist/pair-flow.d.ts +16 -0
- package/dist/pair-flow.d.ts.map +1 -0
- package/dist/pair-flow.js +109 -0
- package/dist/pair-flow.js.map +1 -0
- package/dist/pairing.d.ts +83 -0
- package/dist/pairing.d.ts.map +1 -0
- package/dist/pairing.js +98 -0
- package/dist/pairing.js.map +1 -0
- package/dist/permission.d.ts +30 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +54 -0
- package/dist/permission.js.map +1 -0
- package/dist/render.d.ts +34 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +204 -0
- package/dist/render.js.map +1 -0
- package/dist/schema.d.ts +123 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +70 -0
- package/dist/schema.js.map +1 -0
- package/dist/setup-wizard.d.ts +13 -0
- package/dist/setup-wizard.d.ts.map +1 -0
- package/dist/setup-wizard.js +133 -0
- package/dist/setup-wizard.js.map +1 -0
- package/package.json +89 -0
- package/src/allow-list.test.ts +64 -0
- package/src/allow-list.ts +30 -0
- package/src/approval.ts +76 -0
- package/src/channel/allow-list-store.ts +52 -0
- package/src/channel/approval-prompt.ts +43 -0
- package/src/channel/components.ts +49 -0
- package/src/channel/discord-like.ts +34 -0
- package/src/channel/interaction-handler.test.ts +212 -0
- package/src/channel/interaction-handler.ts +237 -0
- package/src/channel/message-handler.test.ts +295 -0
- package/src/channel/message-handler.ts +195 -0
- package/src/channel/pairing-handler.ts +147 -0
- package/src/channel/permission-prompt.ts +54 -0
- package/src/channel/slash-handler.ts +136 -0
- package/src/channel/turn-runner.test.ts +177 -0
- package/src/channel/turn-runner.ts +139 -0
- package/src/channel/typing-indicator.ts +31 -0
- package/src/channel/voice-handler.test.ts +177 -0
- package/src/channel/voice-handler.ts +150 -0
- package/src/channel/voice-reply.test.ts +142 -0
- package/src/channel.ts +582 -0
- package/src/index.ts +247 -0
- package/src/keys.ts +100 -0
- package/src/pair-flow.ts +122 -0
- package/src/pairing.test.ts +99 -0
- package/src/pairing.ts +178 -0
- package/src/permission.ts +70 -0
- package/src/render.test.ts +82 -0
- package/src/render.ts +228 -0
- package/src/schema.ts +94 -0
- package/src/setup-wizard.ts +162 -0
- 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
|
+
}
|
package/src/pair-flow.ts
ADDED
|
@@ -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
|
+
});
|