@grinev/opencode-telegram-bot 0.22.0 → 0.22.2

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/.env.example CHANGED
@@ -105,6 +105,18 @@ OPENCODE_MODEL_ID=big-pickle
105
105
  # raw = show assistant replies as plain text
106
106
  # MESSAGE_FORMAT_MODE=markdown
107
107
 
108
+ # Initial runtime settings preset (optional)
109
+ # A JSON object that seeds the bot's default /settings values on first run.
110
+ # Keys not yet persisted in settings.json are initialised to these values;
111
+ # any setting the user has already changed via /settings is left untouched.
112
+ # Supported keys: ttsMode ("off"|"all"|"auto"), compactOutputMode (bool),
113
+ # showThinkingContent (bool), showAssistantRunFooter (bool),
114
+ # responseStreamingMode ("edit"|"draft"), sendDiffFileAttachments (bool)
115
+ # The preset is validated at startup: invalid JSON, a non-object value, unknown
116
+ # keys, or wrong value types abort startup with a clear error (fail fast).
117
+ # Example — hide the run footer and enable compact mode by default:
118
+ # INITIAL_SETTINGS_PRESET={"showAssistantRunFooter":false,"compactOutputMode":true}
119
+
108
120
  # Directory Browser Roots (optional)
109
121
  # Comma-separated list of paths that /open is allowed to browse.
110
122
  # Supports ~ for home directory. Defaults to ~ when not set.
@@ -125,11 +137,15 @@ OPENCODE_MODEL_ID=big-pickle
125
137
  # STT_API_KEY=
126
138
  # STT_MODEL=
127
139
  # STT_LANGUAGE=
140
+ # STT request format (default: multipart)
141
+ # multipart = standard OpenAI/Groq Whisper form-data upload
142
+ # json = base64 audio in an `input_audio` JSON body (e.g. OpenRouter)
143
+ # STT_REQUEST_FORMAT=multipart
128
144
  # STT_NOTE_PROMPT="The following text is transcribed from voice. It may contain homophone or phonetic errors. Infer the intended meaning from context."
129
145
 
130
146
  # Text-to-Speech credentials (optional)
131
147
  # TTS reply behavior is controlled globally with /tts and persisted in settings.json.
132
- # Provider: "openai" (default), "elevenlabs", or "google".
148
+ # Provider: "openai" (default), "elevenlabs", "google", or "edge".
133
149
  #
134
150
  # --- OpenAI-compatible (default) ---
135
151
  # Set TTS_API_URL and TTS_API_KEY to any OpenAI-compatible TTS endpoint.
@@ -155,3 +171,10 @@ OPENCODE_MODEL_ID=big-pickle
155
171
  # TTS_PROVIDER=google
156
172
  # TTS_VOICE=en-US-Studio-O
157
173
  # GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json
174
+ #
175
+ # --- Microsoft Edge TTS ---
176
+ # Uses Microsoft Edge's online Read Aloud service. No API key or account
177
+ # required; only an outbound HTTPS/WebSocket connection to
178
+ # speech.platform.bing.com. Voice list: https://learn.microsoft.com/azure/ai-services/speech-service/language-support
179
+ # TTS_PROVIDER=edge
180
+ # TTS_VOICE=en-US-EmmaMultilingualNeural
package/README.md CHANGED
@@ -233,11 +233,13 @@ When installed via npm, the configuration wizard handles the initial setup. The
233
233
  | `TRACK_BACKGROUND_SESSIONS` | Track detached/non-current sessions in the current selected project/worktree and send short notifications | No | `true` |
234
234
  | `RESPONSE_STREAM_THROTTLE_MS` | Stream update throttle in milliseconds for assistant, thinking, and tool message edits | No | `1000` |
235
235
  | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
236
+ | `INITIAL_SETTINGS_PRESET` | JSON object that seeds default `/settings` values on first run (keys not yet persisted); see [Runtime Settings](#runtime-settings) | No | `{}` |
236
237
  | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
237
238
  | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
238
239
  | `STT_API_KEY` | API key for your STT provider | No | — |
239
240
  | `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
240
241
  | `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
242
+ | `STT_REQUEST_FORMAT` | STT request format: `multipart` (standard OpenAI/Groq Whisper) or `json` (base64 `input_audio` body, e.g. OpenRouter) | No | `multipart` |
241
243
  | `STT_NOTE_PROMPT` | Optional note prepended to the LLM prompt as `[Note: ...]` for voice transcriptions; empty / `false` / `0` disable it | No | — |
242
244
  | `TTS_PROVIDER` | TTS provider: `openai` for OpenAI-compatible APIs, `elevenlabs` for ElevenLabs, or `google` for Google Cloud TTS | No | `openai` |
243
245
  | `TTS_API_URL` | TTS API base URL for OpenAI-compatible APIs or ElevenLabs | No | — |
@@ -258,10 +260,17 @@ Runtime preferences are changed from `/settings` and stored in `settings.json`:
258
260
 
259
261
  - Compact output mode
260
262
  - Thinking content display
263
+ - Assistant run footer display
261
264
  - Diff file attachments
262
265
  - Response streaming mode: `edit` or `draft (experimental)`; applies only to final assistant replies, not thinking messages
263
266
  - Audio replies: `off`, `all`, or `auto` when TTS is configured
264
267
 
268
+ You can seed the initial defaults for any of these settings without hard-coding them in your Docker image by setting `INITIAL_SETTINGS_PRESET` to a JSON object. Only keys not yet persisted in `settings.json` are affected — settings the user has already changed via `/settings` are left untouched:
269
+
270
+ ```env
271
+ INITIAL_SETTINGS_PRESET={"showAssistantRunFooter":false,"compactOutputMode":true,"ttsMode":"auto"}
272
+ ```
273
+
265
274
  ### Reverse Proxy (Optional)
266
275
 
267
276
  For environments that block `api.telegram.org` but allow your own HTTPS endpoint (corporate networks, restricted regions), you can route Bot API traffic through a reverse proxy you control. This is an alternative to the SOCKS/HTTP forward proxy configured with `TELEGRAM_PROXY_URL`.
@@ -16,5 +16,5 @@ function formatDuration(elapsedMs) {
16
16
  }
17
17
  export function formatAssistantRunFooter({ agent, providerID, modelID, elapsedMs, }) {
18
18
  const agentDisplay = getAgentDisplayName(agent);
19
- return `${agentDisplay} · 🤖 ${providerID}/${modelID} · 🕒 ${formatDuration(elapsedMs)}`;
19
+ return `${agentDisplay} · 🧠 ${providerID}/${modelID} · 🕒 ${formatDuration(elapsedMs)}`;
20
20
  }
@@ -0,0 +1,322 @@
1
+ import { createHash, randomBytes, randomUUID } from "crypto";
2
+ import { WebSocket } from "ws";
3
+ import { logger } from "../../utils/logger.js";
4
+ /**
5
+ * Microsoft Edge online text-to-speech client.
6
+ *
7
+ * Speaks the same WebSocket protocol used by Microsoft Edge's Read Aloud
8
+ * feature (wss://speech.platform.bing.com/.../readaloud/edge/v1). No API key
9
+ * is required; access is authenticated through a SHA256 "Sec-MS-GEC" token
10
+ * derived from the current time.
11
+ *
12
+ * Ported from the Python reference implementation at
13
+ * https://github.com/rany2/edge-tts.
14
+ */
15
+ const BASE_URL = "speech.platform.bing.com/consumer/speech/synthesize/readaloud";
16
+ const TRUSTED_CLIENT_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4";
17
+ const WSS_URL = `wss://${BASE_URL}/edge/v1?TrustedClientToken=${TRUSTED_CLIENT_TOKEN}`;
18
+ const CHROMIUM_FULL_VERSION = "143.0.3650.75";
19
+ const CHROMIUM_MAJOR_VERSION = CHROMIUM_FULL_VERSION.split(".")[0];
20
+ export const SEC_MS_GEC_VERSION = `1-${CHROMIUM_FULL_VERSION}`;
21
+ export const EDGE_DEFAULT_VOICE = "en-US-EmmaMultilingualNeural";
22
+ const WIN_EPOCH_SECONDS = 11644473600;
23
+ const TICKS_PER_SECOND = 10_000_000;
24
+ const ROUND_SECONDS = 300;
25
+ const WSS_HEADERS = {
26
+ Pragma: "no-cache",
27
+ "Cache-Control": "no-cache",
28
+ Origin: "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold",
29
+ "User-Agent": `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ` +
30
+ `(KHTML, like Gecko) Chrome/${CHROMIUM_MAJOR_VERSION}.0.0.0 Safari/537.36 ` +
31
+ `Edg/${CHROMIUM_MAJOR_VERSION}.0.0.0`,
32
+ "Accept-Encoding": "gzip, deflate, br, zstd",
33
+ "Accept-Language": "en-US,en;q=0.9",
34
+ };
35
+ const SYNTHESIS_TIMEOUT_MS = 60_000;
36
+ const MAX_CHUNK_BYTES = 4096;
37
+ let clockSkewSeconds = 0;
38
+ /**
39
+ * Generates the Sec-MS-GEC DRM token Microsoft requires on every request.
40
+ *
41
+ * The token is the SHA256 (uppercased hex) of `<ticks><token>` where `ticks`
42
+ * is the current time as Windows file time (100-ns intervals since 1601-01-01)
43
+ * rounded down to the nearest 5 minutes. Rounded to limit token churn; the
44
+ * server accepts any token valid within the current 5-minute window.
45
+ */
46
+ export function generateSecMsGec(now = new Date()) {
47
+ let seconds = now.getTime() / 1000 + clockSkewSeconds + WIN_EPOCH_SECONDS;
48
+ seconds -= seconds % ROUND_SECONDS;
49
+ const ticks = BigInt(Math.round(seconds)) * BigInt(TICKS_PER_SECOND);
50
+ const strToHash = `${ticks}${TRUSTED_CLIENT_TOKEN}`;
51
+ return createHash("sha256").update(strToHash, "ascii").digest("hex").toUpperCase();
52
+ }
53
+ /** @internal Reset clock skew (for tests only). */
54
+ export function _resetClockSkew() {
55
+ clockSkewSeconds = 0;
56
+ }
57
+ function generateMuid() {
58
+ return randomBytes(16).toString("hex").toUpperCase();
59
+ }
60
+ function connectId() {
61
+ return randomUUID().replace(/-/g, "");
62
+ }
63
+ /** Formats like JS `Date.toString()` in UTC, the X-Timestamp shape Edge sends. */
64
+ function jsDateString(date = new Date()) {
65
+ // toUTCString gives "Fri, 01 Jan 2024 00:00:00 GMT"; reorder its tokens.
66
+ const [day, dayNum, month, year, time] = date.toUTCString().replace(",", "").split(" ");
67
+ return `${day} ${month} ${dayNum} ${year} ${time} GMT+0000 (Coordinated Universal Time)`;
68
+ }
69
+ function escapeXml(text) {
70
+ return text
71
+ .replace(/&/g, "&amp;")
72
+ .replace(/</g, "&lt;")
73
+ .replace(/>/g, "&gt;");
74
+ }
75
+ /** Escapes a value for interpolation into a single-quoted XML attribute. */
76
+ function escapeXmlAttribute(value) {
77
+ return escapeXml(value).replace(/'/g, "&apos;").replace(/"/g, "&quot;");
78
+ }
79
+ /**
80
+ * Replaces control characters the service rejects (0x00-0x08, 0x0B-0x0C,
81
+ * 0x0E-0x1F) with spaces. Common in OCR'd text; without this the service
82
+ * returns an error.
83
+ */
84
+ function removeIncompatibleCharacters(text) {
85
+ // eslint-disable-next-line no-control-regex
86
+ return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, " ");
87
+ }
88
+ /** Moves a split point back so it does not land inside an XML entity (&amp;). */
89
+ function adjustForXmlEntity(buf, splitAt) {
90
+ let result = splitAt;
91
+ while (result > 0) {
92
+ const ampersandIndex = buf.subarray(0, result).lastIndexOf("&");
93
+ if (ampersandIndex < 0)
94
+ break;
95
+ if (buf.subarray(ampersandIndex, result).includes(";"))
96
+ break;
97
+ result = ampersandIndex;
98
+ }
99
+ return result;
100
+ }
101
+ /**
102
+ * Splits text into chunks no larger than `byteLength` UTF-8 bytes, preferring
103
+ * to break at newlines or spaces and never inside a multi-byte character or
104
+ * XML entity. Mirrors edge-tts's split_text_by_byte_length.
105
+ */
106
+ export function splitTextByByteLength(text, byteLength) {
107
+ if (byteLength <= 0) {
108
+ throw new Error("byteLength must be greater than 0");
109
+ }
110
+ let rest = Buffer.from(text, "utf-8");
111
+ const chunks = [];
112
+ while (rest.length > byteLength) {
113
+ let splitAt = rest.lastIndexOf(0x0a, byteLength - 1);
114
+ if (splitAt < 0)
115
+ splitAt = rest.lastIndexOf(0x20, byteLength - 1);
116
+ if (splitAt < 0) {
117
+ splitAt = byteLength;
118
+ // Back up while the byte at the split is a UTF-8 continuation byte
119
+ // (10xxxxxx) so the cut never lands inside a multi-byte character.
120
+ while (splitAt > 0 && (rest[splitAt] & 0xc0) === 0x80) {
121
+ splitAt--;
122
+ }
123
+ }
124
+ splitAt = adjustForXmlEntity(rest, splitAt);
125
+ if (splitAt <= 0)
126
+ splitAt = 1;
127
+ const chunk = rest.subarray(0, splitAt).toString("utf-8").trim();
128
+ if (chunk)
129
+ chunks.push(chunk);
130
+ rest = rest.subarray(splitAt);
131
+ }
132
+ const remaining = rest.toString("utf-8").trim();
133
+ if (remaining)
134
+ chunks.push(remaining);
135
+ return chunks;
136
+ }
137
+ function buildSsml(voice, rate, volume, pitch, text) {
138
+ return ("<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>" +
139
+ `<voice name='${escapeXmlAttribute(voice)}'>` +
140
+ `<prosody pitch='${escapeXmlAttribute(pitch)}' rate='${escapeXmlAttribute(rate)}' ` +
141
+ `volume='${escapeXmlAttribute(volume)}'>` +
142
+ text +
143
+ "</prosody></voice></speak>");
144
+ }
145
+ function parseRfc2616Date(date) {
146
+ const parsed = Date.parse(date);
147
+ return Number.isNaN(parsed) ? null : parsed / 1000;
148
+ }
149
+ class EdgeHttpUpgradeError extends Error {
150
+ statusCode;
151
+ serverDate;
152
+ constructor(statusCode, serverDate) {
153
+ super(`Edge TTS WebSocket upgrade failed: HTTP ${statusCode}`);
154
+ this.name = "EdgeHttpUpgradeError";
155
+ this.statusCode = statusCode;
156
+ this.serverDate = serverDate;
157
+ }
158
+ }
159
+ /**
160
+ * Synthesizes one SSML chunk over its own WebSocket connection and resolves
161
+ * with that chunk's MP3 audio bytes. Retries once on HTTP 403 (clock skew) by
162
+ * re-deriving the token against the server's reported time.
163
+ *
164
+ * The upstream edge-tts client also opens a fresh connection per chunk; the
165
+ * service does not reliably accept a second SSML turn on the same socket.
166
+ */
167
+ async function synthesizeChunk(chunk, params) {
168
+ try {
169
+ return await attemptSynthesis(chunk, params);
170
+ }
171
+ catch (err) {
172
+ if (err instanceof EdgeHttpUpgradeError && err.statusCode === 403 && err.serverDate) {
173
+ const serverTime = parseRfc2616Date(err.serverDate);
174
+ if (serverTime !== null) {
175
+ clockSkewSeconds = serverTime - Date.now() / 1000;
176
+ logger.warn(`[EdgeTTS] HTTP 403: adjusted clock skew to ${clockSkewSeconds.toFixed(1)}s, retrying`);
177
+ return await attemptSynthesis(chunk, params);
178
+ }
179
+ }
180
+ throw err;
181
+ }
182
+ }
183
+ function attemptSynthesis(chunk, params) {
184
+ return new Promise((resolve, reject) => {
185
+ const gec = generateSecMsGec();
186
+ const url = `${WSS_URL}&ConnectionId=${connectId()}` +
187
+ `&Sec-MS-GEC=${gec}&Sec-MS-GEC-Version=${SEC_MS_GEC_VERSION}`;
188
+ const headers = { ...WSS_HEADERS, Cookie: `muid=${generateMuid()};` };
189
+ const ws = new WebSocket(url, { headers });
190
+ const audioChunks = [];
191
+ let audioReceived = false;
192
+ let settled = false;
193
+ let timer = null;
194
+ const finish = (error, result) => {
195
+ if (settled)
196
+ return;
197
+ settled = true;
198
+ if (timer)
199
+ clearTimeout(timer);
200
+ ws.removeAllListeners();
201
+ // Closing a still-connecting socket makes ws emit 'error' on the next
202
+ // tick; with no listener that is an uncaught exception, so keep a sink.
203
+ ws.on("error", () => { });
204
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
205
+ ws.close();
206
+ }
207
+ if (error)
208
+ reject(error);
209
+ else
210
+ resolve(result ?? Buffer.alloc(0));
211
+ };
212
+ timer = setTimeout(() => {
213
+ finish(new Error(`Edge TTS synthesis timed out after ${params.timeoutMs}ms`));
214
+ }, Math.max(0, params.deadline - Date.now()));
215
+ ws.on("unexpected-response", (_req, res) => {
216
+ const statusCode = res.statusCode ?? 0;
217
+ const serverDate = res.headers["date"] ?? null;
218
+ finish(new EdgeHttpUpgradeError(statusCode, serverDate));
219
+ });
220
+ ws.on("error", (err) => {
221
+ if (!settled)
222
+ finish(err);
223
+ });
224
+ ws.on("open", () => {
225
+ const configMessage = `X-Timestamp:${jsDateString()}\r\n` +
226
+ "Content-Type:application/json; charset=utf-8\r\n" +
227
+ "Path:speech.config\r\n\r\n" +
228
+ '{"context":{"synthesis":{"audio":{"metadataoptions":' +
229
+ '{"sentenceBoundaryEnabled":"true","wordBoundaryEnabled":"false"},' +
230
+ '"outputFormat":"audio-24khz-48kbitrate-mono-mp3"}}}}';
231
+ ws.send(configMessage);
232
+ const ssml = buildSsml(params.voice, params.rate, params.volume, params.pitch, chunk);
233
+ const ssmlMessage = `X-RequestId:${connectId()}\r\n` +
234
+ "Content-Type:application/ssml+xml\r\n" +
235
+ `X-Timestamp:${jsDateString()}Z\r\n` +
236
+ "Path:ssml\r\n\r\n" +
237
+ ssml;
238
+ ws.send(ssmlMessage);
239
+ });
240
+ ws.on("message", (data, isBinary) => {
241
+ if (settled)
242
+ return;
243
+ const buf = Buffer.isBuffer(data)
244
+ ? data
245
+ : Array.isArray(data)
246
+ ? Buffer.concat(data)
247
+ : Buffer.from(data);
248
+ if (isBinary) {
249
+ if (buf.length < 2) {
250
+ finish(new Error("Edge TTS: binary message too short"));
251
+ return;
252
+ }
253
+ // Binary frames: [2-byte big-endian header length][headers + \r\n][audio].
254
+ // The length value includes the trailing \r\n terminator, so audio
255
+ // starts immediately at offset 2 + headerLength.
256
+ const headerLength = buf.readUInt16BE(0);
257
+ if (2 + headerLength > buf.length) {
258
+ finish(new Error("Edge TTS: binary header length exceeds message"));
259
+ return;
260
+ }
261
+ const headersBlock = buf.subarray(2, 2 + headerLength).toString("utf-8");
262
+ if (!headersBlock.includes("Path:audio"))
263
+ return;
264
+ const audioStart = 2 + headerLength;
265
+ const audio = audioStart < buf.length ? buf.subarray(audioStart) : Buffer.alloc(0);
266
+ if (audio.length > 0) {
267
+ audioChunks.push(audio);
268
+ audioReceived = true;
269
+ }
270
+ return;
271
+ }
272
+ const text = buf.toString("utf-8");
273
+ const sep = text.indexOf("\r\n\r\n");
274
+ const headerBlock = sep >= 0 ? text.slice(0, sep) : text;
275
+ if (!headerBlock.includes("Path:turn.end"))
276
+ return;
277
+ if (!audioReceived) {
278
+ finish(new Error("Edge TTS: no audio received from service"));
279
+ }
280
+ else {
281
+ finish(null, Buffer.concat(audioChunks));
282
+ }
283
+ });
284
+ // A close before turn.end means the turn never completed; partial audio
285
+ // must not be returned as success.
286
+ ws.on("close", () => {
287
+ if (!settled) {
288
+ finish(new Error("Edge TTS: connection closed before synthesis completed"));
289
+ }
290
+ });
291
+ });
292
+ }
293
+ /**
294
+ * Synthesizes `text` to an MP3 Buffer using Microsoft Edge's online TTS.
295
+ * Throws on protocol errors, timeouts, or if no audio is returned. The
296
+ * timeout bounds the entire synthesis, however many chunks it spans, so the
297
+ * caller-visible deadline matches the other TTS providers.
298
+ */
299
+ export async function synthesizeWithEdgeTts(text, options) {
300
+ const voice = options.voice || EDGE_DEFAULT_VOICE;
301
+ const rate = options.rate ?? "+0%";
302
+ const volume = options.volume ?? "+0%";
303
+ const pitch = options.pitch ?? "+0Hz";
304
+ const timeoutMs = options.timeoutMs ?? SYNTHESIS_TIMEOUT_MS;
305
+ const cleaned = removeIncompatibleCharacters(text);
306
+ const escaped = escapeXml(cleaned);
307
+ const chunks = splitTextByByteLength(escaped, MAX_CHUNK_BYTES);
308
+ logger.debug(`[EdgeTTS] Synthesizing: voice=${voice}, chunks=${chunks.length}, chars=${text.length}`);
309
+ const params = {
310
+ voice,
311
+ rate,
312
+ volume,
313
+ pitch,
314
+ deadline: Date.now() + timeoutMs,
315
+ timeoutMs,
316
+ };
317
+ const buffers = [];
318
+ for (const chunk of chunks) {
319
+ buffers.push(await synthesizeChunk(chunk, params));
320
+ }
321
+ return Buffer.concat(buffers);
322
+ }
@@ -79,12 +79,61 @@ const APPLICATION_TEXT_MIME_TYPES = new Set([
79
79
  "application/x-yaml",
80
80
  "application/sql",
81
81
  ]);
82
- export function isTextMimeType(mimeType) {
82
+ const TEXT_FILE_EXTENSIONS = new Set([
83
+ "svelte",
84
+ "vue",
85
+ "ts",
86
+ "tsx",
87
+ "jsx",
88
+ "mjs",
89
+ "cjs",
90
+ "go",
91
+ "rs",
92
+ "rb",
93
+ "py",
94
+ "java",
95
+ "c",
96
+ "cpp",
97
+ "h",
98
+ "hpp",
99
+ "cs",
100
+ "swift",
101
+ "kt",
102
+ "kts",
103
+ "sh",
104
+ "bash",
105
+ "yaml",
106
+ "yml",
107
+ "toml",
108
+ "ini",
109
+ "cfg",
110
+ "md",
111
+ "mdx",
112
+ "css",
113
+ "scss",
114
+ "less",
115
+ "html",
116
+ "htm",
117
+ "graphql",
118
+ "gql",
119
+ "proto",
120
+ "gradle",
121
+ ]);
122
+ export function isTextMimeType(mimeType, filename) {
83
123
  if (!mimeType) {
84
124
  return false;
85
125
  }
86
126
  if (mimeType.startsWith("text/")) {
87
127
  return true;
88
128
  }
89
- return APPLICATION_TEXT_MIME_TYPES.has(mimeType);
129
+ if (APPLICATION_TEXT_MIME_TYPES.has(mimeType)) {
130
+ return true;
131
+ }
132
+ if (filename) {
133
+ const ext = filename.split(".").pop()?.toLowerCase();
134
+ if (ext && TEXT_FILE_EXTENSIONS.has(ext)) {
135
+ return true;
136
+ }
137
+ }
138
+ return false;
90
139
  }
@@ -1,19 +1,35 @@
1
1
  import { config } from "../../config.js";
2
2
  import { logger } from "../../utils/logger.js";
3
3
  const STT_REQUEST_TIMEOUT_MS = 60_000;
4
+ const AUDIO_FORMAT_BY_EXTENSION = {
5
+ oga: "ogg",
6
+ ogg: "ogg",
7
+ mp3: "mp3",
8
+ wav: "wav",
9
+ m4a: "m4a",
10
+ flac: "flac",
11
+ aac: "aac",
12
+ webm: "webm",
13
+ };
4
14
  /**
5
15
  * Returns true if STT is configured (API URL and API key are set).
6
16
  */
7
17
  export function isSttConfigured() {
8
18
  return Boolean(config.stt.apiUrl && config.stt.apiKey);
9
19
  }
20
+ function getAudioFormat(filename) {
21
+ const extension = (filename.split(".").pop() || "").toLowerCase();
22
+ return AUDIO_FORMAT_BY_EXTENSION[extension] || "ogg";
23
+ }
10
24
  /**
11
- * Transcribes an audio buffer using a Whisper-compatible API (OpenAI / Groq / etc.).
25
+ * Transcribes an audio buffer using a Whisper-compatible API.
12
26
  *
13
- * Sends a multipart/form-data POST to `{STT_API_URL}/audio/transcriptions`.
27
+ * Two request formats are supported via `STT_REQUEST_FORMAT`:
28
+ * - `multipart` (default): standard OpenAI/Groq `multipart/form-data` upload.
29
+ * - `json`: base64 audio in an `input_audio` JSON body (e.g. OpenRouter).
14
30
  *
15
31
  * @param audioBuffer - Raw audio file bytes (ogg, mp3, wav, m4a, webm, etc.)
16
- * @param filename - Original filename with extension (used by the API to detect format)
32
+ * @param filename - Original filename with extension (used to detect format)
17
33
  * @returns Transcribed text
18
34
  * @throws Error if STT is not configured, the request fails, or the response is invalid
19
35
  */
@@ -22,23 +38,44 @@ export async function transcribeAudio(audioBuffer, filename) {
22
38
  throw new Error("STT is not configured: STT_API_URL and STT_API_KEY are required");
23
39
  }
24
40
  const url = `${config.stt.apiUrl}/audio/transcriptions`;
25
- const formData = new FormData();
26
- formData.append("file", new Blob([new Uint8Array(audioBuffer)]), filename);
27
- formData.append("model", config.stt.model);
28
- formData.append("response_format", "json");
29
- if (config.stt.language) {
30
- formData.append("language", config.stt.language);
41
+ const useJsonFormat = config.stt.requestFormat === "json";
42
+ const headers = {
43
+ Authorization: `Bearer ${config.stt.apiKey}`,
44
+ };
45
+ let body;
46
+ if (useJsonFormat) {
47
+ const payload = {
48
+ model: config.stt.model,
49
+ input_audio: {
50
+ data: Buffer.from(audioBuffer).toString("base64"),
51
+ format: getAudioFormat(filename),
52
+ },
53
+ };
54
+ if (config.stt.language) {
55
+ payload.language = config.stt.language;
56
+ }
57
+ headers["Content-Type"] = "application/json";
58
+ body = JSON.stringify(payload);
59
+ logger.debug(`[STT] Sending transcription request (json): url=${url}, model=${config.stt.model}, format=${getAudioFormat(filename)}, size=${audioBuffer.length} bytes`);
60
+ }
61
+ else {
62
+ const formData = new FormData();
63
+ formData.append("file", new Blob([new Uint8Array(audioBuffer)]), filename);
64
+ formData.append("model", config.stt.model);
65
+ formData.append("response_format", "json");
66
+ if (config.stt.language) {
67
+ formData.append("language", config.stt.language);
68
+ }
69
+ body = formData;
70
+ logger.debug(`[STT] Sending transcription request (multipart): url=${url}, model=${config.stt.model}, filename=${filename}, size=${audioBuffer.length} bytes`);
31
71
  }
32
- logger.debug(`[STT] Sending transcription request: url=${url}, model=${config.stt.model}, filename=${filename}, size=${audioBuffer.length} bytes`);
33
72
  const controller = new AbortController();
34
73
  const timeout = setTimeout(() => controller.abort(), STT_REQUEST_TIMEOUT_MS);
35
74
  try {
36
75
  const response = await fetch(url, {
37
76
  method: "POST",
38
- headers: {
39
- Authorization: `Bearer ${config.stt.apiKey}`,
40
- },
41
- body: formData,
77
+ headers,
78
+ body,
42
79
  signal: controller.signal,
43
80
  });
44
81
  if (!response.ok) {
@@ -1,12 +1,16 @@
1
1
  import { config } from "../../config.js";
2
2
  import { logger } from "../../utils/logger.js";
3
3
  import textToSpeech from "@google-cloud/text-to-speech";
4
+ import { synthesizeWithEdgeTts, EDGE_DEFAULT_VOICE } from "./edge-tts.js";
4
5
  const TTS_REQUEST_TIMEOUT_MS = 60_000;
5
6
  const MAX_TTS_INPUT_CHARS = 4_000;
6
7
  export function isTtsConfigured() {
7
8
  if (config.tts.provider === "google") {
8
9
  return Boolean(process.env.GOOGLE_APPLICATION_CREDENTIALS);
9
10
  }
11
+ if (config.tts.provider === "edge") {
12
+ return true;
13
+ }
10
14
  return Boolean(config.tts.apiUrl && config.tts.apiKey);
11
15
  }
12
16
  /**
@@ -141,6 +145,19 @@ async function synthesizeWithElevenLabs(text) {
141
145
  clearTimeout(timeout);
142
146
  }
143
147
  }
148
+ async function synthesizeWithEdge(text) {
149
+ const voice = config.tts.voice || EDGE_DEFAULT_VOICE;
150
+ logger.debug(`[TTS] Edge: voice=${voice}, chars=${text.length}`);
151
+ const buffer = await synthesizeWithEdgeTts(text, {
152
+ voice,
153
+ timeoutMs: TTS_REQUEST_TIMEOUT_MS,
154
+ });
155
+ if (buffer.length === 0) {
156
+ throw new Error("Edge TTS returned an empty audio response");
157
+ }
158
+ logger.debug(`[TTS] Generated Edge speech audio: ${buffer.length} bytes`);
159
+ return { buffer, filename: "assistant-reply.mp3", mimeType: "audio/mpeg" };
160
+ }
144
161
  // --- Public API ---
145
162
  function getNotConfiguredMessage() {
146
163
  if (config.tts.provider === "google") {
@@ -149,6 +166,8 @@ function getNotConfiguredMessage() {
149
166
  if (config.tts.provider === "elevenlabs") {
150
167
  return "TTS is not configured: set TTS_API_URL and TTS_API_KEY for ElevenLabs";
151
168
  }
169
+ // No "edge" branch: isTtsConfigured() is always true for edge, so this
170
+ // function is unreachable for that provider.
152
171
  return "TTS is not configured: set TTS_API_URL and TTS_API_KEY";
153
172
  }
154
173
  export async function synthesizeSpeech(text) {
@@ -167,6 +186,9 @@ export async function synthesizeSpeech(text) {
167
186
  if (config.tts.provider === "elevenlabs") {
168
187
  return await synthesizeWithElevenLabs(input);
169
188
  }
189
+ if (config.tts.provider === "edge") {
190
+ return await synthesizeWithEdge(input);
191
+ }
170
192
  return await synthesizeWithOpenAi(input);
171
193
  }
172
194
  catch (err) {
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { cloneScheduledTask } from "../types/scheduled-task.js";
3
+ import { config } from "../../config.js";
3
4
  import { getRuntimePaths } from "../../runtime/paths.js";
4
5
  import { logger } from "../../utils/logger.js";
5
6
  function cloneScheduledTasks(tasks) {
@@ -170,6 +171,64 @@ export function __resetSettingsForTests() {
170
171
  currentSettings = {};
171
172
  settingsWriteQueue = Promise.resolve();
172
173
  }
174
+ const VALID_TTS_MODES = ["off", "all", "auto"];
175
+ const VALID_STREAMING_MODES = ["edit", "draft"];
176
+ function applyInitialSettingsPreset(preset) {
177
+ const knownKeys = new Set([
178
+ "ttsMode",
179
+ "compactOutputMode",
180
+ "showThinkingContent",
181
+ "showAssistantRunFooter",
182
+ "responseStreamingMode",
183
+ "sendDiffFileAttachments",
184
+ ]);
185
+ for (const [key, value] of Object.entries(preset)) {
186
+ if (!knownKeys.has(key)) {
187
+ throw new Error(`INITIAL_SETTINGS_PRESET: unknown key "${key}". Supported keys: ${[...knownKeys].join(", ")}.`);
188
+ }
189
+ if (key === "ttsMode") {
190
+ if (typeof value !== "string" || !VALID_TTS_MODES.includes(value)) {
191
+ throw new Error(`INITIAL_SETTINGS_PRESET: invalid value for "ttsMode"; expected one of ${VALID_TTS_MODES.join(", ")}.`);
192
+ }
193
+ if (currentSettings.ttsMode === undefined) {
194
+ currentSettings.ttsMode = value;
195
+ }
196
+ }
197
+ else if (key === "responseStreamingMode") {
198
+ if (typeof value !== "string" ||
199
+ !VALID_STREAMING_MODES.includes(value)) {
200
+ throw new Error(`INITIAL_SETTINGS_PRESET: invalid value for "responseStreamingMode"; expected one of ${VALID_STREAMING_MODES.join(", ")}.`);
201
+ }
202
+ if (currentSettings.responseStreamingMode === undefined) {
203
+ currentSettings.responseStreamingMode = value;
204
+ }
205
+ }
206
+ else {
207
+ // Boolean settings: compactOutputMode, showThinkingContent, showAssistantRunFooter, sendDiffFileAttachments
208
+ if (typeof value !== "boolean") {
209
+ throw new Error(`INITIAL_SETTINGS_PRESET: "${key}" must be a boolean.`);
210
+ }
211
+ switch (key) {
212
+ case "compactOutputMode":
213
+ if (currentSettings.compactOutputMode === undefined)
214
+ currentSettings.compactOutputMode = value;
215
+ break;
216
+ case "showThinkingContent":
217
+ if (currentSettings.showThinkingContent === undefined)
218
+ currentSettings.showThinkingContent = value;
219
+ break;
220
+ case "showAssistantRunFooter":
221
+ if (currentSettings.showAssistantRunFooter === undefined)
222
+ currentSettings.showAssistantRunFooter = value;
223
+ break;
224
+ case "sendDiffFileAttachments":
225
+ if (currentSettings.sendDiffFileAttachments === undefined)
226
+ currentSettings.sendDiffFileAttachments = value;
227
+ break;
228
+ }
229
+ }
230
+ }
231
+ }
173
232
  export async function loadSettings() {
174
233
  const loadedSettings = (await readSettingsFile());
175
234
  let requiresRewrite = false;
@@ -192,6 +251,7 @@ export async function loadSettings() {
192
251
  currentSettings.scheduledTasks = cloneScheduledTasks(loadedSettings.scheduledTasks) ?? [];
193
252
  currentSettings.scheduledTaskSessionIgnores =
194
253
  cloneScheduledTaskSessionIgnores(loadedSettings.scheduledTaskSessionIgnores) ?? [];
254
+ applyInitialSettingsPreset(config.bot.initialSettingsPreset);
195
255
  if (requiresRewrite) {
196
256
  void writeSettingsFile(currentSettings);
197
257
  }
@@ -11,7 +11,7 @@ export function formatModelForButton(providerID, modelID) {
11
11
  // If model name is too long, we only truncate the model part
12
12
  const displayModelId = modelID.length > 20 ? `${modelID.substring(0, 17)}...` : modelID;
13
13
  const displayProviderId = providerID.length > 15 ? `${providerID.substring(0, 12)}...` : providerID;
14
- return `🤖 ${displayProviderId}\n${displayModelId}`;
14
+ return `🧠 ${displayProviderId}\n${displayModelId}`;
15
15
  }
16
16
  /**
17
17
  * Format model for display in messages (full format)
@@ -1,6 +1,6 @@
1
1
  import { InlineKeyboard } from "grammy";
2
2
  import { getStoredAgent, resolveProjectAgent } from "../../app/services/agent-selection-service.js";
3
- import { searchModels, selectModel, } from "../../app/services/model-selection-service.js";
3
+ import { searchModels, selectModel } from "../../app/services/model-selection-service.js";
4
4
  import { formatVariantForButton } from "../../app/services/variant-selection-service.js";
5
5
  import { formatModelForDisplay } from "../../app/types/model.js";
6
6
  import { interactionManager } from "../../app/managers/interaction-manager.js";
@@ -10,7 +10,28 @@ import { createMainKeyboard } from "../keyboards/main-reply-keyboard.js";
10
10
  import { keyboardManager } from "../keyboards/keyboard-manager.js";
11
11
  import { pinnedMessageManager } from "../pinned/pinned-message-manager.js";
12
12
  import { clearActiveInlineMenu, ensureActiveInlineMenu } from "../menus/inline-menu.js";
13
- import { MODEL_SEARCH_AGAIN_CALLBACK, MODEL_SEARCH_CALLBACK, MODEL_SEARCH_CANCEL_CALLBACK, } from "../menus/model-selection-menu.js";
13
+ import { MODEL_LIST_CALLBACK_PREFIX, MODEL_SEARCH_AGAIN_CALLBACK, MODEL_SEARCH_CALLBACK, MODEL_SEARCH_CANCEL_CALLBACK, } from "../menus/model-selection-menu.js";
14
+ const MODEL_SEARCH_RESULT_CALLBACK_PREFIX = "model:result:";
15
+ function parseModelItems(value) {
16
+ if (!Array.isArray(value)) {
17
+ return [];
18
+ }
19
+ return value.flatMap((item) => {
20
+ if (typeof item !== "object" ||
21
+ item === null ||
22
+ !("providerID" in item) ||
23
+ !("modelID" in item)) {
24
+ return [];
25
+ }
26
+ const providerID = item.providerID;
27
+ const modelID = item.modelID;
28
+ if (typeof providerID !== "string" || typeof modelID !== "string") {
29
+ return [];
30
+ }
31
+ const variant = "variant" in item && typeof item.variant === "string" ? item.variant : "default";
32
+ return [{ providerID, modelID, variant }];
33
+ });
34
+ }
14
35
  function parseModelSearchMetadata() {
15
36
  const state = interactionManager.getSnapshot();
16
37
  if (!state || state.kind !== "custom") {
@@ -22,7 +43,84 @@ function parseModelSearchMetadata() {
22
43
  return null;
23
44
  }
24
45
  const messageId = typeof state.metadata.messageId === "number" ? state.metadata.messageId : undefined;
25
- return { flow, stage, messageId };
46
+ return { flow, stage, messageId, models: parseModelItems(state.metadata.models) };
47
+ }
48
+ function parseModelListMetadata() {
49
+ const state = interactionManager.getSnapshot();
50
+ if (!state || state.kind !== "inline" || state.metadata.menuKind !== "model") {
51
+ return null;
52
+ }
53
+ const modelLists = state.metadata.modelLists;
54
+ if (typeof modelLists !== "object" || modelLists === null) {
55
+ return null;
56
+ }
57
+ return {
58
+ favorites: parseModelItems("favorites" in modelLists ? modelLists.favorites : undefined),
59
+ recent: parseModelItems("recent" in modelLists ? modelLists.recent : undefined),
60
+ };
61
+ }
62
+ function parseNonNegativeIndex(value) {
63
+ if (!/^\d+$/.test(value)) {
64
+ return null;
65
+ }
66
+ const index = Number.parseInt(value, 10);
67
+ if (!Number.isInteger(index) || index < 0) {
68
+ return null;
69
+ }
70
+ return index;
71
+ }
72
+ function parseCallbackIndex(data, prefix) {
73
+ if (!data.startsWith(prefix)) {
74
+ return null;
75
+ }
76
+ return parseNonNegativeIndex(data.slice(prefix.length));
77
+ }
78
+ function resolveModelListCallback(data) {
79
+ if (!data.startsWith(MODEL_LIST_CALLBACK_PREFIX)) {
80
+ return null;
81
+ }
82
+ const parts = data.slice(MODEL_LIST_CALLBACK_PREFIX.length).split(":");
83
+ if (parts.length !== 2) {
84
+ return null;
85
+ }
86
+ const [kind, indexText] = parts;
87
+ const index = parseNonNegativeIndex(indexText);
88
+ if ((kind !== "favorites" && kind !== "recent") || index === null) {
89
+ return null;
90
+ }
91
+ const lists = parseModelListMetadata();
92
+ if (!lists) {
93
+ return null;
94
+ }
95
+ const model = kind === "favorites" ? lists.favorites[index] : lists.recent[index];
96
+ if (!model) {
97
+ return null;
98
+ }
99
+ return {
100
+ providerID: model.providerID,
101
+ modelID: model.modelID,
102
+ variant: "default",
103
+ };
104
+ }
105
+ function parseLegacyModelCallback(data) {
106
+ const parts = data.split(":");
107
+ if (parts.length < 3) {
108
+ return null;
109
+ }
110
+ const providerID = parts[1];
111
+ const modelID = parts.slice(2).join(":");
112
+ if (!providerID || !modelID) {
113
+ return null;
114
+ }
115
+ return {
116
+ providerID,
117
+ modelID,
118
+ variant: "default",
119
+ };
120
+ }
121
+ function isShortModelCallback(data) {
122
+ return (data.startsWith(MODEL_SEARCH_RESULT_CALLBACK_PREFIX) ||
123
+ data.startsWith(MODEL_LIST_CALLBACK_PREFIX));
26
124
  }
27
125
  /**
28
126
  * Shared logic for applying a model selection and updating UI.
@@ -75,23 +173,17 @@ export async function handleModelSelect(ctx) {
75
173
  }
76
174
  logger.debug(`[ModelHandler] Received callback: ${callbackQuery.data}`);
77
175
  try {
78
- // Parse callback data: "model:providerID:modelID"
79
- const parts = callbackQuery.data.split(":");
80
- if (parts.length < 3) {
176
+ const modelInfo = resolveModelListCallback(callbackQuery.data);
177
+ const shouldUseLegacyFallback = !isShortModelCallback(callbackQuery.data);
178
+ const resolvedModelInfo = modelInfo ?? (shouldUseLegacyFallback ? parseLegacyModelCallback(callbackQuery.data) : null);
179
+ if (!resolvedModelInfo) {
81
180
  logger.error(`[ModelHandler] Invalid callback data format: ${callbackQuery.data}`);
82
181
  clearActiveInlineMenu("model_select_invalid_callback");
83
182
  await ctx.answerCallbackQuery({ text: t("model.change_error_callback") }).catch(() => { });
84
183
  return true;
85
184
  }
86
- const providerID = parts[1];
87
- const modelID = parts.slice(2).join(":"); // Handle model IDs that may contain ":"
88
- const modelInfo = {
89
- providerID,
90
- modelID,
91
- variant: "default",
92
- };
93
185
  clearActiveInlineMenu("model_selected");
94
- await applyModelSelectionAndNotify(ctx, modelInfo);
186
+ await applyModelSelectionAndNotify(ctx, resolvedModelInfo);
95
187
  return true;
96
188
  }
97
189
  catch (err) {
@@ -150,9 +242,9 @@ export async function handleModelSearchTextInput(ctx) {
150
242
  try {
151
243
  const results = await searchModels(text);
152
244
  const keyboard = new InlineKeyboard();
153
- for (const model of results) {
245
+ for (const [index, model] of results.entries()) {
154
246
  const label = `${model.providerID}/${model.modelID}`;
155
- keyboard.text(label, `model:${model.providerID}:${model.modelID}`).row();
247
+ keyboard.text(label, `${MODEL_SEARCH_RESULT_CALLBACK_PREFIX}${index}`).row();
156
248
  }
157
249
  keyboard.row();
158
250
  keyboard.text(t("model.search.search_again"), MODEL_SEARCH_AGAIN_CALLBACK);
@@ -168,6 +260,11 @@ export async function handleModelSearchTextInput(ctx) {
168
260
  flow: "model-search",
169
261
  stage: "results",
170
262
  messageId: sent.message_id,
263
+ models: results.map((model) => ({
264
+ providerID: model.providerID,
265
+ modelID: model.modelID,
266
+ variant: "default",
267
+ })),
171
268
  },
172
269
  });
173
270
  return true;
@@ -225,19 +322,28 @@ export async function handleModelSearchResults(ctx) {
225
322
  logger.debug("[ModelHandler] Model search prompt shown (search again)");
226
323
  return true;
227
324
  }
228
- // Model selection from search results
325
+ const resultIndex = parseCallbackIndex(data, MODEL_SEARCH_RESULT_CALLBACK_PREFIX);
326
+ if (resultIndex !== null) {
327
+ const modelInfo = meta.models[resultIndex];
328
+ if (!modelInfo) {
329
+ await ctx.answerCallbackQuery({ text: t("model.change_error_callback") }).catch(() => { });
330
+ return true;
331
+ }
332
+ interactionManager.clear("model_search_selected");
333
+ await applyModelSelectionAndNotify(ctx, modelInfo);
334
+ return true;
335
+ }
336
+ // Backward compatibility for callbacks from already-rendered search result messages.
229
337
  if (data.startsWith("model:")) {
230
- const parts = data.split(":");
231
- if (parts.length < 3) {
338
+ if (isShortModelCallback(data)) {
339
+ logger.error(`[ModelHandler] Invalid search result callback data: ${data}`);
340
+ await ctx.answerCallbackQuery({ text: t("model.change_error_callback") }).catch(() => { });
341
+ return true;
342
+ }
343
+ const modelInfo = parseLegacyModelCallback(data);
344
+ if (!modelInfo) {
232
345
  return true;
233
346
  }
234
- const providerID = parts[1];
235
- const modelID = parts.slice(2).join(":");
236
- const modelInfo = {
237
- providerID,
238
- modelID,
239
- variant: "default",
240
- };
241
347
  interactionManager.clear("model_search_selected");
242
348
  await applyModelSelectionAndNotify(ctx, modelInfo);
243
349
  return true;
@@ -100,12 +100,31 @@ function formatDateTime(dateIso, timezone) {
100
100
  return dateIso;
101
101
  }
102
102
  }
103
+ const TASK_DETAIL_PROMPT_BYTE_BUDGET = 3400;
104
+ function truncatePromptForDetails(prompt) {
105
+ if (Buffer.byteLength(prompt, "utf-8") <= TASK_DETAIL_PROMPT_BYTE_BUDGET) {
106
+ return prompt;
107
+ }
108
+ const budget = TASK_DETAIL_PROMPT_BYTE_BUDGET - 3;
109
+ let lo = 0;
110
+ let hi = prompt.length;
111
+ while (lo < hi) {
112
+ const mid = (lo + hi + 1) >>> 1;
113
+ if (Buffer.byteLength(prompt.slice(0, mid), "utf-8") <= budget) {
114
+ lo = mid;
115
+ }
116
+ else {
117
+ hi = mid - 1;
118
+ }
119
+ }
120
+ return `${prompt.slice(0, lo)}...`;
121
+ }
103
122
  function formatTaskDetails(task) {
104
123
  const variant = task.model.variant ? ` (${task.model.variant})` : "";
105
124
  const model = `${task.model.providerID}/${task.model.modelID}${variant}`;
106
125
  const cronLine = task.kind === "cron" ? `${t("tasklist.details.cron", { cron: task.cron })}\n` : "";
107
126
  return t("tasklist.details", {
108
- prompt: task.prompt,
127
+ prompt: truncatePromptForDetails(task.prompt),
109
128
  project: `${task.projectWorktree}\n${t("status.line.model", { model })}`,
110
129
  schedule: task.scheduleSummary,
111
130
  cronLine,
@@ -38,7 +38,7 @@ export async function statusCommand(ctx) {
38
38
  message += `${t("status.line.mode", { mode: agentDisplay })}\n`;
39
39
  // Add model information
40
40
  const currentModel = fetchCurrentModel();
41
- const modelDisplay = `🤖 ${currentModel.providerID}/${currentModel.modelID}`;
41
+ const modelDisplay = `🧠 ${currentModel.providerID}/${currentModel.modelID}`;
42
42
  message += `${t("status.line.model", { model: modelDisplay })}\n`;
43
43
  const currentProject = getCurrentProject();
44
44
  if (currentProject) {
@@ -18,7 +18,7 @@ export async function handleDocumentMessage(ctx, deps) {
18
18
  const mimeType = doc.mime_type || "";
19
19
  const filename = doc.file_name || "document";
20
20
  try {
21
- if (isTextMimeType(mimeType)) {
21
+ if (isTextMimeType(mimeType, filename)) {
22
22
  if (!isFileSizeAllowed(doc.file_size, config.files.maxFileSizeKb)) {
23
23
  logger.warn(`[Document] Text file too large: ${filename} (${doc.file_size} bytes > ${config.files.maxFileSizeKb}KB)`);
24
24
  await ctx.reply(t("bot.text_file_too_large", { maxSizeKb: String(config.files.maxFileSizeKb) }));
@@ -137,7 +137,7 @@ export class MediaGroupAttachmentHandler {
137
137
  const document = item.document;
138
138
  const mimeType = document.mime_type || "";
139
139
  const filename = document.file_name || "document";
140
- if (isTextMimeType(mimeType)) {
140
+ if (isTextMimeType(mimeType, filename)) {
141
141
  if (!isFileSizeAllowed(document.file_size, config.files.maxFileSizeKb)) {
142
142
  return { reason: "text_file_too_large" };
143
143
  }
@@ -71,6 +71,7 @@ export async function replyWithInlineMenu(ctx, options) {
71
71
  kind: "inline",
72
72
  expectedInput: "callback",
73
73
  metadata: {
74
+ ...options.metadata,
74
75
  menuKind: options.menuKind,
75
76
  messageId: message.message_id,
76
77
  },
@@ -6,6 +6,10 @@ import { replyWithInlineMenu } from "./inline-menu.js";
6
6
  export const MODEL_SEARCH_CALLBACK = "model:search";
7
7
  export const MODEL_SEARCH_AGAIN_CALLBACK = "model:search:again";
8
8
  export const MODEL_SEARCH_CANCEL_CALLBACK = "model:search:cancel";
9
+ export const MODEL_LIST_CALLBACK_PREFIX = "model:list:";
10
+ export function buildModelListCallback(kind, index) {
11
+ return `${MODEL_LIST_CALLBACK_PREFIX}${kind}:${index}`;
12
+ }
9
13
  function buildModelSelectionMenuText(modelLists) {
10
14
  const lines = [t("model.menu.select"), t("model.menu.favorites_title")];
11
15
  if (modelLists.favorites.length === 0) {
@@ -31,16 +35,16 @@ export async function buildModelSelectionMenu(currentModel, modelLists) {
31
35
  logger.warn("[ModelHandler] No model choices found in favorites/recent");
32
36
  return keyboard;
33
37
  }
34
- const addButton = (model, prefix) => {
38
+ const addButton = (model, prefix, kind, index) => {
35
39
  const isActive = currentModel &&
36
40
  model.providerID === currentModel.providerID &&
37
41
  model.modelID === currentModel.modelID;
38
42
  const label = `${prefix} ${model.providerID}/${model.modelID}`;
39
43
  const labelWithCheck = isActive ? `✅ ${label}` : label;
40
- keyboard.text(labelWithCheck, `model:${model.providerID}:${model.modelID}`).row();
44
+ keyboard.text(labelWithCheck, buildModelListCallback(kind, index)).row();
41
45
  };
42
- favorites.forEach((model) => addButton(model, "⭐"));
43
- recent.forEach((model) => addButton(model, "🕘"));
46
+ favorites.forEach((model, index) => addButton(model, "⭐", "favorites", index));
47
+ recent.forEach((model, index) => addButton(model, "🕘", "recent", index));
44
48
  return keyboard;
45
49
  }
46
50
  /**
@@ -57,6 +61,7 @@ export async function showModelSelectionMenu(ctx) {
57
61
  menuKind: "model",
58
62
  text,
59
63
  keyboard,
64
+ metadata: { modelLists },
60
65
  });
61
66
  }
62
67
  catch (err) {
@@ -1,4 +1,4 @@
1
1
  export const AGENT_MODE_BUTTON_TEXT_PATTERN = /^(📋|🛠️|💬|🔍|📝|📄|📦|🤖)\s.+\s(?:Mode|Agent)$/;
2
- export const MODEL_BUTTON_TEXT_PATTERN = /^🤖\s(?!.*\s(?:Mode|Agent)$)[\s\S]+$/;
2
+ export const MODEL_BUTTON_TEXT_PATTERN = /^🧠\s(?!.*\s(?:Mode|Agent)$)[\s\S]+$/;
3
3
  // Keep support for both legacy "💭" and current "💡" prefix.
4
4
  export const VARIANT_BUTTON_TEXT_PATTERN = /^(💡|💭)\s.+$/;
@@ -26,19 +26,10 @@ export class CompactProgressStreamer {
26
26
  this.editText = editText;
27
27
  }
28
28
  updateActivity(sessionId, activity) {
29
- const normalizedActivity = activity.trim();
30
- if (!sessionId || !normalizedActivity) {
31
- return;
32
- }
33
- const state = this.getOrCreateState(sessionId);
34
- state.latestText = t("progress.compact.activity", {
35
- header: t("progress.compact.working_header"),
36
- activity: normalizedActivity,
37
- });
38
- this.ensureTimer(state);
29
+ this.updateActivityState(sessionId, activity, true);
39
30
  }
40
31
  updateThinking(sessionId) {
41
- this.updateActivity(sessionId, t("progress.compact.thinking"));
32
+ this.updateActivityState(sessionId, t("progress.compact.thinking"), false);
42
33
  }
43
34
  updateResponding(sessionId) {
44
35
  this.updateActivity(sessionId, t("progress.compact.responding"));
@@ -105,6 +96,21 @@ export class CompactProgressStreamer {
105
96
  this.states.set(sessionId, state);
106
97
  return state;
107
98
  }
99
+ updateActivityState(sessionId, activity, createIfMissing) {
100
+ const normalizedActivity = activity.trim();
101
+ if (!sessionId || !normalizedActivity) {
102
+ return;
103
+ }
104
+ const state = createIfMissing ? this.getOrCreateState(sessionId) : this.states.get(sessionId);
105
+ if (!state) {
106
+ return;
107
+ }
108
+ state.latestText = t("progress.compact.activity", {
109
+ header: t("progress.compact.working_header"),
110
+ activity: normalizedActivity,
111
+ });
112
+ this.ensureTimer(state);
113
+ }
108
114
  ensureTimer(state) {
109
115
  if (state.cancelled || state.timer) {
110
116
  return;
package/dist/config.js CHANGED
@@ -50,7 +50,24 @@ function getOptionalMessageFormatModeEnvVar(key, defaultValue) {
50
50
  }
51
51
  return defaultValue;
52
52
  }
53
- const VALID_TTS_PROVIDERS = ["openai", "google", "elevenlabs"];
53
+ export function parseInitialSettingsPreset() {
54
+ const raw = getEnvVar("INITIAL_SETTINGS_PRESET", false).trim();
55
+ if (!raw) {
56
+ return {};
57
+ }
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(raw);
61
+ }
62
+ catch {
63
+ throw new Error("INITIAL_SETTINGS_PRESET contains invalid JSON. Fix or unset the variable.");
64
+ }
65
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
66
+ throw new Error("INITIAL_SETTINGS_PRESET must be a JSON object.");
67
+ }
68
+ return parsed;
69
+ }
70
+ const VALID_TTS_PROVIDERS = ["openai", "google", "elevenlabs", "edge"];
54
71
  function getOptionalTtsProviderEnvVar(key, defaultValue) {
55
72
  const value = getEnvVar(key, false);
56
73
  if (!value) {
@@ -62,6 +79,18 @@ function getOptionalTtsProviderEnvVar(key, defaultValue) {
62
79
  }
63
80
  return defaultValue;
64
81
  }
82
+ const VALID_STT_REQUEST_FORMATS = ["multipart", "json"];
83
+ function getOptionalSttRequestFormatEnvVar(key, defaultValue) {
84
+ const value = getEnvVar(key, false);
85
+ if (!value) {
86
+ return defaultValue;
87
+ }
88
+ const normalized = value.trim().toLowerCase();
89
+ if (VALID_STT_REQUEST_FORMATS.includes(normalized)) {
90
+ return normalized;
91
+ }
92
+ return defaultValue;
93
+ }
65
94
  export function buildTelegramConfig() {
66
95
  const proxyUrl = getEnvVar("TELEGRAM_PROXY_URL", false);
67
96
  // grammY rejects an apiRoot ending with `/`, so normalize once at config
@@ -116,6 +145,7 @@ export const config = {
116
145
  locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
117
146
  trackBackgroundSessions: getOptionalBooleanEnvVar("TRACK_BACKGROUND_SESSIONS", true),
118
147
  messageFormatMode: getOptionalMessageFormatModeEnvVar("MESSAGE_FORMAT_MODE", "markdown"),
148
+ initialSettingsPreset: parseInitialSettingsPreset(),
119
149
  },
120
150
  files: {
121
151
  maxFileSizeKb: parseInt(getEnvVar("CODE_FILE_MAX_SIZE_KB", false) || "100", 10),
@@ -129,6 +159,9 @@ export const config = {
129
159
  model: getEnvVar("STT_MODEL", false) || "whisper-large-v3-turbo",
130
160
  language: getEnvVar("STT_LANGUAGE", false),
131
161
  notePrompt: getEnvVar("STT_NOTE_PROMPT", false),
162
+ // "multipart" (default) = standard OpenAI/Groq Whisper form-data upload.
163
+ // "json" = base64 audio in an `input_audio` JSON body (e.g. OpenRouter).
164
+ requestFormat: getOptionalSttRequestFormatEnvVar("STT_REQUEST_FORMAT", "multipart"),
132
165
  },
133
166
  tts: (() => {
134
167
  const provider = getOptionalTtsProviderEnvVar("TTS_PROVIDER", "openai");
@@ -136,7 +169,9 @@ export const config = {
136
169
  ? "en-US-Studio-O"
137
170
  : provider === "elevenlabs"
138
171
  ? "21m00Tcm4TlvDq8ikWAM"
139
- : "alloy";
172
+ : provider === "edge"
173
+ ? "en-US-EmmaMultilingualNeural"
174
+ : "alloy";
140
175
  const defaultModel = provider === "elevenlabs" ? "eleven_flash_v2_5" : "gpt-4o-mini-tts";
141
176
  return {
142
177
  apiUrl: getEnvVar("TTS_API_URL", false),
@@ -1,4 +1,6 @@
1
1
  import { exec, spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import * as path from "node:path";
2
4
  import { promisify } from "node:util";
3
5
  const execAsync = promisify(exec);
4
6
  const DEFAULT_OPENCODE_PORT = 4096;
@@ -25,13 +27,56 @@ export function resolveLocalOpencodeTarget(apiUrl) {
25
27
  return null;
26
28
  }
27
29
  }
30
+ function resolveWindowsOpencodeExe() {
31
+ const pathEnv = process.env.PATH ?? "";
32
+ const pathEntries = pathEnv.split(path.delimiter).filter(Boolean);
33
+ // First pass: look for opencode.exe directly on PATH.
34
+ // Covers non-npm installations (install script, scoop, choco, manual download, etc.).
35
+ for (const entry of pathEntries) {
36
+ const candidateExe = path.join(entry, "opencode.exe");
37
+ if (existsSync(candidateExe)) {
38
+ return candidateExe;
39
+ }
40
+ }
41
+ // Second pass: look for opencode.cmd (npm global install).
42
+ // Derive the real exe path from the shim location.
43
+ for (const entry of pathEntries) {
44
+ const opencodeCmd = path.join(entry, "opencode.cmd");
45
+ if (!existsSync(opencodeCmd)) {
46
+ continue;
47
+ }
48
+ const candidateExe = path.join(entry, "node_modules", "opencode-ai", "bin", "opencode.exe");
49
+ if (existsSync(candidateExe)) {
50
+ return candidateExe;
51
+ }
52
+ // Found the shim but not the exe where it usually lives. Stop searching.
53
+ break;
54
+ }
55
+ return "";
56
+ }
28
57
  export function createOpencodeServeSpawnCommand(target) {
29
58
  const isWindows = process.platform === "win32";
30
59
  const port = target.port.toString();
60
+ if (isWindows) {
61
+ const resolvedExe = resolveWindowsOpencodeExe();
62
+ if (resolvedExe) {
63
+ return {
64
+ command: resolvedExe,
65
+ args: ["serve", "--port", port],
66
+ windowsHide: true,
67
+ };
68
+ }
69
+ // Safe fallback: works with default npm installs where only opencode.cmd is on PATH.
70
+ return {
71
+ command: "cmd.exe",
72
+ args: ["/c", "opencode", "serve", "--port", port],
73
+ windowsHide: true,
74
+ };
75
+ }
31
76
  return {
32
- command: isWindows ? "cmd.exe" : "opencode",
33
- args: isWindows ? ["/c", "opencode", "serve", "--port", port] : ["serve", "--port", port],
34
- windowsHide: isWindows,
77
+ command: "opencode",
78
+ args: ["serve", "--port", port],
79
+ windowsHide: false,
35
80
  };
36
81
  }
37
82
  export function startLocalOpencodeServer(target) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.22.0",
3
+ "version": "0.22.2",
4
4
  "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -63,11 +63,13 @@
63
63
  "remark-gfm": "^4.0.1",
64
64
  "remark-parse": "^11.0.0",
65
65
  "socks-proxy-agent": "^8.0.5",
66
- "unified": "^11.0.5"
66
+ "unified": "^11.0.5",
67
+ "ws": "^8.21.0"
67
68
  },
68
69
  "devDependencies": {
69
70
  "@types/better-sqlite3": "^7.6.13",
70
71
  "@types/node": "^25.0.8",
72
+ "@types/ws": "^8.18.1",
71
73
  "@typescript-eslint/eslint-plugin": "^8.53.0",
72
74
  "@typescript-eslint/parser": "^8.53.0",
73
75
  "@vitest/coverage-v8": "^3.2.4",