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 +44 -7
- package/package.json +1 -1
- package/src/main.ts +117 -25
- 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,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. **
|
|
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
|
-
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|