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 +45 -8
- package/package.json +1 -1
- package/src/main.ts +208 -34
- package/src/opencode.ts +25 -17
- package/src/telegram.ts +9 -0
- package/src/voice.ts +81 -0
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. **
|
|
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
|
-
|
|
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
|
-
-
|
|
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
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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
|
+
}
|