@grinev/opencode-telegram-bot 0.22.1 → 0.22.3
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 +39 -1
- package/README.md +28 -2
- package/dist/app/bootstrap/start-bot-app.js +17 -0
- package/dist/app/formatters/assistant-run-footer-formatter.js +1 -1
- package/dist/app/managers/permission-manager.js +93 -2
- package/dist/app/managers/summary-aggregation-manager.js +27 -3
- package/dist/app/services/document-extractor-service.js +47 -0
- package/dist/app/services/edge-tts.js +322 -0
- 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/permission-callback-handler.js +44 -11
- package/dist/bot/commands/status-command.js +1 -1
- package/dist/bot/handlers/document-handler.js +43 -6
- package/dist/bot/handlers/media-group-handler.js +2 -0
- package/dist/bot/handlers/message-merger.js +65 -0
- package/dist/bot/handlers/photo-handler.js +2 -0
- package/dist/bot/handlers/voice-handler.js +2 -0
- package/dist/bot/menus/inline-menu.js +1 -0
- package/dist/bot/menus/model-selection-menu.js +9 -4
- package/dist/bot/menus/permission-menu.js +34 -3
- package/dist/bot/message-patterns.js +1 -1
- package/dist/bot/pinned/pinned-message-manager.js +8 -6
- package/dist/bot/routers/command-router.js +7 -0
- package/dist/bot/routers/message-router.js +4 -3
- package/dist/bot/services/event-subscription-service.js +22 -3
- package/dist/config.js +56 -2
- package/dist/i18n/ar.js +3 -1
- package/dist/i18n/de.js +3 -1
- package/dist/i18n/en.js +3 -1
- package/dist/i18n/es.js +3 -1
- package/dist/i18n/fr.js +3 -1
- package/dist/i18n/ru.js +3 -1
- package/dist/i18n/zh.js +3 -1
- package/dist/opencode/events.js +8 -1
- package/dist/opencode/process.js +10 -2
- package/dist/runtime/bootstrap.js +7 -4
- package/dist/runtime/service/manager.js +58 -2
- package/package.json +4 -2
|
@@ -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
|
+
}
|
|
@@ -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)
|