@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 +24 -1
- package/README.md +9 -0
- package/dist/app/formatters/assistant-run-footer-formatter.js +1 -1
- package/dist/app/services/edge-tts.js +322 -0
- package/dist/app/services/file-download-service.js +51 -2
- package/dist/app/services/stt-service.js +51 -14
- package/dist/app/services/tts-service.js +22 -0
- package/dist/app/stores/settings-store.js +60 -0
- package/dist/app/types/model.js +1 -1
- package/dist/bot/callbacks/model-selection-callback-handler.js +132 -26
- package/dist/bot/callbacks/scheduled-task-callback-handler.js +20 -1
- package/dist/bot/commands/status-command.js +1 -1
- package/dist/bot/handlers/document-handler.js +1 -1
- package/dist/bot/handlers/media-group-handler.js +1 -1
- package/dist/bot/menus/inline-menu.js +1 -0
- package/dist/bot/menus/model-selection-menu.js +9 -4
- package/dist/bot/message-patterns.js +1 -1
- package/dist/bot/streaming/compact-progress-streamer.js +17 -11
- package/dist/config.js +37 -2
- package/dist/opencode/process.js +48 -3
- package/package.json +4 -2
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 "
|
|
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} ·
|
|
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, "&")
|
|
72
|
+
.replace(/</g, "<")
|
|
73
|
+
.replace(/>/g, ">");
|
|
74
|
+
}
|
|
75
|
+
/** Escapes a value for interpolation into a single-quoted XML attribute. */
|
|
76
|
+
function escapeXmlAttribute(value) {
|
|
77
|
+
return escapeXml(value).replace(/'/g, "'").replace(/"/g, """);
|
|
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 (&). */
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
25
|
+
* Transcribes an audio buffer using a Whisper-compatible API.
|
|
12
26
|
*
|
|
13
|
-
*
|
|
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
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/app/types/model.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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 =
|
|
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
|
}
|
|
@@ -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,
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
: "
|
|
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),
|
package/dist/opencode/process.js
CHANGED
|
@@ -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:
|
|
33
|
-
args:
|
|
34
|
-
windowsHide:
|
|
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.
|
|
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",
|