@moxxy/plugin-channel-signal 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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/dist/channel/chunker.d.ts +69 -0
  3. package/dist/channel/chunker.d.ts.map +1 -0
  4. package/dist/channel/chunker.js +133 -0
  5. package/dist/channel/chunker.js.map +1 -0
  6. package/dist/channel/turn-runner.d.ts +33 -0
  7. package/dist/channel/turn-runner.d.ts.map +1 -0
  8. package/dist/channel/turn-runner.js +70 -0
  9. package/dist/channel/turn-runner.js.map +1 -0
  10. package/dist/channel/voice.d.ts +37 -0
  11. package/dist/channel/voice.d.ts.map +1 -0
  12. package/dist/channel/voice.js +88 -0
  13. package/dist/channel/voice.js.map +1 -0
  14. package/dist/channel.d.ts +154 -0
  15. package/dist/channel.d.ts.map +1 -0
  16. package/dist/channel.js +468 -0
  17. package/dist/channel.js.map +1 -0
  18. package/dist/index.d.ts +28 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +215 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/jsonrpc.d.ts +53 -0
  23. package/dist/jsonrpc.d.ts.map +1 -0
  24. package/dist/jsonrpc.js +167 -0
  25. package/dist/jsonrpc.js.map +1 -0
  26. package/dist/keys.d.ts +37 -0
  27. package/dist/keys.d.ts.map +1 -0
  28. package/dist/keys.js +55 -0
  29. package/dist/keys.js.map +1 -0
  30. package/dist/pair-flow.d.ts +21 -0
  31. package/dist/pair-flow.d.ts.map +1 -0
  32. package/dist/pair-flow.js +136 -0
  33. package/dist/pair-flow.js.map +1 -0
  34. package/dist/permission.d.ts +21 -0
  35. package/dist/permission.d.ts.map +1 -0
  36. package/dist/permission.js +27 -0
  37. package/dist/permission.js.map +1 -0
  38. package/dist/schema.d.ts +4295 -0
  39. package/dist/schema.d.ts.map +1 -0
  40. package/dist/schema.js +84 -0
  41. package/dist/schema.js.map +1 -0
  42. package/dist/setup-wizard.d.ts +14 -0
  43. package/dist/setup-wizard.d.ts.map +1 -0
  44. package/dist/setup-wizard.js +85 -0
  45. package/dist/setup-wizard.js.map +1 -0
  46. package/dist/sidecar.d.ts +137 -0
  47. package/dist/sidecar.d.ts.map +1 -0
  48. package/dist/sidecar.js +421 -0
  49. package/dist/sidecar.js.map +1 -0
  50. package/package.json +89 -0
  51. package/src/channel/chunker.test.ts +135 -0
  52. package/src/channel/chunker.ts +147 -0
  53. package/src/channel/turn-runner.ts +97 -0
  54. package/src/channel/voice.test.ts +136 -0
  55. package/src/channel/voice.ts +118 -0
  56. package/src/channel.test.ts +364 -0
  57. package/src/channel.ts +607 -0
  58. package/src/index.ts +289 -0
  59. package/src/jsonrpc.test.ts +128 -0
  60. package/src/jsonrpc.ts +198 -0
  61. package/src/keys.test.ts +45 -0
  62. package/src/keys.ts +56 -0
  63. package/src/pair-flow.ts +161 -0
  64. package/src/permission.ts +36 -0
  65. package/src/schema.ts +98 -0
  66. package/src/setup-wizard.ts +98 -0
  67. package/src/sidecar.test.ts +276 -0
  68. package/src/sidecar.ts +511 -0
  69. package/src/subcommands.test.ts +206 -0
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Buffered chunked sends — Signal's streaming strategy.
3
+ *
4
+ * WHY NOT FramePump edits: signal-cli's `send` does support editing a previous
5
+ * message (`editTimestamp`), but every Signal edit is a full end-to-end
6
+ * re-delivery of the whole message body to every device in the conversation.
7
+ * Driving it at a streaming cadence (an edit per ~1s for a long turn) floods
8
+ * the recipient's devices with dozens of E2E deliveries per reply, official
9
+ * clients keep a bounded edit history per message, and burst sends are exactly
10
+ * what Signal's server-side spam heuristics rate-limit (challenge/backoff).
11
+ * So instead of "send once then edit" we buffer the streamed text and send
12
+ * coherent chunks: nothing goes out until a paragraph-aligned chunk is ready,
13
+ * and the remainder goes out once, at turn end. Liveness comes from the typing
14
+ * indicator (`sendTyping`), not message churn.
15
+ */
16
+
17
+ /** Buffered text below this length is held for the final flush. */
18
+ export const SIGNAL_CHUNK_SOFT_LIMIT = 1_500;
19
+ /**
20
+ * Never send a single message longer than this. Signal itself allows long
21
+ * bodies, but clients render extremely long messages poorly and signal-cli
22
+ * sends the whole body per message; 2000 mirrors common client truncation.
23
+ */
24
+ export const SIGNAL_CHUNK_HARD_LIMIT = 2_000;
25
+
26
+ export interface ChunkLimits {
27
+ readonly softLimit?: number;
28
+ readonly hardLimit?: number;
29
+ }
30
+
31
+ /**
32
+ * If `text` has grown past the soft limit, split off one send-ready chunk at
33
+ * the best boundary (paragraph → line → word) at or below the hard limit.
34
+ * Returns null while the text should keep buffering.
35
+ */
36
+ export function takeChunk(
37
+ text: string,
38
+ limits: ChunkLimits = {},
39
+ ): { chunk: string; rest: string } | null {
40
+ const soft = limits.softLimit ?? SIGNAL_CHUNK_SOFT_LIMIT;
41
+ const hard = limits.hardLimit ?? SIGNAL_CHUNK_HARD_LIMIT;
42
+ if (text.length <= soft) return null;
43
+ const window = text.slice(0, Math.min(text.length, hard));
44
+ // Prefer a paragraph break; a chunk that ends mid-sentence reads badly as a
45
+ // standalone Signal message. Require the boundary to keep a meaningful chunk
46
+ // (≥ half the soft limit) so a leading blank line can't produce confetti.
47
+ const minCut = Math.floor(soft / 2);
48
+ for (const boundary of ['\n\n', '\n', ' ']) {
49
+ const at = window.lastIndexOf(boundary);
50
+ if (at >= minCut) {
51
+ return { chunk: window.slice(0, at).trimEnd(), rest: text.slice(at + boundary.length) };
52
+ }
53
+ }
54
+ if (text.length <= hard) return null; // no boundary yet — wait for more text
55
+ return { chunk: window, rest: text.slice(window.length) }; // pathological: hard cut
56
+ }
57
+
58
+ /** Split a final remainder into hard-limit-sized pieces at the best boundaries. */
59
+ export function splitForSignal(text: string, limits: ChunkLimits = {}): string[] {
60
+ const hard = limits.hardLimit ?? SIGNAL_CHUNK_HARD_LIMIT;
61
+ const parts: string[] = [];
62
+ let rest = text;
63
+ while (rest.length > hard) {
64
+ // Reuse takeChunk with soft==hard-ish so it only splits when forced.
65
+ const taken = takeChunk(rest, { softLimit: Math.floor(hard / 2), hardLimit: hard });
66
+ if (!taken) break;
67
+ if (taken.chunk) parts.push(taken.chunk);
68
+ rest = taken.rest;
69
+ }
70
+ const tail = rest.trim();
71
+ if (tail) parts.push(tail);
72
+ return parts;
73
+ }
74
+
75
+ export interface ChunkedSenderOptions {
76
+ /** Deliver one message. Errors should be handled by the caller-supplied fn
77
+ * (log + swallow) — a failed chunk must never abort the turn. */
78
+ readonly send: (text: string) => Promise<void>;
79
+ readonly limits?: ChunkLimits;
80
+ }
81
+
82
+ /**
83
+ * Drives {@link takeChunk} over a GROWING renderer snapshot: `offer()` on every
84
+ * change (sends any ready chunk), `finalize()` at turn end (sends the
85
+ * remainder, or `emptyText` when the whole turn produced nothing). Sends are
86
+ * serialized on an internal queue so chunks can never interleave out of order.
87
+ */
88
+ export class ChunkedSender {
89
+ private readonly opts: ChunkedSenderOptions;
90
+ /** The exact snapshot prefix already delivered (chunk boundaries included). */
91
+ private sentPrefix = '';
92
+ private sentAnything = false;
93
+ private queue: Promise<void> = Promise.resolve();
94
+
95
+ constructor(opts: ChunkedSenderOptions) {
96
+ this.opts = opts;
97
+ }
98
+
99
+ /** Called with the current full snapshot whenever it changes. */
100
+ offer(snapshot: string): void {
101
+ this.queue = this.queue.then(async () => {
102
+ // Streamed text only ever grows; if the snapshot diverged from what we
103
+ // already delivered (the final assistant_message rewrote history) hold
104
+ // everything for finalize(), which handles divergence explicitly.
105
+ if (!snapshot.startsWith(this.sentPrefix)) return;
106
+ let consumed = this.sentPrefix.length;
107
+ let taken = takeChunk(snapshot.slice(consumed), this.opts.limits);
108
+ while (taken) {
109
+ await this.deliver(taken.chunk);
110
+ consumed = snapshot.length - taken.rest.length;
111
+ this.sentPrefix = snapshot.slice(0, consumed);
112
+ taken = takeChunk(taken.rest, this.opts.limits);
113
+ }
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Turn end: deliver whatever the final snapshot still owes. When the final
119
+ * text no longer extends what we already sent (a divergent final frame —
120
+ * rare), send the full final text so the user always receives the
121
+ * authoritative reply, accepting the duplication.
122
+ */
123
+ async finalize(snapshot: string, emptyText?: string): Promise<void> {
124
+ this.queue = this.queue.then(async () => {
125
+ const finalText = snapshot.trim();
126
+ if (!finalText) {
127
+ if (!this.sentAnything && emptyText) await this.deliver(emptyText);
128
+ return;
129
+ }
130
+ const remainder = snapshot.startsWith(this.sentPrefix)
131
+ ? snapshot.slice(this.sentPrefix.length)
132
+ : snapshot; // diverged: resend the authoritative text in full
133
+ for (const part of splitForSignal(remainder, this.opts.limits)) {
134
+ await this.deliver(part);
135
+ }
136
+ this.sentPrefix = snapshot;
137
+ });
138
+ await this.queue;
139
+ }
140
+
141
+ private async deliver(text: string): Promise<void> {
142
+ const trimmed = text.trim();
143
+ if (!trimmed) return;
144
+ this.sentAnything = true;
145
+ await this.opts.send(trimmed);
146
+ }
147
+ }
@@ -0,0 +1,97 @@
1
+ import type { newTurnId } from '@moxxy/core';
2
+ import { PlainTurnRenderer, driveTurn, subscribeTurn } from '@moxxy/channel-kit';
3
+ import type { ClientSession as Session } from '@moxxy/sdk';
4
+ import { ChunkedSender, type ChunkLimits } from './chunker.js';
5
+
6
+ export interface TurnRunnerLogger {
7
+ warn?(msg: string, meta?: Record<string, unknown>): void;
8
+ }
9
+
10
+ export interface RunSignalTurnDeps {
11
+ readonly session: Session;
12
+ /** Deliver one outbound message to the turn's reply target. Must swallow its
13
+ * own transport errors (log + resolve) — a failed send never aborts the turn. */
14
+ readonly send: (text: string) => Promise<void>;
15
+ /** Start/refresh the typing indicator (best-effort; errors swallowed). */
16
+ readonly sendTyping?: (stop: boolean) => Promise<void>;
17
+ readonly chunkLimits?: ChunkLimits;
18
+ readonly logger?: TurnRunnerLogger;
19
+ }
20
+
21
+ export interface RunSignalTurnOptions {
22
+ readonly text: string;
23
+ readonly model?: string;
24
+ readonly controller: AbortController;
25
+ /** Pre-minted turn id; the channel records it as an own-turn id (invariant #8). */
26
+ readonly turnId: ReturnType<typeof newTurnId>;
27
+ }
28
+
29
+ /** Signal shows a typing indicator for ~15s per sendTyping; refresh under that. */
30
+ const TYPING_REFRESH_MS = 10_000;
31
+
32
+ /**
33
+ * Drive a single Signal turn end-to-end: subscribe to THIS turn's events
34
+ * (filtered by turnId — `session.log` fans out to every listener, AGENTS.md
35
+ * invariant #8), stream the rendered snapshot through the buffered
36
+ * {@link ChunkedSender} (see chunker.ts for why Signal gets chunked sends
37
+ * instead of FramePump edits), keep a typing indicator alive for liveness, run
38
+ * the turn, flush the final remainder, and unwind in `finally`.
39
+ */
40
+ export async function runSignalTurn(
41
+ deps: RunSignalTurnDeps,
42
+ opts: RunSignalTurnOptions,
43
+ ): Promise<void> {
44
+ const { session, send, sendTyping, logger } = deps;
45
+ const { text, model, controller, turnId } = opts;
46
+
47
+ const renderer = new PlainTurnRenderer();
48
+ const sender = new ChunkedSender({
49
+ send: async (t) => {
50
+ try {
51
+ await send(t);
52
+ } catch (err) {
53
+ logger?.warn?.('signal: send failed', { err: err instanceof Error ? err.message : String(err) });
54
+ }
55
+ },
56
+ ...(deps.chunkLimits ? { limits: deps.chunkLimits } : {}),
57
+ });
58
+
59
+ // Liveness: refresh the typing indicator while the turn runs (each
60
+ // sendTyping shows for ~15s). Best-effort — a failure only loses the
61
+ // indicator, never the turn.
62
+ const typing = (stop: boolean): void => {
63
+ if (!sendTyping) return;
64
+ void sendTyping(stop).catch(() => undefined);
65
+ };
66
+ typing(false);
67
+ const typingTimer = setInterval(() => typing(false), TYPING_REFRESH_MS);
68
+ typingTimer.unref?.();
69
+
70
+ const unsubscribe = subscribeTurn(session, turnId, (event) => {
71
+ if (renderer.accept(event)) sender.offer(renderer.snapshot());
72
+ });
73
+
74
+ try {
75
+ await driveTurn(session, {
76
+ turnId,
77
+ prompt: text,
78
+ ...(model ? { model } : {}),
79
+ signal: controller.signal,
80
+ });
81
+ await sender.finalize(renderer.snapshot(), '(no output)');
82
+ } catch (err) {
83
+ logger?.warn?.('signal: turn failed', {
84
+ err: err instanceof Error ? err.message : String(err),
85
+ });
86
+ // Surface the failure to the sender rather than going silent.
87
+ try {
88
+ await send(`Turn failed: ${err instanceof Error ? err.message : String(err)}`);
89
+ } catch {
90
+ /* best-effort */
91
+ }
92
+ } finally {
93
+ clearInterval(typingTimer);
94
+ typing(true);
95
+ unsubscribe();
96
+ }
97
+ }
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { ClientSession as Session } from '@moxxy/sdk';
3
+ import { MAX_AUDIO_BYTES, pickAudioAttachment, transcribeVoiceAttachment } from './voice.js';
4
+
5
+ function fakeSession(transcriber: { transcribe: (bytes: Uint8Array, o: { mimeType: string }) => Promise<{ text: string }> } | null): Session {
6
+ return {
7
+ transcribers: { tryGetActive: () => transcriber },
8
+ } as unknown as Session;
9
+ }
10
+
11
+ function replies(): { sent: string[]; reply: (t: string) => Promise<void> } {
12
+ const sent: string[] = [];
13
+ return {
14
+ sent,
15
+ reply: async (t) => {
16
+ sent.push(t);
17
+ },
18
+ };
19
+ }
20
+
21
+ describe('pickAudioAttachment', () => {
22
+ it('picks the first audio/* attachment', () => {
23
+ expect(
24
+ pickAudioAttachment([
25
+ { id: 'a', contentType: 'image/png' },
26
+ { id: 'b', contentType: 'audio/aac' },
27
+ { id: 'c', contentType: 'audio/ogg' },
28
+ ])?.id,
29
+ ).toBe('b');
30
+ });
31
+
32
+ it('returns null when nothing is audio', () => {
33
+ expect(pickAudioAttachment([{ id: 'a', contentType: 'image/png' }])).toBeNull();
34
+ expect(pickAudioAttachment(undefined)).toBeNull();
35
+ });
36
+ });
37
+
38
+ describe('transcribeVoiceAttachment', () => {
39
+ it('replies with install guidance when no transcriber is configured', async () => {
40
+ const r = replies();
41
+ const out = await transcribeVoiceAttachment(
42
+ { session: fakeSession(null), attachmentsDir: '/tmp', reply: r.reply },
43
+ { id: 'abc', contentType: 'audio/aac', size: 10 },
44
+ );
45
+ expect(out).toBeNull();
46
+ expect(r.sent[0]).toMatch(/plugin-stt-whisper/);
47
+ });
48
+
49
+ it('rejects oversized audio from the declared size before reading', async () => {
50
+ const r = replies();
51
+ const readFile = vi.fn();
52
+ const out = await transcribeVoiceAttachment(
53
+ {
54
+ session: fakeSession({ transcribe: async () => ({ text: 'x' }) }),
55
+ attachmentsDir: '/tmp',
56
+ reply: r.reply,
57
+ readFile,
58
+ },
59
+ { id: 'abc', contentType: 'audio/aac', size: MAX_AUDIO_BYTES + 1 },
60
+ );
61
+ expect(out).toBeNull();
62
+ expect(readFile).not.toHaveBeenCalled();
63
+ expect(r.sent[0]).toMatch(/too large/);
64
+ });
65
+
66
+ it('rejects a body that lies about its declared size', async () => {
67
+ const r = replies();
68
+ const out = await transcribeVoiceAttachment(
69
+ {
70
+ session: fakeSession({ transcribe: async () => ({ text: 'x' }) }),
71
+ attachmentsDir: '/tmp',
72
+ reply: r.reply,
73
+ readFile: async () => new Uint8Array(MAX_AUDIO_BYTES + 1),
74
+ },
75
+ { id: 'abc', contentType: 'audio/aac', size: 10 },
76
+ );
77
+ expect(out).toBeNull();
78
+ expect(r.sent[0]).toMatch(/too large/);
79
+ });
80
+
81
+ it('drops attachments whose id could escape the attachments dir', async () => {
82
+ const r = replies();
83
+ const readFile = vi.fn();
84
+ const out = await transcribeVoiceAttachment(
85
+ {
86
+ session: fakeSession({ transcribe: async () => ({ text: 'x' }) }),
87
+ attachmentsDir: '/tmp',
88
+ reply: r.reply,
89
+ readFile,
90
+ },
91
+ { id: '../../etc/passwd' as never, contentType: 'audio/aac' },
92
+ );
93
+ expect(out).toBeNull();
94
+ expect(readFile).not.toHaveBeenCalled();
95
+ expect(r.sent).toEqual([]); // silent drop — nothing to say to a forged envelope
96
+ });
97
+
98
+ it('replies with the transcription error instead of throwing', async () => {
99
+ const r = replies();
100
+ const out = await transcribeVoiceAttachment(
101
+ {
102
+ session: fakeSession({
103
+ transcribe: async () => {
104
+ throw new Error('whisper 500');
105
+ },
106
+ }),
107
+ attachmentsDir: '/tmp',
108
+ reply: r.reply,
109
+ readFile: async () => new Uint8Array(4),
110
+ },
111
+ { id: 'abc', contentType: 'audio/aac' },
112
+ );
113
+ expect(out).toBeNull();
114
+ expect(r.sent[0]).toMatch(/whisper 500/);
115
+ });
116
+
117
+ it('transcribes, echoes "heard:", and returns the transcript', async () => {
118
+ const r = replies();
119
+ const transcribe = vi.fn(async () => ({ text: ' turn on the lights ' }));
120
+ const out = await transcribeVoiceAttachment(
121
+ {
122
+ session: fakeSession({ transcribe }),
123
+ attachmentsDir: '/data/attachments',
124
+ reply: r.reply,
125
+ readFile: async (p) => {
126
+ expect(p).toBe('/data/attachments/12345.opus');
127
+ return new Uint8Array([1, 2, 3]);
128
+ },
129
+ },
130
+ { id: '12345.opus', contentType: 'audio/ogg', size: 3 },
131
+ );
132
+ expect(out).toBe('turn on the lights');
133
+ expect(transcribe).toHaveBeenCalledWith(expect.any(Uint8Array), { mimeType: 'audio/ogg' });
134
+ expect(r.sent[0]).toBe('heard: turn on the lights');
135
+ });
136
+ });
@@ -0,0 +1,118 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import type { ClientSession as Session } from '@moxxy/sdk';
4
+ import { attachmentIdSchema, type SignalAttachment } from '../schema.js';
5
+
6
+ /**
7
+ * Voice-note handling for the Signal channel. signal-cli writes received
8
+ * attachments to `<dataDir>/attachments/<id>` (it does NOT inline them in the
9
+ * receive notification), so the flow is: pick the first audio attachment,
10
+ * size-cap it, read the file, transcribe via the session's active Transcriber,
11
+ * and hand the transcript back for a normal text turn.
12
+ */
13
+
14
+ /**
15
+ * Hard cap on an audio file we will buffer into memory before transcribing —
16
+ * matches the Telegram channel's ceiling so an authorized-but-compromised
17
+ * sender can't make the runner buffer an arbitrarily large blob.
18
+ */
19
+ export const MAX_AUDIO_BYTES = 20 * 1024 * 1024;
20
+
21
+ export interface VoiceLogger {
22
+ warn?(msg: string, meta?: Record<string, unknown>): void;
23
+ }
24
+
25
+ export interface VoiceDeps {
26
+ readonly session: Session;
27
+ /** signal-cli's attachments dir (injectable for tests). */
28
+ readonly attachmentsDir: string;
29
+ /** Reply to the sender (guidance / error strings). */
30
+ readonly reply: (text: string) => Promise<void>;
31
+ /** Injectable file reader (tests). Defaults to fs.readFile. */
32
+ readonly readFile?: (filePath: string) => Promise<Uint8Array>;
33
+ readonly logger?: VoiceLogger;
34
+ }
35
+
36
+ /** The first audio attachment in a message, or null. */
37
+ export function pickAudioAttachment(
38
+ attachments: ReadonlyArray<SignalAttachment> | undefined,
39
+ ): SignalAttachment | null {
40
+ if (!attachments) return null;
41
+ return attachments.find((a) => (a.contentType ?? '').toLowerCase().startsWith('audio/')) ?? null;
42
+ }
43
+
44
+ /**
45
+ * Transcribe a received audio attachment. Returns the transcript, or null when
46
+ * the message was fully handled with a reply (no transcriber configured,
47
+ * oversized, unreadable, transcription failed).
48
+ */
49
+ export async function transcribeVoiceAttachment(
50
+ deps: VoiceDeps,
51
+ attachment: SignalAttachment,
52
+ ): Promise<string | null> {
53
+ const transcriber = deps.session.transcribers.tryGetActive();
54
+ if (!transcriber) {
55
+ await deps.reply(
56
+ 'Heard a voice note, but no speech-to-text backend is configured. Install @moxxy/plugin-stt-whisper ' +
57
+ 'and run `moxxy login openai` (or set OPENAI_API_KEY) to enable voice input.',
58
+ );
59
+ return null;
60
+ }
61
+
62
+ // Reject oversized audio up-front from the declared size, before any read.
63
+ if (typeof attachment.size === 'number' && attachment.size > MAX_AUDIO_BYTES) {
64
+ await deps.reply(
65
+ `That audio is too large (${Math.round(attachment.size / (1024 * 1024))}MB). The limit is ${MAX_AUDIO_BYTES / (1024 * 1024)}MB.`,
66
+ );
67
+ return null;
68
+ }
69
+
70
+ // The id becomes a filename under signal-cli's attachments dir; re-validate
71
+ // its charset here (defense in depth on top of the zod schema) so a crafted
72
+ // id can never traverse out of the directory.
73
+ const id = attachmentIdSchema.safeParse(attachment.id);
74
+ if (!id.success) {
75
+ deps.logger?.warn?.('signal: dropping voice note with invalid attachment id');
76
+ return null;
77
+ }
78
+ const filePath = path.join(deps.attachmentsDir, id.data);
79
+
80
+ const read = deps.readFile ?? ((p: string) => fs.readFile(p));
81
+ let bytes: Uint8Array;
82
+ try {
83
+ bytes = await read(filePath);
84
+ } catch (err) {
85
+ deps.logger?.warn?.('signal: could not read voice attachment', {
86
+ err: err instanceof Error ? err.message : String(err),
87
+ });
88
+ await deps.reply('Could not read that voice note from the signal-cli attachment store.');
89
+ return null;
90
+ }
91
+ if (bytes.byteLength > MAX_AUDIO_BYTES) {
92
+ await deps.reply('That audio is too large to transcribe.');
93
+ return null;
94
+ }
95
+
96
+ let transcript: string;
97
+ try {
98
+ const result = await transcriber.transcribe(bytes, {
99
+ mimeType: attachment.contentType ?? 'audio/aac',
100
+ });
101
+ transcript = result.text.trim();
102
+ } catch (err) {
103
+ deps.logger?.warn?.('signal: transcription failed', {
104
+ err: err instanceof Error ? err.message : String(err),
105
+ });
106
+ await deps.reply(`Transcription failed: ${err instanceof Error ? err.message : String(err)}`);
107
+ return null;
108
+ }
109
+ if (!transcript) {
110
+ await deps.reply('Could not transcribe the voice note (got empty text).');
111
+ return null;
112
+ }
113
+
114
+ // Echo what we heard so the user can spot misrecognitions before the agent
115
+ // acts on it (same UX as the Telegram voice path).
116
+ await deps.reply(`heard: ${transcript}`);
117
+ return transcript;
118
+ }