@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
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ChannelSubcommandContext } from '@moxxy/sdk';
|
|
2
|
+
/**
|
|
3
|
+
* Interactive Discord setup menu — invoked as the channel's
|
|
4
|
+
* `interactiveCommand` when the user runs `moxxy discord` with no subcommand
|
|
5
|
+
* in a TTY. Headless invocations bypass it and start the bot directly.
|
|
6
|
+
*
|
|
7
|
+
* Menu offers actions appropriate to the current state:
|
|
8
|
+
* - no token -> "Set bot token" + "Quit"
|
|
9
|
+
* - token, not paired -> "Pair a Discord account" + "Change token" + "Quit"
|
|
10
|
+
* - token + paired -> "Start bot" + "Unpair" + "Change token" + "Quit"
|
|
11
|
+
*/
|
|
12
|
+
export declare function runDiscordWizard(ctx: ChannelSubcommandContext): Promise<number>;
|
|
13
|
+
//# sourceMappingURL=setup-wizard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"setup-wizard.d.ts","sourceRoot":"","sources":["../src/setup-wizard.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AA2B3D;;;;;;;;;GASG;AACH,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,wBAAwB,GAAG,OAAO,CAAC,MAAM,CAAC,CAmCrF"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { cancel, intro, isCancel, log, note, outro, password, select } from '@clack/prompts';
|
|
2
|
+
import { DISCORD_ALLOWED_CHANNELS_KEY, DISCORD_AUTHORIZED_USER_KEY, DISCORD_TOKEN_ENV, DISCORD_TOKEN_KEY, DISCORD_TOKEN_RE, parseAllowedChannels, parseAuthorizedUser, } from './keys.js';
|
|
3
|
+
import { runPairFlow } from './pair-flow.js';
|
|
4
|
+
// Tiny zero-dep ANSI helpers (bold + dim) so this wizard stays inside the
|
|
5
|
+
// plugin without depending on the CLI's colors module.
|
|
6
|
+
const ANSI = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
7
|
+
const bold = (s) => (ANSI ? `\x1b[1m${s}\x1b[22m` : s);
|
|
8
|
+
const dim = (s) => (ANSI ? `\x1b[2m${s}\x1b[22m` : s);
|
|
9
|
+
/**
|
|
10
|
+
* Interactive Discord setup menu — invoked as the channel's
|
|
11
|
+
* `interactiveCommand` when the user runs `moxxy discord` with no subcommand
|
|
12
|
+
* in a TTY. Headless invocations bypass it and start the bot directly.
|
|
13
|
+
*
|
|
14
|
+
* Menu offers actions appropriate to the current state:
|
|
15
|
+
* - no token -> "Set bot token" + "Quit"
|
|
16
|
+
* - token, not paired -> "Pair a Discord account" + "Change token" + "Quit"
|
|
17
|
+
* - token + paired -> "Start bot" + "Unpair" + "Change token" + "Quit"
|
|
18
|
+
*/
|
|
19
|
+
export async function runDiscordWizard(ctx) {
|
|
20
|
+
const vault = ctx.deps.vault;
|
|
21
|
+
intro(bold('moxxy discord setup'));
|
|
22
|
+
while (true) {
|
|
23
|
+
const state = await readState(vault);
|
|
24
|
+
printStatus(state);
|
|
25
|
+
const action = await pickAction(state);
|
|
26
|
+
if (action === null) {
|
|
27
|
+
cancel('cancelled.');
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
if (action === 'quit') {
|
|
31
|
+
outro(dim('done.'));
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
if (action === 'set-token') {
|
|
35
|
+
await actionSetToken(vault);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (action === 'pair') {
|
|
39
|
+
return await runPairFlow(ctx);
|
|
40
|
+
}
|
|
41
|
+
if (action === 'unpair') {
|
|
42
|
+
await vault.delete(DISCORD_AUTHORIZED_USER_KEY);
|
|
43
|
+
log.success('Unpaired. Run pairing again to authorize an account.');
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (action === 'start') {
|
|
47
|
+
log.info('Starting the bot. Press Ctrl+C to stop.');
|
|
48
|
+
outro(dim('handing off to bot...'));
|
|
49
|
+
return ctx.startChannel();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function readState(vault) {
|
|
54
|
+
// env beats vault for the token (matches the channel's own precedence) so
|
|
55
|
+
// the wizard reflects what the bot would actually see at start time.
|
|
56
|
+
const envToken = process.env[DISCORD_TOKEN_ENV];
|
|
57
|
+
const vaultToken = envToken ?? (await vault.get(DISCORD_TOKEN_KEY));
|
|
58
|
+
const authorized = await vault.get(DISCORD_AUTHORIZED_USER_KEY);
|
|
59
|
+
const allowed = parseAllowedChannels(await vault.get(DISCORD_ALLOWED_CHANNELS_KEY));
|
|
60
|
+
return {
|
|
61
|
+
hasToken: !!vaultToken,
|
|
62
|
+
authorizedUserId: parseAuthorizedUser(authorized),
|
|
63
|
+
allowedChannelCount: allowed.length,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function printStatus(state) {
|
|
67
|
+
const lines = [];
|
|
68
|
+
lines.push(`Token ${state.hasToken ? bold('set') : dim('not set')}`);
|
|
69
|
+
lines.push(`Paired account ${state.authorizedUserId != null ? bold(state.authorizedUserId) : dim('none')}`);
|
|
70
|
+
lines.push(`Guild channels ${state.allowedChannelCount > 0 ? bold(String(state.allowedChannelCount)) : dim('none allow-listed (DM works once paired)')}`);
|
|
71
|
+
note(lines.join('\n'), 'status');
|
|
72
|
+
}
|
|
73
|
+
async function pickAction(state) {
|
|
74
|
+
const options = [];
|
|
75
|
+
if (state.hasToken && state.authorizedUserId != null) {
|
|
76
|
+
options.push({
|
|
77
|
+
value: 'start',
|
|
78
|
+
label: 'Start the bot',
|
|
79
|
+
hint: 'runs forever - Ctrl+C to stop',
|
|
80
|
+
});
|
|
81
|
+
options.push({
|
|
82
|
+
value: 'unpair',
|
|
83
|
+
label: 'Unpair this account',
|
|
84
|
+
hint: 'pairing must be run again before the bot answers anyone',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
else if (state.hasToken) {
|
|
88
|
+
options.push({
|
|
89
|
+
value: 'pair',
|
|
90
|
+
label: 'Pair a Discord account',
|
|
91
|
+
hint: 'DM the bot; it replies with a code you paste here',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
options.push({
|
|
95
|
+
value: 'set-token',
|
|
96
|
+
label: state.hasToken ? 'Change the bot token' : 'Set the bot token',
|
|
97
|
+
hint: state.hasToken ? undefined : 'create an app + bot at discord.com/developers',
|
|
98
|
+
});
|
|
99
|
+
options.push({ value: 'quit', label: 'Quit' });
|
|
100
|
+
const choice = await select({ message: 'What do you want to do?', options });
|
|
101
|
+
if (isCancel(choice))
|
|
102
|
+
return null;
|
|
103
|
+
return choice;
|
|
104
|
+
}
|
|
105
|
+
async function actionSetToken(vault) {
|
|
106
|
+
note('Open https://discord.com/developers/applications, create an application, add a Bot,\n' +
|
|
107
|
+
'and copy its token (Bot → Reset Token). IMPORTANT: on the same Bot page, under\n' +
|
|
108
|
+
'"Privileged Gateway Intents", enable ' +
|
|
109
|
+
bold('MESSAGE CONTENT INTENT') +
|
|
110
|
+
' — without it the bot\n' +
|
|
111
|
+
'receives empty messages. The token goes straight into the moxxy vault under\n' +
|
|
112
|
+
"'" +
|
|
113
|
+
DISCORD_TOKEN_KEY +
|
|
114
|
+
"' - no env var needed.", 'get a bot token');
|
|
115
|
+
const token = await password({
|
|
116
|
+
message: 'Paste the Discord bot token',
|
|
117
|
+
mask: '•',
|
|
118
|
+
validate: (v) => {
|
|
119
|
+
if (!v || v.trim().length === 0)
|
|
120
|
+
return 'required';
|
|
121
|
+
if (!DISCORD_TOKEN_RE.test(v.trim())) {
|
|
122
|
+
return 'doesn\'t look like a Discord bot token - expected three dot-separated segments';
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
if (isCancel(token))
|
|
128
|
+
return false;
|
|
129
|
+
await vault.set(DISCORD_TOKEN_KEY, String(token).trim(), ['discord']);
|
|
130
|
+
log.success('Token stored in vault.');
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=setup-wizard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"setup-wizard.js","sourceRoot":"","sources":["../src/setup-wizard.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@moxxy/plugin-channel-discord",
|
|
3
|
+
"version": "0.27.0",
|
|
4
|
+
"description": "Discord channel for moxxy via discord.js. Treats Discord DMs and allow-listed guild channels as a bidirectional channel: prompts in, streamed assistant replies out (edit-throttled), button-based permission prompts, DM code pairing.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"moxxy",
|
|
7
|
+
"agent",
|
|
8
|
+
"channel",
|
|
9
|
+
"discord",
|
|
10
|
+
"bot"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://moxxy.ai",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/moxxy-ai/moxxy/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/moxxy-ai/moxxy.git",
|
|
19
|
+
"directory": "packages/plugin-channel-discord"
|
|
20
|
+
},
|
|
21
|
+
"author": "Michal Makowski <michal.makowski97@gmail.com>",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"src"
|
|
38
|
+
],
|
|
39
|
+
"moxxy": {
|
|
40
|
+
"plugin": {
|
|
41
|
+
"entry": "./dist/index.js",
|
|
42
|
+
"kind": "cli"
|
|
43
|
+
},
|
|
44
|
+
"requirements": [
|
|
45
|
+
{
|
|
46
|
+
"kind": "plugin",
|
|
47
|
+
"name": "@moxxy/plugin-vault",
|
|
48
|
+
"hint": "discord resolves the vault from the service registry for its bot token + pairing; @moxxy/plugin-vault must load first"
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"setup": {
|
|
52
|
+
"title": "Discord bot",
|
|
53
|
+
"description": "Create an application at https://discord.com/developers/applications, add a Bot, and under Bot → Privileged Gateway Intents enable MESSAGE CONTENT INTENT (required — without it the bot receives empty messages). Then paste the bot token below.",
|
|
54
|
+
"required": true,
|
|
55
|
+
"fields": [
|
|
56
|
+
{
|
|
57
|
+
"key": "botToken",
|
|
58
|
+
"label": "Bot token",
|
|
59
|
+
"kind": "secret",
|
|
60
|
+
"vaultKey": "discord_bot_token",
|
|
61
|
+
"required": true,
|
|
62
|
+
"placeholder": "MTIz…abc.def…",
|
|
63
|
+
"description": "Developer Portal → your app → Bot → Reset Token. Remember to enable the MESSAGE CONTENT privileged intent on the same page."
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"dependencies": {
|
|
69
|
+
"@clack/prompts": "^1.4.0",
|
|
70
|
+
"discord.js": "^14.16.0",
|
|
71
|
+
"@moxxy/channel-kit": "0.27.0",
|
|
72
|
+
"@moxxy/core": "0.27.0",
|
|
73
|
+
"@moxxy/plugin-vault": "0.27.0",
|
|
74
|
+
"@moxxy/sdk": "0.27.0"
|
|
75
|
+
},
|
|
76
|
+
"devDependencies": {
|
|
77
|
+
"@types/node": "^22.10.0",
|
|
78
|
+
"typescript": "^5.7.3",
|
|
79
|
+
"vitest": "^2.1.8",
|
|
80
|
+
"@moxxy/tsconfig": "0.0.0",
|
|
81
|
+
"@moxxy/vitest-preset": "0.0.0"
|
|
82
|
+
},
|
|
83
|
+
"scripts": {
|
|
84
|
+
"build": "tsc -p tsconfig.json",
|
|
85
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
86
|
+
"test": "vitest run",
|
|
87
|
+
"clean": "rm -rf dist .turbo"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { gateInbound } from './allow-list.js';
|
|
3
|
+
import {
|
|
4
|
+
parseAllowedChannels,
|
|
5
|
+
parseAuthorizedUser,
|
|
6
|
+
serializeAllowedChannels,
|
|
7
|
+
} from './keys.js';
|
|
8
|
+
|
|
9
|
+
const PAIRED = '111111111111';
|
|
10
|
+
const OTHER = '222222222222';
|
|
11
|
+
const GUILD = '333333333333';
|
|
12
|
+
const CHAN = '444444444444';
|
|
13
|
+
|
|
14
|
+
describe('gateInbound (authorization gate on every session-reaching path)', () => {
|
|
15
|
+
it('denies everything when unpaired', () => {
|
|
16
|
+
expect(
|
|
17
|
+
gateInbound({ authorId: PAIRED, guildId: null, channelId: CHAN }, null, new Set()),
|
|
18
|
+
).toEqual({ ok: false, reason: 'not-paired' });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('denies a foreign user even in an allow-listed channel', () => {
|
|
22
|
+
expect(
|
|
23
|
+
gateInbound({ authorId: OTHER, guildId: GUILD, channelId: CHAN }, PAIRED, new Set([CHAN])),
|
|
24
|
+
).toEqual({ ok: false, reason: 'foreign-user' });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('allows the paired user in a DM', () => {
|
|
28
|
+
expect(
|
|
29
|
+
gateInbound({ authorId: PAIRED, guildId: null, channelId: CHAN }, PAIRED, new Set()),
|
|
30
|
+
).toEqual({ ok: true, context: 'dm' });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('denies the paired user in a guild channel that is not allow-listed', () => {
|
|
34
|
+
expect(
|
|
35
|
+
gateInbound({ authorId: PAIRED, guildId: GUILD, channelId: CHAN }, PAIRED, new Set()),
|
|
36
|
+
).toEqual({ ok: false, reason: 'channel-not-allowed' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('allows the paired user in an allow-listed guild channel', () => {
|
|
40
|
+
expect(
|
|
41
|
+
gateInbound({ authorId: PAIRED, guildId: GUILD, channelId: CHAN }, PAIRED, new Set([CHAN])),
|
|
42
|
+
).toEqual({ ok: true, context: 'guild' });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('vault value parsers (corrupt values fail closed)', () => {
|
|
47
|
+
it('parseAuthorizedUser accepts a snowflake, rejects junk', () => {
|
|
48
|
+
expect(parseAuthorizedUser('123456789')).toBe('123456789');
|
|
49
|
+
expect(parseAuthorizedUser(' 123456789 ')).toBe('123456789');
|
|
50
|
+
expect(parseAuthorizedUser('not-a-user')).toBeNull();
|
|
51
|
+
expect(parseAuthorizedUser('')).toBeNull();
|
|
52
|
+
expect(parseAuthorizedUser(null)).toBeNull();
|
|
53
|
+
expect(parseAuthorizedUser('123; DROP TABLE')).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('parseAllowedChannels round-trips and rejects corrupt JSON / junk entries', () => {
|
|
57
|
+
const raw = serializeAllowedChannels(['111111', '222222', '111111']);
|
|
58
|
+
expect(parseAllowedChannels(raw)).toEqual(['111111', '222222']);
|
|
59
|
+
expect(parseAllowedChannels('not json')).toEqual([]);
|
|
60
|
+
expect(parseAllowedChannels('{"a":1}')).toEqual([]);
|
|
61
|
+
expect(parseAllowedChannels(JSON.stringify(['111111', 'nope']))).toEqual([]);
|
|
62
|
+
expect(parseAllowedChannels(null)).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { InboundMessage } from './schema.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where the paired principal may drive the session from:
|
|
5
|
+
* - their own DM with the bot — always allowed once paired;
|
|
6
|
+
* - a guild channel — only when that channel id is on the allow-list
|
|
7
|
+
* (managed by the paired user via the local /allow and /deny commands).
|
|
8
|
+
*
|
|
9
|
+
* Authorship is checked FIRST: a message from anyone but the paired user never
|
|
10
|
+
* reaches the session, no matter where it was sent (single-operator model,
|
|
11
|
+
* matching Telegram's single paired chat).
|
|
12
|
+
*/
|
|
13
|
+
export type GateVerdict =
|
|
14
|
+
| { readonly ok: true; readonly context: 'dm' | 'guild' }
|
|
15
|
+
| {
|
|
16
|
+
readonly ok: false;
|
|
17
|
+
readonly reason: 'not-paired' | 'foreign-user' | 'channel-not-allowed';
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function gateInbound(
|
|
21
|
+
msg: Pick<InboundMessage, 'authorId' | 'guildId' | 'channelId'>,
|
|
22
|
+
authorizedUserId: string | null,
|
|
23
|
+
allowedChannels: ReadonlySet<string>,
|
|
24
|
+
): GateVerdict {
|
|
25
|
+
if (!authorizedUserId) return { ok: false, reason: 'not-paired' };
|
|
26
|
+
if (msg.authorId !== authorizedUserId) return { ok: false, reason: 'foreign-user' };
|
|
27
|
+
if (msg.guildId == null) return { ok: true, context: 'dm' };
|
|
28
|
+
if (allowedChannels.has(msg.channelId)) return { ok: true, context: 'guild' };
|
|
29
|
+
return { ok: false, reason: 'channel-not-allowed' };
|
|
30
|
+
}
|
package/src/approval.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ApprovalDecision, ApprovalRequest, ApprovalResolver } from '@moxxy/sdk';
|
|
2
|
+
|
|
3
|
+
export interface PendingApproval {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly request: ApprovalRequest;
|
|
6
|
+
readonly resolve: (decision: ApprovalDecision) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generic approval resolver for the Discord channel — counterpart to the TUI's
|
|
11
|
+
* ApprovalDialog. The channel renders the request as a message with a button
|
|
12
|
+
* per option and routes the user's click (or follow-up text, for options with
|
|
13
|
+
* `requestsText: true`) back here via `resolvePending` /
|
|
14
|
+
* `resolvePendingWithText`. Mirrors the Telegram approval resolver's shape.
|
|
15
|
+
*/
|
|
16
|
+
export class DiscordApprovalResolver implements ApprovalResolver {
|
|
17
|
+
readonly name = 'discord-approval';
|
|
18
|
+
private readonly pending = new Map<string, PendingApproval>();
|
|
19
|
+
private deciderFn: ((id: string, request: ApprovalRequest) => Promise<void>) | null = null;
|
|
20
|
+
private nextId = 1;
|
|
21
|
+
|
|
22
|
+
setDecider(fn: (id: string, request: ApprovalRequest) => Promise<void>): void {
|
|
23
|
+
this.deciderFn = fn;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async confirm(request: ApprovalRequest): Promise<ApprovalDecision> {
|
|
27
|
+
if (!this.deciderFn) {
|
|
28
|
+
// No bot wired up — best fallback is the request's defaultOptionId.
|
|
29
|
+
return { optionId: request.defaultOptionId ?? request.options[0]?.id ?? 'approve' };
|
|
30
|
+
}
|
|
31
|
+
const id = `appr_${this.nextId++}`;
|
|
32
|
+
return new Promise<ApprovalDecision>((resolve) => {
|
|
33
|
+
this.pending.set(id, { id, request, resolve });
|
|
34
|
+
this.deciderFn!(id, request).catch((err) => {
|
|
35
|
+
this.pending.delete(id);
|
|
36
|
+
resolve({
|
|
37
|
+
optionId: request.defaultOptionId ?? request.options[0]?.id ?? 'cancel',
|
|
38
|
+
text: `decider failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Look up a pending approval without resolving it (text follow-up latch). */
|
|
45
|
+
getPending(id: string): PendingApproval | undefined {
|
|
46
|
+
return this.pending.get(id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Resolve directly with an option id (no text). */
|
|
50
|
+
resolvePending(id: string, optionId: string): boolean {
|
|
51
|
+
const pending = this.pending.get(id);
|
|
52
|
+
if (!pending) return false;
|
|
53
|
+
this.pending.delete(id);
|
|
54
|
+
pending.resolve({ optionId });
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Resolve with an option id AND a free-text follow-up. */
|
|
59
|
+
resolvePendingWithText(id: string, optionId: string, text: string): boolean {
|
|
60
|
+
const pending = this.pending.get(id);
|
|
61
|
+
if (!pending) return false;
|
|
62
|
+
this.pending.delete(id);
|
|
63
|
+
pending.resolve({ optionId, text });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
abortAll(reason = 'channel closed'): void {
|
|
68
|
+
for (const pending of this.pending.values()) {
|
|
69
|
+
pending.resolve({
|
|
70
|
+
optionId: pending.request.defaultOptionId ?? pending.request.options[0]?.id ?? 'cancel',
|
|
71
|
+
text: reason,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
this.pending.clear();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { VaultStore } from '@moxxy/plugin-vault';
|
|
2
|
+
import {
|
|
3
|
+
DISCORD_ALLOWED_CHANNELS_KEY,
|
|
4
|
+
parseAllowedChannels,
|
|
5
|
+
serializeAllowedChannels,
|
|
6
|
+
snowflakeSchema,
|
|
7
|
+
} from '../keys.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Vault-backed guild-channel allow-list: the channels (beyond the paired
|
|
11
|
+
* user's DM) the paired user may drive the session from. Managed via the
|
|
12
|
+
* local `/allow` and `/deny` commands; persisted as a JSON snowflake array
|
|
13
|
+
* under {@link DISCORD_ALLOWED_CHANNELS_KEY} (corrupt values read as empty —
|
|
14
|
+
* deny-by-default).
|
|
15
|
+
*/
|
|
16
|
+
export class AllowListStore {
|
|
17
|
+
private ids = new Set<string>();
|
|
18
|
+
|
|
19
|
+
constructor(private readonly vault: VaultStore) {}
|
|
20
|
+
|
|
21
|
+
async load(): Promise<void> {
|
|
22
|
+
this.ids = new Set(parseAllowedChannels(await this.vault.get(DISCORD_ALLOWED_CHANNELS_KEY)));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
snapshot(): ReadonlySet<string> {
|
|
26
|
+
return this.ids;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
has(channelId: string): boolean {
|
|
30
|
+
return this.ids.has(channelId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Add a channel id. Returns false (no write) for a non-snowflake id. */
|
|
34
|
+
async add(channelId: string): Promise<boolean> {
|
|
35
|
+
if (!snowflakeSchema.safeParse(channelId).success) return false;
|
|
36
|
+
if (this.ids.has(channelId)) return true;
|
|
37
|
+
this.ids.add(channelId);
|
|
38
|
+
await this.persist();
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Remove a channel id. Returns true when it was present. */
|
|
43
|
+
async remove(channelId: string): Promise<boolean> {
|
|
44
|
+
const removed = this.ids.delete(channelId);
|
|
45
|
+
if (removed) await this.persist();
|
|
46
|
+
return removed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private async persist(): Promise<void> {
|
|
50
|
+
await this.vault.set(DISCORD_ALLOWED_CHANNELS_KEY, serializeAllowedChannels(this.ids));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ApprovalRequest } from '@moxxy/sdk';
|
|
2
|
+
import type { DiscordApprovalResolver } from '../approval.js';
|
|
3
|
+
import { BUTTON_STYLE, button, packRows } from './components.js';
|
|
4
|
+
import type { ChannelLogger, SendableChannelLike } from './discord-like.js';
|
|
5
|
+
|
|
6
|
+
export interface ApprovalPromptDeps {
|
|
7
|
+
readonly channel: SendableChannelLike | null;
|
|
8
|
+
readonly resolver: DiscordApprovalResolver;
|
|
9
|
+
readonly logger?: ChannelLogger;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Render an approval request (e.g. plan-execute "validate plan") as a message
|
|
14
|
+
* with a button per option. Options with `requestsText` are still picked here;
|
|
15
|
+
* the interaction handler then captures the user's NEXT message as the
|
|
16
|
+
* follow-up text via the channel's awaiting-text state.
|
|
17
|
+
*/
|
|
18
|
+
export async function askForApproval(
|
|
19
|
+
id: string,
|
|
20
|
+
request: ApprovalRequest,
|
|
21
|
+
deps: ApprovalPromptDeps,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
if (!deps.channel) return;
|
|
24
|
+
const rows = packRows(
|
|
25
|
+
request.options.map((opt) =>
|
|
26
|
+
button(`appr:${id}:${opt.id}`, opt.label, BUTTON_STYLE.secondary),
|
|
27
|
+
),
|
|
28
|
+
);
|
|
29
|
+
// Discord caps messages at 2000 chars; keep the body well under so the
|
|
30
|
+
// prompt always sends. The full plan streams in as assistant messages.
|
|
31
|
+
const body = request.body.length > 1500 ? request.body.slice(0, 1499) + '…' : request.body;
|
|
32
|
+
const summary =
|
|
33
|
+
`📋 **${request.title}**\n\n${body}\n\n` +
|
|
34
|
+
`Pick an option below. Some options (e.g. Redraft) prompt for follow-up text after you click.`;
|
|
35
|
+
try {
|
|
36
|
+
await deps.channel.send({ content: summary, components: rows });
|
|
37
|
+
} catch (err) {
|
|
38
|
+
deps.logger?.warn('discord approval send failed', { err: String(err) });
|
|
39
|
+
// Default-resolve on send failure so the loop strategy doesn't hang.
|
|
40
|
+
const fallback = request.defaultOptionId ?? request.options[0]?.id ?? 'cancel';
|
|
41
|
+
deps.resolver.resolvePending(id, fallback);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw Discord message-component JSON (API shape) for the button rows the
|
|
3
|
+
* channel renders. Built as plain objects rather than discord.js Builders so
|
|
4
|
+
* the prompt modules stay dependency-free and unit-testable — discord.js
|
|
5
|
+
* accepts raw API component data in `send()` payloads.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Discord API button styles. */
|
|
9
|
+
export const BUTTON_STYLE = {
|
|
10
|
+
primary: 1,
|
|
11
|
+
secondary: 2,
|
|
12
|
+
success: 3,
|
|
13
|
+
danger: 4,
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export interface ApiButton {
|
|
17
|
+
readonly type: 2;
|
|
18
|
+
readonly style: number;
|
|
19
|
+
readonly label: string;
|
|
20
|
+
readonly custom_id: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ApiActionRow {
|
|
24
|
+
readonly type: 1;
|
|
25
|
+
readonly components: ReadonlyArray<ApiButton>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MAX_LABEL_CHARS = 80;
|
|
29
|
+
const MAX_CUSTOM_ID_CHARS = 100;
|
|
30
|
+
const MAX_BUTTONS_PER_ROW = 5;
|
|
31
|
+
const MAX_ROWS = 5;
|
|
32
|
+
|
|
33
|
+
export function button(customId: string, label: string, style: number): ApiButton {
|
|
34
|
+
return {
|
|
35
|
+
type: 2,
|
|
36
|
+
style,
|
|
37
|
+
label: label.slice(0, MAX_LABEL_CHARS) || '…',
|
|
38
|
+
custom_id: customId.slice(0, MAX_CUSTOM_ID_CHARS),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Pack buttons into action rows (5 per row, 5 rows max — 25 button cap). */
|
|
43
|
+
export function packRows(buttons: ReadonlyArray<ApiButton>): ApiActionRow[] {
|
|
44
|
+
const rows: ApiActionRow[] = [];
|
|
45
|
+
for (let i = 0; i < buttons.length && rows.length < MAX_ROWS; i += MAX_BUTTONS_PER_ROW) {
|
|
46
|
+
rows.push({ type: 1, components: buttons.slice(i, i + MAX_BUTTONS_PER_ROW) });
|
|
47
|
+
}
|
|
48
|
+
return rows;
|
|
49
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural slices of the discord.js surface the handlers touch. Keeping the
|
|
3
|
+
* handlers typed against these (instead of discord.js classes) keeps the
|
|
4
|
+
* messenger-specific transport at the channel edge and lets unit tests drive
|
|
5
|
+
* every gating / streaming / pairing path with plain fake objects — no gateway
|
|
6
|
+
* connection, no discord.js instances.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A message we sent and can edit in place (the streamed frame). */
|
|
10
|
+
export interface SentMessageLike {
|
|
11
|
+
edit(content: string): Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Payload accepted by `send` — text plus optional component rows (buttons) or
|
|
15
|
+
* file attachments (a synthesized voice reply). `content` is optional so a
|
|
16
|
+
* file-only message (audio, no text) is expressible. */
|
|
17
|
+
export interface OutboundPayload {
|
|
18
|
+
readonly content?: string;
|
|
19
|
+
readonly components?: ReadonlyArray<unknown>;
|
|
20
|
+
/** File attachments (e.g. a discord.js `AttachmentBuilder`). */
|
|
21
|
+
readonly files?: ReadonlyArray<unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A channel (DM or guild text channel) we can post into. */
|
|
25
|
+
export interface SendableChannelLike {
|
|
26
|
+
send(payload: string | OutboundPayload): Promise<SentMessageLike>;
|
|
27
|
+
/** Discord's typing indicator lasts ~10s per call; refresh to keep it alive. */
|
|
28
|
+
sendTyping?(): Promise<unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ChannelLogger {
|
|
32
|
+
info?(msg: string, meta?: Record<string, unknown>): void;
|
|
33
|
+
warn(msg: string, meta?: Record<string, unknown>): void;
|
|
34
|
+
}
|