opencode-telegram-mirror 0.3.0 โ†’ 0.4.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/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,19 +32,19 @@ 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
 
34
- That's it! Your OpenCode sessions will now be mirrored to Telegram.
42
+ 4. **Run the mirror in your project**:
43
+ ```bash
44
+ opencode-telegram-mirror .
45
+ ```
46
+
47
+ That's it! Your OpenCode session will now be mirrored to Telegram.
35
48
 
36
49
  ## How it works
37
50
 
@@ -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.2",
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
 
@@ -93,6 +98,10 @@ interface BotState {
93
98
  assistantMessageIds: Set<string>;
94
99
  pendingParts: Map<string, Part[]>;
95
100
  sentPartIds: Set<string>;
101
+ typingIndicators: Map<
102
+ string,
103
+ { stop: () => void; timeout: ReturnType<typeof setTimeout> | null; mode: "idle" | "tool" }
104
+ >;
96
105
  }
97
106
 
98
107
  async function main() {
@@ -206,12 +215,12 @@ async function main() {
206
215
  botId: botInfo.id,
207
216
  })
208
217
 
209
- // Register bot commands (menu)
210
218
  const commandsResult = await telegram.setMyCommands([
211
219
  { command: "interrupt", description: "Stop the current operation" },
212
220
  { command: "plan", description: "Switch to plan mode" },
213
221
  { command: "build", description: "Switch to build mode" },
214
222
  { command: "review", description: "Review changes [commit|branch|pr]" },
223
+ { command: "rename", description: "Rename the session" },
215
224
  ])
216
225
  if (commandsResult.status === "error") {
217
226
  log("warn", "Failed to set bot commands", { error: commandsResult.error.message })
@@ -221,10 +230,11 @@ async function main() {
221
230
  log("info", "Checking for existing session...")
222
231
  let sessionId: string | null = sessionIdArg || getSessionId(log)
223
232
 
233
+ let initialThreadTitle: string | null = null
224
234
  if (sessionId) {
225
235
  log("info", "Found existing session ID, validating...", { sessionId })
226
236
  const sessionCheck = await server.client.session.get({
227
- path: { id: sessionId },
237
+ sessionID: sessionId,
228
238
  })
229
239
  if (!sessionCheck.data) {
230
240
  log("warn", "Stored session not found on server, will create new", {
@@ -233,6 +243,7 @@ async function main() {
233
243
  sessionId = null
234
244
  } else {
235
245
  log("info", "Session validated successfully", { sessionId })
246
+ initialThreadTitle = sessionCheck.data.title || null
236
247
  }
237
248
  } else {
238
249
  log("info", "No existing session found, will create on first message")
@@ -245,13 +256,23 @@ async function main() {
245
256
  directory,
246
257
  chatId: config.chatId,
247
258
  threadId: config.threadId ?? null,
248
- threadTitle: null,
259
+ threadTitle: initialThreadTitle,
249
260
  updatesUrl: config.updatesUrl || null,
250
261
  botUserId: botInfo.id,
251
262
  sessionId,
252
263
  assistantMessageIds: new Set(),
253
264
  pendingParts: new Map(),
254
265
  sentPartIds: new Set(),
266
+ typingIndicators: new Map(),
267
+ }
268
+
269
+ if (initialThreadTitle && config.threadId) {
270
+ const renameResult = await telegram.editForumTopic(config.threadId, initialThreadTitle)
271
+ if (renameResult.status === "ok") {
272
+ log("info", "Synced thread title from session", { title: initialThreadTitle })
273
+ } else {
274
+ log("warn", "Failed to sync thread title", { error: renameResult.error.message })
275
+ }
255
276
  }
256
277
 
257
278
  log("info", "Bot state initialized", {
@@ -263,7 +284,6 @@ async function main() {
263
284
  pollSource: state.updatesUrl ? "Cloudflare DO" : "Telegram API",
264
285
  })
265
286
 
266
- // Start polling for updates
267
287
  log("info", "Starting updates poller...")
268
288
  startUpdatesPoller(state)
269
289
 
@@ -357,10 +377,9 @@ async function main() {
357
377
 
358
378
  Do not start implementing until you have clarity on what needs to be done.`
359
379
 
360
- // Create session and send prompt
361
380
  try {
362
381
  const sessionResult = await state.server.client.session.create({
363
- body: { title: `Telegram: ${branchName || "session"}` },
382
+ title: `Telegram: ${branchName || "session"}`,
364
383
  })
365
384
 
366
385
  if (sessionResult.data?.id) {
@@ -368,10 +387,9 @@ Do not start implementing until you have clarity on what needs to be done.`
368
387
  setSessionId(sessionResult.data.id, log)
369
388
  log("info", "Created OpenCode session", { sessionId: state.sessionId })
370
389
 
371
- // Send the initial prompt
372
390
  await state.server.client.session.prompt({
373
- path: { id: state.sessionId },
374
- body: { parts: [{ type: "text", text: prompt }] },
391
+ sessionID: state.sessionId,
392
+ parts: [{ type: "text", text: prompt }],
375
393
  })
376
394
  log("info", "Sent initial prompt to OpenCode")
377
395
  }
@@ -401,6 +419,13 @@ interface TelegramUpdate {
401
419
  width: number
402
420
  height: number
403
421
  }>
422
+ voice?: {
423
+ file_id: string
424
+ file_unique_id: string
425
+ duration: number
426
+ mime_type?: string
427
+ file_size?: number
428
+ }
404
429
  from?: { id: number; username?: string }
405
430
  chat: { id: number }
406
431
  }
@@ -599,7 +624,7 @@ async function handleTelegramMessage(
599
624
  msg: NonNullable<TelegramUpdate["message"]>,
600
625
  ) {
601
626
  const messageText = msg.text || msg.caption
602
- if (!messageText && !msg.photo) return
627
+ if (!messageText && !msg.photo && !msg.voice) return
603
628
 
604
629
  // Ignore all bot messages - context is sent directly via OpenCode API
605
630
  if (msg.from?.id === state.botUserId) {
@@ -619,12 +644,12 @@ async function handleTelegramMessage(
619
644
  if (messageText?.trim().toLowerCase() === "x") {
620
645
  log("info", "Received interrupt command 'x'")
621
646
  if (state.sessionId) {
622
- const abortResult = await state.server.clientV2.session.abort({
647
+ const abortResult = await state.server.client.session.abort({
623
648
  sessionID: state.sessionId,
624
649
  directory: state.directory,
625
650
  })
626
651
  if (abortResult.data) {
627
- await state.telegram.sendMessage("Interrupted.")
652
+ log("info", "Abort request sent", { sessionId: state.sessionId })
628
653
  } else {
629
654
  log("error", "Failed to abort session", {
630
655
  sessionId: state.sessionId,
@@ -665,12 +690,12 @@ async function handleTelegramMessage(
665
690
  if (messageText?.trim() === "/interrupt") {
666
691
  log("info", "Received /interrupt command")
667
692
  if (state.sessionId) {
668
- const abortResult = await state.server.clientV2.session.abort({
693
+ const abortResult = await state.server.client.session.abort({
669
694
  sessionID: state.sessionId,
670
695
  directory: state.directory,
671
696
  })
672
697
  if (abortResult.data) {
673
- await state.telegram.sendMessage("Interrupted.")
698
+ log("info", "Abort request sent", { sessionId: state.sessionId })
674
699
  } else {
675
700
  log("error", "Failed to abort session", {
676
701
  sessionId: state.sessionId,
@@ -684,6 +709,34 @@ async function handleTelegramMessage(
684
709
  return
685
710
  }
686
711
 
712
+ const renameMatch = messageText?.trim().match(/^\/rename(?:\s+(.+))?$/)
713
+ if (renameMatch) {
714
+ const newTitle = renameMatch[1]?.trim()
715
+ if (!newTitle) {
716
+ await state.telegram.sendMessage("Usage: /rename <new title>")
717
+ return
718
+ }
719
+ if (!state.sessionId) {
720
+ await state.telegram.sendMessage("No active session to rename.")
721
+ return
722
+ }
723
+
724
+ const updateResult = await state.server.client.session.update({
725
+ sessionID: state.sessionId,
726
+ title: newTitle,
727
+ })
728
+ if (updateResult.data) {
729
+ state.threadTitle = newTitle
730
+ if (state.threadId) {
731
+ await state.telegram.editForumTopic(state.threadId, newTitle)
732
+ }
733
+ await state.telegram.sendMessage(`Session renamed to: ${newTitle}`)
734
+ } else {
735
+ await state.telegram.sendMessage("Failed to rename session.")
736
+ }
737
+ return
738
+ }
739
+
687
740
  const commandMatch = messageText?.trim().match(/^\/(build|plan|review)(?:\s+(.*))?$/)
688
741
  if (commandMatch) {
689
742
  const [, command, args] = commandMatch
@@ -691,7 +744,7 @@ async function handleTelegramMessage(
691
744
 
692
745
  if (!state.sessionId) {
693
746
  const result = await state.server.client.session.create({
694
- body: { title: "Telegram" },
747
+ title: "Telegram",
695
748
  })
696
749
  if (result.data) {
697
750
  state.sessionId = result.data.id
@@ -704,7 +757,7 @@ async function handleTelegramMessage(
704
757
  }
705
758
  }
706
759
 
707
- state.server.clientV2.session
760
+ state.server.client.session
708
761
  .command({
709
762
  sessionID: state.sessionId,
710
763
  directory: state.directory,
@@ -721,7 +774,7 @@ async function handleTelegramMessage(
721
774
 
722
775
  log("info", "Received message", {
723
776
  from: msg.from?.username,
724
- preview: messageText?.slice(0, 50) ?? "[photo]",
777
+ preview: messageText?.slice(0, 50) ?? (msg.voice ? "[voice]" : "[photo]"),
725
778
  })
726
779
 
727
780
  // Check for freetext answer
@@ -737,7 +790,7 @@ async function handleTelegramMessage(
737
790
  })
738
791
 
739
792
  if (result) {
740
- await state.server.clientV2.question.reply({
793
+ await state.server.client.question.reply({
741
794
  requestID: result.requestId,
742
795
  answers: result.answers,
743
796
  })
@@ -748,23 +801,22 @@ async function handleTelegramMessage(
748
801
  // Cancel pending questions/permissions
749
802
  const cancelledQ = cancelPendingQuestion(msg.chat.id, threadId)
750
803
  if (cancelledQ) {
751
- await state.server.clientV2.question.reject({
804
+ await state.server.client.question.reject({
752
805
  requestID: cancelledQ.requestId,
753
806
  })
754
807
  }
755
808
 
756
809
  const cancelledP = cancelPendingPermission(msg.chat.id, threadId)
757
810
  if (cancelledP) {
758
- await state.server.clientV2.permission.reply({
811
+ await state.server.client.permission.reply({
759
812
  requestID: cancelledP.requestId,
760
813
  reply: "reject",
761
814
  })
762
815
  }
763
816
 
764
- // Create session if needed
765
817
  if (!state.sessionId) {
766
818
  const result = await state.server.client.session.create({
767
- body: { title: "Telegram" },
819
+ title: "Telegram",
768
820
  })
769
821
 
770
822
  if (result.data) {
@@ -804,6 +856,51 @@ async function handleTelegramMessage(
804
856
  }
805
857
  }
806
858
 
859
+ if (msg.voice) {
860
+ if (!isVoiceTranscriptionAvailable()) {
861
+ await state.telegram.sendMessage(getVoiceNotSupportedMessage())
862
+ return
863
+ }
864
+
865
+ log("info", "Processing voice message", {
866
+ duration: msg.voice.duration,
867
+ fileId: msg.voice.file_id,
868
+ })
869
+
870
+ const fileUrlResult = await state.telegram.getFileUrl(msg.voice.file_id)
871
+ if (fileUrlResult.status === "error") {
872
+ log("error", "Failed to get voice file URL", {
873
+ error: fileUrlResult.error.message,
874
+ })
875
+ await state.telegram.sendMessage("Failed to download voice message.")
876
+ return
877
+ }
878
+
879
+ const audioResponse = await fetch(fileUrlResult.value)
880
+ if (!audioResponse.ok) {
881
+ log("error", "Failed to download voice file", { status: audioResponse.status })
882
+ await state.telegram.sendMessage("Failed to download voice message.")
883
+ return
884
+ }
885
+
886
+ const audioBuffer = await audioResponse.arrayBuffer()
887
+ const transcriptionResult = await transcribeVoice(audioBuffer, log)
888
+
889
+ if (transcriptionResult.status === "error") {
890
+ log("error", "Voice transcription failed", {
891
+ error: transcriptionResult.error.message,
892
+ })
893
+ await state.telegram.sendMessage(
894
+ `Failed to transcribe voice message: ${transcriptionResult.error.message}`
895
+ )
896
+ return
897
+ }
898
+
899
+ const transcribedText = transcriptionResult.value
900
+ log("info", "Voice transcribed", { preview: transcribedText.slice(0, 50) })
901
+ parts.push({ type: "text", text: transcribedText })
902
+ }
903
+
807
904
  if (messageText) {
808
905
  parts.push({ type: "text", text: messageText })
809
906
  }
@@ -811,7 +908,7 @@ async function handleTelegramMessage(
811
908
  if (parts.length === 0) return
812
909
 
813
910
  // Send to OpenCode
814
- state.server.clientV2.session
911
+ state.server.client.session
815
912
  .prompt({
816
913
  sessionID: state.sessionId,
817
914
  directory: state.directory,
@@ -846,7 +943,7 @@ async function handleTelegramCallback(
846
943
  if (questionResult) {
847
944
  if ("awaitingFreetext" in questionResult) return
848
945
 
849
- await state.server.clientV2.question.reply({
946
+ await state.server.client.question.reply({
850
947
  requestID: questionResult.requestId,
851
948
  answers: questionResult.answers,
852
949
  })
@@ -860,7 +957,7 @@ async function handleTelegramCallback(
860
957
  })
861
958
 
862
959
  if (permResult) {
863
- await state.server.clientV2.permission.reply({
960
+ await state.server.client.permission.reply({
864
961
  requestID: permResult.requestId,
865
962
  reply: permResult.reply,
866
963
  })
@@ -888,7 +985,7 @@ async function subscribeToEvents(state: BotState) {
888
985
  log("info", "Subscribing to OpenCode events")
889
986
 
890
987
  try {
891
- const eventsResult = await state.server.clientV2.event.subscribe(
988
+ const eventsResult = await state.server.client.event.subscribe(
892
989
  { directory: state.directory },
893
990
  {}
894
991
  )
@@ -922,16 +1019,36 @@ async function handleOpenCodeEvent(state: BotState, ev: OpenCodeEvent) {
922
1019
  const sessionId =
923
1020
  ev.properties?.sessionID ??
924
1021
  ev.properties?.info?.sessionID ??
925
- ev.properties?.part?.sessionID
1022
+ ev.properties?.part?.sessionID ??
1023
+ ev.properties?.session?.id
926
1024
  const sessionTitle = ev.properties?.session?.title
927
1025
 
928
1026
  // Log errors in full and send to Telegram
929
1027
  if (ev.type === "session.error") {
930
1028
  const errorMsg = JSON.stringify(ev.properties, null, 2)
1029
+ const error = ev.properties?.error as
1030
+ | { name?: string; data?: { message?: string } }
1031
+ | undefined
1032
+ const errorName = error?.name
1033
+ const errorText = error?.data?.message
1034
+ const isInterrupted =
1035
+ errorName === "MessageAbortedError" || errorText === "The operation was aborted."
1036
+
931
1037
  log("error", "OpenCode session error", {
932
1038
  sessionId,
933
1039
  error: ev.properties,
934
1040
  })
1041
+
1042
+ if (isInterrupted) {
1043
+ const sendResult = await state.telegram.sendMessage("Interrupted.")
1044
+ if (sendResult.status === "error") {
1045
+ log("error", "Failed to send interrupt message", {
1046
+ error: sendResult.error.message,
1047
+ })
1048
+ }
1049
+ return
1050
+ }
1051
+
935
1052
  // Send error to Telegram for visibility
936
1053
  const sendResult = await state.telegram.sendMessage(
937
1054
  `OpenCode Error:\n${errorMsg.slice(0, 3500)}`
@@ -984,6 +1101,12 @@ async function handleOpenCodeEvent(state: BotState, ev: OpenCodeEvent) {
984
1101
  const key = `${info.sessionID}:${info.id}`
985
1102
  state.assistantMessageIds.add(key)
986
1103
  log("debug", "Registered assistant message", { key })
1104
+ const entry = state.typingIndicators.get(key)
1105
+ if (entry && entry.mode === "tool") {
1106
+ if (entry.timeout) clearTimeout(entry.timeout)
1107
+ entry.stop()
1108
+ state.typingIndicators.delete(key)
1109
+ }
987
1110
  }
988
1111
  }
989
1112
 
@@ -1001,6 +1124,39 @@ async function handleOpenCodeEvent(state: BotState, ev: OpenCodeEvent) {
1001
1124
  return
1002
1125
  }
1003
1126
 
1127
+ const stopTypingIndicator = (targetKey: string) => {
1128
+ const entry = state.typingIndicators.get(targetKey)
1129
+ if (!entry) return
1130
+ if (entry.timeout) clearTimeout(entry.timeout)
1131
+ entry.stop()
1132
+ state.typingIndicators.delete(targetKey)
1133
+ }
1134
+
1135
+ const startTypingIndicator = (targetKey: string, mode: "idle" | "tool") => {
1136
+ const existing = state.typingIndicators.get(targetKey)
1137
+ if (existing && existing.mode === mode) return
1138
+ if (existing) {
1139
+ if (existing.timeout) clearTimeout(existing.timeout)
1140
+ existing.stop()
1141
+ }
1142
+
1143
+ const stop = state.telegram.startTyping(mode === "tool" ? 2000 : 4000)
1144
+ state.typingIndicators.set(targetKey, { stop, timeout: null, mode })
1145
+ }
1146
+
1147
+ const bumpTypingIndicator = (targetKey: string, mode: "idle" | "tool") => {
1148
+ const existing = state.typingIndicators.get(targetKey)
1149
+ if (!existing || existing.mode !== mode) {
1150
+ startTypingIndicator(targetKey, mode)
1151
+ return
1152
+ }
1153
+
1154
+ if (existing.timeout) clearTimeout(existing.timeout)
1155
+ existing.timeout = setTimeout(() => {
1156
+ stopTypingIndicator(targetKey)
1157
+ }, 12000)
1158
+ }
1159
+
1004
1160
  log("debug", "Processing message part", {
1005
1161
  key,
1006
1162
  partType: part.type,
@@ -1014,12 +1170,11 @@ async function handleOpenCodeEvent(state: BotState, ev: OpenCodeEvent) {
1014
1170
  state.pendingParts.set(key, existing)
1015
1171
 
1016
1172
  if (part.type !== "step-finish") {
1017
- const typingResult = await state.telegram.sendTypingAction()
1018
- if (typingResult.status === "error") {
1019
- log("debug", "Typing action failed", {
1020
- error: typingResult.error.message,
1021
- })
1022
- }
1173
+ const typingMode =
1174
+ part.type === "tool" && (part.tool === "edit" || part.tool === "write")
1175
+ ? "tool"
1176
+ : "idle"
1177
+ bumpTypingIndicator(key, typingMode)
1023
1178
  }
1024
1179
 
1025
1180
  // Send tools/reasoning immediately (except edit/write tools - wait for completion to get diff data)
@@ -1047,6 +1202,7 @@ async function handleOpenCodeEvent(state: BotState, ev: OpenCodeEvent) {
1047
1202
 
1048
1203
  // On step-finish, send remaining parts
1049
1204
  if (part.type === "step-finish") {
1205
+ stopTypingIndicator(key)
1050
1206
  for (const p of existing) {
1051
1207
  if (p.type === "step-start" || p.type === "step-finish") continue
1052
1208
  if (state.sentPartIds.has(p.id)) continue
@@ -1149,6 +1305,24 @@ async function handleOpenCodeEvent(state: BotState, ev: OpenCodeEvent) {
1149
1305
  }
1150
1306
  }
1151
1307
 
1308
+ if (ev.type === "message.updated") {
1309
+ const info = ev.properties.info
1310
+ if (info?.role === "assistant") {
1311
+ const key = `${info.sessionID}:${info.id}`
1312
+ const entry = state.typingIndicators.get(key)
1313
+ if (entry && entry.mode === "tool") {
1314
+ const stopTypingIndicator = (targetKey: string) => {
1315
+ const existing = state.typingIndicators.get(targetKey)
1316
+ if (!existing) return
1317
+ if (existing.timeout) clearTimeout(existing.timeout)
1318
+ existing.stop()
1319
+ state.typingIndicators.delete(targetKey)
1320
+ }
1321
+ stopTypingIndicator(key)
1322
+ }
1323
+ }
1324
+ }
1325
+
1152
1326
  const threadId = state.threadId ?? 0
1153
1327
 
1154
1328
  if (ev.type === "question.asked") {
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
+ }