@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,212 @@
|
|
|
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 { PendingToolCall, PermissionContext } from '@moxxy/sdk';
|
|
7
|
+
import { DISCORD_AUTHORIZED_USER_KEY } from '../keys.js';
|
|
8
|
+
import { DiscordApprovalResolver } from '../approval.js';
|
|
9
|
+
import { DiscordPermissionResolver } from '../permission.js';
|
|
10
|
+
import { AllowListStore } from './allow-list-store.js';
|
|
11
|
+
import { PairingHandler } from './pairing-handler.js';
|
|
12
|
+
import { handleInteraction, type InteractionLike } from './interaction-handler.js';
|
|
13
|
+
|
|
14
|
+
const PAIRED = '111111111111';
|
|
15
|
+
const STRANGER = '222222222222';
|
|
16
|
+
const GUILD = '333333333333';
|
|
17
|
+
const CHAN = '444444444444';
|
|
18
|
+
|
|
19
|
+
let tmp: string;
|
|
20
|
+
let vault: VaultStore;
|
|
21
|
+
let pairing: PairingHandler;
|
|
22
|
+
let allowList: AllowListStore;
|
|
23
|
+
let approvalResolver: DiscordApprovalResolver;
|
|
24
|
+
let permissionResolver: DiscordPermissionResolver;
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-dc-int-'));
|
|
28
|
+
vault = new VaultStore({
|
|
29
|
+
filePath: path.join(tmp, 'vault.json'),
|
|
30
|
+
keySource: createStaticKeySource(deriveKey('test', generateSalt())),
|
|
31
|
+
});
|
|
32
|
+
pairing = new PairingHandler({ vault });
|
|
33
|
+
allowList = new AllowListStore(vault);
|
|
34
|
+
approvalResolver = new DiscordApprovalResolver();
|
|
35
|
+
permissionResolver = new DiscordPermissionResolver();
|
|
36
|
+
await vault.set(DISCORD_AUTHORIZED_USER_KEY, PAIRED);
|
|
37
|
+
await pairing.loadAuthorized();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(async () => {
|
|
41
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
interface FakeInteraction extends InteractionLike {
|
|
45
|
+
readonly replies: Array<{ content: string; ephemeral?: boolean }>;
|
|
46
|
+
readonly updates: unknown[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buttonInteraction(customId: string, userId = PAIRED): FakeInteraction {
|
|
50
|
+
const replies: Array<{ content: string; ephemeral?: boolean }> = [];
|
|
51
|
+
const updates: unknown[] = [];
|
|
52
|
+
return {
|
|
53
|
+
replies,
|
|
54
|
+
updates,
|
|
55
|
+
isButton: () => true,
|
|
56
|
+
isChatInputCommand: () => false,
|
|
57
|
+
customId,
|
|
58
|
+
user: { id: userId },
|
|
59
|
+
reply: async (p) => {
|
|
60
|
+
replies.push(p);
|
|
61
|
+
},
|
|
62
|
+
update: async (p) => {
|
|
63
|
+
updates.push(p);
|
|
64
|
+
},
|
|
65
|
+
followUp: async (p) => {
|
|
66
|
+
replies.push(p);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function slashInteraction(
|
|
72
|
+
commandName: string,
|
|
73
|
+
opts: { userId?: string; guildId?: string | null; channelId?: string | null } = {},
|
|
74
|
+
): FakeInteraction {
|
|
75
|
+
const replies: Array<{ content: string; ephemeral?: boolean }> = [];
|
|
76
|
+
return {
|
|
77
|
+
replies,
|
|
78
|
+
updates: [],
|
|
79
|
+
isButton: () => false,
|
|
80
|
+
isChatInputCommand: () => true,
|
|
81
|
+
commandName,
|
|
82
|
+
user: { id: opts.userId ?? PAIRED },
|
|
83
|
+
guildId: opts.guildId ?? null,
|
|
84
|
+
channelId: opts.channelId ?? null,
|
|
85
|
+
reply: async (p) => {
|
|
86
|
+
replies.push(p);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function deps() {
|
|
92
|
+
return { pairing, allowList, permissionResolver, approvalResolver };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function callbacks() {
|
|
96
|
+
return {
|
|
97
|
+
setAwaitingApprovalText: vi.fn(),
|
|
98
|
+
toggleYolo: vi.fn(() => true),
|
|
99
|
+
performSessionAction: vi.fn(async () => '✓ done'),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const state = { session: null, turnController: null };
|
|
104
|
+
|
|
105
|
+
describe('handleInteraction — authorization gate', () => {
|
|
106
|
+
it('refuses button clicks from an unpaired user (buttons are visible to a whole guild)', async () => {
|
|
107
|
+
// Park a real pending permission so a bypass would be observable.
|
|
108
|
+
permissionResolver.setDecider(async () => undefined);
|
|
109
|
+
const decision = permissionResolver.check(
|
|
110
|
+
{ callId: 'c1', name: 'bash', input: {} } as PendingToolCall,
|
|
111
|
+
{} as PermissionContext,
|
|
112
|
+
);
|
|
113
|
+
const interaction = buttonInteraction('perm:c1:allow', STRANGER);
|
|
114
|
+
await handleInteraction(interaction, state, deps(), callbacks());
|
|
115
|
+
expect(interaction.replies[0]?.content).toMatch(/different Discord account/);
|
|
116
|
+
expect(interaction.updates).toEqual([]);
|
|
117
|
+
// The prompt is still pending — the stranger's click resolved nothing.
|
|
118
|
+
permissionResolver.abortAll('test done');
|
|
119
|
+
await expect(decision).resolves.toEqual({ mode: 'deny', reason: 'test done' });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('refuses slash commands from an unpaired user', async () => {
|
|
123
|
+
const interaction = slashInteraction('info', { userId: STRANGER });
|
|
124
|
+
await handleInteraction(interaction, state, deps(), callbacks());
|
|
125
|
+
expect(interaction.replies[0]?.content).toMatch(/different Discord account/);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('handleInteraction — permission buttons', () => {
|
|
130
|
+
it.each([
|
|
131
|
+
['allow', { mode: 'allow' }],
|
|
132
|
+
['allow_session', { mode: 'allow_session' }],
|
|
133
|
+
['deny', { mode: 'deny', reason: 'denied by user' }],
|
|
134
|
+
] as const)('maps %s clicks to the resolver decision', async (choice, expected) => {
|
|
135
|
+
permissionResolver.setDecider(async () => undefined);
|
|
136
|
+
const decision = permissionResolver.check(
|
|
137
|
+
{ callId: 'c1', name: 'bash', input: {} } as PendingToolCall,
|
|
138
|
+
{} as PermissionContext,
|
|
139
|
+
);
|
|
140
|
+
const interaction = buttonInteraction(`perm:c1:${choice}`);
|
|
141
|
+
await handleInteraction(interaction, state, deps(), callbacks());
|
|
142
|
+
await expect(decision).resolves.toEqual(expected);
|
|
143
|
+
// Buttons cleared so the user can't double-click.
|
|
144
|
+
expect(interaction.updates).toEqual([{ components: [] }]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('answers "no pending permission" for a stale click', async () => {
|
|
148
|
+
const interaction = buttonInteraction('perm:ghost:allow');
|
|
149
|
+
await handleInteraction(interaction, state, deps(), callbacks());
|
|
150
|
+
expect(interaction.replies[0]?.content).toMatch(/no pending permission/);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('handleInteraction — approval buttons', () => {
|
|
155
|
+
it('resolves a pending approval by option id', async () => {
|
|
156
|
+
approvalResolver.setDecider(async () => undefined);
|
|
157
|
+
const decision = approvalResolver.confirm({
|
|
158
|
+
title: 't',
|
|
159
|
+
body: 'b',
|
|
160
|
+
options: [
|
|
161
|
+
{ id: 'approve', label: 'Approve' },
|
|
162
|
+
{ id: 'cancel', label: 'Cancel' },
|
|
163
|
+
],
|
|
164
|
+
defaultOptionId: 'approve',
|
|
165
|
+
} as never);
|
|
166
|
+
const interaction = buttonInteraction('appr:appr_1:approve');
|
|
167
|
+
await handleInteraction(interaction, state, deps(), callbacks());
|
|
168
|
+
await expect(decision).resolves.toEqual({ optionId: 'approve' });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('latches awaiting-text for options that request follow-up text', async () => {
|
|
172
|
+
approvalResolver.setDecider(async () => undefined);
|
|
173
|
+
void approvalResolver.confirm({
|
|
174
|
+
title: 't',
|
|
175
|
+
body: 'b',
|
|
176
|
+
options: [{ id: 'redraft', label: 'Redraft', requestsText: true, textPrompt: 'Say more' }],
|
|
177
|
+
defaultOptionId: 'redraft',
|
|
178
|
+
} as never);
|
|
179
|
+
const cb = callbacks();
|
|
180
|
+
const interaction = buttonInteraction('appr:appr_1:redraft');
|
|
181
|
+
await handleInteraction(interaction, state, deps(), cb);
|
|
182
|
+
expect(cb.setAwaitingApprovalText).toHaveBeenCalledWith({
|
|
183
|
+
approvalId: 'appr_1',
|
|
184
|
+
optionId: 'redraft',
|
|
185
|
+
});
|
|
186
|
+
expect(interaction.replies[0]?.content).toMatch(/Say more/);
|
|
187
|
+
approvalResolver.abortAll('test done');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('handleInteraction — slash commands', () => {
|
|
192
|
+
it('/allow works in a guild channel that is NOT yet allow-listed', async () => {
|
|
193
|
+
const interaction = slashInteraction('allow', { guildId: GUILD, channelId: CHAN });
|
|
194
|
+
await handleInteraction(interaction, state, deps(), callbacks());
|
|
195
|
+
expect(allowList.has(CHAN)).toBe(true);
|
|
196
|
+
expect(interaction.replies[0]?.content).toMatch(/✓/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('other commands in a non-allow-listed guild channel get the /allow hint', async () => {
|
|
200
|
+
const interaction = slashInteraction('info', { guildId: GUILD, channelId: CHAN });
|
|
201
|
+
await handleInteraction(interaction, state, deps(), callbacks());
|
|
202
|
+
expect(interaction.replies[0]?.content).toMatch(/\/allow/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('/cancel aborts the in-flight turn', async () => {
|
|
206
|
+
const controller = new AbortController();
|
|
207
|
+
const interaction = slashInteraction('cancel');
|
|
208
|
+
await handleInteraction(interaction, { session: null, turnController: controller }, deps(), callbacks());
|
|
209
|
+
expect(controller.signal.aborted).toBe(true);
|
|
210
|
+
expect(interaction.replies[0]?.content).toMatch(/cancelling/);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
2
|
+
import type { PermissionDecision } from '@moxxy/sdk';
|
|
3
|
+
import type { DiscordApprovalResolver } from '../approval.js';
|
|
4
|
+
import type { DiscordPermissionResolver } from '../permission.js';
|
|
5
|
+
import type { AllowListStore } from './allow-list-store.js';
|
|
6
|
+
import type { ChannelLogger } from './discord-like.js';
|
|
7
|
+
import type { AwaitingApprovalText } from './message-handler.js';
|
|
8
|
+
import type { PairingHandler } from './pairing-handler.js';
|
|
9
|
+
import { runSlash } from './slash-handler.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Structural slice of a discord.js Interaction the handler needs — buttons
|
|
13
|
+
* (permission / approval prompts) and chat-input (slash) commands. Kept
|
|
14
|
+
* structural so tests drive it with plain objects.
|
|
15
|
+
*/
|
|
16
|
+
export interface InteractionLike {
|
|
17
|
+
isButton(): boolean;
|
|
18
|
+
isChatInputCommand(): boolean;
|
|
19
|
+
/** Button custom id (`perm:<callId>:<choice>` / `appr:<id>:<optionId>`). */
|
|
20
|
+
readonly customId?: string;
|
|
21
|
+
/** Slash-command name. */
|
|
22
|
+
readonly commandName?: string;
|
|
23
|
+
readonly user: { readonly id: string };
|
|
24
|
+
readonly channelId?: string | null;
|
|
25
|
+
readonly guildId?: string | null;
|
|
26
|
+
reply(payload: { content: string; ephemeral?: boolean }): Promise<unknown>;
|
|
27
|
+
/** Buttons only: edit the prompt message (used to clear the button row).
|
|
28
|
+
* NB: this ACKS the interaction — any later message must use followUp. */
|
|
29
|
+
update?(payload: { components: ReadonlyArray<unknown> }): Promise<unknown>;
|
|
30
|
+
/** Post-ack follow-up message (valid only after update/reply succeeded). */
|
|
31
|
+
followUp?(payload: { content: string; ephemeral?: boolean }): Promise<unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface InteractionState {
|
|
35
|
+
readonly session: Session | null;
|
|
36
|
+
readonly turnController: AbortController | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface InteractionDeps {
|
|
40
|
+
readonly pairing: PairingHandler;
|
|
41
|
+
readonly allowList: AllowListStore;
|
|
42
|
+
readonly permissionResolver: DiscordPermissionResolver;
|
|
43
|
+
readonly approvalResolver: DiscordApprovalResolver;
|
|
44
|
+
readonly logger?: ChannelLogger;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface InteractionCallbacks {
|
|
48
|
+
readonly setAwaitingApprovalText: (state: AwaitingApprovalText | null) => void;
|
|
49
|
+
readonly toggleYolo: () => boolean;
|
|
50
|
+
/** Handle `/voice [on|off|status]` — persist + apply, return the reply text. */
|
|
51
|
+
readonly voice: (arg: string) => Promise<string>;
|
|
52
|
+
readonly performSessionAction: (
|
|
53
|
+
action: 'new' | 'clear' | 'exit',
|
|
54
|
+
notice: string | undefined,
|
|
55
|
+
) => Promise<string>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Interaction router (button clicks + slash commands). The authorization gate
|
|
60
|
+
* runs FIRST and for EVERY path (AGENTS.md A46 — Telegram's callback handlers
|
|
61
|
+
* once skipped it): button clicks resolve permission prompts and approvals,
|
|
62
|
+
* slash commands reach the session, and a prompt message can be seen (and its
|
|
63
|
+
* buttons clicked) by any member of a guild channel — so an unpaired user's
|
|
64
|
+
* interactions must be refused exactly like their messages.
|
|
65
|
+
*/
|
|
66
|
+
export async function handleInteraction(
|
|
67
|
+
interaction: InteractionLike,
|
|
68
|
+
state: InteractionState,
|
|
69
|
+
deps: InteractionDeps,
|
|
70
|
+
cb: InteractionCallbacks,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
if (!interaction.isButton() && !interaction.isChatInputCommand()) return;
|
|
73
|
+
|
|
74
|
+
if (!deps.pairing.isAuthorized(interaction.user.id)) {
|
|
75
|
+
await safeReply(interaction, 'This bot is paired with a different Discord account.', deps.logger);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (interaction.isButton()) {
|
|
80
|
+
await handleButton(interaction, deps, cb);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
await handleSlashCommand(interaction, state, deps, cb);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function handleButton(
|
|
87
|
+
interaction: InteractionLike,
|
|
88
|
+
deps: InteractionDeps,
|
|
89
|
+
cb: InteractionCallbacks,
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
const data = interaction.customId ?? '';
|
|
92
|
+
if (data.startsWith('perm:')) {
|
|
93
|
+
const parts = data.split(':');
|
|
94
|
+
if (parts.length !== 3 || !parts[1] || !parts[2]) return;
|
|
95
|
+
const handled = deps.permissionResolver.resolvePending(parts[1], mapChoice(parts[2]));
|
|
96
|
+
const acked = await clearButtons(interaction, deps.logger);
|
|
97
|
+
if (!handled) await respond(interaction, 'no pending permission', acked, deps.logger);
|
|
98
|
+
// A click must always be acked or Discord shows "interaction failed".
|
|
99
|
+
else if (!acked) await respond(interaction, `→ ${parts[2]}`, acked, deps.logger);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (data.startsWith('appr:')) {
|
|
103
|
+
const idx = data.indexOf(':', 5);
|
|
104
|
+
if (idx < 0) return;
|
|
105
|
+
const approvalId = data.slice(5, idx);
|
|
106
|
+
const optionId = data.slice(idx + 1);
|
|
107
|
+
const pending = deps.approvalResolver.getPending(approvalId);
|
|
108
|
+
if (!pending) {
|
|
109
|
+
await safeReply(interaction, 'no pending approval', deps.logger);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const option = pending.request.options.find((o) => o.id === optionId);
|
|
113
|
+
if (!option) {
|
|
114
|
+
await safeReply(interaction, 'unknown option', deps.logger);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const acked = await clearButtons(interaction, deps.logger);
|
|
118
|
+
if (option.requestsText) {
|
|
119
|
+
// Don't resolve yet — capture the user's next message as the follow-up
|
|
120
|
+
// text. Mirrors the TUI dialog's text-entry sub-mode.
|
|
121
|
+
cb.setAwaitingApprovalText({ approvalId, optionId });
|
|
122
|
+
const prompt =
|
|
123
|
+
option.textPrompt ??
|
|
124
|
+
`Send your message — the next text you type becomes the ${optionId} input.`;
|
|
125
|
+
await respond(interaction, `✏️ ${prompt}`, acked, deps.logger);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
deps.approvalResolver.resolvePending(approvalId, optionId);
|
|
129
|
+
await respond(interaction, `→ ${option.label}`, acked, deps.logger);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function handleSlashCommand(
|
|
134
|
+
interaction: InteractionLike,
|
|
135
|
+
state: InteractionState,
|
|
136
|
+
deps: InteractionDeps,
|
|
137
|
+
cb: InteractionCallbacks,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const name = interaction.commandName ?? '';
|
|
140
|
+
const channelId = interaction.channelId ?? null;
|
|
141
|
+
const inGuild = interaction.guildId != null;
|
|
142
|
+
|
|
143
|
+
// Allow-list management works from the guild channel itself; /allow is the
|
|
144
|
+
// one command that must work BEFORE the channel is allow-listed.
|
|
145
|
+
if (name === 'allow' && inGuild && channelId) {
|
|
146
|
+
await deps.allowList.add(channelId);
|
|
147
|
+
await safeReply(interaction, '✓ this channel can now drive moxxy. Use /deny here to revoke.', deps.logger);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (name === 'deny' && inGuild && channelId) {
|
|
151
|
+
const removed = await deps.allowList.remove(channelId);
|
|
152
|
+
await safeReply(
|
|
153
|
+
interaction,
|
|
154
|
+
removed ? '✓ channel removed from the allow-list.' : 'this channel was not allow-listed.',
|
|
155
|
+
deps.logger,
|
|
156
|
+
);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Everything else obeys the same channel gate as plain messages: a guild
|
|
161
|
+
// channel must be allow-listed (DMs with the paired user always pass).
|
|
162
|
+
if (inGuild && channelId && !deps.allowList.has(channelId)) {
|
|
163
|
+
await safeReply(interaction, 'This channel is not allow-listed. Send /allow here to enable it.', deps.logger);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (name === 'cancel') {
|
|
168
|
+
if (state.turnController && !state.turnController.signal.aborted) {
|
|
169
|
+
state.turnController.abort('user cancel');
|
|
170
|
+
await safeReply(interaction, 'cancelling current turn…', deps.logger);
|
|
171
|
+
} else {
|
|
172
|
+
await safeReply(interaction, 'nothing to cancel.', deps.logger);
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!state.session) {
|
|
178
|
+
await safeReply(interaction, 'Session is not ready yet.', deps.logger);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const reply = await runSlash(name, '', state.session, {
|
|
182
|
+
toggleYolo: cb.toggleYolo,
|
|
183
|
+
voice: cb.voice,
|
|
184
|
+
performSessionAction: cb.performSessionAction,
|
|
185
|
+
});
|
|
186
|
+
await safeReply(interaction, reply.length > 1_900 ? reply.slice(0, 1_899) + '…' : reply, deps.logger);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function mapChoice(choice: string): PermissionDecision {
|
|
190
|
+
if (choice === 'allow') return { mode: 'allow' };
|
|
191
|
+
if (choice === 'allow_session') return { mode: 'allow_session' };
|
|
192
|
+
return { mode: 'deny', reason: 'denied by user' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Clear the prompt's button row. Returns true when the update ACKED the
|
|
196
|
+
* interaction (later messages must then use followUp, not reply). */
|
|
197
|
+
async function clearButtons(interaction: InteractionLike, logger?: ChannelLogger): Promise<boolean> {
|
|
198
|
+
if (!interaction.update) return false;
|
|
199
|
+
try {
|
|
200
|
+
await interaction.update({ components: [] });
|
|
201
|
+
return true;
|
|
202
|
+
} catch (err) {
|
|
203
|
+
logger?.warn('discord clear-buttons failed', { err: String(err) });
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Send a user-visible ack: followUp when the interaction was already acked
|
|
209
|
+
* (a successful `update`), plain ephemeral reply otherwise. */
|
|
210
|
+
async function respond(
|
|
211
|
+
interaction: InteractionLike,
|
|
212
|
+
content: string,
|
|
213
|
+
acked: boolean,
|
|
214
|
+
logger?: ChannelLogger,
|
|
215
|
+
): Promise<void> {
|
|
216
|
+
try {
|
|
217
|
+
if (acked && interaction.followUp) {
|
|
218
|
+
await interaction.followUp({ content, ephemeral: true });
|
|
219
|
+
} else {
|
|
220
|
+
await interaction.reply({ content, ephemeral: true });
|
|
221
|
+
}
|
|
222
|
+
} catch (err) {
|
|
223
|
+
logger?.warn('discord interaction respond failed', { err: String(err) });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function safeReply(
|
|
228
|
+
interaction: InteractionLike,
|
|
229
|
+
content: string,
|
|
230
|
+
logger?: ChannelLogger,
|
|
231
|
+
): Promise<void> {
|
|
232
|
+
try {
|
|
233
|
+
await interaction.reply({ content, ephemeral: true });
|
|
234
|
+
} catch (err) {
|
|
235
|
+
logger?.warn('discord interaction reply failed', { err: String(err) });
|
|
236
|
+
}
|
|
237
|
+
}
|