@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,295 @@
|
|
|
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 { ClientSession as Session } from '@moxxy/sdk';
|
|
7
|
+
import { DISCORD_AUTHORIZED_USER_KEY } from '../keys.js';
|
|
8
|
+
import type { InboundMessage } from '../schema.js';
|
|
9
|
+
import { extractInboundMessage, MAX_CONTENT_CHARS } from '../schema.js';
|
|
10
|
+
import { DiscordApprovalResolver } from '../approval.js';
|
|
11
|
+
import { DiscordPermissionResolver } from '../permission.js';
|
|
12
|
+
import { AllowListStore } from './allow-list-store.js';
|
|
13
|
+
import { PairingHandler } from './pairing-handler.js';
|
|
14
|
+
import {
|
|
15
|
+
handleInboundMessage,
|
|
16
|
+
type InboundContext,
|
|
17
|
+
type MessageHandlerCallbacks,
|
|
18
|
+
type MessageHandlerState,
|
|
19
|
+
} from './message-handler.js';
|
|
20
|
+
|
|
21
|
+
const PAIRED = '111111111111';
|
|
22
|
+
const STRANGER = '222222222222';
|
|
23
|
+
const GUILD = '333333333333';
|
|
24
|
+
const CHAN = '444444444444';
|
|
25
|
+
const DM_CHAN = '555555555555';
|
|
26
|
+
|
|
27
|
+
let tmp: string;
|
|
28
|
+
let vault: VaultStore;
|
|
29
|
+
let pairing: PairingHandler;
|
|
30
|
+
let allowList: AllowListStore;
|
|
31
|
+
let approvalResolver: DiscordApprovalResolver;
|
|
32
|
+
let permissionResolver: DiscordPermissionResolver;
|
|
33
|
+
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-dc-msg-'));
|
|
36
|
+
vault = new VaultStore({
|
|
37
|
+
filePath: path.join(tmp, 'vault.json'),
|
|
38
|
+
keySource: createStaticKeySource(deriveKey('test', generateSalt())),
|
|
39
|
+
});
|
|
40
|
+
pairing = new PairingHandler({ vault });
|
|
41
|
+
allowList = new AllowListStore(vault);
|
|
42
|
+
approvalResolver = new DiscordApprovalResolver();
|
|
43
|
+
permissionResolver = new DiscordPermissionResolver();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(async () => {
|
|
47
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
async function pairAs(userId: string): Promise<void> {
|
|
51
|
+
await vault.set(DISCORD_AUTHORIZED_USER_KEY, userId);
|
|
52
|
+
await pairing.loadAuthorized();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function msg(overrides: Partial<InboundMessage> = {}): InboundMessage {
|
|
56
|
+
return {
|
|
57
|
+
id: '999999999999',
|
|
58
|
+
content: 'hello',
|
|
59
|
+
channelId: DM_CHAN,
|
|
60
|
+
guildId: null,
|
|
61
|
+
authorId: PAIRED,
|
|
62
|
+
authorIsBot: false,
|
|
63
|
+
attachments: [],
|
|
64
|
+
...overrides,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface Ctxed {
|
|
69
|
+
ctx: InboundContext;
|
|
70
|
+
replies: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeCtx(m: InboundMessage): Ctxed {
|
|
74
|
+
const replies: string[] = [];
|
|
75
|
+
return {
|
|
76
|
+
replies,
|
|
77
|
+
ctx: {
|
|
78
|
+
msg: m,
|
|
79
|
+
channel: { send: async () => ({ edit: async () => undefined }) },
|
|
80
|
+
reply: async (text: string) => {
|
|
81
|
+
replies.push(text);
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function makeState(overrides: Partial<MessageHandlerState> = {}): MessageHandlerState {
|
|
88
|
+
return {
|
|
89
|
+
session: null,
|
|
90
|
+
busy: false,
|
|
91
|
+
turnController: null,
|
|
92
|
+
awaitingApprovalText: null,
|
|
93
|
+
handle: null,
|
|
94
|
+
botUserId: null,
|
|
95
|
+
...overrides,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function makeCallbacks(overrides: Partial<MessageHandlerCallbacks> = {}): MessageHandlerCallbacks & {
|
|
100
|
+
runUserTurn: ReturnType<typeof vi.fn>;
|
|
101
|
+
runVoiceMessage: ReturnType<typeof vi.fn>;
|
|
102
|
+
} {
|
|
103
|
+
const runUserTurn = vi.fn(async () => undefined);
|
|
104
|
+
const runVoiceMessage = vi.fn(async () => false);
|
|
105
|
+
return {
|
|
106
|
+
setAwaitingApprovalText: () => undefined,
|
|
107
|
+
toggleYolo: () => false,
|
|
108
|
+
setYolo: () => undefined,
|
|
109
|
+
runUserTurn,
|
|
110
|
+
runVoiceMessage,
|
|
111
|
+
...overrides,
|
|
112
|
+
} as never;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function deps() {
|
|
116
|
+
return { pairing, allowList, approvalResolver, permissionResolver };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe('handleInboundMessage — gating (every session-reaching path)', () => {
|
|
120
|
+
it('drops an unpaired DM with pairing guidance (no window armed)', async () => {
|
|
121
|
+
const { ctx, replies } = makeCtx(msg({ authorId: STRANGER }));
|
|
122
|
+
const cb = makeCallbacks();
|
|
123
|
+
await handleInboundMessage(ctx, makeState(), deps(), cb);
|
|
124
|
+
expect(cb.runUserTurn).not.toHaveBeenCalled();
|
|
125
|
+
expect(replies.join(' ')).toMatch(/pairing window/i);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('issues a pairing code to an unpaired DM when the window is armed', async () => {
|
|
129
|
+
pairing.arm();
|
|
130
|
+
const { ctx, replies } = makeCtx(msg({ authorId: STRANGER }));
|
|
131
|
+
const cb = makeCallbacks();
|
|
132
|
+
await handleInboundMessage(ctx, makeState(), deps(), cb);
|
|
133
|
+
expect(cb.runUserTurn).not.toHaveBeenCalled();
|
|
134
|
+
expect(replies[0]).toMatch(/pairing code/i);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('silently drops unpaired GUILD messages (no server spam)', async () => {
|
|
138
|
+
pairing.arm();
|
|
139
|
+
const { ctx, replies } = makeCtx(msg({ authorId: STRANGER, guildId: GUILD, channelId: CHAN }));
|
|
140
|
+
const cb = makeCallbacks();
|
|
141
|
+
await handleInboundMessage(ctx, makeState(), deps(), cb);
|
|
142
|
+
expect(cb.runUserTurn).not.toHaveBeenCalled();
|
|
143
|
+
expect(replies).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('drops a foreign user in a guild even when the channel is allow-listed', async () => {
|
|
147
|
+
await pairAs(PAIRED);
|
|
148
|
+
await allowList.add(CHAN);
|
|
149
|
+
const { ctx, replies } = makeCtx(msg({ authorId: STRANGER, guildId: GUILD, channelId: CHAN }));
|
|
150
|
+
const cb = makeCallbacks();
|
|
151
|
+
await handleInboundMessage(ctx, makeState(), deps(), cb);
|
|
152
|
+
expect(cb.runUserTurn).not.toHaveBeenCalled();
|
|
153
|
+
expect(replies).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('blocks the paired user in a non-allow-listed guild channel with a hint', async () => {
|
|
157
|
+
await pairAs(PAIRED);
|
|
158
|
+
const { ctx, replies } = makeCtx(msg({ guildId: GUILD, channelId: CHAN }));
|
|
159
|
+
const cb = makeCallbacks();
|
|
160
|
+
await handleInboundMessage(ctx, makeState(), deps(), cb);
|
|
161
|
+
expect(cb.runUserTurn).not.toHaveBeenCalled();
|
|
162
|
+
expect(replies[0]).toMatch(/\/allow/);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('/allow from the paired user opts a guild channel in; messages then flow', async () => {
|
|
166
|
+
await pairAs(PAIRED);
|
|
167
|
+
const first = makeCtx(msg({ guildId: GUILD, channelId: CHAN, content: '/allow' }));
|
|
168
|
+
const cb = makeCallbacks();
|
|
169
|
+
await handleInboundMessage(first.ctx, makeState(), deps(), cb);
|
|
170
|
+
expect(first.replies[0]).toMatch(/✓/);
|
|
171
|
+
expect(allowList.has(CHAN)).toBe(true);
|
|
172
|
+
|
|
173
|
+
const second = makeCtx(msg({ guildId: GUILD, channelId: CHAN, content: 'do the thing' }));
|
|
174
|
+
await handleInboundMessage(second.ctx, makeState(), deps(), cb);
|
|
175
|
+
expect(cb.runUserTurn).toHaveBeenCalledWith(second.ctx, 'do the thing');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('/deny removes a guild channel from the allow-list', async () => {
|
|
179
|
+
await pairAs(PAIRED);
|
|
180
|
+
await allowList.add(CHAN);
|
|
181
|
+
const { ctx, replies } = makeCtx(msg({ guildId: GUILD, channelId: CHAN, content: '/deny' }));
|
|
182
|
+
await handleInboundMessage(ctx, makeState(), deps(), makeCallbacks());
|
|
183
|
+
expect(replies[0]).toMatch(/removed/);
|
|
184
|
+
expect(allowList.has(CHAN)).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('never reacts to bot-authored messages (paired or not)', async () => {
|
|
188
|
+
await pairAs(PAIRED);
|
|
189
|
+
const { ctx, replies } = makeCtx(msg({ authorIsBot: true }));
|
|
190
|
+
const cb = makeCallbacks();
|
|
191
|
+
await handleInboundMessage(ctx, makeState(), deps(), cb);
|
|
192
|
+
expect(cb.runUserTurn).not.toHaveBeenCalled();
|
|
193
|
+
expect(replies).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('drops oversized content at the validation boundary (extractInboundMessage)', () => {
|
|
197
|
+
const raw = {
|
|
198
|
+
id: '999999999999',
|
|
199
|
+
content: 'x'.repeat(MAX_CONTENT_CHARS + 1),
|
|
200
|
+
channelId: DM_CHAN,
|
|
201
|
+
guildId: null,
|
|
202
|
+
author: { id: PAIRED, bot: false },
|
|
203
|
+
attachments: null,
|
|
204
|
+
};
|
|
205
|
+
expect(extractInboundMessage(raw)).toBeNull();
|
|
206
|
+
// Under the cap it validates.
|
|
207
|
+
expect(extractInboundMessage({ ...raw, content: 'ok' })).not.toBeNull();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('handleInboundMessage — dispatch', () => {
|
|
212
|
+
it('runs a user turn for a paired DM', async () => {
|
|
213
|
+
await pairAs(PAIRED);
|
|
214
|
+
const { ctx } = makeCtx(msg({ content: 'what time is it' }));
|
|
215
|
+
const cb = makeCallbacks();
|
|
216
|
+
await handleInboundMessage(ctx, makeState(), deps(), cb);
|
|
217
|
+
expect(cb.runUserTurn).toHaveBeenCalledWith(ctx, 'what time is it');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('refuses new prompts while busy', async () => {
|
|
221
|
+
await pairAs(PAIRED);
|
|
222
|
+
const { ctx, replies } = makeCtx(msg());
|
|
223
|
+
const cb = makeCallbacks();
|
|
224
|
+
await handleInboundMessage(ctx, makeState({ busy: true }), deps(), cb);
|
|
225
|
+
expect(cb.runUserTurn).not.toHaveBeenCalled();
|
|
226
|
+
expect(replies[0]).toMatch(/still working/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('/cancel aborts the in-flight turn even while busy', async () => {
|
|
230
|
+
await pairAs(PAIRED);
|
|
231
|
+
const controller = new AbortController();
|
|
232
|
+
const { ctx, replies } = makeCtx(msg({ content: '/cancel' }));
|
|
233
|
+
await handleInboundMessage(
|
|
234
|
+
ctx,
|
|
235
|
+
makeState({ busy: true, turnController: controller }),
|
|
236
|
+
deps(),
|
|
237
|
+
makeCallbacks(),
|
|
238
|
+
);
|
|
239
|
+
expect(controller.signal.aborted).toBe(true);
|
|
240
|
+
expect(replies[0]).toMatch(/cancelling/);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('captures awaiting-approval text before the busy guard', async () => {
|
|
244
|
+
await pairAs(PAIRED);
|
|
245
|
+
// Wire a decider so confirm() parks as pending (id 'appr_1').
|
|
246
|
+
approvalResolver.setDecider(async () => undefined);
|
|
247
|
+
const pendingDecision = approvalResolver.confirm({
|
|
248
|
+
title: 't',
|
|
249
|
+
body: 'b',
|
|
250
|
+
options: [{ id: 'redraft', label: 'Redraft', requestsText: true }],
|
|
251
|
+
defaultOptionId: 'redraft',
|
|
252
|
+
} as never);
|
|
253
|
+
|
|
254
|
+
const setAwaiting = vi.fn();
|
|
255
|
+
const { ctx, replies } = makeCtx(msg({ content: 'my feedback here' }));
|
|
256
|
+
await handleInboundMessage(
|
|
257
|
+
ctx,
|
|
258
|
+
makeState({ busy: true, awaitingApprovalText: { approvalId: 'appr_1', optionId: 'redraft' } }),
|
|
259
|
+
deps(),
|
|
260
|
+
makeCallbacks({ setAwaitingApprovalText: setAwaiting }),
|
|
261
|
+
);
|
|
262
|
+
expect(setAwaiting).toHaveBeenCalledWith(null);
|
|
263
|
+
expect(replies[0]).toMatch(/✓ submitted/);
|
|
264
|
+
await expect(pendingDecision).resolves.toEqual({ optionId: 'redraft', text: 'my feedback here' });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('routes registry slash commands through session.commands', async () => {
|
|
268
|
+
await pairAs(PAIRED);
|
|
269
|
+
const handler = vi.fn(async () => ({ kind: 'text', text: 'info output' }));
|
|
270
|
+
const session = {
|
|
271
|
+
id: 'sess',
|
|
272
|
+
commands: { get: (name: string) => (name === 'info' ? { handler } : undefined) },
|
|
273
|
+
} as unknown as Session;
|
|
274
|
+
const { ctx, replies } = makeCtx(msg({ content: '/info' }));
|
|
275
|
+
await handleInboundMessage(ctx, makeState({ session }), deps(), makeCallbacks());
|
|
276
|
+
expect(handler).toHaveBeenCalled();
|
|
277
|
+
expect(replies[0]).toBe('info output');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('dispatches audio attachments to the voice path instead of a text turn', async () => {
|
|
281
|
+
await pairAs(PAIRED);
|
|
282
|
+
const { ctx } = makeCtx(
|
|
283
|
+
msg({
|
|
284
|
+
content: '',
|
|
285
|
+
attachments: [
|
|
286
|
+
{ id: '888888888888', url: 'https://cdn.example/x.ogg', contentType: 'audio/ogg', size: 100, name: 'v.ogg' },
|
|
287
|
+
],
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
290
|
+
const cb = makeCallbacks({ runVoiceMessage: vi.fn(async () => true) });
|
|
291
|
+
await handleInboundMessage(ctx, makeState(), deps(), cb);
|
|
292
|
+
expect(cb.runVoiceMessage).toHaveBeenCalledWith(ctx);
|
|
293
|
+
expect(cb.runUserTurn).not.toHaveBeenCalled();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { ChannelHandle, ClientSession as Session } from '@moxxy/sdk';
|
|
2
|
+
import { gateInbound } from '../allow-list.js';
|
|
3
|
+
import type { InboundMessage } from '../schema.js';
|
|
4
|
+
import type { DiscordApprovalResolver } from '../approval.js';
|
|
5
|
+
import type { DiscordPermissionResolver } from '../permission.js';
|
|
6
|
+
import type { AllowListStore } from './allow-list-store.js';
|
|
7
|
+
import type { ChannelLogger, SendableChannelLike } from './discord-like.js';
|
|
8
|
+
import type { PairingHandler } from './pairing-handler.js';
|
|
9
|
+
import { runSlash } from './slash-handler.js';
|
|
10
|
+
|
|
11
|
+
export interface AwaitingApprovalText {
|
|
12
|
+
approvalId: string;
|
|
13
|
+
optionId: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A validated inbound message plus the transport bits needed to answer it. */
|
|
17
|
+
export interface InboundContext {
|
|
18
|
+
readonly msg: InboundMessage;
|
|
19
|
+
readonly channel: SendableChannelLike;
|
|
20
|
+
reply(text: string): Promise<unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MessageHandlerState {
|
|
24
|
+
readonly session: Session | null;
|
|
25
|
+
readonly busy: boolean;
|
|
26
|
+
readonly turnController: AbortController | null;
|
|
27
|
+
readonly awaitingApprovalText: AwaitingApprovalText | null;
|
|
28
|
+
readonly handle: ChannelHandle | null;
|
|
29
|
+
/** The bot's own user id (ignore self-authored messages defensively). */
|
|
30
|
+
readonly botUserId: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MessageHandlerDeps {
|
|
34
|
+
readonly pairing: PairingHandler;
|
|
35
|
+
readonly allowList: AllowListStore;
|
|
36
|
+
readonly approvalResolver: DiscordApprovalResolver;
|
|
37
|
+
readonly permissionResolver: DiscordPermissionResolver;
|
|
38
|
+
readonly logger?: ChannelLogger;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MessageHandlerCallbacks {
|
|
42
|
+
readonly setAwaitingApprovalText: (state: AwaitingApprovalText | null) => void;
|
|
43
|
+
readonly toggleYolo: () => boolean;
|
|
44
|
+
readonly setYolo: (value: boolean) => void;
|
|
45
|
+
/** Handle `/voice [on|off|status]` — persist + apply, return the reply text. */
|
|
46
|
+
readonly voice: (arg: string) => Promise<string>;
|
|
47
|
+
readonly runUserTurn: (ctx: InboundContext, text: string) => Promise<void>;
|
|
48
|
+
/** Handle audio attachments (voice messages). Returns true when it consumed
|
|
49
|
+
* the message (so the text path is skipped). */
|
|
50
|
+
readonly runVoiceMessage: (ctx: InboundContext) => Promise<boolean>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Top-level dispatch for inbound Discord messages. Every session-reaching path
|
|
55
|
+
* is behind the pairing + allow-list gate (AGENTS.md A46: gate EVERY path):
|
|
56
|
+
* validation happened upstream (the caller only builds an {@link
|
|
57
|
+
* InboundContext} from a zod-validated extraction), authorization happens
|
|
58
|
+
* here, and only then do approval-capture / cancel / slash / turn paths run.
|
|
59
|
+
*/
|
|
60
|
+
export async function handleInboundMessage(
|
|
61
|
+
ctx: InboundContext,
|
|
62
|
+
state: MessageHandlerState,
|
|
63
|
+
deps: MessageHandlerDeps,
|
|
64
|
+
cb: MessageHandlerCallbacks,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
const { msg } = ctx;
|
|
67
|
+
// Never react to bots (including ourselves) — bot-to-bot loops.
|
|
68
|
+
if (msg.authorIsBot || (state.botUserId != null && msg.authorId === state.botUserId)) return;
|
|
69
|
+
|
|
70
|
+
const text = msg.content.trim();
|
|
71
|
+
const verdict = gateInbound(msg, deps.pairing.authorizedUserId(), deps.allowList.snapshot());
|
|
72
|
+
|
|
73
|
+
if (!verdict.ok) {
|
|
74
|
+
if (verdict.reason === 'not-paired' || verdict.reason === 'foreign-user') {
|
|
75
|
+
// DMs get pairing guidance (a fresh code when a window is armed, a
|
|
76
|
+
// rejection for a foreign account); guild messages are dropped silently
|
|
77
|
+
// so an unpaired bot can't be made to spam a server.
|
|
78
|
+
if (msg.guildId == null) {
|
|
79
|
+
const reply = deps.pairing.handleUnpairedDm(msg.authorId);
|
|
80
|
+
if (reply) await ctx.reply(reply);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Paired user in a guild channel that isn't allow-listed: /allow opts the
|
|
85
|
+
// channel in (this is the ONE command that must work pre-allow-list —
|
|
86
|
+
// it's how the list gets populated); anything else gets a hint.
|
|
87
|
+
if (text === '/allow') {
|
|
88
|
+
await deps.allowList.add(msg.channelId);
|
|
89
|
+
await ctx.reply('✓ this channel can now drive moxxy. Use /deny here to revoke.');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
await ctx.reply('This channel is not allow-listed. Send /allow here to enable it.');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!text && msg.attachments.length === 0) return;
|
|
97
|
+
|
|
98
|
+
// Capture awaiting-text BEFORE the busy guard so the user can answer an
|
|
99
|
+
// approval text prompt even while the strategy is technically mid-turn
|
|
100
|
+
// (it's pending on us).
|
|
101
|
+
if (state.awaitingApprovalText && text) {
|
|
102
|
+
const { approvalId, optionId } = state.awaitingApprovalText;
|
|
103
|
+
cb.setAwaitingApprovalText(null);
|
|
104
|
+
const handled = deps.approvalResolver.resolvePendingWithText(approvalId, optionId, text);
|
|
105
|
+
await ctx.reply(handled ? `✓ submitted (${optionId})` : 'that approval is no longer pending');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// /cancel works even while busy; everything else routes below.
|
|
110
|
+
if (text === '/cancel') {
|
|
111
|
+
if (state.turnController && !state.turnController.signal.aborted) {
|
|
112
|
+
state.turnController.abort('user cancel');
|
|
113
|
+
await ctx.reply('cancelling current turn…');
|
|
114
|
+
} else {
|
|
115
|
+
await ctx.reply('nothing to cancel.');
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (text === '/deny' && msg.guildId != null) {
|
|
121
|
+
const removed = await deps.allowList.remove(msg.channelId);
|
|
122
|
+
await ctx.reply(removed ? '✓ channel removed from the allow-list.' : 'this channel was not allow-listed.');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (text === '/allow' && msg.guildId != null) {
|
|
126
|
+
await ctx.reply('this channel is already allow-listed.');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (text.startsWith('/')) {
|
|
131
|
+
if (!state.session) return;
|
|
132
|
+
const [head, ...rest] = text.split(/\s+/);
|
|
133
|
+
const reply = await runSlash(head!.slice(1), rest.join(' '), state.session, {
|
|
134
|
+
toggleYolo: cb.toggleYolo,
|
|
135
|
+
voice: cb.voice,
|
|
136
|
+
performSessionAction: (action, notice) =>
|
|
137
|
+
performSessionAction(action, notice, state, deps, cb),
|
|
138
|
+
});
|
|
139
|
+
if (reply) await ctx.reply(reply.length > 1_900 ? reply.slice(0, 1_899) + '…' : reply);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Voice messages / audio uploads take the transcribe-then-turn path.
|
|
144
|
+
if (await cb.runVoiceMessage(ctx)) return;
|
|
145
|
+
if (!text) return;
|
|
146
|
+
|
|
147
|
+
if (state.busy) {
|
|
148
|
+
await ctx.reply('I am still working on the previous prompt. Send /cancel to abort it.');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await cb.runUserTurn(ctx, text);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Channel-side handler for `session-action` outputs from registered commands.
|
|
157
|
+
* Mirrors the Telegram channel's semantics; returns the reply text. Shared by
|
|
158
|
+
* the plain-text slash path (above) and the interaction (slash-command) path
|
|
159
|
+
* in the channel.
|
|
160
|
+
*/
|
|
161
|
+
export async function performSessionAction(
|
|
162
|
+
action: 'new' | 'clear' | 'exit',
|
|
163
|
+
notice: string | undefined,
|
|
164
|
+
state: Pick<MessageHandlerState, 'session' | 'turnController' | 'handle'>,
|
|
165
|
+
deps: Pick<MessageHandlerDeps, 'approvalResolver' | 'permissionResolver'>,
|
|
166
|
+
cb: Pick<MessageHandlerCallbacks, 'setAwaitingApprovalText' | 'setYolo'>,
|
|
167
|
+
): Promise<string> {
|
|
168
|
+
if (!state.session) return 'session is not ready yet.';
|
|
169
|
+
if (action === 'exit') {
|
|
170
|
+
// Fire the stop AFTER we return so the reply can still be delivered.
|
|
171
|
+
setTimeout(() => void state.handle?.stop('user /exit'), 250);
|
|
172
|
+
return notice ?? 'closing Discord channel';
|
|
173
|
+
}
|
|
174
|
+
if (action === 'clear') {
|
|
175
|
+
return `✓ ${notice ?? 'cleared'}`;
|
|
176
|
+
}
|
|
177
|
+
// action === 'new'
|
|
178
|
+
if (state.turnController && !state.turnController.signal.aborted) {
|
|
179
|
+
state.turnController.abort('user reset');
|
|
180
|
+
}
|
|
181
|
+
cb.setYolo(false);
|
|
182
|
+
cb.setAwaitingApprovalText(null);
|
|
183
|
+
deps.approvalResolver.abortAll('session reset');
|
|
184
|
+
deps.permissionResolver.abortAll('session reset');
|
|
185
|
+
// Wipe the history at its source (RemoteSession.reset() asks the runner; a
|
|
186
|
+
// mirror-only log.clear() would desync) and only claim success when the
|
|
187
|
+
// reset actually happened (AGENTS.md A10).
|
|
188
|
+
try {
|
|
189
|
+
if (typeof state.session.reset === 'function') await state.session.reset();
|
|
190
|
+
else state.session.log.clear();
|
|
191
|
+
} catch (err) {
|
|
192
|
+
return `⚠ /new failed: ${err instanceof Error ? err.message : String(err)} — history NOT cleared`;
|
|
193
|
+
}
|
|
194
|
+
return `✓ ${notice ?? 'new session — conversation history cleared'}`;
|
|
195
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { VaultStore } from '@moxxy/plugin-vault';
|
|
2
|
+
import {
|
|
3
|
+
armPairing,
|
|
4
|
+
clearDiscordPairing,
|
|
5
|
+
confirmPendingCode,
|
|
6
|
+
createDiscordPairingState,
|
|
7
|
+
isUserAuthorized,
|
|
8
|
+
mintCodeForPeer,
|
|
9
|
+
pairingPhase,
|
|
10
|
+
type DiscordPairingDecision,
|
|
11
|
+
type DiscordPairingPhase,
|
|
12
|
+
type DiscordPairingState,
|
|
13
|
+
} from '../pairing.js';
|
|
14
|
+
import { DISCORD_AUTHORIZED_USER_KEY, parseAuthorizedUser } from '../keys.js';
|
|
15
|
+
|
|
16
|
+
/** Result returned by {@link PairingHandler.confirmCode}. */
|
|
17
|
+
export type PairingConfirmResult =
|
|
18
|
+
| { ok: true; userId: string }
|
|
19
|
+
| { ok: false; reason: 'mismatch' | 'expired' | 'not-pending'; message: string };
|
|
20
|
+
|
|
21
|
+
export interface PairingHandlerOptions {
|
|
22
|
+
readonly vault: VaultStore;
|
|
23
|
+
readonly logger?: {
|
|
24
|
+
info?(msg: string, meta?: Record<string, unknown>): void;
|
|
25
|
+
warn(msg: string, meta?: Record<string, unknown>): void;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Owns the pairing state machine + vault persistence for the DM code flow
|
|
31
|
+
* (see `../pairing.ts` for the flow + security property). The channel feeds it
|
|
32
|
+
* DMs from unauthorized users ({@link handleUnpairedDm}) and the terminal
|
|
33
|
+
* wizard feeds it pasted codes ({@link confirmCode}).
|
|
34
|
+
*/
|
|
35
|
+
export class PairingHandler {
|
|
36
|
+
private state: DiscordPairingState = createDiscordPairingState();
|
|
37
|
+
private readonly opts: PairingHandlerOptions;
|
|
38
|
+
private readonly pairedListeners = new Set<(userId: string) => void>();
|
|
39
|
+
|
|
40
|
+
constructor(opts: PairingHandlerOptions) {
|
|
41
|
+
this.opts = opts;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async loadAuthorized(): Promise<void> {
|
|
45
|
+
const raw = await this.opts.vault.get(DISCORD_AUTHORIZED_USER_KEY);
|
|
46
|
+
const authorizedUserId = parseAuthorizedUser(raw);
|
|
47
|
+
if (authorizedUserId == null && raw) {
|
|
48
|
+
this.opts.logger?.warn(
|
|
49
|
+
'discord pairing: stored user id is not a snowflake — treating as unpaired',
|
|
50
|
+
{ raw },
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
this.state = createDiscordPairingState({ authorizedUserId });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
phase(): DiscordPairingPhase {
|
|
57
|
+
return pairingPhase(this.state);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
isAuthorized(userId: string): boolean {
|
|
61
|
+
return isUserAuthorized(this.state, userId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
authorizedUserId(): string | null {
|
|
65
|
+
return this.state.kit.phase === 'paired' ? this.state.kit.authorizedPeer : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Arm the pairing window: unauthorized DMs will now be issued codes. */
|
|
69
|
+
arm(): void {
|
|
70
|
+
this.state = armPairing(this.state);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
unpair(): void {
|
|
74
|
+
this.state = clearDiscordPairing(this.state);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Subscribe to "a user just became authorized" — fires once each time pairing
|
|
79
|
+
* completes. The `pair` terminal flow uses it to print success; the channel
|
|
80
|
+
* uses it to flip its connect-state. Returns an unsubscribe function.
|
|
81
|
+
*/
|
|
82
|
+
onPaired(listener: (userId: string) => void): () => void {
|
|
83
|
+
this.pairedListeners.add(listener);
|
|
84
|
+
return () => this.pairedListeners.delete(listener);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* An unauthorized user DMed the bot. Returns the reply text to DM back:
|
|
89
|
+
* a freshly minted one-time code (window armed), a nudge to open a window,
|
|
90
|
+
* or a foreign-account rejection. Null when no reply is warranted.
|
|
91
|
+
*/
|
|
92
|
+
handleUnpairedDm(userId: string): string | null {
|
|
93
|
+
const decision = mintCodeForPeer(this.state, userId);
|
|
94
|
+
this.state = decision.state;
|
|
95
|
+
const action = decision.action;
|
|
96
|
+
if (action.kind === 'code-minted') {
|
|
97
|
+
this.opts.logger?.info?.('discord pairing: code minted', { userId });
|
|
98
|
+
return (
|
|
99
|
+
`Your one-time pairing code is: **${action.code}**\n` +
|
|
100
|
+
'Paste it into the moxxy terminal (`moxxy discord pair`) to finish pairing.'
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (action.kind === 'still-paired') return 'Already paired — send me a prompt.';
|
|
104
|
+
if (action.kind === 'reject') return action.message;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The operator pasted a code into the terminal. On match the pending DM user
|
|
110
|
+
* is authorized + persisted. The vault write happens BEFORE listeners fire so
|
|
111
|
+
* a `pair`-flow exit right after success never loses the pairing.
|
|
112
|
+
*/
|
|
113
|
+
async confirmCode(rawCode: string): Promise<PairingConfirmResult> {
|
|
114
|
+
const decision = confirmPendingCode(this.state, rawCode);
|
|
115
|
+
return this.applyConfirmDecision(decision);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async applyConfirmDecision(
|
|
119
|
+
decision: DiscordPairingDecision,
|
|
120
|
+
): Promise<PairingConfirmResult> {
|
|
121
|
+
this.state = decision.state;
|
|
122
|
+
const action = decision.action;
|
|
123
|
+
if (action.kind === 'paired') {
|
|
124
|
+
await this.opts.vault.set(DISCORD_AUTHORIZED_USER_KEY, action.userId);
|
|
125
|
+
this.emitPaired(action.userId);
|
|
126
|
+
return { ok: true, userId: action.userId };
|
|
127
|
+
}
|
|
128
|
+
if (action.kind === 'still-paired') return { ok: true, userId: action.userId };
|
|
129
|
+
if (action.kind === 'mismatch') return { ok: false, reason: 'mismatch', message: action.message };
|
|
130
|
+
if (action.kind === 'expired') return { ok: false, reason: 'expired', message: action.message };
|
|
131
|
+
if (action.kind === 'not-pending' || action.kind === 'reject') {
|
|
132
|
+
return { ok: false, reason: 'not-pending', message: action.message };
|
|
133
|
+
}
|
|
134
|
+
// 'code-minted' can't come out of a confirm; treat as a mismatch.
|
|
135
|
+
return { ok: false, reason: 'mismatch', message: 'unexpected pairing state' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private emitPaired(userId: string): void {
|
|
139
|
+
for (const listener of this.pairedListeners) {
|
|
140
|
+
try {
|
|
141
|
+
listener(userId);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
this.opts.logger?.warn('discord pairing paired-listener threw', { err: String(err) });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { PendingToolCall, PermissionContext } from '@moxxy/sdk';
|
|
2
|
+
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
3
|
+
import type { DiscordPermissionResolver } from '../permission.js';
|
|
4
|
+
import { BUTTON_STYLE, button, packRows } from './components.js';
|
|
5
|
+
import type { ChannelLogger, SendableChannelLike } from './discord-like.js';
|
|
6
|
+
|
|
7
|
+
export interface PermissionPromptDeps {
|
|
8
|
+
readonly channel: SendableChannelLike | null;
|
|
9
|
+
readonly session: Session | null;
|
|
10
|
+
readonly resolver: DiscordPermissionResolver;
|
|
11
|
+
readonly yolo: boolean;
|
|
12
|
+
readonly logger?: ChannelLogger;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function truncate(s: string, max: number): string {
|
|
16
|
+
return s.length <= max ? s : s.slice(0, max - 1) + '…';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Render a button-row permission prompt for a pending tool call (the Discord
|
|
21
|
+
* mapping of Telegram's inline keyboard). The decider promise resolves when
|
|
22
|
+
* the paired user clicks a button (routed back via the interaction handler)
|
|
23
|
+
* or when the resolver aborts on stop.
|
|
24
|
+
*/
|
|
25
|
+
export async function askForPermission(
|
|
26
|
+
call: PendingToolCall,
|
|
27
|
+
ctx: PermissionContext,
|
|
28
|
+
deps: PermissionPromptDeps,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
void ctx;
|
|
31
|
+
if (!deps.channel || !deps.session) return;
|
|
32
|
+
// YOLO short-circuit: resolve immediately without rendering a prompt.
|
|
33
|
+
if (deps.yolo) {
|
|
34
|
+
deps.resolver.resolvePending(call.callId, { mode: 'allow', reason: 'yolo mode' });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const rows = packRows([
|
|
38
|
+
button(`perm:${call.callId}:allow`, 'Allow once', BUTTON_STYLE.success),
|
|
39
|
+
button(`perm:${call.callId}:allow_session`, 'Allow session', BUTTON_STYLE.primary),
|
|
40
|
+
button(`perm:${call.callId}:deny`, 'Deny', BUTTON_STYLE.danger),
|
|
41
|
+
]);
|
|
42
|
+
const description = deps.session.tools.get(call.name)?.description ?? '';
|
|
43
|
+
const summary =
|
|
44
|
+
`🔐 **Tool permission requested**\n` +
|
|
45
|
+
`Tool: \`${call.name}\`\n` +
|
|
46
|
+
(description ? `Desc: ${truncate(description, 200)}\n` : '') +
|
|
47
|
+
`Input: \`${truncate(JSON.stringify(call.input), 300)}\``;
|
|
48
|
+
try {
|
|
49
|
+
await deps.channel.send({ content: summary, components: rows });
|
|
50
|
+
} catch (err) {
|
|
51
|
+
deps.logger?.warn('discord permission send failed', { err: String(err) });
|
|
52
|
+
deps.resolver.resolvePending(call.callId, { mode: 'deny', reason: 'unable to render prompt' });
|
|
53
|
+
}
|
|
54
|
+
}
|