Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ This project follows Semantic Versioning.

## Unreleased

## [0.8.0] - 2026-05-28

### Added

- Added `/voice captions` and `voice.captions` so successful Telegram voice replies can include assistant text as a caption or companion message. (#26)

## [0.7.0] - 2026-05-28

### Added
Expand Down
5 changes: 3 additions & 2 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ OpenCode Remote is currently a Telegram gateway for OpenCode with text, image, s
- `/sessions` lists recent OpenCode sessions and lets the user switch with inline buttons.
- `/stop` requests abort for the active OpenCode session.
- `/progress` shows or sets private-chat prompt activity visibility: `off`, `new`, `all`, or `verbose`.
- `/voice` shows and controls voice mode, lists voices by required short country/locale filter, sets the active Edge TTS voice, and sends a test voice note.
- `/voice` shows and controls voice mode, toggles voice captions, lists voices by required short country/locale filter, sets the active Edge TTS voice, and sends a test voice note.
- `/stickers` saves, lists, and forgets sticker packs for future sticker replies.
- `/group` opens a private-chat management menu for known allowed groups. In groups, `/group` replies with a short DM-only notice.
- Per-group custom trigger phrases are managed from the DM `/group` menu.
Expand All @@ -48,7 +48,7 @@ OpenCode Remote is currently a Telegram gateway for OpenCode with text, image, s
- When saved sticker packs are available, explicit user requests for a sticker can be answered with a saved sticker reply through a hidden gateway marker.
- User emoji reactions to recent bot messages are sent back to OpenCode as feedback prompts.
- Telegram voice messages are transcribed and sent to OpenCode when voice mode is enabled.
- Voice replies replace text replies after voice prompts in `/voice on` mode and after text, photo, and voice prompts in `/voice all` mode, with text fallback if speech generation or sending fails.
- Voice replies replace text replies after voice prompts in `/voice on` mode and after text, photo, and voice prompts in `/voice all` mode, with optional text captions and text fallback if speech generation or sending fails.

## OpenCode Sessions

Expand All @@ -73,6 +73,7 @@ OpenCode Remote is currently a Telegram gateway for OpenCode with text, image, s
## Voice Mode

- Voice mode is disabled by default.
- Voice captions are disabled by default and can be enabled with `/voice captions on`.
- Groq Whisper uses `whisper-large-v3-turbo` by default for transcription.
- `/voice list <countryCode|locale> [page]` pages Edge TTS voices for a required country code such as `ua` or `us`, or a full locale such as `uk-UA`.
- Edge TTS voice short names such as `en-US-AndrewNeural` and `uk-UA-OstapNeural` can be selected with `/voice set`.
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ The config file is JSON:
"voice": {
"enabled": false,
"mode": "on",
"captions": false,
"voice": "en-US-AndrewNeural",
"groqApiKey": null,
"sttModel": "whisper-large-v3-turbo"
Expand All @@ -143,7 +144,7 @@ The config file is JSON:

Group behavior is managed from a private DM with the bot using `/group`. The DM menu lists known allowed groups, including groups from `telegram.allowedChatIds` and groups the bot has seen. Only configured `allowedUserIds` can use this menu. Running `/group` inside a group replies with a short notice to configure the bot in DM instead. Custom trigger phrases are configured per group from this DM menu; they are plain text, case-insensitive, and match as bounded words or phrases anywhere in text, captions, and voice transcripts.

`voice` controls optional Telegram voice input and spoken replies. `mode="on"` sends voice-note replies only after voice prompts, `mode="all"` sends voice-note replies after text, photo, and voice prompts, and `mode="off"` disables voice. When a voice-note reply succeeds, the bot does not also send the text reply; if speech generation or sending fails, it falls back to text. Voice mode requires `voice.groqApiKey` and local `ffmpeg` when enabled.
`voice` controls optional Telegram voice input and spoken replies. `mode="on"` sends voice-note replies only after voice prompts, `mode="all"` sends voice-note replies after text, photo, and voice prompts, and `mode="off"` disables voice. By default, successful voice-note replies are voice-only. Set `voice.captions=true` or use `/voice captions on` to include short assistant text as the voice caption, or send longer assistant text as a companion text message. If speech generation or sending fails, the bot falls back to text once. Voice mode requires `voice.groqApiKey` and local `ffmpeg` when enabled.

`logLevel` controls structured log verbosity. Supported values are `fatal`, `error`, `warn`, `info`, `debug`, `trace`, and `silent`.

Expand All @@ -153,6 +154,7 @@ Set individual config values from the CLI:
opencode-remote config set voice.enabled true
opencode-remote config set voice.groqApiKey gsk_...
opencode-remote config set voice.mode all -g
opencode-remote config set voice.captions true
```

Clear generated voice files from the app-data cache:
Expand Down Expand Up @@ -210,12 +212,13 @@ Voice commands:
/voice on
/voice off
/voice all
/voice captions [on|off]
/voice list <countryCode|locale> [page]
/voice set <voiceShortName>
/voice test
```

`/voice list` requires a short country code such as `ua` or `us`, or a full locale such as `uk-UA`; page is optional. Short codes match Edge TTS country/region codes first and fall back to language codes when no matching region exists. `/voice on` transcribes Telegram voice messages with Groq Whisper and replies with a voice note only for voice prompts. `/voice all` sends voice notes for text, photo, and voice prompts. Successful voice-note replies are voice-only; if speech generation or sending fails, the bot falls back to the text reply. Telegram voice notes are sent as OGG Opus files converted with `ffmpeg`.
`/voice list` requires a short country code such as `ua` or `us`, or a full locale such as `uk-UA`; page is optional. Short codes match Edge TTS country/region codes first and fall back to language codes when no matching region exists. `/voice on` transcribes Telegram voice messages with Groq Whisper and replies with a voice note only for voice prompts. `/voice all` sends voice notes for text, photo, and voice prompts. Successful voice-note replies are voice-only by default; `/voice captions on` includes short assistant text as the voice caption and sends longer assistant text as a companion text message. If speech generation or sending fails, the bot falls back to the text reply. Telegram voice notes are sent as OGG Opus files converted with `ffmpeg`.

## Troubleshooting

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@crankshift/opencode-remote",
"description": "A messenger-based chat interface for OpenCode, starting with Telegram.",
"version": "0.7.0",
"version": "0.8.0",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
73 changes: 69 additions & 4 deletions src/adapters/telegram/bot.js
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,29 @@ export function createTelegramBot({
return
}

if (request.action === "captions") {
if (request.captions === undefined) {
const status = await voiceService.status()
await replyAndRemember(
ctx,
`Voice captions are ${status.captions ? "on" : "off"}. ${voiceCaptionsUsageText()}`,
botMessageMemory,
)
return
}
if (request.captions === null) {
await replyAndRemember(ctx, voiceCaptionsUsageText(), botMessageMemory)
return
}
const result = await voiceService.setCaptions(request.captions)
await replyAndRemember(
ctx,
`Voice captions set to ${result.captions ? "on" : "off"}.`,
botMessageMemory,
)
return
}

if (request.action === "list") {
if (!request.filters) {
await replyAndRemember(ctx, voiceListUsageText(), botMessageMemory)
Expand Down Expand Up @@ -640,14 +663,26 @@ export function createTelegramBot({
return
}

let sentMessage
let caption
try {
const voice = await voiceService.synthesizeTelegramVoice(text)
const sentMessage = await sendVoice({ ctx, filePath: voice.filePath })
const chatId = sentMessage?.chat?.id ?? ctx.chat?.id ?? ctx.message?.chat?.id
botMessageMemory.remember(chatId, sentMessage?.message_id, text)
caption = voiceCaptionForText(text, voiceService)
sentMessage = await sendVoice({
ctx,
filePath: voice.filePath,
...(caption ? { caption } : {}),
})
} catch (error) {
logger.warn({ error }, "Could not send Telegram voice reply")
await sendTextReply(ctx, text)
return
}

const chatId = sentMessage?.chat?.id ?? ctx.chat?.id ?? ctx.message?.chat?.id
botMessageMemory.remember(chatId, sentMessage?.message_id, text)
if (voiceService?.shouldCaption?.() && !caption) {
await sendTextReply(ctx, text)
}
}

Expand Down Expand Up @@ -932,9 +967,25 @@ function parseVoiceCommand(text) {
if (action === "set") {
return { action, voice: parts[2] }
}
if (action === "captions") {
return { action, captions: parseVoiceCaptionsValue(parts[2]) }
}
return { action }
}

function parseVoiceCaptionsValue(value) {
if (value === undefined) {
return undefined
}
if (value === "on") {
return true
}
if (value === "off") {
return false
}
return null
}

function parseStickersCommand(text) {
const parts = String(text ?? "")
.trim()
Expand Down Expand Up @@ -984,6 +1035,7 @@ function parseVoiceListFilters(parts) {
function formatVoiceStatus(status) {
return [
`Voice mode: ${status.enabled ? status.mode : "off"}`,
`Voice captions: ${status.captions ? "on" : "off"}`,
`Voice: ${status.voice}`,
`STT model: ${status.sttModel}`,
`Groq API key: ${status.hasGroqApiKey ? "configured" : "missing"}`,
Expand All @@ -1007,13 +1059,26 @@ function formatVoiceListItem(voice) {
}

function voiceUsageText() {
return "Use /voice status|on|off|all|list|set|test."
return "Use /voice status|on|off|all|captions|list|set|test."
}

function voiceCaptionsUsageText() {
return "Use /voice captions on|off to change it."
}

function voiceListUsageText() {
return "Use /voice list <countryCode|locale> [page]."
}

const TELEGRAM_VOICE_CAPTION_LIMIT = 1024

function voiceCaptionForText(text, voiceService) {
if (!voiceService?.shouldCaption?.()) {
return null
}
return text.length <= TELEGRAM_VOICE_CAPTION_LIMIT ? text : null
}

function createTelegramProgressRenderer({ ctx, logger, verbosity, editThrottleMs }) {
const state = createProgressTextState({ verbosity })
const enabled = state.verbosity !== "off"
Expand Down
6 changes: 4 additions & 2 deletions src/adapters/telegram/voice.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,16 @@ export async function downloadTelegramVoice({
export async function sendTelegramVoice({
ctx,
filePath,
caption,
inputFileFactory = (path) => new InputFile(path),
} = {}) {
const voice = inputFileFactory(filePath)
const options = caption ? { caption } : undefined
if (typeof ctx.replyWithVoice === "function") {
return ctx.replyWithVoice(voice)
return options ? ctx.replyWithVoice(voice, options) : ctx.replyWithVoice(voice)
}
const chatId = ctx.chat?.id ?? ctx.message?.chat?.id
return ctx.api.sendVoice(chatId, voice)
return options ? ctx.api.sendVoice(chatId, voice, options) : ctx.api.sendVoice(chatId, voice)
}

function mimeFromFilePath(filePath) {
Expand Down
2 changes: 1 addition & 1 deletion src/bin/program.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function createGatewayProgram({
const program = new Command()
const afterCreate = createStartupAfterConfigHook({ enableGatewayStartup, output })

program.name("opencode-remote").description("OpenCode messaging gateway").version("0.7.0")
program.name("opencode-remote").description("OpenCode messaging gateway").version("0.8.0")

program
.command("setup")
Expand Down
3 changes: 3 additions & 0 deletions src/config/loadConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const telegramConfigSchema = z
const defaultVoiceConfig = {
enabled: false,
mode: "on",
captions: false,
voice: "en-US-AndrewNeural",
groqApiKey: null,
sttModel: "whisper-large-v3-turbo",
Expand All @@ -52,6 +53,7 @@ const configSchema = z.object({
.object({
enabled: z.boolean().default(false),
mode: voiceModeSchema.default("on"),
captions: z.boolean().default(false),
voice: z.string().min(1).default("en-US-AndrewNeural"),
groqApiKey: z.string().min(1).nullable().default(null),
sttModel: z.string().min(1).default("whisper-large-v3-turbo"),
Expand Down Expand Up @@ -147,6 +149,7 @@ export function loadConfigFromObject(rawConfig, { configPath, cwd = process.cwd(
voice: {
enabled: parsed.data.voice.enabled,
mode: parsed.data.voice.mode,
captions: parsed.data.voice.captions,
voice: parsed.data.voice.voice,
groqApiKey: parsed.data.voice.groqApiKey,
sttModel: parsed.data.voice.sttModel,
Expand Down
2 changes: 1 addition & 1 deletion src/core/commands/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export const publicBotCommands = [
{ command: "sessions", description: "List and switch OpenCode sessions" },
{ command: "stop", description: "Abort current OpenCode task" },
{ command: "progress", description: "Set tool progress visibility" },
{ command: "voice", description: "Show or set voice mode" },
{ command: "voice", description: "Show or set voice mode and captions" },
{ command: "stickers", description: "Manage saved sticker packs" },
]

Expand Down
2 changes: 1 addition & 1 deletion src/core/gateway/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function createGatewayController({
}

async function primeSession(sessionId, additionalContext) {
if (!gatewayContext || typeof opencode.sendContext !== "function") {
if ((!gatewayContext && !additionalContext) || typeof opencode.sendContext !== "function") {
return
}
const context = [gatewayContext, additionalContext].filter(Boolean).join("\n\n")
Expand Down
13 changes: 13 additions & 0 deletions src/core/voice/voiceService.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function createVoiceService({
return {
enabled: voiceConfig.enabled,
mode: voiceConfig.mode,
captions: voiceConfig.captions,
voice: voiceConfig.voice,
sttModel: voiceConfig.sttModel,
hasGroqApiKey: Boolean(voiceConfig.groqApiKey),
Expand All @@ -47,6 +48,10 @@ export function createVoiceService({
return voiceConfig.mode === "all" || source === "voice"
},

shouldCaption() {
return voiceConfig.captions
},

async transcribe(filePath) {
if (!voiceConfig.enabled || voiceConfig.mode === "off") {
throw new Error("Voice mode is disabled.")
Expand All @@ -72,6 +77,13 @@ export function createVoiceService({
return next
},

async setCaptions(captions) {
const next = { captions: Boolean(captions) }
await saveConfig(next)
voiceConfig = { ...voiceConfig, ...next }
return next
},

async setVoice(shortName) {
const voice = await findVoice(shortName)
if (!voice) {
Expand Down Expand Up @@ -102,6 +114,7 @@ function normalizeVoiceConfig(config = {}) {
return {
enabled: Boolean(config.enabled),
mode: VOICE_MODES.includes(config.mode) ? config.mode : "on",
captions: Boolean(config.captions),
voice: config.voice || "en-US-AndrewNeural",
groqApiKey: config.groqApiKey || null,
sttModel: config.sttModel || "whisper-large-v3-turbo",
Expand Down
Loading