opencode-telegram-mirror 0.3.0 โ†’ 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  A standalone bot that mirrors OpenCode sessions to Telegram topics, enabling collaborative AI-assisted coding conversations in Telegram.
4
4
 
5
+ ## โœจ Features
6
+
7
+ | Feature | Description |
8
+ |---------|-------------|
9
+ | **๐Ÿ“ฑ Real-time Streaming** | Live responses with typing indicators, markdown, code blocks, and inline diffs |
10
+ | **๐ŸŽฏ Interactive Controls** | Buttons for questions, permissions, mode switching, and session control |
11
+ | **๐Ÿ“‹ Slash Commands** | `/interrupt`, `/plan`, `/build`, `/review`, `/rename` for quick actions |
12
+ | **๐Ÿ” Diff Viewer** | Automatic diff generation with syntax highlighting and shareable links |
13
+ | **๐Ÿ“ธ Media Support** | Send images and voice messages (transcribed via Whisper) as prompts |
14
+ | **๐Ÿงต Thread Support** | Telegram forum threads with automatic title sync from OpenCode sessions |
15
+ | **๐Ÿ’พ Session Persistence** | Resume sessions across devices and restarts |
16
+ | **๐Ÿ”„ Multi-instance** | Run multiple mirrors for different sessions/channels |
17
+
5
18
  ## Installation
6
19
 
7
20
  ```bash
@@ -19,18 +32,18 @@ npm install -g opencode-telegram-mirror
19
32
  - Message [@userinfobot](https://t.me/userinfobot)
20
33
  - Copy your chat ID
21
34
 
22
- 3. **Run the mirror**:
23
- ```bash
24
- opencode-telegram-mirror
25
- ```
26
-
27
- 4. **Configure environment variables**:
35
+ 3. **Configure environment variables**:
28
36
  ```bash
29
37
  export TELEGRAM_BOT_TOKEN="your-bot-token"
30
38
  export TELEGRAM_CHAT_ID="your-chat-id"
31
39
  # Optional: export TELEGRAM_THREAD_ID="your-thread-id"
32
40
  ```
33
41
 
42
+ 4. **Run the mirror in your project**:
43
+ ```bash
44
+ opencode-telegram-mirror .
45
+ ```
46
+
34
47
  That's it! Your OpenCode sessions will now be mirrored to Telegram.
35
48
 
36
49
  ## How it works
@@ -138,6 +151,7 @@ opencode-telegram-mirror [directory] [session-id]
138
151
  | `TELEGRAM_UPDATES_URL` | Central updates endpoint for multi-instance deployments | No |
139
152
  | `TELEGRAM_SEND_URL` | Custom Telegram API endpoint (defaults to api.telegram.org) | No |
140
153
  | `OPENCODE_URL` | External OpenCode server URL (if not set, spawns local server) | No |
154
+ | `OPENAI_API_KEY` | OpenAI API key for voice message transcription (Whisper) | No |
141
155
 
142
156
  ### Configuration Files
143
157
 
@@ -165,8 +179,31 @@ Example config file:
165
179
  Send messages in Telegram to interact with OpenCode:
166
180
  - **Text messages**: Sent as prompts to OpenCode
167
181
  - **Photos**: Attached as image files to prompts
182
+ - **Voice messages**: Transcribed via OpenAI Whisper and sent as text prompts
168
183
  - **"x"**: Interrupt the current session
169
- - **"/connect"**: Get the OpenCode server URL
184
+ - **/connect**: Get the OpenCode server URL
185
+ - **/interrupt**: Stop the current operation
186
+ - **/plan**: Switch to plan mode
187
+ - **/build**: Switch to build mode
188
+ - **/review**: Review changes (accepts optional argument: commit, branch, or pr)
189
+ - **/rename `<title>`**: Rename the session and sync to Telegram thread
190
+
191
+ ### Title Sync
192
+
193
+ Session titles are automatically synchronized between OpenCode and Telegram:
194
+ - **On startup**: If resuming an existing session, the thread title syncs from the session
195
+ - **On auto-title**: When OpenCode generates a title, it updates the Telegram thread
196
+ - **On /rename**: Manually set a title that updates both OpenCode and Telegram
197
+
198
+ ### Voice Messages
199
+
200
+ Voice messages are transcribed using OpenAI's Whisper API. To enable:
201
+
202
+ 1. Get an API key from [OpenAI Platform](https://platform.openai.com/api-keys)
203
+ 2. Set `OPENAI_API_KEY` in your environment
204
+ 3. Send voice messages to the bot - they'll be transcribed and sent to OpenCode
205
+
206
+ If `OPENAI_API_KEY` is not set, the bot will respond with setup instructions when a voice message is received.
170
207
 
171
208
  ### Interactive Controls
172
209
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-telegram-mirror",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Standalone bot that mirrors OpenCode sessions to Telegram topics",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
package/src/main.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  getServer,
21
21
  type OpenCodeServer,
22
22
  } from "./opencode"
23
- import { TelegramClient } from "./telegram"
23
+ import { TelegramClient, type TelegramVoice } from "./telegram"
24
24
  import { loadConfig } from "./config"
25
25
  import { createLogger } from "./log"
26
26
  import {
@@ -49,6 +49,11 @@ import {
49
49
  createDiffFromEdit,
50
50
  generateInlineDiffPreview,
51
51
  } from "./diff-service"
52
+ import {
53
+ isVoiceTranscriptionAvailable,
54
+ transcribeVoice,
55
+ getVoiceNotSupportedMessage,
56
+ } from "./voice"
52
57
 
53
58
  const log = createLogger()
54
59
 
@@ -206,12 +211,12 @@ async function main() {
206
211
  botId: botInfo.id,
207
212
  })
208
213
 
209
- // Register bot commands (menu)
210
214
  const commandsResult = await telegram.setMyCommands([
211
215
  { command: "interrupt", description: "Stop the current operation" },
212
216
  { command: "plan", description: "Switch to plan mode" },
213
217
  { command: "build", description: "Switch to build mode" },
214
218
  { command: "review", description: "Review changes [commit|branch|pr]" },
219
+ { command: "rename", description: "Rename the session" },
215
220
  ])
216
221
  if (commandsResult.status === "error") {
217
222
  log("warn", "Failed to set bot commands", { error: commandsResult.error.message })
@@ -221,10 +226,11 @@ async function main() {
221
226
  log("info", "Checking for existing session...")
222
227
  let sessionId: string | null = sessionIdArg || getSessionId(log)
223
228
 
229
+ let initialThreadTitle: string | null = null
224
230
  if (sessionId) {
225
231
  log("info", "Found existing session ID, validating...", { sessionId })
226
232
  const sessionCheck = await server.client.session.get({
227
- path: { id: sessionId },
233
+ sessionID: sessionId,
228
234
  })
229
235
  if (!sessionCheck.data) {
230
236
  log("warn", "Stored session not found on server, will create new", {
@@ -233,6 +239,7 @@ async function main() {
233
239
  sessionId = null
234
240
  } else {
235
241
  log("info", "Session validated successfully", { sessionId })
242
+ initialThreadTitle = sessionCheck.data.title || null
236
243
  }
237
244
  } else {
238
245
  log("info", "No existing session found, will create on first message")
@@ -245,7 +252,7 @@ async function main() {
245
252
  directory,
246
253
  chatId: config.chatId,
247
254
  threadId: config.threadId ?? null,
248
- threadTitle: null,
255
+ threadTitle: initialThreadTitle,
249
256
  updatesUrl: config.updatesUrl || null,
250
257
  botUserId: botInfo.id,
251
258
  sessionId,
@@ -254,6 +261,15 @@ async function main() {
254
261
  sentPartIds: new Set(),
255
262
  }
256
263
 
264
+ if (initialThreadTitle && config.threadId) {
265
+ const renameResult = await telegram.editForumTopic(config.threadId, initialThreadTitle)
266
+ if (renameResult.status === "ok") {
267
+ log("info", "Synced thread title from session", { title: initialThreadTitle })
268
+ } else {
269
+ log("warn", "Failed to sync thread title", { error: renameResult.error.message })
270
+ }
271
+ }
272
+
257
273
  log("info", "Bot state initialized", {
258
274
  directory: state.directory,
259
275
  chatId: state.chatId,
@@ -263,7 +279,6 @@ async function main() {
263
279
  pollSource: state.updatesUrl ? "Cloudflare DO" : "Telegram API",
264
280
  })
265
281
 
266
- // Start polling for updates
267
282
  log("info", "Starting updates poller...")
268
283
  startUpdatesPoller(state)
269
284
 
@@ -357,10 +372,9 @@ async function main() {
357
372
 
358
373
  Do not start implementing until you have clarity on what needs to be done.`
359
374
 
360
- // Create session and send prompt
361
375
  try {
362
376
  const sessionResult = await state.server.client.session.create({
363
- body: { title: `Telegram: ${branchName || "session"}` },
377
+ title: `Telegram: ${branchName || "session"}`,
364
378
  })
365
379
 
366
380
  if (sessionResult.data?.id) {
@@ -368,10 +382,9 @@ Do not start implementing until you have clarity on what needs to be done.`
368
382
  setSessionId(sessionResult.data.id, log)
369
383
  log("info", "Created OpenCode session", { sessionId: state.sessionId })
370
384
 
371
- // Send the initial prompt
372
385
  await state.server.client.session.prompt({
373
- path: { id: state.sessionId },
374
- body: { parts: [{ type: "text", text: prompt }] },
386
+ sessionID: state.sessionId,
387
+ parts: [{ type: "text", text: prompt }],
375
388
  })
376
389
  log("info", "Sent initial prompt to OpenCode")
377
390
  }
@@ -401,6 +414,13 @@ interface TelegramUpdate {
401
414
  width: number
402
415
  height: number
403
416
  }>
417
+ voice?: {
418
+ file_id: string
419
+ file_unique_id: string
420
+ duration: number
421
+ mime_type?: string
422
+ file_size?: number
423
+ }
404
424
  from?: { id: number; username?: string }
405
425
  chat: { id: number }
406
426
  }
@@ -599,7 +619,7 @@ async function handleTelegramMessage(
599
619
  msg: NonNullable<TelegramUpdate["message"]>,
600
620
  ) {
601
621
  const messageText = msg.text || msg.caption
602
- if (!messageText && !msg.photo) return
622
+ if (!messageText && !msg.photo && !msg.voice) return
603
623
 
604
624
  // Ignore all bot messages - context is sent directly via OpenCode API
605
625
  if (msg.from?.id === state.botUserId) {
@@ -619,7 +639,7 @@ async function handleTelegramMessage(
619
639
  if (messageText?.trim().toLowerCase() === "x") {
620
640
  log("info", "Received interrupt command 'x'")
621
641
  if (state.sessionId) {
622
- const abortResult = await state.server.clientV2.session.abort({
642
+ const abortResult = await state.server.client.session.abort({
623
643
  sessionID: state.sessionId,
624
644
  directory: state.directory,
625
645
  })
@@ -665,7 +685,7 @@ async function handleTelegramMessage(
665
685
  if (messageText?.trim() === "/interrupt") {
666
686
  log("info", "Received /interrupt command")
667
687
  if (state.sessionId) {
668
- const abortResult = await state.server.clientV2.session.abort({
688
+ const abortResult = await state.server.client.session.abort({
669
689
  sessionID: state.sessionId,
670
690
  directory: state.directory,
671
691
  })
@@ -684,6 +704,34 @@ async function handleTelegramMessage(
684
704
  return
685
705
  }
686
706
 
707
+ const renameMatch = messageText?.trim().match(/^\/rename(?:\s+(.+))?$/)
708
+ if (renameMatch) {
709
+ const newTitle = renameMatch[1]?.trim()
710
+ if (!newTitle) {
711
+ await state.telegram.sendMessage("Usage: /rename <new title>")
712
+ return
713
+ }
714
+ if (!state.sessionId) {
715
+ await state.telegram.sendMessage("No active session to rename.")
716
+ return
717
+ }
718
+
719
+ const updateResult = await state.server.client.session.update({
720
+ sessionID: state.sessionId,
721
+ title: newTitle,
722
+ })
723
+ if (updateResult.data) {
724
+ state.threadTitle = newTitle
725
+ if (state.threadId) {
726
+ await state.telegram.editForumTopic(state.threadId, newTitle)
727
+ }
728
+ await state.telegram.sendMessage(`Session renamed to: ${newTitle}`)
729
+ } else {
730
+ await state.telegram.sendMessage("Failed to rename session.")
731
+ }
732
+ return
733
+ }
734
+
687
735
  const commandMatch = messageText?.trim().match(/^\/(build|plan|review)(?:\s+(.*))?$/)
688
736
  if (commandMatch) {
689
737
  const [, command, args] = commandMatch
@@ -691,7 +739,7 @@ async function handleTelegramMessage(
691
739
 
692
740
  if (!state.sessionId) {
693
741
  const result = await state.server.client.session.create({
694
- body: { title: "Telegram" },
742
+ title: "Telegram",
695
743
  })
696
744
  if (result.data) {
697
745
  state.sessionId = result.data.id
@@ -704,7 +752,7 @@ async function handleTelegramMessage(
704
752
  }
705
753
  }
706
754
 
707
- state.server.clientV2.session
755
+ state.server.client.session
708
756
  .command({
709
757
  sessionID: state.sessionId,
710
758
  directory: state.directory,
@@ -721,7 +769,7 @@ async function handleTelegramMessage(
721
769
 
722
770
  log("info", "Received message", {
723
771
  from: msg.from?.username,
724
- preview: messageText?.slice(0, 50) ?? "[photo]",
772
+ preview: messageText?.slice(0, 50) ?? (msg.voice ? "[voice]" : "[photo]"),
725
773
  })
726
774
 
727
775
  // Check for freetext answer
@@ -737,7 +785,7 @@ async function handleTelegramMessage(
737
785
  })
738
786
 
739
787
  if (result) {
740
- await state.server.clientV2.question.reply({
788
+ await state.server.client.question.reply({
741
789
  requestID: result.requestId,
742
790
  answers: result.answers,
743
791
  })
@@ -748,23 +796,22 @@ async function handleTelegramMessage(
748
796
  // Cancel pending questions/permissions
749
797
  const cancelledQ = cancelPendingQuestion(msg.chat.id, threadId)
750
798
  if (cancelledQ) {
751
- await state.server.clientV2.question.reject({
799
+ await state.server.client.question.reject({
752
800
  requestID: cancelledQ.requestId,
753
801
  })
754
802
  }
755
803
 
756
804
  const cancelledP = cancelPendingPermission(msg.chat.id, threadId)
757
805
  if (cancelledP) {
758
- await state.server.clientV2.permission.reply({
806
+ await state.server.client.permission.reply({
759
807
  requestID: cancelledP.requestId,
760
808
  reply: "reject",
761
809
  })
762
810
  }
763
811
 
764
- // Create session if needed
765
812
  if (!state.sessionId) {
766
813
  const result = await state.server.client.session.create({
767
- body: { title: "Telegram" },
814
+ title: "Telegram",
768
815
  })
769
816
 
770
817
  if (result.data) {
@@ -804,6 +851,51 @@ async function handleTelegramMessage(
804
851
  }
805
852
  }
806
853
 
854
+ if (msg.voice) {
855
+ if (!isVoiceTranscriptionAvailable()) {
856
+ await state.telegram.sendMessage(getVoiceNotSupportedMessage())
857
+ return
858
+ }
859
+
860
+ log("info", "Processing voice message", {
861
+ duration: msg.voice.duration,
862
+ fileId: msg.voice.file_id,
863
+ })
864
+
865
+ const fileUrlResult = await state.telegram.getFileUrl(msg.voice.file_id)
866
+ if (fileUrlResult.status === "error") {
867
+ log("error", "Failed to get voice file URL", {
868
+ error: fileUrlResult.error.message,
869
+ })
870
+ await state.telegram.sendMessage("Failed to download voice message.")
871
+ return
872
+ }
873
+
874
+ const audioResponse = await fetch(fileUrlResult.value)
875
+ if (!audioResponse.ok) {
876
+ log("error", "Failed to download voice file", { status: audioResponse.status })
877
+ await state.telegram.sendMessage("Failed to download voice message.")
878
+ return
879
+ }
880
+
881
+ const audioBuffer = await audioResponse.arrayBuffer()
882
+ const transcriptionResult = await transcribeVoice(audioBuffer, log)
883
+
884
+ if (transcriptionResult.status === "error") {
885
+ log("error", "Voice transcription failed", {
886
+ error: transcriptionResult.error.message,
887
+ })
888
+ await state.telegram.sendMessage(
889
+ `Failed to transcribe voice message: ${transcriptionResult.error.message}`
890
+ )
891
+ return
892
+ }
893
+
894
+ const transcribedText = transcriptionResult.value
895
+ log("info", "Voice transcribed", { preview: transcribedText.slice(0, 50) })
896
+ parts.push({ type: "text", text: transcribedText })
897
+ }
898
+
807
899
  if (messageText) {
808
900
  parts.push({ type: "text", text: messageText })
809
901
  }
@@ -811,7 +903,7 @@ async function handleTelegramMessage(
811
903
  if (parts.length === 0) return
812
904
 
813
905
  // Send to OpenCode
814
- state.server.clientV2.session
906
+ state.server.client.session
815
907
  .prompt({
816
908
  sessionID: state.sessionId,
817
909
  directory: state.directory,
@@ -846,7 +938,7 @@ async function handleTelegramCallback(
846
938
  if (questionResult) {
847
939
  if ("awaitingFreetext" in questionResult) return
848
940
 
849
- await state.server.clientV2.question.reply({
941
+ await state.server.client.question.reply({
850
942
  requestID: questionResult.requestId,
851
943
  answers: questionResult.answers,
852
944
  })
@@ -860,7 +952,7 @@ async function handleTelegramCallback(
860
952
  })
861
953
 
862
954
  if (permResult) {
863
- await state.server.clientV2.permission.reply({
955
+ await state.server.client.permission.reply({
864
956
  requestID: permResult.requestId,
865
957
  reply: permResult.reply,
866
958
  })
@@ -888,7 +980,7 @@ async function subscribeToEvents(state: BotState) {
888
980
  log("info", "Subscribing to OpenCode events")
889
981
 
890
982
  try {
891
- const eventsResult = await state.server.clientV2.event.subscribe(
983
+ const eventsResult = await state.server.client.event.subscribe(
892
984
  { directory: state.directory },
893
985
  {}
894
986
  )
package/src/opencode.ts CHANGED
@@ -10,10 +10,6 @@ import {
10
10
  createOpencodeClient,
11
11
  type OpencodeClient,
12
12
  type Config,
13
- } from "@opencode-ai/sdk"
14
- import {
15
- createOpencodeClient as createOpencodeClientV2,
16
- type OpencodeClient as OpencodeClientV2,
17
13
  } from "@opencode-ai/sdk/v2"
18
14
  import { Result, TaggedError } from "better-result"
19
15
  import { createLogger } from "./log"
@@ -23,7 +19,6 @@ const log = createLogger()
23
19
  export interface OpenCodeServer {
24
20
  process: ChildProcess | null // null when connecting to external server
25
21
  client: OpencodeClient
26
- clientV2: OpencodeClientV2
27
22
  port: number
28
23
  directory: string
29
24
  baseUrl: string
@@ -114,6 +109,22 @@ async function waitForServer(
114
109
  )
115
110
  }
116
111
 
112
+ /**
113
+ * Build auth headers for OpenCode server if credentials are configured.
114
+ * Uses OPENCODE_SERVER_USERNAME and OPENCODE_SERVER_PASSWORD env vars.
115
+ * If only password is set, username defaults to "opencode".
116
+ */
117
+ function getAuthHeaders(): Record<string, string> {
118
+ const password = process.env.OPENCODE_SERVER_PASSWORD
119
+ if (!password) {
120
+ return {}
121
+ }
122
+
123
+ const username = process.env.OPENCODE_SERVER_USERNAME || "opencode"
124
+ const credentials = btoa(`${username}:${password}`)
125
+ return { Authorization: `Basic ${credentials}` }
126
+ }
127
+
117
128
  /**
118
129
  * Connect to an already-running OpenCode server
119
130
  */
@@ -141,6 +152,14 @@ export async function connectToServer(
141
152
 
142
153
  log("info", "External server ready", { baseUrl })
143
154
 
155
+ const authHeaders = getAuthHeaders()
156
+ const hasAuth = Object.keys(authHeaders).length > 0
157
+ if (hasAuth) {
158
+ log("info", "Using basic auth for OpenCode server", {
159
+ username: process.env.OPENCODE_SERVER_USERNAME || "opencode",
160
+ })
161
+ }
162
+
144
163
  const fetchWithTimeout = (request: Request) =>
145
164
  fetch(request, {
146
165
  // @ts-ignore - bun supports timeout
@@ -148,19 +167,14 @@ export async function connectToServer(
148
167
  })
149
168
 
150
169
  const client = createOpencodeClient({
151
- baseUrl,
152
- fetch: fetchWithTimeout,
153
- })
154
-
155
- const clientV2 = createOpencodeClientV2({
156
170
  baseUrl,
157
171
  fetch: fetchWithTimeout as typeof fetch,
172
+ headers: authHeaders,
158
173
  })
159
174
 
160
175
  server = {
161
176
  process: null, // No process - external server
162
177
  client,
163
- clientV2,
164
178
  port,
165
179
  directory,
166
180
  baseUrl,
@@ -261,11 +275,6 @@ export async function startServer(
261
275
  })
262
276
 
263
277
  const client = createOpencodeClient({
264
- baseUrl,
265
- fetch: fetchWithTimeout,
266
- })
267
-
268
- const clientV2 = createOpencodeClientV2({
269
278
  baseUrl,
270
279
  fetch: fetchWithTimeout as typeof fetch,
271
280
  })
@@ -273,7 +282,6 @@ export async function startServer(
273
282
  server = {
274
283
  process: serverProcess,
275
284
  client,
276
- clientV2,
277
285
  port,
278
286
  directory,
279
287
  baseUrl,
package/src/telegram.ts CHANGED
@@ -21,6 +21,14 @@ export interface TelegramPhotoSize {
21
21
  file_size?: number
22
22
  }
23
23
 
24
+ export interface TelegramVoice {
25
+ file_id: string
26
+ file_unique_id: string
27
+ duration: number
28
+ mime_type?: string
29
+ file_size?: number
30
+ }
31
+
24
32
  export interface TelegramMessage {
25
33
  message_id: number
26
34
  from?: {
@@ -38,6 +46,7 @@ export interface TelegramMessage {
38
46
  text?: string
39
47
  caption?: string
40
48
  photo?: TelegramPhotoSize[]
49
+ voice?: TelegramVoice
41
50
  reply_to_message?: TelegramMessage
42
51
  }
43
52
 
package/src/voice.ts ADDED
@@ -0,0 +1,81 @@
1
+ import { Result, TaggedError } from "better-result"
2
+ import type { LogFn } from "./log"
3
+
4
+ export class VoiceTranscriptionError extends TaggedError("VoiceTranscriptionError")<{
5
+ message: string
6
+ cause?: unknown
7
+ }>() {}
8
+
9
+ export class NoApiKeyError extends TaggedError("NoApiKeyError")<{
10
+ message: string
11
+ }>() {
12
+ constructor() {
13
+ super({ message: "No OPENAI_API_KEY set" })
14
+ }
15
+ }
16
+
17
+ export type TranscriptionResult = Result<string, VoiceTranscriptionError | NoApiKeyError>
18
+
19
+ export function isVoiceTranscriptionAvailable(): boolean {
20
+ return !!process.env.OPENAI_API_KEY
21
+ }
22
+
23
+ export async function transcribeVoice(
24
+ audioBuffer: ArrayBuffer,
25
+ log?: LogFn
26
+ ): Promise<TranscriptionResult> {
27
+ const apiKey = process.env.OPENAI_API_KEY
28
+
29
+ if (!apiKey) {
30
+ return Result.err(new NoApiKeyError())
31
+ }
32
+
33
+ log?.("debug", "Transcribing voice message", { size: audioBuffer.byteLength })
34
+
35
+ try {
36
+ const formData = new FormData()
37
+ const blob = new Blob([audioBuffer], { type: "audio/ogg" })
38
+ formData.append("file", blob, "voice.ogg")
39
+ formData.append("model", "whisper-1")
40
+
41
+ const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
42
+ method: "POST",
43
+ headers: {
44
+ Authorization: `Bearer ${apiKey}`,
45
+ },
46
+ body: formData,
47
+ })
48
+
49
+ if (!response.ok) {
50
+ const errorText = await response.text()
51
+ log?.("error", "Whisper API error", { status: response.status, error: errorText })
52
+ return Result.err(
53
+ new VoiceTranscriptionError({
54
+ message: `Whisper API error: ${response.status} - ${errorText}`,
55
+ })
56
+ )
57
+ }
58
+
59
+ const data = (await response.json()) as { text: string }
60
+ log?.("info", "Voice transcription complete", { textLength: data.text.length })
61
+
62
+ return Result.ok(data.text)
63
+ } catch (error) {
64
+ log?.("error", "Voice transcription failed", { error: String(error) })
65
+ return Result.err(
66
+ new VoiceTranscriptionError({
67
+ message: `Transcription failed: ${String(error)}`,
68
+ cause: error,
69
+ })
70
+ )
71
+ }
72
+ }
73
+
74
+ export function getVoiceNotSupportedMessage(): string {
75
+ return `Cannot transcribe voice message - no OPENAI_API_KEY set.
76
+
77
+ To enable voice message support:
78
+ 1. Get an API key from https://platform.openai.com/api-keys
79
+ 2. Add OPENAI_API_KEY to opencode-telegram-mirror's environment
80
+ 3. Restart the bot and try again`
81
+ }