Goal: chat with Zumba (text + voice notes) from Telegram, with full memory
integration, mirroring how Friday/Ares does it (ares/channels/telegram.py)
but sized to Zumba's much simpler architecture.
- Direct Bot API over HTTPS long-polling via
httpx— no webhook, no bot framework, no public URL needed. OnegetUpdates(offset)loop. - Offset cursor persisted in SQLite (
channel_cursorstable,MAX()upsert) so restarts never replay or drop messages. - Security = chat-ID allowlist. Nothing is processed until the exact chat id is in the allowlist; unknown chats get a polite refusal.
- Voice notes: download via
getFile→ local faster-whisper (small, task=translate) → English transcript → normal chat pipeline. Duration cap enforced (config). - Chat mapping: (channel, external_chat_id) → conversation id so history survives across restarts.
- Message splitting to Telegram's 4096-char limit; typing indicator
(
sendChatAction) while thinking; attachment handling.
| Need | Existing piece |
|---|---|
| Chat brain | server/app.py /api/chat (uses _recall_block()) |
| Memory read | memory.get_memory().recall(query, top_k, max_bytes) |
| Memory write | mem.capture_async(user_text, reply, session_id=...) |
| Voice → text | POST /api/voice/stt (frontend webm/opus upload) |
| Text → voice | POST /api/voice/tts (edge-tts, returns mp3 bytes) |
| Sessions | core/store.py sessions (id, model, messages) |
| Config | .env loader in core/config.py |
Run Telegram as an in-process background task inside the existing FastAPI
server, not a separate process. Zumba's chat endpoint is a plain function
call internally (_build_messages + api_client + capture), so the channel
can call the same internal helpers directly — no HTTP self-calls.
New modules: server/telegram_channel.py (+ server/channel_store.py for
the cursor, server/tts.py shared TTS helper). Estimated ~400 lines —
Friday's is 2295 because it also routes skills, multi-agent, watchers and
medical flows; Zumba only needs chat + voice.
ZUMBA_TG_BOT_TOKEN= # from @BotFather
ZUMBA_TG_ALLOWED_CHAT_IDS= # comma-separated numeric chat ids; empty = nobody
ZUMBA_TG_VOICE_REPLY=1 # reply with voice (edge-tts) when user sent voice
Token + allowlist read via env. Token never logged. /start from an unknown
chat replies with the chat id and a hint to add it to .env — that's the
server/channel_store.py: SQLite in~/.zumba/memory.db(reuse existing connection helper):channel_cursors(channel TEXT PRIMARY KEY, next_offset INTEGER, updated_at)— copy Friday'sMAX()upsert semantics exactly.channel_map(channel, external_chat_id, session_id)— chat → session.
server/telegram_channel.py:TelegramAPIthin wrapper:get_updates,send_message,get_file,send_chat_action(httpx, ~60 lines).TelegramChannel.run(): asyncio long-poll loop (timeout 30s), persist offset per Friday's pattern (MAX()upsert means no replays on restart).- Allowlist gate on every update; log-and-ignore non-allowed chats.
- Text messages → resolve session via
channel_map(auto-createtg-<chat_id>on first message) → refactor_build_messages()+_recall_block()fromapp.pyinto a sharedcore/chat_pipeline.answer(session_id, text)helper so HTTP and Telegram share one code path — do not duplicate recall injection. - Reply: split at 4096 chars on newlines; send
typingaction before the LLM call.
- Memory: after each reply,
mem.capture_async(user_text, reply, session_id=f"tg-{chat_id}", kind="chat")— exactly likemain.py:290. Telegram conversations become memory episodes like CLI ones. - Startup: in
run.py, spawn the channel as an asyncio task ifZUMBA_TG_BOT_TOKENis set; channel crash must not kill the API (wrap loop, backoff on 429/5xx respectingretry_after). - Commands (minimal):
/new(fresh session for that chat),/help. Skip Friday's/model /provider /watchers …zoo.
Deliverable: text chat on Telegram with memory recall + capture working.
- On
message.voice/message.audio: download viaget_fileto~/.zumba/tg_audio/<msg_id>.ogg. - Transcribe reusing Friday's proven approach: local faster-whisper
(
small, task=translatefor Hinglish→English — this machine already ran it fine for Ares). Addfaster-whispertoserver/requirements.txt; lazy-load behind an asyncio lock (copy the structure ofares/channels/audio.py::EnglishAudioTranscriber). - Duration cap (config, default 5 min) → friendly error reply.
- Pipeline the transcript through the same text path as M1. Memory sees plain text, same as typed messages.
- If the incoming message was a voice note and
ZUMBA_TG_VOICE_REPLY=1: extract the edge-tts logic fromapp.py/api/voice/ttsintoserver/tts.pyso endpoints and channel share it._tts_short_textalready trims to 280 chars — right for voice. - Send as
sendVoice(multipart mp3 upload) plus the full text reply as a message (voice alone loses detail). record_voicechat action while generating.
- Markdown scrubbing: plain text first (parse_mode omitted), HTML later.
- Per-chat rate limiting (simple token bucket).
/memorycommand → shows recall for the user's last message (CLI/why).- Graceful shutdown: cancel loop,
mem.flush()on exit. - Tests:
tests/test_telegram_channel.pywith a mockedTelegramAPI: offset persistence, allowlist rejection, 4096 splitting, chat→session mapping, voice duration cap.
- Webhook mode (needs public TLS; long-polling is correct for localhost).
- Attachments/documents (later; needs an
inspect_attachmentequivalent). - Group chats (single-user personal bot; allowlist one chat id).
- Friday's marketplace/skills/multi-agent Telegram surfaces.
- faster-whisper model download (~500MB): first voice msg slow; preload
at server start when
ZUMBA_TG_BOT_TOKENis set. - Kilo free-tier latency: send typing action immediately; 10s "still working…" nudge for long turns.
- Blocking the event loop:
api_clientis sync httpx → wrap LLM calls inasyncio.to_threadso the web API stays responsive. - Token security:
.envonly;.envalready gitignored.
M1 ~1 session, M2 ~0.5, M3 ~0.5, M4 ~0.5. Total ≈ one focused day.
onboarding path (Friday's allowlist UX, minus config-file rewriting).