@moxxy/plugin-telegram 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/approval.d.ts +37 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +75 -0
- package/dist/approval.js.map +1 -0
- package/dist/channel/approval-prompt.d.ts +20 -0
- package/dist/channel/approval-prompt.d.ts.map +1 -0
- package/dist/channel/approval-prompt.js +34 -0
- package/dist/channel/approval-prompt.js.map +1 -0
- package/dist/channel/callback-handler.d.ts +32 -0
- package/dist/channel/callback-handler.d.ts.map +1 -0
- package/dist/channel/callback-handler.js +212 -0
- package/dist/channel/callback-handler.js.map +1 -0
- package/dist/channel/frame-pump.d.ts +58 -0
- package/dist/channel/frame-pump.d.ts.map +1 -0
- package/dist/channel/frame-pump.js +159 -0
- package/dist/channel/frame-pump.js.map +1 -0
- package/dist/channel/html.d.ts +24 -0
- package/dist/channel/html.d.ts.map +1 -0
- package/dist/channel/html.js +62 -0
- package/dist/channel/html.js.map +1 -0
- package/dist/channel/pairing-handler.d.ts +66 -0
- package/dist/channel/pairing-handler.d.ts.map +1 -0
- package/dist/channel/pairing-handler.js +147 -0
- package/dist/channel/pairing-handler.js.map +1 -0
- package/dist/channel/permission-prompt.d.ts +21 -0
- package/dist/channel/permission-prompt.d.ts.map +1 -0
- package/dist/channel/permission-prompt.js +36 -0
- package/dist/channel/permission-prompt.js.map +1 -0
- package/dist/channel/slash-handler.d.ts +47 -0
- package/dist/channel/slash-handler.d.ts.map +1 -0
- package/dist/channel/slash-handler.js +214 -0
- package/dist/channel/slash-handler.js.map +1 -0
- package/dist/channel/text-handler.d.ts +43 -0
- package/dist/channel/text-handler.d.ts.map +1 -0
- package/dist/channel/text-handler.js +116 -0
- package/dist/channel/text-handler.js.map +1 -0
- package/dist/channel/turn-runner.d.ts +40 -0
- package/dist/channel/turn-runner.d.ts.map +1 -0
- package/dist/channel/turn-runner.js +67 -0
- package/dist/channel/turn-runner.js.map +1 -0
- package/dist/channel/typing-indicator.d.ts +14 -0
- package/dist/channel/typing-indicator.d.ts.map +1 -0
- package/dist/channel/typing-indicator.js +29 -0
- package/dist/channel/typing-indicator.js.map +1 -0
- package/dist/channel/voice-handler.d.ts +42 -0
- package/dist/channel/voice-handler.d.ts.map +1 -0
- package/dist/channel/voice-handler.js +138 -0
- package/dist/channel/voice-handler.js.map +1 -0
- package/dist/channel.d.ts +118 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +478 -0
- package/dist/channel.js.map +1 -0
- package/dist/channel.test-d.d.ts +2 -0
- package/dist/channel.test-d.d.ts.map +1 -0
- package/dist/channel.test-d.js +5 -0
- package/dist/channel.test-d.js.map +1 -0
- package/dist/format.d.ts +38 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +237 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +304 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +37 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +42 -0
- package/dist/keys.js.map +1 -0
- package/dist/pair-flow.d.ts +18 -0
- package/dist/pair-flow.d.ts.map +1 -0
- package/dist/pair-flow.js +119 -0
- package/dist/pair-flow.js.map +1 -0
- package/dist/pairing.d.ts +90 -0
- package/dist/pairing.d.ts.map +1 -0
- package/dist/pairing.js +97 -0
- package/dist/pairing.js.map +1 -0
- package/dist/permission.d.ts +25 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +49 -0
- package/dist/permission.js.map +1 -0
- package/dist/render.d.ts +68 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +439 -0
- package/dist/render.js.map +1 -0
- package/dist/setup-wizard.d.ts +21 -0
- package/dist/setup-wizard.d.ts.map +1 -0
- package/dist/setup-wizard.js +140 -0
- package/dist/setup-wizard.js.map +1 -0
- package/package.json +91 -0
- package/src/approval.test.ts +103 -0
- package/src/approval.ts +86 -0
- package/src/channel/approval-prompt.ts +46 -0
- package/src/channel/callback-handler.test.ts +188 -0
- package/src/channel/callback-handler.ts +256 -0
- package/src/channel/frame-pump.ts +176 -0
- package/src/channel/html.test.ts +40 -0
- package/src/channel/html.ts +60 -0
- package/src/channel/pairing-handler.ts +181 -0
- package/src/channel/permission-prompt.ts +51 -0
- package/src/channel/slash-handler.ts +259 -0
- package/src/channel/text-handler.test.ts +222 -0
- package/src/channel/text-handler.ts +174 -0
- package/src/channel/turn-runner.ts +102 -0
- package/src/channel/typing-indicator.ts +31 -0
- package/src/channel/voice-handler.test.ts +328 -0
- package/src/channel/voice-handler.ts +198 -0
- package/src/channel/voice-reply.test.ts +223 -0
- package/src/channel.test-d.ts +7 -0
- package/src/channel.ts +564 -0
- package/src/format.test.ts +159 -0
- package/src/format.ts +257 -0
- package/src/index.ts +347 -0
- package/src/keys.ts +52 -0
- package/src/pair-flow.ts +131 -0
- package/src/pairing.test.ts +113 -0
- package/src/pairing.ts +184 -0
- package/src/permission.test.ts +56 -0
- package/src/permission.ts +61 -0
- package/src/render.test.ts +350 -0
- package/src/render.ts +522 -0
- package/src/setup-wizard.ts +178 -0
- package/src/subcommands.test.ts +153 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { Context } from 'grammy';
|
|
3
|
+
import { Session } from '@moxxy/core';
|
|
4
|
+
import { defineTranscriber } from '@moxxy/sdk';
|
|
5
|
+
import { handleVoiceMessage } from './voice-handler.js';
|
|
6
|
+
|
|
7
|
+
const TOKEN = '1234567890:test-token';
|
|
8
|
+
|
|
9
|
+
const makeSession = (): Session => new Session({ cwd: '/tmp', silent: true });
|
|
10
|
+
|
|
11
|
+
const fakeCtx = (overrides: Partial<{ chatId: number; voice: unknown; audio: unknown; filePath: string | null }> = {}) => {
|
|
12
|
+
const replies: string[] = [];
|
|
13
|
+
const reply = vi.fn(async (text: string) => {
|
|
14
|
+
replies.push(text);
|
|
15
|
+
});
|
|
16
|
+
const getFile = vi.fn(async () => ({
|
|
17
|
+
file_id: 'f',
|
|
18
|
+
file_unique_id: 'u',
|
|
19
|
+
file_path: overrides.filePath === undefined ? 'voice/file.ogg' : overrides.filePath,
|
|
20
|
+
}));
|
|
21
|
+
return {
|
|
22
|
+
ctx: {
|
|
23
|
+
chat: { id: overrides.chatId ?? 99 },
|
|
24
|
+
message: {
|
|
25
|
+
voice: overrides.voice,
|
|
26
|
+
audio: overrides.audio,
|
|
27
|
+
},
|
|
28
|
+
reply,
|
|
29
|
+
api: { getFile },
|
|
30
|
+
} as unknown as Context,
|
|
31
|
+
replies,
|
|
32
|
+
reply,
|
|
33
|
+
getFile,
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const okFetch = (bytes: Uint8Array) =>
|
|
38
|
+
vi.fn(async () => ({ ok: true, arrayBuffer: async () => bytes.buffer.slice(0) }));
|
|
39
|
+
|
|
40
|
+
describe('handleVoiceMessage', () => {
|
|
41
|
+
const baseDeps = (token = TOKEN) => ({
|
|
42
|
+
pairing: { isAuthorized: () => true } as never,
|
|
43
|
+
token,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('rejects unauthorized chats', async () => {
|
|
47
|
+
const { ctx, replies } = fakeCtx({ voice: { file_id: 'f' } });
|
|
48
|
+
const runUserTurn = vi.fn();
|
|
49
|
+
await handleVoiceMessage(
|
|
50
|
+
ctx,
|
|
51
|
+
{
|
|
52
|
+
session: makeSession(),
|
|
53
|
+
busy: false,
|
|
54
|
+
},
|
|
55
|
+
{ ...baseDeps(), pairing: { isAuthorized: () => false } as never },
|
|
56
|
+
{ runUserTurn, fetchAudio: okFetch(new Uint8Array([1])) },
|
|
57
|
+
);
|
|
58
|
+
expect(replies[0]).toMatch(/paired with a different chat/);
|
|
59
|
+
expect(runUserTurn).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('warns when no transcriber is registered on the session', async () => {
|
|
63
|
+
const { ctx, replies } = fakeCtx({ voice: { file_id: 'f' } });
|
|
64
|
+
const runUserTurn = vi.fn();
|
|
65
|
+
const session = makeSession();
|
|
66
|
+
await handleVoiceMessage(
|
|
67
|
+
ctx,
|
|
68
|
+
{
|
|
69
|
+
session,
|
|
70
|
+
busy: false,
|
|
71
|
+
},
|
|
72
|
+
baseDeps(),
|
|
73
|
+
{ runUserTurn, fetchAudio: okFetch(new Uint8Array([1])) },
|
|
74
|
+
);
|
|
75
|
+
expect(replies[0]).toMatch(/no speech-to-text backend/);
|
|
76
|
+
expect(runUserTurn).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('transcribes and forwards the transcript to runUserTurn', async () => {
|
|
80
|
+
const audio = new Uint8Array([1, 2, 3, 4]);
|
|
81
|
+
const session = makeSession();
|
|
82
|
+
const transcribe = vi.fn(async () => ({ text: 'hello agent' }));
|
|
83
|
+
session.transcribers.register(
|
|
84
|
+
defineTranscriber({
|
|
85
|
+
name: 't',
|
|
86
|
+
createClient: () => ({ name: 't', transcribe }),
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
session.transcribers.setActive('t');
|
|
90
|
+
|
|
91
|
+
const { ctx, replies, getFile } = fakeCtx({
|
|
92
|
+
voice: { file_id: 'voice-1', mime_type: 'audio/ogg' },
|
|
93
|
+
});
|
|
94
|
+
const runUserTurn = vi.fn(async () => {});
|
|
95
|
+
await handleVoiceMessage(
|
|
96
|
+
ctx,
|
|
97
|
+
{
|
|
98
|
+
session,
|
|
99
|
+
busy: false,
|
|
100
|
+
},
|
|
101
|
+
baseDeps(),
|
|
102
|
+
{ runUserTurn, fetchAudio: okFetch(audio) },
|
|
103
|
+
);
|
|
104
|
+
expect(getFile).toHaveBeenCalledWith('voice-1');
|
|
105
|
+
expect(transcribe).toHaveBeenCalled();
|
|
106
|
+
const transcribeArgs = transcribe.mock.calls[0]!;
|
|
107
|
+
expect((transcribeArgs[1] as { mimeType: string }).mimeType).toBe('audio/ogg');
|
|
108
|
+
expect(replies.some((r) => /heard:/.test(r) && /hello agent/.test(r))).toBe(true);
|
|
109
|
+
expect(runUserTurn).toHaveBeenCalledWith(ctx, 99, 'hello agent');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('refuses to start a new turn while busy', async () => {
|
|
113
|
+
const session = makeSession();
|
|
114
|
+
session.transcribers.register(
|
|
115
|
+
defineTranscriber({
|
|
116
|
+
name: 't',
|
|
117
|
+
createClient: () => ({ name: 't', transcribe: async () => ({ text: 'x' }) }),
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
session.transcribers.setActive('t');
|
|
121
|
+
const { ctx, replies } = fakeCtx({ voice: { file_id: 'f' } });
|
|
122
|
+
const runUserTurn = vi.fn();
|
|
123
|
+
await handleVoiceMessage(
|
|
124
|
+
ctx,
|
|
125
|
+
{
|
|
126
|
+
session,
|
|
127
|
+
busy: true,
|
|
128
|
+
},
|
|
129
|
+
baseDeps(),
|
|
130
|
+
{ runUserTurn, fetchAudio: okFetch(new Uint8Array([1])) },
|
|
131
|
+
);
|
|
132
|
+
expect(replies[0]).toMatch(/working on the previous prompt/);
|
|
133
|
+
expect(runUserTurn).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('replies on empty transcript and skips the turn', async () => {
|
|
137
|
+
const session = makeSession();
|
|
138
|
+
session.transcribers.register(
|
|
139
|
+
defineTranscriber({
|
|
140
|
+
name: 't',
|
|
141
|
+
createClient: () => ({ name: 't', transcribe: async () => ({ text: ' ' }) }),
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
session.transcribers.setActive('t');
|
|
145
|
+
const { ctx, replies } = fakeCtx({ voice: { file_id: 'f' } });
|
|
146
|
+
const runUserTurn = vi.fn();
|
|
147
|
+
await handleVoiceMessage(
|
|
148
|
+
ctx,
|
|
149
|
+
{
|
|
150
|
+
session,
|
|
151
|
+
busy: false,
|
|
152
|
+
},
|
|
153
|
+
baseDeps(),
|
|
154
|
+
{ runUserTurn, fetchAudio: okFetch(new Uint8Array([1])) },
|
|
155
|
+
);
|
|
156
|
+
expect(replies.some((r) => /empty text/.test(r))).toBe(true);
|
|
157
|
+
expect(runUserTurn).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('logs the real HTTP status when the download fails', async () => {
|
|
161
|
+
const session = makeSession();
|
|
162
|
+
session.transcribers.register(
|
|
163
|
+
defineTranscriber({
|
|
164
|
+
name: 't',
|
|
165
|
+
createClient: () => ({ name: 't', transcribe: async () => ({ text: 'x' }) }),
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
session.transcribers.setActive('t');
|
|
169
|
+
const { ctx, replies } = fakeCtx({ voice: { file_id: 'f' } });
|
|
170
|
+
const runUserTurn = vi.fn();
|
|
171
|
+
const warn = vi.fn();
|
|
172
|
+
await handleVoiceMessage(
|
|
173
|
+
ctx,
|
|
174
|
+
{ session, busy: false },
|
|
175
|
+
{ ...baseDeps(), logger: { warn } },
|
|
176
|
+
{
|
|
177
|
+
runUserTurn,
|
|
178
|
+
fetchAudio: vi.fn(async () => ({
|
|
179
|
+
ok: false,
|
|
180
|
+
status: 404,
|
|
181
|
+
statusText: 'Not Found',
|
|
182
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
183
|
+
})),
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
expect(replies.some((r) => /Failed to download/.test(r))).toBe(true);
|
|
187
|
+
expect(runUserTurn).not.toHaveBeenCalled();
|
|
188
|
+
expect(warn).toHaveBeenCalledWith('telegram voice download failed', {
|
|
189
|
+
status: 404,
|
|
190
|
+
statusText: 'Not Found',
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('rejects an oversized upload before downloading it', async () => {
|
|
195
|
+
const session = makeSession();
|
|
196
|
+
session.transcribers.register(
|
|
197
|
+
defineTranscriber({
|
|
198
|
+
name: 't',
|
|
199
|
+
createClient: () => ({ name: 't', transcribe: async () => ({ text: 'x' }) }),
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
session.transcribers.setActive('t');
|
|
203
|
+
const { ctx, replies, getFile } = fakeCtx({
|
|
204
|
+
voice: { file_id: 'f', file_size: 50 * 1024 * 1024 },
|
|
205
|
+
});
|
|
206
|
+
const runUserTurn = vi.fn();
|
|
207
|
+
const fetchAudio = vi.fn();
|
|
208
|
+
await handleVoiceMessage(
|
|
209
|
+
ctx,
|
|
210
|
+
{ session, busy: false },
|
|
211
|
+
baseDeps(),
|
|
212
|
+
{ runUserTurn, fetchAudio },
|
|
213
|
+
);
|
|
214
|
+
expect(replies.some((r) => /too large/.test(r))).toBe(true);
|
|
215
|
+
expect(getFile).not.toHaveBeenCalled();
|
|
216
|
+
expect(fetchAudio).not.toHaveBeenCalled();
|
|
217
|
+
expect(runUserTurn).not.toHaveBeenCalled();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('rejects a body that exceeds the cap after download (Content-Length)', async () => {
|
|
221
|
+
const session = makeSession();
|
|
222
|
+
session.transcribers.register(
|
|
223
|
+
defineTranscriber({
|
|
224
|
+
name: 't',
|
|
225
|
+
createClient: () => ({ name: 't', transcribe: async () => ({ text: 'x' }) }),
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
session.transcribers.setActive('t');
|
|
229
|
+
const { ctx, replies } = fakeCtx({ voice: { file_id: 'f' } });
|
|
230
|
+
const runUserTurn = vi.fn();
|
|
231
|
+
await handleVoiceMessage(
|
|
232
|
+
ctx,
|
|
233
|
+
{ session, busy: false },
|
|
234
|
+
baseDeps(),
|
|
235
|
+
{
|
|
236
|
+
runUserTurn,
|
|
237
|
+
fetchAudio: vi.fn(async () => ({
|
|
238
|
+
ok: true,
|
|
239
|
+
headers: { get: (n: string) => (n === 'content-length' ? String(99 * 1024 * 1024) : null) },
|
|
240
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
241
|
+
})),
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
expect(replies.some((r) => /too large/.test(r))).toBe(true);
|
|
245
|
+
expect(runUserTurn).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('passes an AbortSignal to the fetch and reports a timeout abort', async () => {
|
|
249
|
+
const session = makeSession();
|
|
250
|
+
session.transcribers.register(
|
|
251
|
+
defineTranscriber({
|
|
252
|
+
name: 't',
|
|
253
|
+
createClient: () => ({ name: 't', transcribe: async () => ({ text: 'x' }) }),
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
session.transcribers.setActive('t');
|
|
257
|
+
const { ctx, replies } = fakeCtx({ voice: { file_id: 'f' } });
|
|
258
|
+
const runUserTurn = vi.fn();
|
|
259
|
+
let sawSignal = false;
|
|
260
|
+
const fetchAudio = vi.fn(async (_url: string, init?: { signal?: AbortSignal }) => {
|
|
261
|
+
sawSignal = init?.signal instanceof AbortSignal;
|
|
262
|
+
// Simulate the AbortController firing mid-download.
|
|
263
|
+
const err = new Error('aborted');
|
|
264
|
+
(init?.signal as AbortSignal | undefined)?.dispatchEvent?.(new Event('abort'));
|
|
265
|
+
throw err;
|
|
266
|
+
});
|
|
267
|
+
await handleVoiceMessage(
|
|
268
|
+
ctx,
|
|
269
|
+
{ session, busy: false },
|
|
270
|
+
baseDeps(),
|
|
271
|
+
{ runUserTurn, fetchAudio },
|
|
272
|
+
);
|
|
273
|
+
expect(sawSignal).toBe(true);
|
|
274
|
+
expect(replies.some((r) => /Failed to download|timed out/.test(r))).toBe(true);
|
|
275
|
+
expect(runUserTurn).not.toHaveBeenCalled();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('replies (not throws) when getFile itself fails', async () => {
|
|
279
|
+
const session = makeSession();
|
|
280
|
+
session.transcribers.register(
|
|
281
|
+
defineTranscriber({
|
|
282
|
+
name: 't',
|
|
283
|
+
createClient: () => ({ name: 't', transcribe: async () => ({ text: 'x' }) }),
|
|
284
|
+
}),
|
|
285
|
+
);
|
|
286
|
+
session.transcribers.setActive('t');
|
|
287
|
+
const { ctx, replies } = fakeCtx({ voice: { file_id: 'f' } });
|
|
288
|
+
// Override getFile to throw.
|
|
289
|
+
(ctx.api as unknown as { getFile: () => Promise<never> }).getFile = vi.fn(async () => {
|
|
290
|
+
throw new Error('network down');
|
|
291
|
+
});
|
|
292
|
+
const runUserTurn = vi.fn();
|
|
293
|
+
await expect(
|
|
294
|
+
handleVoiceMessage(
|
|
295
|
+
ctx,
|
|
296
|
+
{ session, busy: false },
|
|
297
|
+
baseDeps(),
|
|
298
|
+
{ runUserTurn, fetchAudio: okFetch(new Uint8Array([1])) },
|
|
299
|
+
),
|
|
300
|
+
).resolves.toBeUndefined();
|
|
301
|
+
expect(replies.some((r) => /Could not look up/.test(r))).toBe(true);
|
|
302
|
+
expect(runUserTurn).not.toHaveBeenCalled();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('handles uploaded audio (message:audio) with its own mime_type', async () => {
|
|
306
|
+
const session = makeSession();
|
|
307
|
+
const transcribe = vi.fn(async () => ({ text: 'recorded earlier' }));
|
|
308
|
+
session.transcribers.register(
|
|
309
|
+
defineTranscriber({ name: 't', createClient: () => ({ name: 't', transcribe }) }),
|
|
310
|
+
);
|
|
311
|
+
session.transcribers.setActive('t');
|
|
312
|
+
const { ctx } = fakeCtx({
|
|
313
|
+
audio: { file_id: 'a-1', mime_type: 'audio/mpeg' },
|
|
314
|
+
});
|
|
315
|
+
const runUserTurn = vi.fn(async () => {});
|
|
316
|
+
await handleVoiceMessage(
|
|
317
|
+
ctx,
|
|
318
|
+
{
|
|
319
|
+
session,
|
|
320
|
+
busy: false,
|
|
321
|
+
},
|
|
322
|
+
baseDeps(),
|
|
323
|
+
{ runUserTurn, fetchAudio: okFetch(new Uint8Array([9])) },
|
|
324
|
+
);
|
|
325
|
+
expect((transcribe.mock.calls[0]![1] as { mimeType: string }).mimeType).toBe('audio/mpeg');
|
|
326
|
+
expect(runUserTurn).toHaveBeenCalledWith(ctx, 99, 'recorded earlier');
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { Context } from 'grammy';
|
|
2
|
+
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
3
|
+
import type { PairingHandler } from './pairing-handler.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hard cap on an inbound audio file we will buffer into memory before
|
|
7
|
+
* transcribing. Telegram allows ~20MB uploads via getFile, and the handler
|
|
8
|
+
* accepts arbitrary uploaded `message:audio` (not just short voice notes), so
|
|
9
|
+
* an authorized-but-hostile chat could otherwise push a large file we fully
|
|
10
|
+
* buffer. 20MB matches Telegram's own getFile ceiling.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_AUDIO_BYTES = 20 * 1024 * 1024;
|
|
13
|
+
/** Abort the file download if Telegram's CDN hasn't responded in this window. */
|
|
14
|
+
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
// NB: the voice path reads only a narrow slice of the channel's state/deps.
|
|
17
|
+
// Voice-as-approval-text is intentionally unsupported — a voice note never
|
|
18
|
+
// satisfies an awaiting approval-text prompt (the text handler owns that
|
|
19
|
+
// path) — so the wider model/turn/approval/frame fields the text handler
|
|
20
|
+
// needs are deliberately absent here rather than carried dead.
|
|
21
|
+
export interface VoiceHandlerState {
|
|
22
|
+
readonly session: Session | null;
|
|
23
|
+
readonly busy: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface VoiceHandlerDeps {
|
|
27
|
+
readonly pairing: PairingHandler;
|
|
28
|
+
/** Bot token, used to build the Telegram file-download URL. */
|
|
29
|
+
readonly token: string;
|
|
30
|
+
readonly logger?: { warn(msg: string, meta?: Record<string, unknown>): void };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface VoiceHandlerCallbacks {
|
|
34
|
+
readonly runUserTurn: (ctx: Context, chatId: number, text: string) => Promise<void>;
|
|
35
|
+
/** Override the network fetch for tests. Defaults to global `fetch`. */
|
|
36
|
+
readonly fetchAudio?: (
|
|
37
|
+
url: string,
|
|
38
|
+
init?: { signal?: AbortSignal },
|
|
39
|
+
) => Promise<{
|
|
40
|
+
ok: boolean;
|
|
41
|
+
/** HTTP status — surfaced in the download-failure log for diagnostics. */
|
|
42
|
+
status?: number;
|
|
43
|
+
statusText?: string;
|
|
44
|
+
/** Content-Length header, when present — used to reject oversized bodies pre-buffer. */
|
|
45
|
+
headers?: { get(name: string): string | null };
|
|
46
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
47
|
+
}>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Handle inbound voice notes and uploaded audio files. Authorization
|
|
52
|
+
* gate matches the text path. If no Transcriber is registered on the
|
|
53
|
+
* session, reply with a one-time guidance message instead of silently
|
|
54
|
+
* swallowing the audio. Otherwise: download via Bot-API file URL,
|
|
55
|
+
* transcribe through the active Transcriber, echo a short "heard:"
|
|
56
|
+
* confirmation, then run a normal user turn with the transcript.
|
|
57
|
+
*/
|
|
58
|
+
export async function handleVoiceMessage(
|
|
59
|
+
ctx: Context,
|
|
60
|
+
state: VoiceHandlerState,
|
|
61
|
+
deps: VoiceHandlerDeps,
|
|
62
|
+
cb: VoiceHandlerCallbacks,
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
const chatId = ctx.chat?.id;
|
|
65
|
+
if (!chatId) return;
|
|
66
|
+
|
|
67
|
+
// Pull whichever audio variant Telegram delivered. `voice` is a press-
|
|
68
|
+
// and-hold voice note; `audio` is an uploaded audio file. Both expose
|
|
69
|
+
// a `file_id` and (for voice) a mime_type — we trust voice's
|
|
70
|
+
// `audio/ogg` default when missing.
|
|
71
|
+
const voice = ctx.message?.voice;
|
|
72
|
+
const audio = ctx.message?.audio;
|
|
73
|
+
const media = voice ?? audio;
|
|
74
|
+
if (!media) return;
|
|
75
|
+
|
|
76
|
+
if (!deps.pairing.isAuthorized(chatId)) {
|
|
77
|
+
await ctx.reply(
|
|
78
|
+
'This bot is paired with a different chat (or not paired yet). Run `moxxy telegram pair` to (re-)pair.',
|
|
79
|
+
);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (state.busy) {
|
|
84
|
+
await ctx.reply('I am still working on the previous prompt. Send /cancel to abort it.');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!state.session) {
|
|
89
|
+
await ctx.reply('Session is not ready yet.');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const transcriber = state.session.transcribers.tryGetActive();
|
|
94
|
+
if (!transcriber) {
|
|
95
|
+
await ctx.reply(
|
|
96
|
+
"Heard a voice note, 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.",
|
|
97
|
+
);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Reject oversized uploads up-front using the size Telegram reports on the
|
|
102
|
+
// voice/audio object, before we spend a download or any memory on it.
|
|
103
|
+
const declaredSize = (media as { file_size?: number }).file_size;
|
|
104
|
+
if (typeof declaredSize === 'number' && declaredSize > MAX_AUDIO_BYTES) {
|
|
105
|
+
await ctx.reply(
|
|
106
|
+
`That audio is too large (${Math.round(declaredSize / (1024 * 1024))}MB). The limit is ${MAX_AUDIO_BYTES / (1024 * 1024)}MB.`,
|
|
107
|
+
);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let fileInfo: Awaited<ReturnType<typeof ctx.api.getFile>>;
|
|
112
|
+
try {
|
|
113
|
+
fileInfo = await ctx.api.getFile(media.file_id);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
deps.logger?.warn('telegram getFile failed', {
|
|
116
|
+
err: err instanceof Error ? err.message : String(err),
|
|
117
|
+
});
|
|
118
|
+
await ctx.reply('Could not look up that voice note with Telegram.');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (!fileInfo.file_path) {
|
|
122
|
+
await ctx.reply('Telegram did not return a downloadable file path for that voice note.');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const url = `https://api.telegram.org/file/bot${deps.token}/${fileInfo.file_path}`;
|
|
126
|
+
const fetcher =
|
|
127
|
+
cb.fetchAudio ?? ((u: string, init?: { signal?: AbortSignal }) => fetch(u, init));
|
|
128
|
+
|
|
129
|
+
// Bound the download with an AbortSignal timeout so a hung/slow Telegram CDN
|
|
130
|
+
// connection can't block the handler indefinitely.
|
|
131
|
+
const ac = new AbortController();
|
|
132
|
+
const timer = setTimeout(() => ac.abort(), DOWNLOAD_TIMEOUT_MS);
|
|
133
|
+
let bytes: Uint8Array;
|
|
134
|
+
try {
|
|
135
|
+
const response = await fetcher(url, { signal: ac.signal });
|
|
136
|
+
if (!response.ok) {
|
|
137
|
+
deps.logger?.warn('telegram voice download failed', {
|
|
138
|
+
...(response.status !== undefined ? { status: response.status } : {}),
|
|
139
|
+
...(response.statusText ? { statusText: response.statusText } : {}),
|
|
140
|
+
});
|
|
141
|
+
await ctx.reply('Failed to download the voice note from Telegram.');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// Trust the Content-Length header (when present) to bail before buffering a
|
|
145
|
+
// body that lies about its size on the voice/audio object.
|
|
146
|
+
const declaredLen = Number(response.headers?.get('content-length') ?? '');
|
|
147
|
+
if (Number.isFinite(declaredLen) && declaredLen > MAX_AUDIO_BYTES) {
|
|
148
|
+
await ctx.reply('That audio is too large to transcribe.');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const buf = await response.arrayBuffer();
|
|
152
|
+
if (buf.byteLength > MAX_AUDIO_BYTES) {
|
|
153
|
+
await ctx.reply('That audio is too large to transcribe.');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
bytes = new Uint8Array(buf);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
deps.logger?.warn('telegram voice fetch failed', {
|
|
159
|
+
err: err instanceof Error ? err.message : String(err),
|
|
160
|
+
});
|
|
161
|
+
await ctx.reply(
|
|
162
|
+
ac.signal.aborted
|
|
163
|
+
? 'Downloading the voice note timed out.'
|
|
164
|
+
: 'Failed to download the voice note from Telegram.',
|
|
165
|
+
);
|
|
166
|
+
return;
|
|
167
|
+
} finally {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Voice notes are OGG/Opus by default; uploaded audio carries its own mime.
|
|
172
|
+
const mimeType = media.mime_type ?? (voice ? 'audio/ogg' : 'audio/mpeg');
|
|
173
|
+
let transcript: string;
|
|
174
|
+
try {
|
|
175
|
+
const result = await transcriber.transcribe(bytes, { mimeType });
|
|
176
|
+
transcript = result.text.trim();
|
|
177
|
+
} catch (err) {
|
|
178
|
+
deps.logger?.warn('telegram voice transcription failed', {
|
|
179
|
+
err: err instanceof Error ? err.message : String(err),
|
|
180
|
+
});
|
|
181
|
+
await ctx.reply(
|
|
182
|
+
`Transcription failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
183
|
+
);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!transcript) {
|
|
188
|
+
await ctx.reply('Could not transcribe the voice note (got empty text).');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Echo what we heard so the user can spot misrecognitions before the
|
|
193
|
+
// agent acts on it. Italics keep it visually distinct from a normal
|
|
194
|
+
// reply.
|
|
195
|
+
await ctx.reply(`_heard:_ ${transcript}`, { parse_mode: 'Markdown' });
|
|
196
|
+
|
|
197
|
+
await cb.runUserTurn(ctx, chatId, transcript);
|
|
198
|
+
}
|