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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ TELEGRAM_ENABLED=false
# TELEGRAM_ACCESS_CODE=replace-with-a-dedicated-high-entropy-code
# Telegram turns automatically show one live-updating tool status message.

# Optional Telegram voice-note transcription through Cloudflare Workers AI.
# Use a Cloudflare API token with Workers AI Read and Write permissions.
# CLOUDFLARE_ACCOUNT_ID=replace-me
# CLOUDFLARE_API_TOKEN=replace-me

# Optional private text-to-speech delivery for Telegram replies. The Kokoro
# server must be reachable from the Blacki container; localhost points back to
# the container itself. A Tailscale address is appropriate for a private API.
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ jobs:
TELEGRAM_ACCESS_CODE: ${{ secrets.TELEGRAM_ACCESS_CODE }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_TOOL_NOTIFICATIONS: ${{ secrets.TELEGRAM_TOOL_NOTIFICATIONS }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
KOKORO_TTS_BASE_URL: ${{ secrets.KOKORO_TTS_BASE_URL }}
KOKORO_TTS_VOICE: ${{ secrets.KOKORO_TTS_VOICE }}
ZEPTO_MCP_ENABLED: ${{ secrets.ZEPTO_MCP_ENABLED }}
Expand Down Expand Up @@ -189,6 +191,8 @@ jobs:
TELEGRAM_ACCESS_CODE \
TELEGRAM_BOT_TOKEN \
TELEGRAM_TOOL_NOTIFICATIONS \
CLOUDFLARE_ACCOUNT_ID \
CLOUDFLARE_API_TOKEN \
KOKORO_TTS_BASE_URL \
KOKORO_TTS_VOICE \
ZEPTO_MCP_ENABLED \
Expand Down
10 changes: 10 additions & 0 deletions docs/base-infra/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ its presence changes model routing.
| `TELEGRAM_ENABLED` | `false` | Start Telegram long polling |
| `TELEGRAM_BOT_TOKEN` | unset | Token from BotFather |
| `TELEGRAM_ACCESS_CODE` | unset | Shared code required for new private Telegram chats |
| `CLOUDFLARE_ACCOUNT_ID` | unset | Cloudflare account ID for Telegram voice transcription |
| `CLOUDFLARE_API_TOKEN` | unset | Cloudflare Workers AI API token for Telegram voice transcription |
| `KOKORO_TTS_BASE_URL` | unset | Register private Kokoro speech delivery for Telegram |
| `KOKORO_TTS_VOICE` | `af_heart` | Kokoro voice ID used for generated MP3 audio |

Expand All @@ -111,6 +113,14 @@ Kokoro process on another Tailscale host; configure that host's Tailscale IP or
MagicDNS name instead. Plain HTTP is suitable only across a trusted private
network such as the encrypted Tailscale connection.

`CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` are an optional pair. When
both are configured, Telegram voice notes are transcribed with Cloudflare
Workers AI's `@cf/openai/whisper-large-v3-turbo` model. The token should have
Workers AI Read and Write permissions. Blacki keeps the downloaded voice note
and provider request payload transient; it does not send the raw voice note to
the normal sandbox/file-ingestion path. The resulting transcript follows the
same conversation-history and logging/privacy policy as ordinary Telegram text.

### Google Health connector

The optional connector is enabled when all three required values are present.
Expand Down
21 changes: 21 additions & 0 deletions docs/telegram-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,27 @@ delete any chat history, preferences, reminders, files, or health data.
At least one model provider must also be configured. See
[Configuration](base-infra/environment-variables.md).

### Optional voice-note transcription

To let Blacki turn Telegram's native voice-note button into a normal text
conversation, add a Cloudflare Workers AI account ID and API token:

```dotenv
CLOUDFLARE_ACCOUNT_ID=replace-me
CLOUDFLARE_API_TOKEN=replace-me
```

The token should have Workers AI Read and Write permissions. Blacki sends
voice notes to Cloudflare's hosted
`@cf/openai/whisper-large-v3-turbo` model, then passes the transcript through
the existing Telegram text-turn path so conversation history and tool
confirmations continue to work. Voice notes are kept transiently in memory;
regular audio files continue to use the existing file-upload behavior. If the
credentials are absent, Blacki reports that voice transcription is not
configured instead of failing startup. The raw voice bytes are transient; the
resulting transcript is handled like ordinary Telegram text and follows the
existing conversation-history and logging/privacy settings.

### Optional Kokoro speech replies

To let the Telegram-only root agent turn text into playable MP3 audio, add:
Expand Down
133 changes: 125 additions & 8 deletions src/blacki/telegram/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from collections.abc import Coroutine, Sequence
from contextvars import ContextVar
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any, Protocol, cast

from google.genai import types

Expand All @@ -36,6 +36,12 @@
from .formatting import escape_markdown_plain, format_for_telegram
from .settings_menu import SettingsMenu
from .streaming import split_long_message
from .transcription import (
MAX_CLOUDFLARE_WHISPER_AUDIO_BYTES,
MAX_CONCURRENT_CLOUDFLARE_TRANSCRIPTIONS,
CloudflareWhisperError,
CloudflareWhisperTranscriber,
)
from .types import (
BotCommand,
CallbackQuery,
Expand Down Expand Up @@ -93,6 +99,16 @@ class TelegramSessionIdentity:
session_id_prefix: str


class VoiceTranscriber(Protocol):
"""Protocol implemented by the Telegram voice transcription service."""

async def transcribe(self, audio_bytes: bytes) -> str:
"""Return the transcript for one voice note."""

async def close(self) -> None:
"""Release any provider resources."""


class TelegramBot:
"""Telegram bot client that sends typing indicators and final replies."""

Expand All @@ -102,12 +118,19 @@ def __init__(
runtime: AdkRuntime,
google_health_service: GoogleHealthService | None = None,
access_storage: TelegramAccessStorage | None = None,
voice_transcriber: VoiceTranscriber | None = None,
) -> None:
"""Initialize the Telegram bot."""
self.config = config
self.runtime = runtime
self.google_health_service = google_health_service
self.access_storage = access_storage
self._voice_transcriber = (
voice_transcriber or CloudflareWhisperTranscriber.from_environment()
)
self._voice_transcription_semaphore = asyncio.Semaphore(
MAX_CONCURRENT_CLOUDFLARE_TRANSCRIPTIONS
)
self._api: TelegramApiClient | None = None
self._running = False
self._polling_task: asyncio.Task[None] | None = None
Expand Down Expand Up @@ -166,7 +189,11 @@ async def stop(self) -> None:
with contextlib.suppress(asyncio.CancelledError):
await asyncio.gather(*self._background_tasks, return_exceptions=True)

await self.runtime.close()
try:
await self.runtime.close()
finally:
if self._voice_transcriber is not None:
await self._voice_transcriber.close()

await self._settings_menu.aclose()

Expand Down Expand Up @@ -737,12 +764,16 @@ async def _route_non_text_message(self, message: Message) -> None:
mime_type = message.video.mime_type
media_kind = "video"
elif message.voice:
file_id = message.voice.file_id
file_unique_id = message.voice.file_unique_id
file_name = "voice.ogg"
file_size = message.voice.file_size
mime_type = message.voice.mime_type
media_kind = "voice"
await self._handle_voice_upload(
chat_id=chat_id,
message_thread_id=message_thread_id,
file_id=message.voice.file_id,
file_size=message.voice.file_size,
mime_type=message.voice.mime_type,
caption=message.caption,
sender_user_id=sender_user_id,
)
return
else:
logger.debug("Unsupported non-text message from chat %s", chat_id)
return
Expand All @@ -760,6 +791,92 @@ async def _route_non_text_message(self, message: Message) -> None:
sender_user_id=sender_user_id,
)

async def _handle_voice_upload(
self,
*,
chat_id: int,
message_thread_id: int | None,
file_id: str,
file_size: int | None,
mime_type: str | None,
caption: str | None,
sender_user_id: int | None = None,
) -> None:
"""Transcribe a Telegram voice note and process it as a text turn."""
if self._voice_transcriber is None:
await self.api.send_message(
chat_id=chat_id,
text=(
"Voice transcription is not configured. Add "
"CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN to Blacki's "
"environment."
),
message_thread_id=message_thread_id,
)
return

if file_size is not None and file_size > MAX_CLOUDFLARE_WHISPER_AUDIO_BYTES:
await self.api.send_message(
chat_id=chat_id,
text="❌ Voice notes must be 8 MB or smaller to transcribe.",
message_thread_id=message_thread_id,
)
return

try:
async with self._voice_transcription_semaphore:
await self.api.send_chat_action(
chat_id=chat_id,
action="typing",
message_thread_id=message_thread_id,
)
file_info = await self.api.get_file(file_id)
file_path_api = file_info.get("file_path")
if not file_path_api:
raise ValueError("Failed to get voice file path from Telegram API")

audio_bytes = await self.api.download_file(file_path_api)
if not audio_bytes:
raise ValueError("Telegram returned an empty voice note")
if len(audio_bytes) > MAX_CLOUDFLARE_WHISPER_AUDIO_BYTES:
raise ValueError("Telegram voice note exceeds the 8 MB limit")

transcript = await self._voice_transcriber.transcribe(audio_bytes)
del audio_bytes

user_message = transcript
if caption and caption.strip():
user_message = f"{caption.strip()}\n\n{transcript}"

await self._handle_message(
chat_id=chat_id,
message_thread_id=message_thread_id,
user_message=user_message,
sender_user_id=sender_user_id,
)
except CloudflareWhisperError as exc:
logger.warning(
"Failed to transcribe Telegram voice note (%s)",
type(exc).__name__,
)
await self.api.send_message(
chat_id=chat_id,
text=(
"❌ Sorry, I couldn't transcribe that voice note. Please try again."
),
message_thread_id=message_thread_id,
)
except Exception as exc:
logger.warning(
"Failed to handle Telegram voice note (%s)",
type(exc).__name__,
)
await self.api.send_message(
chat_id=chat_id,
text="❌ Sorry, I failed to process the voice note.",
message_thread_id=message_thread_id,
)

async def _handle_photo_upload(
self,
*,
Expand Down
Loading
Loading