@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,150 @@
|
|
|
1
|
+
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
2
|
+
import { MAX_AUDIO_BYTES, type InboundAttachment } from '../schema.js';
|
|
3
|
+
import type { ChannelLogger } from './discord-like.js';
|
|
4
|
+
import type { InboundContext } from './message-handler.js';
|
|
5
|
+
|
|
6
|
+
/** Abort the attachment download if Discord's CDN hasn't responded in this window. */
|
|
7
|
+
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
8
|
+
|
|
9
|
+
export interface VoiceHandlerState {
|
|
10
|
+
readonly session: Session | null;
|
|
11
|
+
readonly busy: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface VoiceHandlerDeps {
|
|
15
|
+
readonly logger?: ChannelLogger;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface VoiceHandlerCallbacks {
|
|
19
|
+
readonly runUserTurn: (ctx: InboundContext, text: string) => Promise<void>;
|
|
20
|
+
/** Override the network fetch for tests. Defaults to global `fetch`. */
|
|
21
|
+
readonly fetchAudio?: (
|
|
22
|
+
url: string,
|
|
23
|
+
init?: { signal?: AbortSignal },
|
|
24
|
+
) => Promise<{
|
|
25
|
+
ok: boolean;
|
|
26
|
+
status?: number;
|
|
27
|
+
statusText?: string;
|
|
28
|
+
headers?: { get(name: string): string | null };
|
|
29
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
30
|
+
}>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The first audio attachment on the message (Discord voice messages arrive as
|
|
34
|
+
* an `audio/ogg` attachment; uploaded audio files carry their own mime). */
|
|
35
|
+
export function pickAudioAttachment(
|
|
36
|
+
attachments: ReadonlyArray<InboundAttachment>,
|
|
37
|
+
): InboundAttachment | null {
|
|
38
|
+
return attachments.find((a) => (a.contentType ?? '').startsWith('audio/')) ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Handle a Discord voice message / uploaded audio file. Called AFTER the
|
|
43
|
+
* pairing + allow-list gate (the message handler owns authorization). Returns
|
|
44
|
+
* true when the message carried audio and was consumed here (successfully or
|
|
45
|
+
* not), false when there was no audio and the text path should proceed.
|
|
46
|
+
*
|
|
47
|
+
* Mirrors the Telegram voice handler: size caps (declared size, then
|
|
48
|
+
* Content-Length, then the buffered body), bounded download, transcriber gate
|
|
49
|
+
* with install guidance, a "heard:" echo, then a normal user turn.
|
|
50
|
+
*/
|
|
51
|
+
export async function handleVoiceMessage(
|
|
52
|
+
ctx: InboundContext,
|
|
53
|
+
state: VoiceHandlerState,
|
|
54
|
+
deps: VoiceHandlerDeps,
|
|
55
|
+
cb: VoiceHandlerCallbacks,
|
|
56
|
+
): Promise<boolean> {
|
|
57
|
+
const audio = pickAudioAttachment(ctx.msg.attachments);
|
|
58
|
+
if (!audio) return false;
|
|
59
|
+
|
|
60
|
+
if (state.busy) {
|
|
61
|
+
await ctx.reply('I am still working on the previous prompt. Send /cancel to abort it.');
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (!state.session) {
|
|
65
|
+
await ctx.reply('Session is not ready yet.');
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const transcriber = state.session.transcribers.tryGetActive();
|
|
70
|
+
if (!transcriber) {
|
|
71
|
+
await ctx.reply(
|
|
72
|
+
'Heard a voice message, but no speech-to-text backend is configured. Install @moxxy/plugin-stt-whisper and run `moxxy login openai` (or set OPENAI_API_KEY) to enable voice input.',
|
|
73
|
+
);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Reject oversized uploads up-front using the size Discord reports, before
|
|
78
|
+
// we spend a download or any memory on it.
|
|
79
|
+
if (audio.size > MAX_AUDIO_BYTES) {
|
|
80
|
+
await ctx.reply(
|
|
81
|
+
`That audio is too large (${Math.round(audio.size / (1024 * 1024))}MB). The limit is ${MAX_AUDIO_BYTES / (1024 * 1024)}MB.`,
|
|
82
|
+
);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const fetcher = cb.fetchAudio ?? ((u: string, init?: { signal?: AbortSignal }) => fetch(u, init));
|
|
87
|
+
const ac = new AbortController();
|
|
88
|
+
const timer = setTimeout(() => ac.abort(), DOWNLOAD_TIMEOUT_MS);
|
|
89
|
+
let bytes: Uint8Array;
|
|
90
|
+
try {
|
|
91
|
+
const response = await fetcher(audio.url, { signal: ac.signal });
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
deps.logger?.warn('discord voice download failed', {
|
|
94
|
+
...(response.status !== undefined ? { status: response.status } : {}),
|
|
95
|
+
...(response.statusText ? { statusText: response.statusText } : {}),
|
|
96
|
+
});
|
|
97
|
+
await ctx.reply('Failed to download the voice message from Discord.');
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
// Trust the Content-Length header (when present) to bail before buffering
|
|
101
|
+
// a body that lies about its size on the attachment object.
|
|
102
|
+
const declaredLen = Number(response.headers?.get('content-length') ?? '');
|
|
103
|
+
if (Number.isFinite(declaredLen) && declaredLen > MAX_AUDIO_BYTES) {
|
|
104
|
+
await ctx.reply('That audio is too large to transcribe.');
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
const buf = await response.arrayBuffer();
|
|
108
|
+
if (buf.byteLength > MAX_AUDIO_BYTES) {
|
|
109
|
+
await ctx.reply('That audio is too large to transcribe.');
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
bytes = new Uint8Array(buf);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
deps.logger?.warn('discord voice fetch failed', {
|
|
115
|
+
err: err instanceof Error ? err.message : String(err),
|
|
116
|
+
});
|
|
117
|
+
await ctx.reply(
|
|
118
|
+
ac.signal.aborted
|
|
119
|
+
? 'Downloading the voice message timed out.'
|
|
120
|
+
: 'Failed to download the voice message from Discord.',
|
|
121
|
+
);
|
|
122
|
+
return true;
|
|
123
|
+
} finally {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const mimeType = audio.contentType ?? 'audio/ogg';
|
|
128
|
+
let transcript: string;
|
|
129
|
+
try {
|
|
130
|
+
const result = await transcriber.transcribe(bytes, { mimeType });
|
|
131
|
+
transcript = result.text.trim();
|
|
132
|
+
} catch (err) {
|
|
133
|
+
deps.logger?.warn('discord voice transcription failed', {
|
|
134
|
+
err: err instanceof Error ? err.message : String(err),
|
|
135
|
+
});
|
|
136
|
+
await ctx.reply(`Transcription failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!transcript) {
|
|
141
|
+
await ctx.reply('Could not transcribe the voice message (got empty text).');
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Echo what we heard so the user can spot misrecognitions before the agent
|
|
146
|
+
// acts on it. Italics keep it visually distinct from a normal reply.
|
|
147
|
+
await ctx.reply(`*heard:* ${transcript}`);
|
|
148
|
+
await cb.runUserTurn(ctx, transcript);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
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 } from 'vitest';
|
|
5
|
+
import { VaultStore, createStaticKeySource, deriveKey, generateSalt } from '@moxxy/plugin-vault';
|
|
6
|
+
import { asTurnId, type MoxxyEvent } from '@moxxy/sdk';
|
|
7
|
+
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
8
|
+
import { DISCORD_VOICE_REPLIES_KEY, loadVoiceReplies, saveVoiceReplies } from '../keys.js';
|
|
9
|
+
import { runSlash } from './slash-handler.js';
|
|
10
|
+
import { runDiscordTurn } from './turn-runner.js';
|
|
11
|
+
import type { SendableChannelLike, SentMessageLike } from './discord-like.js';
|
|
12
|
+
import { TypingIndicator } from './typing-indicator.js';
|
|
13
|
+
|
|
14
|
+
describe('discord voice-replies vault flag round-trip', () => {
|
|
15
|
+
let tmp: string;
|
|
16
|
+
let vault: VaultStore;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-dc-voice-'));
|
|
20
|
+
vault = new VaultStore({
|
|
21
|
+
filePath: path.join(tmp, 'vault.json'),
|
|
22
|
+
keySource: createStaticKeySource(deriveKey('test', generateSalt())),
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('defaults to off, persists on, then off', async () => {
|
|
30
|
+
expect(await loadVoiceReplies(vault)).toBe(false);
|
|
31
|
+
await saveVoiceReplies(vault, true);
|
|
32
|
+
expect(await vault.get(DISCORD_VOICE_REPLIES_KEY)).toBe('1');
|
|
33
|
+
expect(await loadVoiceReplies(vault)).toBe(true);
|
|
34
|
+
await saveVoiceReplies(vault, false);
|
|
35
|
+
expect(await vault.get(DISCORD_VOICE_REPLIES_KEY)).toBe(null);
|
|
36
|
+
expect(await loadVoiceReplies(vault)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('runSlash routes /voice to the channel callback', () => {
|
|
41
|
+
it('forwards the argument and returns the callback reply', async () => {
|
|
42
|
+
const args: string[] = [];
|
|
43
|
+
const session = { commands: { get: () => undefined } } as unknown as Session;
|
|
44
|
+
const reply = await runSlash('voice', 'on', session, {
|
|
45
|
+
toggleYolo: () => false,
|
|
46
|
+
voice: async (a) => {
|
|
47
|
+
args.push(a);
|
|
48
|
+
return '🔊 Voice replies ON';
|
|
49
|
+
},
|
|
50
|
+
performSessionAction: async () => '',
|
|
51
|
+
});
|
|
52
|
+
expect(args).toEqual(['on']);
|
|
53
|
+
expect(reply).toContain('Voice replies ON');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
interface Recorded {
|
|
58
|
+
sends: string[];
|
|
59
|
+
channel: SendableChannelLike;
|
|
60
|
+
}
|
|
61
|
+
function recordedChannel(): Recorded {
|
|
62
|
+
const sends: string[] = [];
|
|
63
|
+
const channel: SendableChannelLike = {
|
|
64
|
+
send: async (payload) => {
|
|
65
|
+
sends.push(typeof payload === 'string' ? payload : payload.content ?? '');
|
|
66
|
+
const message: SentMessageLike = { edit: async () => {} };
|
|
67
|
+
return message;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
return { sends, channel };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fakeSession(script: (emit: (e: Record<string, unknown>) => void) => void): Session {
|
|
74
|
+
const listeners = new Set<(e: MoxxyEvent) => void>();
|
|
75
|
+
return {
|
|
76
|
+
log: {
|
|
77
|
+
subscribe(fn: (e: MoxxyEvent) => void) {
|
|
78
|
+
listeners.add(fn);
|
|
79
|
+
return () => listeners.delete(fn);
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
runTurn: (_p: string, opts: { turnId: string }) =>
|
|
83
|
+
(async function* () {
|
|
84
|
+
const emit = (e: Record<string, unknown>): void => {
|
|
85
|
+
const ev = { ...e, turnId: opts.turnId } as unknown as MoxxyEvent;
|
|
86
|
+
for (const fn of listeners) fn(ev);
|
|
87
|
+
};
|
|
88
|
+
script(emit);
|
|
89
|
+
yield undefined as never;
|
|
90
|
+
})(),
|
|
91
|
+
} as unknown as Session;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('runDiscordTurn onFinalReply seam (final assistant text)', () => {
|
|
95
|
+
it('calls onFinalReply with the assistant body after the text is flushed', async () => {
|
|
96
|
+
const { sends, channel } = recordedChannel();
|
|
97
|
+
const seen: string[] = [];
|
|
98
|
+
const session = fakeSession((emit) => {
|
|
99
|
+
emit({ type: 'assistant_chunk', delta: 'Hi' });
|
|
100
|
+
emit({ type: 'assistant_message', content: 'Hi there, done.' });
|
|
101
|
+
});
|
|
102
|
+
await runDiscordTurn(
|
|
103
|
+
{ session, channel, typing: new TypingIndicator(), editFrameMs: 10, onFinalReply: async (t) => void seen.push(t) },
|
|
104
|
+
{ text: 'hi', controller: new AbortController(), turnId: asTurnId('d1') },
|
|
105
|
+
);
|
|
106
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
107
|
+
expect(seen).toEqual(['Hi there, done.']);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('does not speak a tool-only turn (empty assistant body)', async () => {
|
|
111
|
+
const { channel } = recordedChannel();
|
|
112
|
+
const seen: string[] = [];
|
|
113
|
+
const session = fakeSession((emit) => {
|
|
114
|
+
emit({ type: 'tool_call_requested', callId: 'c1', name: 'read', input: {} });
|
|
115
|
+
});
|
|
116
|
+
await runDiscordTurn(
|
|
117
|
+
{ session, channel, typing: new TypingIndicator(), editFrameMs: 10, onFinalReply: async (t) => void seen.push(t) },
|
|
118
|
+
{ text: 'hi', controller: new AbortController(), turnId: asTurnId('d2') },
|
|
119
|
+
);
|
|
120
|
+
expect(seen).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('never breaks the (already-sent) text turn when the voice hook rejects', async () => {
|
|
124
|
+
const { sends, channel } = recordedChannel();
|
|
125
|
+
const session = fakeSession((emit) => emit({ type: 'assistant_message', content: 'Done.' }));
|
|
126
|
+
await runDiscordTurn(
|
|
127
|
+
{
|
|
128
|
+
session,
|
|
129
|
+
channel,
|
|
130
|
+
typing: new TypingIndicator(),
|
|
131
|
+
editFrameMs: 10,
|
|
132
|
+
onFinalReply: async () => {
|
|
133
|
+
throw new Error('tts exploded');
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{ text: 'hi', controller: new AbortController(), turnId: asTurnId('d3') },
|
|
137
|
+
);
|
|
138
|
+
// The text still landed; the failed hook did NOT surface a "Turn failed".
|
|
139
|
+
expect(sends.join(' ')).toContain('Done.');
|
|
140
|
+
expect(sends.join(' ')).not.toContain('Turn failed');
|
|
141
|
+
});
|
|
142
|
+
});
|