From ebdbd87bda4f932e899f66ecf5b76fd7aeab8013 Mon Sep 17 00:00:00 2001 From: DCHA <426225+daocha@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:27:10 +0800 Subject: [PATCH 1/5] Add Speech-To-Text Feature and fix queued messages behavior + misc bugs (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ** Add Speech-To-Text support: - Add local Whisper speech-to-text support for Telegram voice/audio messages, including startup prerequisite checks, shared STT installer flow, env configuration, and transcript dispatch into the normal message pipeline. - Add dependency installation script and environment detection - Add OpenAI-Whisper and required dependencies in the setup script and server startup script - Server startups now checks the dependencies when STT config is enabled - Document the Whisper/STT flow, update localized user-facing strings, and add regression tests for speech-to-text, queue ordering, reply behavior, and installer prerequisite checks. - Add test cases: ** Runtime issue fix: harden queue, reply threading, and startup consistency: - Fix pending-action and queue drain ordering, busy/queue race handling, reply threading for working/final output, and ensure install.sh launches with the same Python interpreter used for installation. - Fix queue messages during pending session setup - Fix Queue text and voice transcripts while session prerequisites are unresolved, then drain the queue after session creation completes. Add regression coverage for pending new-session text/voice cases and clean up localized README diff wording. ** Bug fix: Fix 1 — Double HTML escaping in bold text Fix 2 — callback_data 64-byte limit for branch source buttons Fix 3 — Queue delimiter injection corrupts queued messages --------- Co-authored-by: DCHA Agent <259406208+dcha-agent@users.noreply.github.com> --- README.de.md | 144 ++-- README.fr.md | 198 +++-- README.ja.md | 252 +++--- README.ko.md | 252 +++--- README.md | 91 ++- README.nl.md | 194 +++-- README.th.md | 248 +++--- README.vi.md | 272 ++++--- README.zh-CN.md | 252 +++--- README.zh-HK.md | 268 ++++--- README.zh-TW.md | 268 ++++--- install-stt.sh | 37 + install.sh | 3 - pyproject.toml | 1 + src/coding_agent_telegram/bot.py | 38 +- src/coding_agent_telegram/cli.py | 11 + src/coding_agent_telegram/config.py | 14 + .../resources/.env.example | 16 + .../resources/locales/de.json | 8 +- .../resources/locales/en.json | 10 +- .../resources/locales/fr.json | 8 +- .../resources/locales/ja.json | 8 +- .../resources/locales/ko.json | 8 +- .../resources/locales/nl.json | 8 +- .../resources/locales/th.json | 8 +- .../resources/locales/vi.json | 8 +- .../resources/locales/zh-CN.json | 8 +- .../resources/locales/zh-HK.json | 10 +- .../resources/locales/zh-TW.json | 10 +- src/coding_agent_telegram/router/base.py | 25 + .../router/message_commands.py | 221 +++++- .../router/project_commands.py | 26 +- .../router/queue_processing.py | 135 +++- .../router/session_branch_resolution.py | 8 +- .../router/session_common.py | 8 + .../router/session_lifecycle_commands.py | 98 ++- .../router/session_provider_commands.py | 2 +- src/coding_agent_telegram/session_runtime.py | 68 +- src/coding_agent_telegram/speech_to_text.py | 140 ++++ src/coding_agent_telegram/stt_setup.py | 306 +++++++ src/coding_agent_telegram/telegram_sender.py | 75 +- startup.sh | 87 +- tests/test_command_router.py | 745 +++++++++++++++++- tests/test_config.py | 34 + tests/test_speech_to_text.py | 105 +++ tests/test_stt_setup.py | 99 +++ tests/test_telegram_sender.py | 30 +- 47 files changed, 3761 insertions(+), 1104 deletions(-) create mode 100755 install-stt.sh create mode 100644 src/coding_agent_telegram/speech_to_text.py create mode 100644 src/coding_agent_telegram/stt_setup.py create mode 100644 tests/test_speech_to_text.py create mode 100644 tests/test_stt_setup.py diff --git a/README.de.md b/README.de.md index 49fb922..2049d43 100644 --- a/README.de.md +++ b/README.de.md @@ -38,7 +38,7 @@ - ✅ Telegram zum Steuern von Codex / Copilot CLI verwenden - ✅ Antworten und geänderte Dateien bequem in Code-Blöcken prüfen - ✅ Folgefragen während eines laufenden Agentenlaufs in die Queue stellen - - ✅ Unterstützt Text- und Bildeingaben + - ✅ Akzeptiert ✏️ Text-, 🌄 Bild- und 🎙️ Sprachnachrichten ## 🔁 Nahtlos zwischen Geräten und Sessions wechseln @@ -49,7 +49,7 @@ ## 🛠️ Typischer lokaler Ablauf ```bash - coding-agent-telegram # or run ./startup.sh + coding-agent-telegram # oder ./startup.sh ausführen ``` ##### In Telegram: @@ -99,6 +99,7 @@ Vor dem Start des Servers brauchst du: - Lokal installiertes Codex CLI und/oder Copilot CLI - [Codex CLI Installation](https://developers.openai.com/codex/cli) - [Copilot CLI Installation](https://github.com/features/copilot/cli) +- [Optional] Whisper, ffmpeg @@ -108,35 +109,61 @@ Openclaw bietet dir sehr umfassende Funktionen und hat mit Pi-Agent bereits eine ## 🚀 Schnellstart -### Option A: Einzeiliges Bootstrap-Skript +### Variante A: Einzeiliges Bootstrap-Skript ```bash curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/install.sh | bash ``` -### Option B: Installation über PyPI mit `pip` +### Variante B: Installation über PyPI mit `pip` ```bash pip install coding-agent-telegram coding-agent-telegram ``` -### Option C: Aus einem geklonten Repository starten +### Variante C: Aus einem geklonten Repository starten ```bash git clone https://github.com/daocha/coding-agent-telegram cd coding-agent-telegram ./startup.sh ``` -### Bot-Server starten +### 🌐 Bot-Server starten ##### Beim ersten Start legt die App die Env-Datei an und sagt dir, welche Felder du ausfüllen musst. ##### Nach dem Bearbeiten der Env-Datei starte erneut: ```bash -# if you follow Option A or Option B, then run +# wenn du Variante A oder Variante B verwendest, dann ausführen coding-agent-telegram -# if you follow Option C, then run this again +# wenn du Variante C verwendest, dann dies erneut ausführen ./startup.sh ``` +## 🎙️ [Optional] Sprach-zu-Text-Funktion: lokale OpenAI-Whisper-Voraussetzungen vorbereiten + +Damit aktivierst du optional lokale Whisper-basierte Sprach-zu-Text-Unterstützung für Telegram-Sprachnotizen. Audiodateien sind auf maximal `20 MB` begrenzt. + +```bash +# wenn du per pip oder per Einzeiler install.sh installiert hast +coding-agent-telegram-stt-install + +# wenn du aus einem geklonten Repository startest +./install-stt.sh +``` + +Empfohlene Env-Einstellungen: + +```text +ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true +OPENAI_WHISPER_MODEL=base +OPENAI_WHISPER_TIMEOUT_SECONDS=120 +``` + +Hinweise: + +- Whisper lädt das ausgewählte Modell beim ersten Aufruf automatisch nach `~/.cache/whisper` herunter. +- Wenn du `OPENAI_WHISPER_MODEL=turbo` wählst, ist es wahrscheinlicher, dass die erste Sprachnachricht das Zeitlimit erreicht, während `large-v3-turbo.pt` noch heruntergeladen wird. +- Nach der Transkription einer Sprachnachricht sendet der Bot das erkannte Transkript zuerst zurück an Telegram und gibt es danach an den Agenten weiter. So lassen sich Erkennungsfehler leichter prüfen. + ## 🔑 Telegram-Einrichtung ### Bot-Token holen @@ -175,61 +202,62 @@ Der Bot akzeptiert derzeit: - Textnachrichten - Fotos +- Sprachnachrichten und Audiodateien, wenn `ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true` gesetzt ist und die lokalen Whisper-Voraussetzungen installiert sind - Codex und Copilot unterstützen aktuell nur Text und Bilder, kein Video. ## 🤖 Telegram-Befehle - + - + - + - - + + - + - + - + - + - + - + - - + + - - + + - +
/provider/Anbieter Provider für neue Sessions wählen. Die Auswahl wird pro Bot und Chat gespeichert, bis du sie änderst.
/project <project_folder>/project <project_folder> Aktuellen Projektordner setzen. Falls der Ordner nicht existiert, erstellt die App ihn und markiert ihn als vertrauenswürdig. Wenn er bereits existiert und noch nicht vertraut ist, fragt die App nach einer Bestätigung.
/branch <new_branch>/branch <new_branch> Eine branch für das aktuelle Projekt vorbereiten oder wechseln. Wenn die branch bereits existiert, nutzt der Bot sie als Quellkandidaten. Andernfalls verwendet er die Standard-branch des Repositorys als Quellkandidaten.
/branch <origin_branch> <new_branch>Eine branch mit `` als Quellkandidaten vorbereiten oder wechseln. Für beide Formen bietet der Bot anschließend nur die Quelloptionen an, die tatsächlich existieren: `local/` und `origin/`. Wenn nur eine davon existiert, wird nur diese angezeigt. Wenn keine existiert, meldet der Bot, dass die branch-Quelle fehlt./branch <origin_branch> <new_branch>Eine branch mit <origin_branch> als Quellkandidaten vorbereiten oder wechseln. Für beide Formen bietet der Bot anschließend nur die Quelloptionen an, die tatsächlich existieren: local/<branch> und origin/<branch>. Wenn nur eine davon existiert, wird nur diese angezeigt. Wenn keine existiert, meldet der Bot, dass die branch-Quelle fehlt.
/current/current Die aktive Session für den aktuellen Bot und Chat anzeigen.
/new [session_name]/new [session_name] Eine neue Session für das aktuelle Projekt erstellen. Wenn du keinen Namen angibst, verwendet der Bot die echte Session-ID. Fehlen Provider, Projekt oder branch, führt dich der Bot durch den fehlenden Schritt.
/switch/switch Die neuesten Sessions anzeigen, zuerst die neuesten. Die Liste enthält sowohl vom Bot verwaltete Sessions als auch lokale Codex/Copilot CLI-Sessions für das aktuelle Projekt.
/switch page <number>/switch page <number> Eine andere Seite der gespeicherten Sessions anzeigen.
/switch <session_id>/switch <session_id> Zu einer bestimmten Session per ID wechseln. Wenn du eine lokale CLI-Session auswählst, importiert der Bot sie und setzt dort fort.
/compact/compact Aus der aktiven Session eine neue kompakte Session erzeugen und dorthin wechseln.
/commit <git commands>Geprüfte `git commit`-bezogene Befehle im Projekt der aktiven Session ausführen. Nur verfügbar, wenn `ENABLE_COMMIT_COMMAND=true`. Schreibende Git-Befehle erfordern ein vertrauenswürdiges Projekt./commit <git commands>Geprüfte git commit-bezogene Befehle im Projekt der aktiven Session ausführen. Nur verfügbar, wenn ENABLE_COMMIT_COMMAND=true. Schreibende Git-Befehle erfordern ein vertrauenswürdiges Projekt.
/push`origin ` für die aktuelle aktive Session pushen. Der Bot fragt vor dem Push nach einer Bestätigung./pushorigin <branch> für die aktuelle aktive Session pushen. Der Bot fragt vor dem Push nach einer Bestätigung.
/abort/abort Den aktuellen Agentenlauf für das aktuelle Projekt abbrechen. Wenn Fragen in der Queue warten, fragt der Bot, ob sie weiter verarbeitet werden sollen.
@@ -257,15 +285,15 @@ Der Bot akzeptiert derzeit: - + - + - +
WORKSPACE_ROOTWORKSPACE_ROOT Übergeordneter Ordner, der deine Projektverzeichnisse enthält.
TELEGRAM_BOT_TOKENSTELEGRAM_BOT_TOKENS Kommagetrennte Telegram-Bot-Tokens.
ALLOWED_CHAT_IDSALLOWED_CHAT_IDS Kommagetrennte Telegram-Chat-IDs privater Chats, die den Bot verwenden dürfen.
@@ -274,71 +302,91 @@ Der Bot akzeptiert derzeit: - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - +
APP_LOCALEAPP_LOCALE UI-Sprache für gemeinsame Bot-Meldungen und Befehlsbeschreibungen. Unterstützte Werte: en, de, fr, ja, ko, nl, th, vi, zh-CN, zh-HK, zh-TW.
CODEX_BINCODEX_BIN Befehl zum Starten von Codex CLI. Standard: codex.
COPILOT_BINCOPILOT_BIN Befehl zum Starten von Copilot CLI. Standard: copilot.
CODEX_MODELCODEX_MODEL Optionale Model-Überschreibung für Codex. Leer lassen, um das Standardmodell von Codex CLI zu verwenden. Beispiel: gpt-5.4 OpenAI Codex/OpenAI modelle
COPILOT_MODELCOPILOT_MODEL Optionale Model-Überschreibung für Copilot. Leer lassen, um das Standardmodell von Copilot CLI zu verwenden. Beispiele: gpt-5.4, claude-sonnet-4.6 GitHub Copilot unterstützte modelle
CODEX_APPROVAL_POLICYCODEX_APPROVAL_POLICY An Codex übergebener Freigabemodus. Standard: never.
CODEX_SANDBOX_MODECODEX_SANDBOX_MODE An Codex übergebener Sandbox-Modus. Standard: workspace-write.
CODEX_SKIP_GIT_REPO_CHECKCODEX_SKIP_GIT_REPO_CHECK Wenn aktiviert, werden Codex-Prüfungen für vertrauenswürdige Repositories immer übersprungen.
ENABLE_COMMIT_COMMANDENABLE_COMMIT_COMMAND Den Telegram-Befehl /commit aktivieren. Standard: false.
AGENT_HARD_TIMEOUT_SECONDSAGENT_HARD_TIMEOUT_SECONDS Hartes Zeitlimit für einen einzelnen Agentenlauf. Standard: 0 (deaktiviert).
SNAPSHOT_TEXT_FILE_MAX_BYTESSNAPSHOT_TEXT_FILE_MAX_BYTES Maximale Dateigröße, die der Bot als Text liest, wenn er Vorher/Nachher-Snapshots für Run-Diffs erstellt. Standard: 200000.
MAX_TELEGRAM_MESSAGE_LENGTHMAX_TELEGRAM_MESSAGE_LENGTH Maximale Nachrichtengröße, bevor die App Antworten aufteilt. Standard: 3000.
ENABLE_SENSITIVE_DIFF_FILTERENABLE_SENSITIVE_DIFF_FILTER Diffs für sensible Dateien ausblenden. Standard: true.
ENABLE_SECRET_SCRUB_FILTERENABLE_SECRET_SCRUB_FILTER Tokens, Schlüssel, .env-Werte, Zertifikate und ähnliche geheime Ausgaben vor dem Senden an Telegram unkenntlich machen. Standard: true (dringend empfohlen).
SNAPSHOT_INCLUDE_PATH_GLOBSSNAPSHOT_INCLUDE_PATH_GLOBS Passende Pfade in Diffs immer einschließen. Beispiel: .github/*,.profile.test,.profile.prod
SNAPSHOT_EXCLUDE_PATH_GLOBSSNAPSHOT_EXCLUDE_PATH_GLOBS Zusätzliche Diff-Ausschlüsse zusätzlich zu den Standardwerten hinzufügen. Beispiel: .*,personal/*,sensitive*.txt Hinweis: .* erfasst versteckte Pfade, auch Dateien in versteckten Verzeichnissen.
+ + + +

Spracherkennung

+ + + + + + + + + + + + + + +
ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXTStandard: false. Wenn true, werden Sprachnachrichten und Audiodateien erkannt. Das System prüft die erforderlichen Binärdateien oder Bibliotheken und fordert zur Installation auf, falls etwas fehlt.
OPENAI_WHISPER_MODELModell für Whisper STT. Standard: base
Verfügbare Modelle: tiny ca. 72 MB, base ca. 139 MB, large-v3-turbo ca. 1.5 GB
Modelle werden bei der ersten Sprachnachricht automatisch heruntergeladen. Empfehlung: base für den allgemeinen Gebrauch. Wenn du bessere Genauigkeit und Qualität willst, kannst du turbo ausprobieren.
OPENAI_WHISPER_TIMEOUT_SECONDSStandard: 120. Timeout für den STT-Prozess. Normalerweise ist die Verarbeitung schnell genug. Wenn du jedoch turbo wählst, kann der erste Sprachaufruf während des Modelldownloads je nach Internetgeschwindigkeit das Timeout überschreiten.
+

Status und Logs

@@ -348,7 +396,7 @@ Der Bot akzeptiert derzeit: - + @@ -474,8 +522,8 @@ Der Bot behandelt Projekt und branch als zusammengehörig. Wenn du eine branch erstellst oder wechselst, führt dich der Bot explizit durch die Quelle: -- `local/` bedeutet: lokale branch als Quelle verwenden -- `origin/` bedeutet: zuerst von der Remote-branch aktualisieren und dann wechseln +- local/<branch> bedeutet: lokale branch als Quelle verwenden +- origin/<branch> bedeutet: zuerst von der Remote-branch aktualisieren und dann wechseln Wenn der Bot feststellt, dass die in der Session gespeicherte branch und die aktuelle Repository-branch nicht übereinstimmen, macht er nicht blind weiter. Er fragt dich, welche branch verwendet werden soll: @@ -493,7 +541,7 @@ Wenn die bevorzugte Quell-branch fehlt, bietet der Bot stattdessen Fallback-Quel - `/commit` kann mit `ENABLE_COMMIT_COMMAND` komplett deaktiviert werden - Schreibende `/commit`-Operationen sind nur für vertrauenswürdige Projekte erlaubt -## 🪵 Logs +## 🪵 Protokolle Logs werden **sowohl auf stdout als auch in eine rotierende Log-Datei** geschrieben unter: @@ -533,7 +581,7 @@ Logs werden **sowohl auf stdout als auch in eine rotierende Log-Datei** geschrie - `pyproject.toml` Packaging- und Abhängigkeitskonfiguration -## 📦 Release-Versionierung +## 📦 Veröffentlichung-Versionierung Paketversionen werden aus Git-Tags abgeleitet. diff --git a/README.fr.md b/README.fr.md index 2ee4349..9d1b853 100644 --- a/README.fr.md +++ b/README.fr.md @@ -38,7 +38,7 @@ - ✅ Utiliser Telegram pour piloter Codex / Copilot CLI - ✅ Révision facile des réponses et des fichiers modifiés dans des blocs de code - ✅ Les messages de suivi peuvent être mis en file d’attente pendant qu’un agent travaille - - ✅ Prend en charge le texte et les images + - ✅ Accepte les messages ✏️ texte, 🌄 image et 🎙️ vocaux ## 🔁 Changement fluide entre appareils et sessions @@ -49,7 +49,7 @@ ## 🛠️ Flux local typique ```bash - coding-agent-telegram # or run ./startup.sh + coding-agent-telegram # ou exécutez ./startup.sh ``` ##### Dans Telegram : @@ -99,6 +99,7 @@ Avant de démarrer le serveur, assurez-vous d’avoir : - Codex CLI et/ou Copilot CLI installés localement - [Installation Codex CLI](https://developers.openai.com/codex/cli) - [Installation Copilot CLI](https://github.com/features/copilot/cli) +- [Optionnel] Whisper, ffmpeg
~/.coding-agent-telegram/state.json.bakBackup-Datei für den Status.Sicherungsdatei für den Status.
~/.coding-agent-telegram/logs
@@ -108,35 +109,61 @@ Openclaw offre des capacités très complètes et intègre déjà une boucle d ## 🚀 Démarrage rapide -### Option A : Script bootstrap en une ligne +### Variante A : Script bootstrap en une ligne ```bash curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/install.sh | bash ``` -### Option B : Installation depuis PyPI avec `pip` +### Variante B : Installation depuis PyPI avec `pip` ```bash pip install coding-agent-telegram coding-agent-telegram ``` -### Option C : Exécution depuis un dépôt cloné +### Variante C : Exécution depuis un dépôt cloné ```bash git clone https://github.com/daocha/coding-agent-telegram cd coding-agent-telegram ./startup.sh ``` -### Démarrer le serveur du bot +### 🌐 Démarrer le serveur du bot ##### Au premier lancement, l’application crée le fichier env et vous indique quels champs remplir. ##### Après avoir mis à jour le fichier env, relancez : ```bash -# if you follow Option A or Option B, then run +# si vous suivez l’option A ou l’option B, exécutez ensuite coding-agent-telegram -# if you follow Option C, then run this again +# si vous suivez l’option C, exécutez ceci de nouveau ./startup.sh ``` +## 🎙️ [Optionnel] Fonction de transcription vocale : préparer les prérequis locaux OpenAI-Whisper + +Cela active la transcription locale optionnelle des notes vocales Telegram avec Whisper. Les fichiers audio sont limités à `20 MB` maximum. + +```bash +# si vous avez installé avec pip ou avec l’install.sh en une ligne +coding-agent-telegram-stt-install + +# si vous utilisez un dépôt cloné +./install-stt.sh +``` + +Réglages env recommandés : + +```text +ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true +OPENAI_WHISPER_MODEL=base +OPENAI_WHISPER_TIMEOUT_SECONDS=120 +``` + +Remarques : + +- Whisper télécharge automatiquement le modèle sélectionné lors du premier usage dans `~/.cache/whisper`. +- Si vous choisissez `OPENAI_WHISPER_MODEL=turbo`, la première transcription vocale a davantage de chances d’atteindre le délai pendant que `large-v3-turbo.pt` est encore en cours de téléchargement. +- Après transcription d’un message vocal, le bot renvoie d’abord le texte reconnu dans Telegram avant de l’envoyer à l’agent. Cela aide à diagnostiquer les erreurs de reconnaissance. + ## 🔑 Configuration Telegram ### Obtenir un Bot Token @@ -171,59 +198,66 @@ Remarques : ## 📨 Types de messages pris en charge +Le bot accepte actuellement : + +- les messages texte +- les photos +- les messages vocaux et les fichiers audio quand `ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true` et que les prérequis locaux de Whisper sont installés +- Codex et Copilot prennent actuellement en charge uniquement le texte et les images, pas la vidéo + ## 🤖 Commandes Telegram - - + + - + - + - - + + - + - - + + - + - + - + - + - - + + - - + + - +
/providerChoisir le provider pour les nouvelles sessions. Le choix est stocké par bot et par chat jusqu’à modification./providerChoisir le fournisseur pour les nouvelles sessions. Le choix est stocké par bot et par chat jusqu’à modification.
/project <project_folder>/project <project_folder> Définir le dossier de projet courant. Si le dossier n’existe pas, l’app le crée et le marque trusted. S’il existe déjà mais reste untrusted, l’app vous demande une confirmation.
/branch <new_branch>/branch <new_branch> Préparer ou changer une branch pour le projet courant. Si la branch existe déjà, le bot la traite comme source candidate. Sinon il utilise la branch par défaut du dépôt.
/branch <origin_branch> <new_branch>Préparer ou changer une branch en utilisant `` comme source candidate. Pour les deux formes, le bot ne propose ensuite que les sources réellement disponibles : `local/` et `origin/`. Si une seule existe, seule celle-ci est affichée. Si aucune n’existe, le bot signale que la source de branch est introuvable./branch <origin_branch> <new_branch>Préparer ou changer une branch en utilisant <origin_branch> comme source candidate. Pour les deux formes, le bot ne propose ensuite que les sources réellement disponibles : local/<branch> et origin/<branch>. Si une seule existe, seule celle-ci est affichée. Si aucune n’existe, le bot signale que la source de branch est introuvable.
/current/current Afficher la session active pour le bot et le chat courants.
/new [session_name]Créer une nouvelle session pour le projet courant. Si vous omettez le nom, le bot utilise la vraie session ID. Si provider, projet ou branch manque, le bot vous guide./new [session_name]Créer une nouvelle session pour le projet courant. Si vous omettez le nom, le bot utilise le véritable ID de session. Si fournisseur, projet ou branch manque, le bot vous guide.
/switch/switch Afficher les sessions les plus récentes, de la plus récente à la plus ancienne. La liste inclut les sessions gérées par le bot et les sessions locales Codex/Copilot CLI du projet courant.
/switch page <number>/switch page <number> Afficher une autre page des sessions enregistrées.
/switch <session_id>/switch <session_id> Basculer vers une session précise via son ID. Si vous choisissez une session CLI locale, le bot l’importe et reprend à partir d’elle.
/compact/compact Créer une nouvelle session compactée à partir de la session active et basculer dessus.
/commit <git commands>Exécuter des commandes liées à `git commit` validées dans le projet de la session active. Disponible uniquement si `ENABLE_COMMIT_COMMAND=true`. Les commandes Git mutantes exigent un projet trusted./commit <git commands>Exécuter des commandes liées à git commit validées dans le projet de la session active. Disponible uniquement si ENABLE_COMMIT_COMMAND=true. Les commandes Git mutantes exigent un projet trusted.
/pushPousser `origin ` pour la session active courante. Le bot demande une confirmation avant le push./pushPousser origin <branch> pour la session active courante. Le bot demande une confirmation avant le push.
/abort/abort Annuler l’exécution d’agent en cours pour le projet courant. Si des questions attendent dans la file, le bot demande si elles doivent continuer.
@@ -251,15 +285,15 @@ Remarques : - + - + - +
WORKSPACE_ROOTWORKSPACE_ROOT Dossier parent qui contient vos répertoires de projet.
TELEGRAM_BOT_TOKENSTELEGRAM_BOT_TOKENS Liste de tokens de bot Telegram séparés par des virgules.
ALLOWED_CHAT_IDSALLOWED_CHAT_IDS Liste d’IDs de chat privés Telegram autorisés, séparés par des virgules.
@@ -268,68 +302,88 @@ Remarques : - + - + - + - + - + - + - + - + - + - + - - + + - + - + - + - + - - + + + +
APP_LOCALEAPP_LOCALE Langue de l’interface pour les messages partagés du bot et les descriptions de commandes. Valeurs prises en charge : en, de, fr, ja, ko, nl, th, vi, zh-CN, zh-HK, zh-TW.
CODEX_BINCODEX_BIN Commande utilisée pour lancer Codex CLI. Valeur par défaut : codex.
COPILOT_BINCOPILOT_BIN Commande utilisée pour lancer Copilot CLI. Valeur par défaut : copilot.
CODEX_MODELCODEX_MODEL Remplacement optionnel du modèle Codex. Laissez vide pour utiliser le modèle par défaut de Codex CLI. Exemple : gpt-5.4 Modèles OpenAI Codex/OpenAI
COPILOT_MODELCOPILOT_MODEL Remplacement optionnel du modèle Copilot. Laissez vide pour utiliser le modèle par défaut de Copilot CLI. Exemples : gpt-5.4, claude-sonnet-4.6 Modèles pris en charge par GitHub Copilot
CODEX_APPROVAL_POLICYCODEX_APPROVAL_POLICY Mode d’approbation transmis à Codex. Défaut : never.
CODEX_SANDBOX_MODECODEX_SANDBOX_MODE Mode sandbox transmis à Codex. Défaut : workspace-write.
CODEX_SKIP_GIT_REPO_CHECKCODEX_SKIP_GIT_REPO_CHECK Si activé, contourne toujours les vérifications de dépôt trusted de Codex.
ENABLE_COMMIT_COMMANDENABLE_COMMIT_COMMAND Active la commande Telegram /commit. Défaut : false.
AGENT_HARD_TIMEOUT_SECONDSAGENT_HARD_TIMEOUT_SECONDS Timeout dur pour une exécution d’agent. Défaut : 0 (désactivé).
SNAPSHOT_TEXT_FILE_MAX_BYTESTaille maximale de fichier que le bot lira en texte pour construire le snapshot avant/après des diffs. Défaut : 200000.SNAPSHOT_TEXT_FILE_MAX_BYTESTaille maximale de fichier que le bot lira en texte pour construire le instantané avant/après des diffs. Défaut : 200000.
MAX_TELEGRAM_MESSAGE_LENGTHMAX_TELEGRAM_MESSAGE_LENGTH Taille maximale d’un message avant découpage de la réponse. Défaut : 3000.
ENABLE_SENSITIVE_DIFF_FILTERENABLE_SENSITIVE_DIFF_FILTER Masquer les diffs des fichiers sensibles. Défaut : true.
ENABLE_SECRET_SCRUB_FILTERENABLE_SECRET_SCRUB_FILTER Masquer tokens, clés, valeurs .env, certificats et sorties similaires avant envoi vers Telegram. Défaut : true (fortement recommandé).
SNAPSHOT_INCLUDE_PATH_GLOBSSNAPSHOT_INCLUDE_PATH_GLOBS Toujours inclure les chemins correspondants dans les diffs. Exemple : .github/*,.profile.test,.profile.prod
SNAPSHOT_EXCLUDE_PATH_GLOBSAjouter des exclusions de diff supplémentaires au-dessus des valeurs par défaut du package. Exemple : .*,personal/*,sensitive*.txt Remarque : .* inclut les chemins cachés, y compris les fichiers dans les dossiers cachés.SNAPSHOT_EXCLUDE_PATH_GLOBSAjouter des exclusions de diff supplémentaires au-dessus des valeurs par défaut du paquet. Exemple : .*,personal/*,sensitive*.txt Remarque : .* inclut les chemins cachés, y compris les fichiers dans les dossiers cachés.
+ + + + +

Reconnaissance vocale

+ + + + + + + + + + + + +
ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXTValeur par défaut : false. Si activé, la reconnaissance des messages vocaux et des fichiers audio est disponible. Le système vérifie les binaires ou bibliothèques requis et invite l’utilisateur à les installer si nécessaire.
OPENAI_WHISPER_MODELModèle utilisé pour la STT Whisper. Valeur par défaut : base
Modèles disponibles : tiny environ 72 MB, base environ 139 MB, large-v3-turbo environ 1.5 GB
Les modèles sont téléchargés automatiquement lors de votre premier message vocal. Recommandé : base pour un usage général. Si vous souhaitez une meilleure précision et qualité, vous pouvez essayer turbo.
OPENAI_WHISPER_TIMEOUT_SECONDSValeur par défaut : 120. Délai d’expiration du processus STT. En général, le traitement est assez rapide. Mais si vous choisissez turbo, le premier message vocal peut dépasser ce délai pendant le téléchargement du modèle selon la vitesse de votre connexion.
@@ -338,15 +392,15 @@ Remarques : - + - + - +
~/.coding-agent-telegram/state.jsonHauptdatei für den Session-Status.Fichier principal de l’état des sessions.
~/.coding-agent-telegram/state.json.bakBackup-Datei für den Status.Fichier de sauvegarde de l’état.
~/.coding-agent-telegram/logsLog-Verzeichnis.Répertoire des logs.
@@ -383,27 +437,27 @@ Exemple : La session active est aussi liée à : -- project folder -- provider +- dossier de projet +- fournisseur - nom de branch quand disponible
Chaque session stocke : - nom de session -- project folder +- dossier de projet - nom de branch -- provider +- fournisseur - horodatages - sélection de session active pour cette portée bot/chat
### 🔓 Verrou de concurrence du workspace -Une seule exécution d'agent peut être active à la fois par **project folder**, quel que soit le chat ou le bot Telegram qui l'a déclenchée. +Une seule exécution d'agent peut être active à la fois par **dossier de projet**, quel que soit le chat ou le bot Telegram qui l'a déclenchée. -- **project is busy** : un agent est déjà en cours dans ce workspace -- **agent is busy** : cette exécution unique traite encore la requête courante +- **le projet est occupé** : un agent est déjà en cours dans cet espace de travail +- **l’agent est occupé** : cette exécution unique traite encore la requête courante Le bot impose cette limite pour éviter que deux agents écrivent en même temps dans le même workspace. Cela réduit les modifications conflictuelles et le risque de corruption. @@ -425,32 +479,32 @@ Si l'exécution en cours est annulée et que des questions attendent encore, le ## ⚠️ Diff (modifications de fichiers) -_Pendant chaque exécution d'agent, le bot prend aussi un léger snapshot avant/après du projet afin de résumer les fichiers modifiés et d'envoyer des diffs vers Telegram. Ce snapshot est produit par le bot lui-même, pas par Codex ou Copilot._ +_Pendant chaque exécution d'agent, le bot prend aussi un léger instantané avant/après du projet afin de résumer les fichiers modifiés et d'envoyer des diffs vers Telegram. Ce instantané est produit par le bot lui-même, pas par Codex ou Copilot._ -**À savoir sur le snapshot :** +**À savoir sur le instantané :** - l'app parcourt le dossier du projet avant et après l'exécution -- pour les fichiers texte normaux, l'app préfère le diff du snapshot du run plutôt qu'un diff contre le head Git +- pour les fichiers texte normaux, l'app préfère le diff du instantané du run plutôt qu'un diff contre le head Git - les répertoires courants de dépendances, cache et runtime sont aussi ignorés - les fichiers binaires et les fichiers plus gros que `SNAPSHOT_TEXT_FILE_MAX_BYTES` ne sont pas lus comme texte - sur les très gros projets, ce scan supplémentaire peut ajouter un surcoût notable en I/O et en mémoire -- si un snapshot ne peut pas représenter un fichier comme texte, l'app retombe sur `git diff` lorsque c'est possible +- si un instantané ne peut pas représenter un fichier comme texte, l'app retombe sur `git diff` lorsque c'est possible - pour les gros fichiers ou les fichiers non textuels, le diff peut quand même être omis et remplacé par un court message -Les règles d'exclusion du snapshot se trouvent dans les ressources du package : +Les règles d'exclusion du instantané se trouvent dans les ressources du paquet : -- `src/coding_agent_telegram/resources/snapshot_excluded_dir_names.txt` -- `src/coding_agent_telegram/resources/snapshot_excluded_dir_globs.txt` -- `src/coding_agent_telegram/resources/snapshot_excluded_file_globs.txt` +- `src/coding_agent_telegram/resources/instantané_excluded_dir_names.txt` +- `src/coding_agent_telegram/resources/instantané_excluded_dir_globs.txt` +- `src/coding_agent_telegram/resources/instantané_excluded_file_globs.txt` -Vous pouvez surcharger ces valeurs dans le fichier env sans modifier le package installé : +Vous pouvez surcharger ces valeurs dans le fichier env sans modifier le paquet installé : - `SNAPSHOT_INCLUDE_PATH_GLOBS` Force l'inclusion des chemins correspondants dans les diffs. Exemple : `.github/*,.profile.test,.profile.prod` - `SNAPSHOT_EXCLUDE_PATH_GLOBS` - Ajoute des exclusions de diff supplémentaires au-dessus des valeurs par défaut du package. + Ajoute des exclusions de diff supplémentaires au-dessus des valeurs par défaut du paquet. Exemple : `.*,personal/*,sensitive*.txt` Remarque : `.*` couvre les chemins cachés, y compris les fichiers dans des dossiers cachés. @@ -466,8 +520,8 @@ Le bot traite le projet et la branch comme un ensemble. Quand vous créez ou changez une branch, le bot vous guide explicitement sur la source : -- `local/` : utiliser la branch locale comme source -- `origin/` : mettre à jour depuis la branch distante puis basculer +- local/<branch> : utiliser la branch locale comme source +- origin/<branch> : mettre à jour depuis la branch distante puis basculer Si le bot détecte que la branch stockée dans la session ne correspond pas à la branch courante du dépôt, il ne continue pas à l'aveugle. Il vous demande quelle branch utiliser : @@ -485,7 +539,7 @@ Si votre branch source préférée est introuvable, le bot propose des sources d - `/commit` peut être désactivé complètement avec `ENABLE_COMMIT_COMMAND` - les opérations `/commit` qui modifient des fichiers ne sont autorisées que pour les projets trusted -## 🪵 Logs +## 🪵 Journaux Les logs sont écrits **à la fois sur stdout et dans un fichier rotatif** sous : @@ -518,14 +572,14 @@ Les logs sont écrits **à la fois sur stdout et dans un fichier rotatif** sous point d'entrée local pour le bootstrap et le démarrage - `src/coding_agent_telegram/resources/.env.example` - modèle d'environnement canonique utilisé à la fois par le démarrage depuis le dépôt et par les installations du package + modèle d'environnement canonique utilisé à la fois par le démarrage depuis le dépôt et par les installations du paquet - `pyproject.toml` configuration du packaging et des dépendances ## 📦 Versionnement des releases -Les versions du package sont dérivées des tags Git. +Les versions du paquet sont dérivées des tags Git. - TestPyPI/test : `v2026.3.26.dev1` - préversion PyPI : `v2026.3.26rc1` diff --git a/README.ja.md b/README.ja.md index a196d37..30b183e 100644 --- a/README.ja.md +++ b/README.ja.md @@ -38,7 +38,7 @@ - ✅ Telegram で Codex / Copilot CLI を操作できる - ✅ エージェントの回答や変更ファイルをコードブロックで確認しやすい - ✅ エージェント実行中でも追加入力をキューに積める - - ✅ テキストと画像入力に対応 + - ✅ ✏️ テキスト、🌄 画像、🎙️ 音声メッセージに対応 ## 🔁 デバイス/セッションをシームレスに切り替え @@ -49,7 +49,7 @@ ## 🛠️ 典型的なローカルフロー ```bash - coding-agent-telegram # or run ./startup.sh + coding-agent-telegram # または ./startup.sh を実行 ``` ##### Telegram では: @@ -99,6 +99,7 @@ curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/i - ローカルにインストール済みの Codex CLI または Copilot CLI - [Codex CLI インストール](https://developers.openai.com/codex/cli) - [Copilot CLI インストール](https://github.com/features/copilot/cli) +- [任意] Whisper、ffmpeg @@ -108,35 +109,61 @@ Openclaw は非常に多機能で、Pi-Agent という統合 agent loop も備 ## 🚀 クイックスタート -### Option A: ワンライナーのブートストラップスクリプト +### 方法A: ワンライナーのブートストラップスクリプト ```bash curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/install.sh | bash ``` -### Option B: `pip` で PyPI からインストール +### 方法B: `pip` で PyPI からインストール ```bash pip install coding-agent-telegram coding-agent-telegram ``` -### Option C: クローンしたリポジトリから実行 +### 方法C: クローンしたリポジトリから実行 ```bash git clone https://github.com/daocha/coding-agent-telegram cd coding-agent-telegram ./startup.sh ``` -### Bot サーバーを起動 +### 🌐 Bot サーバーを起動 ##### 初回起動時にアプリが env ファイルを作成し、入力すべき項目を案内します。 ##### env ファイルを更新したら、次を再実行してください: ```bash -# if you follow Option A or Option B, then run +# 方法A または 方法B に従う場合は、次を実行 coding-agent-telegram -# if you follow Option C, then run this again +# 方法C に従う場合は、これをもう一度実行 ./startup.sh ``` +## 🎙️ [任意] 音声文字起こし機能: ローカル OpenAI-Whisper の前提条件を準備 + +これにより、Telegram のボイスノートに対するローカル Whisper ベースの音声文字起こしを任意で有効にできます。音声ファイルは最大 `20 MB` に制限されます。 + +```bash +# pip または one-liner install.sh でインストールした場合 +coding-agent-telegram-stt-install + +# クローンしたリポジトリから使う場合 +./install-stt.sh +``` + +推奨される env 設定: + +```text +ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true +OPENAI_WHISPER_MODEL=base +OPENAI_WHISPER_TIMEOUT_SECONDS=120 +``` + +メモ: + +- Whisper は選択したモデルを初回利用時に `~/.cache/whisper` へ自動ダウンロードします。 +- `OPENAI_WHISPER_MODEL=turbo` を選ぶと、`large-v3-turbo.pt` のダウンロード中に最初の音声文字起こしがタイムアウトしやすくなります。 +- 音声メッセージを文字起こしした後、ボットはまず認識したテキストを Telegram に返し、その後でエージェントへ渡します。これにより認識ミスを確認しやすくなります。 + ## 🔑 Telegram セットアップ ### Bot Token を取得 @@ -171,60 +198,67 @@ https://api.telegram.org/bot/getUpdates ## 📨 対応メッセージタイプ +このボットが現在受け付けるもの: + +- テキストメッセージ +- 写真 +- `ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true` が設定され、ローカル Whisper の前提条件がインストールされている場合の音声メッセージと音声ファイル +- Codex と Copilot は現在、テキストと画像のみをサポートしており、動画はサポートしていません + ## 🤖 Telegram コマンド - - + + - + - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + +
/provider新しい session 用の provider を選択します。選択は変更するまで bot と chat ごとに保存されます。/provider新しいセッション用のプロバイダーを選択します。選択は変更するまで bot と chat ごとに保存されます。
/project <project_folder>/project <project_folder> 現在のプロジェクトフォルダを設定します。フォルダが存在しない場合は作成して trusted として扱います。既存で untrusted の場合は明示的に trust を確認します。
/branch <new_branch>/branch <new_branch> 現在のプロジェクトで branch を準備または切り替えます。branch が既に存在する場合はその branch を source candidate として扱います。存在しない場合は repository の default branch を source candidate に使います。
/branch <origin_branch> <new_branch>`` を source candidate として branch を準備または切り替えます。どちらの形式でも bot は実在する source choice のみを提示します: `local/` と `origin/`。片方だけ存在する場合はその選択肢だけが表示され、どちらも無い場合は branch source が無いと通知します。/branch <origin_branch> <new_branch><origin_branch> を source candidate として branch を準備または切り替えます。どちらの形式でも bot は実在する source choice のみを提示します: local/<branch>origin/<branch>。片方だけ存在する場合はその選択肢だけが表示され、どちらも無い場合は branch source が無いと通知します。
/current現在の bot と chat の active session を表示します。/current現在の bot と chat の アクティブなセッション を表示します。
/new [session_name]現在のプロジェクトに新しい session を作成します。名前を省略すると実際の session ID を使います。provider、project、branch が不足している場合は bot が不足分を案内します。/new [session_name]現在のプロジェクトに新しいセッションを作成します。名前を省略すると実際のセッション ID を使います。プロバイダー、プロジェクト、branch が不足している場合は bot が不足分を案内します。
/switch最新の session を新しい順で表示します。現在のプロジェクトに対する bot-managed session とローカルの Codex/Copilot CLI session の両方を含みます。/switch最新のセッションを新しい順で表示します。現在のプロジェクトに対する bot 管理セッションとローカルの Codex/Copilot CLI セッションの両方を含みます。
/switch page <number>保存済み session の別ページを表示します。/switch page <number>保存済みセッションの別ページを表示します。
/switch <session_id>ID を指定して特定の session に切り替えます。ローカル CLI session を選ぶと bot がそれを取り込み、そこから続行します。/switch <session_id>ID を指定して特定のセッションに切り替えます。ローカル CLI セッションを選ぶと bot がそれを取り込み、そこから続行します。
/compactアクティブな session から新しい compact 済み session を作成し、そこへ切り替えます。/compactアクティブなセッションから新しい compact 済みセッションを作成し、そこへ切り替えます。
/commit <git commands>active session の project 内で、検証済みの `git commit` 関連コマンドを実行します。`ENABLE_COMMIT_COMMAND=true` のときだけ利用できます。変更を伴う Git コマンドには trusted project が必要です。/commit <git commands>アクティブなセッション の project 内で、検証済みの git commit 関連コマンドを実行します。ENABLE_COMMIT_COMMAND=true のときだけ利用できます。変更を伴う Git コマンドには trusted project が必要です。
/push現在の active session に対して `origin ` を push します。push 前に bot が確認します。/push現在の アクティブなセッション に対して origin <branch> を push します。push 前に bot が確認します。
/abort現在のプロジェクトで実行中の agent run を中断します。queued questions がある場合は続行するか確認します。/abort現在のプロジェクトで実行中の エージェント実行 を中断します。キューされた質問 がある場合は続行するか確認します。
@@ -251,15 +285,15 @@ https://api.telegram.org/bot/getUpdates - + - + - +
WORKSPACE_ROOTWORKSPACE_ROOT プロジェクトディレクトリを含む親フォルダです。
TELEGRAM_BOT_TOKENSTELEGRAM_BOT_TOKENS カンマ区切りの Telegram bot token です。
ALLOWED_CHAT_IDSALLOWED_CHAT_IDS この bot の利用を許可する Telegram プライベート chat ID をカンマ区切りで指定します。
@@ -268,85 +302,103 @@ https://api.telegram.org/bot/getUpdates - + - + - + - + - + - + - + - + - + - - + + - - + + - + - + - + - + - - + + + +
APP_LOCALEAPP_LOCALE 共有 bot メッセージとコマンド説明の UI 言語です。対応値: en, de, fr, ja, ko, nl, th, vi, zh-CN, zh-HK, zh-TW.
CODEX_BINCODEX_BIN Codex CLI を起動するコマンドです。既定値: codex.
COPILOT_BINCOPILOT_BIN Copilot CLI を起動するコマンドです。既定値: copilot.
CODEX_MODELCODEX_MODEL Codex モデルの任意上書きです。空欄なら Codex CLI の既定モデルを使います。例: gpt-5.4 OpenAI Codex/OpenAI models
COPILOT_MODELCOPILOT_MODEL Copilot モデルの任意上書きです。空欄なら Copilot CLI の既定モデルを使います。例: gpt-5.4, claude-sonnet-4.6 GitHub Copilot supported models
CODEX_APPROVAL_POLICYCODEX_APPROVAL_POLICY Codex に渡す approval mode。既定: never.
CODEX_SANDBOX_MODECODEX_SANDBOX_MODE Codex に渡す sandbox mode。既定: workspace-write.
CODEX_SKIP_GIT_REPO_CHECKCODEX_SKIP_GIT_REPO_CHECK 有効にすると Codex の trusted-repo check を常にスキップします。
ENABLE_COMMIT_COMMANDENABLE_COMMIT_COMMAND Telegram の /commit コマンドを有効にします。既定: false.
AGENT_HARD_TIMEOUT_SECONDS単一の agent run に対するハードタイムアウト。既定: 0(無効)。AGENT_HARD_TIMEOUT_SECONDS単一の エージェント実行 に対するハードタイムアウト。既定: 0(無効)。
SNAPSHOT_TEXT_FILE_MAX_BYTES実行ごとの diff 用に before/after snapshot を作る際、bot がテキストとして読む最大ファイルサイズです。既定: 200000.SNAPSHOT_TEXT_FILE_MAX_BYTES実行ごとの diff 用に 実行前後のスナップショット を作る際、bot がテキストとして読む最大ファイルサイズです。既定: 200000.
MAX_TELEGRAM_MESSAGE_LENGTHMAX_TELEGRAM_MESSAGE_LENGTH 応答を分割する前に使う最大メッセージサイズ。既定: 3000.
ENABLE_SENSITIVE_DIFF_FILTERENABLE_SENSITIVE_DIFF_FILTER 機密ファイルの diff を隠します。既定: true.
ENABLE_SECRET_SCRUB_FILTERENABLE_SECRET_SCRUB_FILTER tokens、keys、.env 値、certificates などの秘密らしい出力を Telegram 送信前にマスクします。既定: true(強く推奨)。
SNAPSHOT_INCLUDE_PATH_GLOBSSNAPSHOT_INCLUDE_PATH_GLOBS 一致するパスを diff に強制的に含めます。例: .github/*,.profile.test,.profile.prod
SNAPSHOT_EXCLUDE_PATH_GLOBSパッケージ既定値に加えて diff 除外を追加します。例: .*,personal/*,sensitive*.txt 注: .* は hidden directory 内のファイルも含む hidden path に一致します。SNAPSHOT_EXCLUDE_PATH_GLOBSパッケージ既定値に加えて diff 除外を追加します。例: .*,personal/*,sensitive*.txt 注: .* は 隠しディレクトリ内のファイルも含む隠しパス に一致します。
+ + +

音声文字起こし

+ + + + + + + + + + + + +
ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXTデフォルト: falsetrue の場合、音声メッセージと音声ファイルの認識を有効にします。必要なバイナリやライブラリを起動時に確認し、不足していればインストールを案内します。
OPENAI_WHISPER_MODELWhisper STT で使うモデルです。デフォルト: base
利用可能なモデル: tiny72 MBbase139 MBlarge-v3-turbo1.5 GB
モデルは最初の音声メッセージ時に自動でダウンロードされます。一般用途では base を推奨します。より高い精度や品質が必要なら turbo を試してください。
OPENAI_WHISPER_TIMEOUT_SECONDSデフォルト: 120。STT プロセスのタイムアウトです。通常は十分高速ですが、turbo を選ぶと最初の音声メッセージでモデルをダウンロードする間に、回線速度によってはタイムアウトすることがあります。
-

状態ファイルとログ

+

状態とログ

- + - + - +
~/.coding-agent-telegram/state.jsonHauptdatei für den Session-Status.セッション状態のメインファイル。
~/.coding-agent-telegram/state.json.bakBackup-Datei für den Status.状態のバックアップファイル。
~/.coding-agent-telegram/logsLog-Verzeichnis.ログディレクトリ。
@@ -366,14 +418,14 @@ ENABLE_SENSITIVE_DIFF_FILTER=true ENABLE_SECRET_SCRUB_FILTER=true ``` -## 🧠 Session 管理 +## 🧠 セッション管理 -Session は次の単位で分かれます: +セッションは次の単位で分かれます: - Telegram bot - Telegram chat -そのため、同じ Telegram アカウントでも複数の bot を使い分けながら session を混在させずに運用できます。 +そのため、同じ Telegram アカウントでも複数の bot を使い分けながらセッションを混在させずに運用できます。 例: @@ -381,29 +433,29 @@ Session は次の単位で分かれます: - Bot B + あなたの chat -> frontend 作業 - Bot C + あなたの chat -> infra 作業 -active session はさらに次にも紐づきます: +アクティブなセッション はさらに次にも紐づきます: -- project folder -- provider +- プロジェクトフォルダー +- プロバイダー - 利用可能なら branch 名
-各 session に保存される内容 +各セッションに保存される内容 -- session 名 -- project folder +- セッション名 +- プロジェクトフォルダー - branch 名 -- provider +- プロバイダー - timestamps -- その bot/chat スコープでの active session 選択 +- その bot/chat スコープでの アクティブなセッション 選択
-### 🔓 Workspace concurrency lock +### 🔓 ワークスペース同時実行ロック -**project folder** ごとに同時に動ける agent run は 1 つだけです。どの chat や Telegram bot から起動したかは関係ありません。 +**プロジェクトフォルダー** ごとに同時に動ける エージェント実行 は 1 つだけです。どの chat や Telegram bot から起動したかは関係ありません。 -- **project is busy**: その workspace ですでに agent run が動いている状態 -- **agent is busy**: その 1 つの run が現在の依頼をまだ処理中の状態 +- **プロジェクトは使用中**: その workspace ですでに エージェント実行 が動いている状態 +- **エージェントは使用中**: その 1 つの run が現在の依頼をまだ処理中の状態 2 つの agent が同じ workspace に同時に書き込まないように、この制約を設けています。競合する編集やデータ破損の可能性を減らすためです。 @@ -415,44 +467,44 @@ lock はディスクではなくメモリ上に保持されるため、agent 完 ### 💬 キューされた質問 -現在の project で既に agent run が動いている場合、後から送られたテキストメッセージは拒否されずに queue されます。 +現在の project で既に エージェント実行 が動いている場合、後から送られたテキストメッセージは拒否されずに queue されます。 - 新しい質問はディスク上の queued-questions file に追記されます - 現在の agent は先の依頼をそのまま処理し続けます - run が正常終了すると、bot は queue 内の質問の処理を自動で開始します -現在の run が abort され、まだ queued questions が残っている場合は自動継続しません。残りを続けるかどうか、まとめて処理するか 1 件ずつ処理するかを bot が確認します。 +現在の run が abort され、まだ キューされた質問 が残っている場合は自動継続しません。残りを続けるかどうか、まとめて処理するか 1 件ずつ処理するかを bot が確認します。 ## ⚠️ Diff(ファイル変更) -_各 agent run のたびに、bot はプロジェクトの軽量な before/after snapshot も取得し、変更ファイルの要約と diff を Telegram に送ります。この snapshot は Codex や Copilot ではなく、bot 自身が作成します。_ +_各 エージェント実行 のたびに、bot はプロジェクトの軽量な 実行前後のスナップショット も取得し、変更ファイルの要約と diff を Telegram に送ります。この スナップショット は Codex や Copilot ではなく、bot 自身が作成します。_ **Snapshot のポイント:** -- app は run の前後で project directory を走査します -- 通常のテキストファイルでは、git head diff よりも run ごとの snapshot diff を優先します -- 一般的な依存関係、cache、runtime directory も除外されます -- binary file と `SNAPSHOT_TEXT_FILE_MAX_BYTES` を超える file は text として読み込みません +- app は run の前後で プロジェクトディレクトリ を走査します +- 通常のテキストファイルでは、git head diff よりも 実行ごとのスナップショット差分 を優先します +- 一般的な依存関係、キャッシュ、実行時ディレクトリ も除外されます +- binary ファイル と `SNAPSHOT_TEXT_FILE_MAX_BYTES` を超える ファイル は text として読み込みません - 非常に大きな project では、この追加走査によって I/O と memory の負荷が増えることがあります -- snapshot で text として表現できない file は、可能なら `git diff` に fallback します -- 大きい file や非 text file では、diff を省略して短いメッセージに置き換えることがあります +- スナップショット で text として表現できない ファイル は、可能なら `git diff` に fallback します +- 大きい ファイル や非 text ファイル では、diff を省略して短いメッセージに置き換えることがあります -Snapshot の除外ルールは package resource にあります: +Snapshot の除外ルールは パッケージ resource にあります: - `src/coding_agent_telegram/resources/snapshot_excluded_dir_names.txt` - `src/coding_agent_telegram/resources/snapshot_excluded_dir_globs.txt` - `src/coding_agent_telegram/resources/snapshot_excluded_file_globs.txt` -これらの既定値は、インストール済み package を編集せずに env file から上書きできます: +これらの既定値は、インストール済み パッケージ を編集せずに 環境設定ファイル から上書きできます: - `SNAPSHOT_INCLUDE_PATH_GLOBS` 一致した path を diff に強制的に含めます。 例: `.github/*,.profile.test,.profile.prod` - `SNAPSHOT_EXCLUDE_PATH_GLOBS` - package 既定値に追加の diff 除外を加えます。 + パッケージ 既定値に追加の diff 除外を加えます。 例: `.*,personal/*,sensitive*.txt` - 注: `.*` は hidden directory 内の file も含む hidden path に一致します。 + 注: `.*` は 隠しディレクトリ内のファイルも含む隠しパス に一致します。 include と exclude の両方が一致した場合は include が優先されます。 @@ -462,16 +514,16 @@ bot は project と branch をひとまとまりとして扱います。 - project を選んでも、無関係な branch を勝手に選びません - branch が必要なときは、bot が選択を求めます -- session 関連メッセージで branch を表示するときは、project と branch を一緒に表示します +- セッション関連メッセージで branch を表示するときは、プロジェクトと branch を一緒に表示します branch を作成または切り替えるとき、bot は source を明示的に案内します: -- `local/`: ローカル branch を source に使う -- `origin/`: remote branch から更新してから切り替える +- local/<branch>: ローカル branch を source に使う +- origin/<branch>: remote branch から更新してから切り替える -保存済み session の branch と現在の repository branch が一致しない場合、bot はそのまま続行しません。どちらの branch を使うか確認します: +保存済みセッションの branch と現在の repository branch が一致しない場合、bot はそのまま続行しません。どちらの branch を使うか確認します: -- 保存済み session の branch を使う +- 保存済みセッションの branch を使う - 現在の repository branch を使う 希望する source branch が存在しない場合は、生の Git error にせず、default branch と current branch を元に fallback source を提案します。 @@ -481,28 +533,28 @@ branch を作成または切り替えるとき、bot は source を明示的に - 既存 folder は `CODEX_SKIP_GIT_REPO_CHECK` に従います - `/project ` で作成した folder は、この app により trusted として扱われます - 既存 folder を `/project ` で選択した場合は、Telegram prompt で trust を確認するまで untrusted のままです -- そのため、新しく作成した project folder はすぐに使えます +- そのため、新しく作成した プロジェクトフォルダー はすぐに使えます - `/commit` は `ENABLE_COMMIT_COMMAND` で完全に無効化できます - 変更を伴う `/commit` 操作は trusted project でのみ許可されます -## 🪵 Logs +## 🪵 ログ -log は **stdout とローテーションする log file の両方**に書き込まれます: +log は **stdout とローテーションする ログファイル の両方**に書き込まれます: - `~/.coding-agent-telegram/logs`(10 MB でローテーション、3 世代保持) -> **注意:** terminal を見ながら同時に log file を tail すると、各メッセージが 2 回表示されます。これは想定どおりです。どちらか一方だけを見てください。 +> **注意:** terminal を見ながら同時に ログファイル を tail すると、各メッセージが 2 回表示されます。これは想定どおりです。どちらか一方だけを見てください。
よく記録されるイベント - bot 起動と polling 開始 - project 選択 -- session 作成 -- session 切り替え -- active session の表示 +- セッション作成 +- セッション切り替え +- アクティブなセッション の表示 - 通常の run 実行(切り詰められた prompt を含む audit log 行も含む) -- resume 失敗後の session 置き換え +- resume 失敗後のセッション置き換え - warning と runtime error
@@ -521,7 +573,7 @@ log は **stdout とローテーションする log file の両方**に書き込 repo 起動と package インストールの両方で使う canonical な environment template - `pyproject.toml` - packaging と dependency の設定 + パッケージング と 依存関係 の設定 ## 📦 リリース版の付け方 diff --git a/README.ko.md b/README.ko.md index 9f68399..5cf2365 100644 --- a/README.ko.md +++ b/README.ko.md @@ -38,7 +38,7 @@ - ✅ Telegram 으로 Codex / Copilot CLI 를 제어 - ✅ 에이전트 응답과 변경 파일을 코드 블록으로 쉽게 검토 - ✅ 에이전트가 작업 중일 때도 후속 질문을 큐에 저장 - - ✅ 텍스트와 이미지 입력 지원 + - ✅ ✏️ 텍스트, 🌄 이미지, 🎙️ 음성 메시지 지원 ## 🔁 기기/세션 간 자연스러운 전환 @@ -49,7 +49,7 @@ ## 🛠️ 일반적인 로컬 흐름 ```bash - coding-agent-telegram # or run ./startup.sh + coding-agent-telegram # 또는 ./startup.sh 실행 ``` ##### Telegram에서: @@ -99,6 +99,7 @@ curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/i - 로컬에 설치된 Codex CLI 및/또는 Copilot CLI - [Codex CLI 설치](https://developers.openai.com/codex/cli) - [Copilot CLI 설치](https://github.com/features/copilot/cli) +- [선택 사항] Whisper, ffmpeg @@ -108,35 +109,61 @@ Openclaw 는 매우 다양한 기능을 제공하고 Pi-Agent 라는 통합 agen ## 🚀 빠른 시작 -### Option A: 한 줄 부트스트랩 스크립트 +### 방법 A: 한 줄 부트스트랩 스크립트 ```bash curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/install.sh | bash ``` -### Option B: `pip`으로 PyPI 설치 +### 방법 B: `pip`으로 PyPI 설치 ```bash pip install coding-agent-telegram coding-agent-telegram ``` -### Option C: 저장소를 clone해서 실행 +### 방법 C: 저장소를 clone해서 실행 ```bash git clone https://github.com/daocha/coding-agent-telegram cd coding-agent-telegram ./startup.sh ``` -### Bot 서버 시작 +### 🌐 Bot 서버 시작 ##### 첫 실행 시 앱이 env 파일을 만들고 어떤 항목을 채워야 하는지 알려줍니다. ##### env 파일을 수정한 뒤 다시 실행하세요: ```bash -# if you follow Option A or Option B, then run +# 방법 A 또는 방법 B를 따르는 경우 다음을 실행 coding-agent-telegram -# if you follow Option C, then run this again +# 방법 C를 따르는 경우 이것을 다시 실행 ./startup.sh ``` +## 🎙️ [선택 사항] 음성 텍스트 변환 기능: 로컬 OpenAI-Whisper 전제 조건 준비 + +이 기능을 사용하면 Telegram 음성 노트에 대해 로컬 Whisper 기반 음성-텍스트 기능을 선택적으로 활성화할 수 있습니다. 오디오 파일은 최대 `20 MB` 까지만 지원됩니다. + +```bash +# pip 으로 설치한 경우 +coding-agent-telegram-stt-install + +# 클론한 저장소에서 실행하는 경우 +./install-stt.sh +``` + +권장 env 설정: + +```text +ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true +OPENAI_WHISPER_MODEL=base +OPENAI_WHISPER_TIMEOUT_SECONDS=120 +``` + +참고: + +- Whisper 는 선택한 모델을 처음 사용할 때 `~/.cache/whisper` 로 자동 다운로드합니다. +- `OPENAI_WHISPER_MODEL=turbo` 를 선택하면 `large-v3-turbo.pt` 를 다운로드하는 동안 첫 음성 전사가 시간 초과에 걸릴 가능성이 더 높습니다. +- 음성 메시지를 전사한 뒤 봇은 먼저 인식된 텍스트를 Telegram 에 다시 보여주고, 그 다음 에이전트에 전달합니다. 그래서 인식 오류를 확인하기 쉽습니다. + ## 🔑 Telegram 설정 ### Bot Token 받기 @@ -171,60 +198,67 @@ https://api.telegram.org/bot/getUpdates ## 📨 지원되는 메시지 유형 +현재 이 봇이 받는 메시지: + +- 텍스트 메시지 +- 사진 +- `ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true` 로 설정되어 있고 로컬 Whisper 전제 조건이 설치된 경우의 음성 메시지와 오디오 파일 +- Codex 와 Copilot 은 현재 텍스트와 이미지만 지원하며, 비디오는 지원하지 않습니다 + ## 🤖 Telegram 명령어 - - + + - - + + - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + +
/provider새 session용 provider 를 선택합니다. 선택 내용은 바꿀 때까지 bot/chat 단위로 저장됩니다./provider새 세션용 제공자를 선택합니다. 선택 내용은 바꿀 때까지 bot/chat 단위로 저장됩니다.
/project <project_folder>현재 project folder를 설정합니다. 폴더가 없으면 앱이 만들고 trusted 로 표시합니다. 이미 존재하지만 아직 untrusted 이면 trust 확인을 요청합니다./project <project_folder>현재 프로젝트 폴더를 설정합니다. 폴더가 없으면 앱이 만들고 trusted 로 표시합니다. 이미 존재하지만 아직 untrusted 이면 trust 확인을 요청합니다.
/branch <new_branch>/branch <new_branch> 현재 project에서 branch 를 준비하거나 전환합니다. branch 가 이미 있으면 source candidate 로 취급하고, 없으면 repository 의 default branch 를 source candidate 로 사용합니다.
/branch <origin_branch> <new_branch>`` 를 source candidate 로 사용해 branch 를 준비하거나 전환합니다. 두 형식 모두 bot 은 실제로 존재하는 source choice 만 보여줍니다: `local/`, `origin/`. 하나만 있으면 그것만 보이고, 둘 다 없으면 branch source 가 없다고 알립니다./branch <origin_branch> <new_branch><origin_branch> 를 source candidate 로 사용해 branch 를 준비하거나 전환합니다. 두 형식 모두 bot 은 실제로 존재하는 source choice 만 보여줍니다: local/<branch>, origin/<branch>. 하나만 있으면 그것만 보이고, 둘 다 없으면 branch source 가 없다고 알립니다.
/current현재 bot/chat 의 active session 을 보여줍니다./current현재 bot/chat 의 활성 세션 을 보여줍니다.
/new [session_name]현재 project에 새 session을 만듭니다. 이름을 생략하면 실제 session ID를 사용합니다. provider, project, branch 가 없으면 bot 이 필요한 단계를 안내합니다./new [session_name]현재 프로젝트에 새 세션을 만듭니다. 이름을 생략하면 실제 세션 ID를 사용합니다. 제공자, 프로젝트, branch 가 없으면 bot 이 필요한 단계를 안내합니다.
/switch가장 최근 session 을 최신순으로 보여줍니다. 현재 project 의 bot-managed session 과 로컬 Codex/Copilot CLI session 이 함께 표시됩니다./switch가장 최근 세션을 최신순으로 보여줍니다. 현재 프로젝트의 bot 관리 세션과 로컬 Codex/Copilot CLI 세션이 함께 표시됩니다.
/switch page <number>저장된 session 의 다른 페이지를 보여줍니다./switch page <number>저장된 세션의 다른 페이지를 보여줍니다.
/switch <session_id>ID 로 특정 session 으로 전환합니다. 로컬 CLI session 을 선택하면 bot 이 state 에 가져와 이어서 진행합니다./switch <session_id>ID 로 특정 세션으로 전환합니다. 로컬 CLI 세션을 선택하면 bot 이 상태에 가져와 이어서 진행합니다.
/compact활성 session 에서 새 compact session 을 만들고 그쪽으로 전환합니다./compact활성 세션에서 새 compact 세션을 만들고 그쪽으로 전환합니다.
/commit <git commands>active session project 안에서 검증된 `git commit` 관련 명령을 실행합니다. `ENABLE_COMMIT_COMMAND=true` 일 때만 사용할 수 있습니다. 변경성 Git 명령은 trusted project 가 필요합니다./commit <git commands>활성 세션 project 안에서 검증된 git commit 관련 명령을 실행합니다. ENABLE_COMMIT_COMMAND=true 일 때만 사용할 수 있습니다. 변경성 Git 명령은 trusted project 가 필요합니다.
/push현재 active session 에 대해 `origin ` 를 push 합니다. push 전에 bot 이 확인합니다./push현재 활성 세션 에 대해 origin <branch> 를 push 합니다. push 전에 bot 이 확인합니다.
/abort현재 project 의 agent run 을 중단합니다. 대기 중인 queued question 이 있으면 계속할지 묻습니다./abort현재 project 의 에이전트 실행 을 중단합니다. 대기 중인 queued question 이 있으면 계속할지 묻습니다.
@@ -251,15 +285,15 @@ https://api.telegram.org/bot/getUpdates - + - + - +
WORKSPACE_ROOTWORKSPACE_ROOT 프로젝트 디렉터리를 담는 상위 폴더입니다.
TELEGRAM_BOT_TOKENSTELEGRAM_BOT_TOKENS 쉼표로 구분된 Telegram bot token 목록입니다.
ALLOWED_CHAT_IDSALLOWED_CHAT_IDS 이 bot 사용을 허용할 Telegram 개인 chat ID 목록입니다.
@@ -268,68 +302,86 @@ https://api.telegram.org/bot/getUpdates - + - + - + - + - + - + - + - + - + - - + + - + - + - + - + - + - - + + + +
APP_LOCALEAPP_LOCALE 공용 bot 메시지와 명령 설명에 사용할 UI locale 입니다. 지원 값: en, de, fr, ja, ko, nl, th, vi, zh-CN, zh-HK, zh-TW.
CODEX_BINCODEX_BIN Codex CLI 를 실행할 명령입니다. 기본값: codex.
COPILOT_BINCOPILOT_BIN Copilot CLI 를 실행할 명령입니다. 기본값: copilot.
CODEX_MODELCODEX_MODEL 선택적 Codex model override 입니다. 비워 두면 Codex CLI 기본 model 을 사용합니다. 예: gpt-5.4 OpenAI Codex/OpenAI models
COPILOT_MODELCOPILOT_MODEL 선택적 Copilot model override 입니다. 비워 두면 Copilot CLI 기본 model 을 사용합니다. 예: gpt-5.4, claude-sonnet-4.6 GitHub Copilot supported models
CODEX_APPROVAL_POLICYCODEX_APPROVAL_POLICY Codex 에 전달할 approval mode 입니다. 기본값: never.
CODEX_SANDBOX_MODECODEX_SANDBOX_MODE Codex 에 전달할 sandbox mode 입니다. 기본값: workspace-write.
CODEX_SKIP_GIT_REPO_CHECKCODEX_SKIP_GIT_REPO_CHECK 활성화하면 Codex trusted-repo check 를 항상 건너뜁니다.
ENABLE_COMMIT_COMMANDENABLE_COMMIT_COMMAND Telegram /commit 명령을 활성화합니다. 기본값: false.
AGENT_HARD_TIMEOUT_SECONDS단일 agent run 의 하드 타임아웃입니다. 기본값: 0 (비활성화).AGENT_HARD_TIMEOUT_SECONDS단일 에이전트 실행 의 하드 타임아웃입니다. 기본값: 0 (비활성화).
SNAPSHOT_TEXT_FILE_MAX_BYTESSNAPSHOT_TEXT_FILE_MAX_BYTES 실행별 diff 스냅샷을 만들 때 bot 이 텍스트로 읽을 최대 파일 크기입니다. 기본값: 200000.
MAX_TELEGRAM_MESSAGE_LENGTHMAX_TELEGRAM_MESSAGE_LENGTH 응답을 분할하기 전 최대 메시지 크기입니다. 기본값: 3000.
ENABLE_SENSITIVE_DIFF_FILTERENABLE_SENSITIVE_DIFF_FILTER 민감한 파일의 diff 를 숨깁니다. 기본값: true.
ENABLE_SECRET_SCRUB_FILTERENABLE_SECRET_SCRUB_FILTER tokens, keys, .env 값, certificates 등 비밀스러운 출력을 Telegram 으로 보내기 전에 마스킹합니다. 기본값: true (강력 권장).
SNAPSHOT_INCLUDE_PATH_GLOBSSNAPSHOT_INCLUDE_PATH_GLOBS 일치하는 경로를 diff 에 강제로 포함합니다. 예: .github/*,.profile.test,.profile.prod
SNAPSHOT_EXCLUDE_PATH_GLOBS패키지 기본값 위에 추가 diff 제외 규칙을 더합니다. 예: .*,personal/*,sensitive*.txt 참고: .* 는 hidden directory 안 파일을 포함한 hidden path 에도 매칭됩니다.SNAPSHOT_EXCLUDE_PATH_GLOBS패키지 기본값 위에 추가 diff 제외 규칙을 더합니다. 예: .*,personal/*,sensitive*.txt 참고: .* 는 숨김 디렉터리 안 파일을 포함한 숨김 경로 에도 매칭됩니다.
+ + +

음성 텍스트 변환

+ + + + + + + + + + + + +
ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT기본값: false. true이면 음성 메시지와 오디오 파일 인식을 활성화합니다. 시스템은 필요한 바이너리나 라이브러리를 확인하고, 누락된 경우 설치를 안내합니다.
OPENAI_WHISPER_MODELWhisper STT에 사용할 모델입니다. 기본값: base
사용 가능한 모델: tiny72 MB, base139 MB, large-v3-turbo1.5 GB
모델은 첫 음성 메시지 전송 시 자동으로 다운로드됩니다. 일반적인 사용에는 base를 권장합니다. 더 나은 정확도와 품질이 필요하면 turbo를 시도할 수 있습니다.
OPENAI_WHISPER_TIMEOUT_SECONDS기본값: 120. STT 프로세스 제한 시간입니다. 보통은 충분히 빠르지만 turbo를 선택하면 첫 음성 메시지에서 모델 다운로드로 인해 인터넷 속도에 따라 제한 시간을 초과할 수 있습니다.
@@ -338,15 +390,15 @@ https://api.telegram.org/bot/getUpdates - + - + - +
~/.coding-agent-telegram/state.jsonHauptdatei für den Session-Status.세션 상태의 기본 파일입니다.
~/.coding-agent-telegram/state.json.bakBackup-Datei für den Status.상태 백업 파일입니다.
~/.coding-agent-telegram/logsLog-Verzeichnis.로그 디렉터리입니다.
@@ -366,14 +418,14 @@ ENABLE_SENSITIVE_DIFF_FILTER=true ENABLE_SECRET_SCRUB_FILTER=true ``` -## 🧠 Session 관리 +## 🧠 세션 관리 -Session 은 다음 범위로 구분됩니다: +세션은 다음 범위로 구분됩니다: - Telegram bot - Telegram chat -따라서 같은 Telegram 계정이라도 여러 bot 을 사용하면서 session 이 섞이지 않게 운영할 수 있습니다. +따라서 같은 Telegram 계정이라도 여러 bot 을 사용하면서 세션이 섞이지 않게 운영할 수 있습니다. 예시: @@ -381,29 +433,29 @@ Session 은 다음 범위로 구분됩니다: - Bot B + 내 chat -> frontend 작업 - Bot C + 내 chat -> infra 작업 -active session 은 다음에도 연결됩니다: +활성 세션 은 다음에도 연결됩니다: -- project folder -- provider +- 프로젝트 폴더 +- 제공자 - 가능할 경우 branch 이름
-각 session 에 저장되는 내용 +각 세션에 저장되는 내용 -- session 이름 -- project folder +- 세션 이름 +- 프로젝트 폴더 - branch 이름 -- provider +- 제공자 - timestamps -- 해당 bot/chat 범위의 active session 선택 +- 해당 bot/chat 범위의 활성 세션 선택
-### 🔓 Workspace concurrency lock +### 🔓 워크스페이스 동시 실행 잠금 -동시에 실행될 수 있는 agent run 은 **project folder** 당 하나뿐입니다. 어떤 chat 이나 Telegram bot 이 시작했는지는 관계없습니다. +동시에 실행될 수 있는 에이전트 실행 은 **프로젝트 폴더** 당 하나뿐입니다. 어떤 chat 이나 Telegram bot 이 시작했는지는 관계없습니다. -- **project is busy**: 그 workspace 에 이미 agent run 이 있는 상태 -- **agent is busy**: 그 하나의 run 이 현재 요청을 아직 처리 중인 상태 +- **프로젝트가 사용 중**: 그 workspace 에 이미 에이전트 실행 이 있는 상태 +- **에이전트가 사용 중**: 그 하나의 run 이 현재 요청을 아직 처리 중인 상태 두 agent 가 같은 workspace 에 동시에 쓰지 않도록 bot 이 이 제한을 강제합니다. 충돌하는 수정과 데이터 손상 가능성을 줄이기 위함입니다. @@ -415,44 +467,44 @@ lock 은 디스크가 아니라 메모리에만 유지되므로 agent 가 끝나 ### 💬 Queued questions -현재 project 에 이미 agent run 이 있으면, 이후의 텍스트 메시지는 거절되지 않고 queue 됩니다. +현재 project 에 이미 에이전트 실행 이 있으면, 이후의 텍스트 메시지는 거절되지 않고 queue 됩니다. - 새 질문은 디스크의 queued-questions file 에 추가됩니다 - 현재 agent 는 이전 요청을 계속 처리합니다 -- run 이 정상적으로 끝나면 bot 이 queued questions 를 자동으로 처리하기 시작합니다 +- run 이 정상적으로 끝나면 bot 이 대기 중인 질문 를 자동으로 처리하기 시작합니다 -현재 run 이 abort 되었고 queued questions 가 남아 있으면 자동으로 계속하지 않습니다. 남은 질문을 계속 처리할지, 묶어서 할지, 하나씩 할지를 bot 이 묻습니다. +현재 run 이 abort 되었고 대기 중인 질문 가 남아 있으면 자동으로 계속하지 않습니다. 남은 질문을 계속 처리할지, 묶어서 할지, 하나씩 할지를 bot 이 묻습니다. ## ⚠️ Diff (파일 변경) -_각 agent run 동안 bot 은 project 의 가벼운 before/after snapshot 도 만들어 변경 파일 요약과 diff 를 Telegram 으로 보낼 수 있게 합니다. 이 snapshot 은 Codex 나 Copilot 이 아니라 bot 앱 자체가 만듭니다._ +_각 에이전트 실행 동안 bot 은 project 의 가벼운 실행 전후 스냅샷 도 만들어 변경 파일 요약과 diff 를 Telegram 으로 보낼 수 있게 합니다. 이 스냅샷 은 Codex 나 Copilot 이 아니라 bot 앱 자체가 만듭니다._ **Snapshot 참고 사항:** -- app 은 run 전후에 project directory 를 순회합니다 -- 일반 텍스트 파일은 git head diff 보다 run 별 snapshot diff 를 우선합니다 -- 일반적인 dependency, cache, runtime directory 도 건너뜁니다 -- binary file 과 `SNAPSHOT_TEXT_FILE_MAX_BYTES` 보다 큰 file 은 텍스트로 읽지 않습니다 +- app 은 run 전후에 프로젝트 디렉터리 를 순회합니다 +- 일반 텍스트 파일은 git head diff 보다 run 별 스냅샷 diff 를 우선합니다 +- 일반적인 의존성, 캐시, 런타임 디렉터리 도 건너뜁니다 +- binary 파일 과 `SNAPSHOT_TEXT_FILE_MAX_BYTES` 보다 큰 파일 은 텍스트로 읽지 않습니다 - 매우 큰 project 에서는 이 추가 스캔으로 I/O 와 memory 부담이 커질 수 있습니다 -- snapshot 이 file 을 텍스트로 표현할 수 없으면 가능할 때 `git diff` 로 fallback 합니다 -- 큰 file 이나 비텍스트 file 은 diff 를 생략하고 짧은 안내로 대체할 수 있습니다 +- 스냅샷 이 파일 을 텍스트로 표현할 수 없으면 가능할 때 `git diff` 로 fallback 합니다 +- 큰 파일 이나 비텍스트 파일 은 diff 를 생략하고 짧은 안내로 대체할 수 있습니다 -Snapshot 제외 규칙은 package resource 에 있습니다: +Snapshot 제외 규칙은 패키지 resource 에 있습니다: - `src/coding_agent_telegram/resources/snapshot_excluded_dir_names.txt` - `src/coding_agent_telegram/resources/snapshot_excluded_dir_globs.txt` - `src/coding_agent_telegram/resources/snapshot_excluded_file_globs.txt` -설치된 package 를 수정하지 않고 env file 에서 기본값을 덮어쓸 수 있습니다: +설치된 패키지 를 수정하지 않고 환경 파일 에서 기본값을 덮어쓸 수 있습니다: - `SNAPSHOT_INCLUDE_PATH_GLOBS` 일치하는 path 를 diff 에 강제로 포함합니다. 예: `.github/*,.profile.test,.profile.prod` - `SNAPSHOT_EXCLUDE_PATH_GLOBS` - package 기본값 위에 추가 diff 제외를 더합니다. + 패키지 기본값 위에 추가 diff 제외를 더합니다. 예: `.*,personal/*,sensitive*.txt` - 참고: `.*` 는 hidden directory 안의 file 을 포함한 hidden path 와도 일치합니다. + 참고: `.*` 는 숨김 디렉터리 안의 파일을 포함한 숨김 경로 와도 일치합니다. include 와 exclude 가 모두 맞으면 include 가 우선합니다. @@ -462,16 +514,16 @@ bot 은 project 와 branch 를 하나의 묶음으로 다룹니다. - project 를 선택해도 관련 없는 branch 를 조용히 선택하지 않습니다 - branch 가 필요하면 bot 이 직접 선택을 요청합니다 -- session 관련 메시지에서 branch 정보를 보여줄 때는 project 와 branch 를 함께 표시합니다 +- 세션 관련 메시지에서 branch 정보를 보여줄 때는 프로젝트와 branch 를 함께 표시합니다 branch 를 만들거나 바꿀 때 bot 은 source 를 명시적으로 안내합니다: -- `local/`: local branch 를 source 로 사용 -- `origin/`: remote branch 에서 먼저 업데이트한 뒤 전환 +- local/<branch>: local branch 를 source 로 사용 +- origin/<branch>: remote branch 에서 먼저 업데이트한 뒤 전환 -저장된 session branch 와 현재 repository branch 가 다르면 bot 은 그대로 진행하지 않습니다. 어떤 branch 를 쓸지 물어봅니다: +저장된 세션 branch 와 현재 repository branch 가 다르면 bot 은 그대로 진행하지 않습니다. 어떤 branch 를 쓸지 물어봅니다: -- 저장된 session branch 사용 +- 저장된 세션 branch 사용 - 현재 repository branch 사용 원하는 source branch 가 없으면 raw Git error 대신 default branch 와 current branch 를 기반으로 fallback source 를 제안합니다. @@ -481,28 +533,28 @@ branch 를 만들거나 바꿀 때 bot 은 source 를 명시적으로 안내합 - 기존 folder 는 `CODEX_SKIP_GIT_REPO_CHECK` 를 따릅니다 - `/project ` 로 만든 folder 는 이 app 이 trusted 로 표시합니다 - `/project ` 로 선택한 기존 folder 는 Telegram prompt 에서 trust 를 확인하기 전까지 untrusted 로 남습니다 -- 따라서 새로 만든 project folder 는 바로 사용할 수 있습니다 +- 따라서 새로 만든 프로젝트 폴더 는 바로 사용할 수 있습니다 - `/commit` 은 `ENABLE_COMMIT_COMMAND` 로 완전히 비활성화할 수 있습니다 - 변경을 일으키는 `/commit` 작업은 trusted project 에서만 허용됩니다 -## 🪵 Logs +## 🪵 로그 -log 는 **stdout 과 회전하는 log file 양쪽**에 기록됩니다: +log 는 **stdout 과 회전하는 로그 파일 양쪽**에 기록됩니다: - `~/.coding-agent-telegram/logs` (10 MB 에서 회전, 3개 백업 유지) -> **참고:** terminal 을 보면서 동시에 log file 을 tail 하면 각 메시지가 두 번 보입니다. 정상 동작입니다. 둘 중 하나만 보세요. +> **참고:** terminal 을 보면서 동시에 로그 파일 을 tail 하면 각 메시지가 두 번 보입니다. 정상 동작입니다. 둘 중 하나만 보세요.
자주 기록되는 이벤트 - bot 시작과 polling 시작 - project 선택 -- session 생성 -- session 전환 -- active session 표시 +- 세션 생성 +- 세션 전환 +- 활성 세션 표시 - 일반 run 실행 (잘린 prompt 가 포함된 audit log line 포함) -- resume 실패 후 session 교체 +- resume 실패 후 세션 교체 - warning 과 runtime error
@@ -521,7 +573,7 @@ log 는 **stdout 과 회전하는 log file 양쪽**에 기록됩니다: repo 시작과 package 설치에서 모두 사용하는 canonical environment template - `pyproject.toml` - packaging 및 dependency 설정 + 패키징 및 의존성 설정 ## 📦 릴리스 버전 규칙 diff --git a/README.md b/README.md index 2ddc80c..1a77861 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ - ✅ Use Telegram to control Codex / Copilot CLI - ✅ Easily review files changed by agent in code block - ✅ Queue follow-up messages while the agent is working - - ✅ Accept Text and Image input + - ✅ Accept ✏️ Text, 🌄 Image, and 🎙️ Voice messages ## 🔁 Seamless Device/Session Switching @@ -97,8 +97,8 @@ curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/i - Telegram bot token created from _@BotFather_ - Your Telegram chat ID - Codex CLI and/or Copilot CLI installed locally - - [Codex CLI install](https://developers.openai.com/codex/cli) - - [Copilot CLI install](https://github.com/features/copilot/cli) + - [Codex CLI install](https://developers.openai.com/codex/cli) / [Copilot CLI install](https://github.com/features/copilot/cli) + - [Optional] `Whisper`, `ffmpeg` @@ -129,7 +129,7 @@ cd coding-agent-telegram ./startup.sh ``` -### Start Bot Server +### 🌐 Start Bot Server ##### On first run, the app creates the env file, tells you what to fill in. ##### After updating the environment file then run: @@ -141,6 +141,40 @@ coding-agent-telegram ./startup.sh ``` +## 🎙️ [Optional] Speech-to-Text Feature: prepare local OpenAI-Whisper prerequisites + +This enables optional local Whisper-based voice-message speech-to-text for Telegram voice notes. Voice files are capped to `20MB` max. + +```bash +# if you installed from pip or one-liner install.sh +coding-agent-telegram-stt-install + +# if you run from a cloned repository +./install-stt.sh +``` + +The installer writes the STT env flags automatically after prerequisites are ready. + +Estimated local footprint: + +- `openai-whisper`: about `50 MB` +- `ffmpeg` package: about `50 MB` +- Whisper model downloads vary by model: `tiny` about `72 MB`, `base` about `139 MB`, `large-v3-turbo` about `1.5 GB` + +Recommended env settings for the local Whisper backend: + +```text +ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true +OPENAI_WHISPER_MODEL=base +OPENAI_WHISPER_TIMEOUT_SECONDS=120 +``` + +Notes: + +- Whisper downloads the selected model automatically on first use into `~/.cache/whisper`. +- If you choose `OPENAI_WHISPER_MODEL=turbo`, the first voice transcription is more likely to hit the timeout while `large-v3-turbo.pt` is still downloading. +- After a voice note is transcribed, the bot immediately sends the recognized transcript back to Telegram before the agent reply. If the run can start immediately it says “working on it”; if the project is busy it shows that the transcript was queued instead. + ## 🔑 Telegram Setup ### Get a Bot Token @@ -179,61 +213,62 @@ The bot currently accepts: - Text messages - photos +- voice messages when `ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true` and local Whisper prerequisites are installed - Codex and Copilot currently supports text and image only, video is not supported. ## 🤖 Telegram Commands - + - + - + - + - + - + - + - + - + - + - + - + - +
/provider/provider Choose the provider for new sessions. The selection is stored per bot and chat until you change it.
/project <project_folder>/project <project_folder> Set the current project folder. If the folder does not exist, the app creates it and marks it trusted. If it already exists and is still untrusted, the app asks you to trust it explicitly.
/branch <new_branch>/branch <new_branch> Prepare or switch a branch for the current project. If the branch already exists, the bot treats that branch as the source candidate. Otherwise it uses the repository default branch as the source candidate.
/branch <origin_branch> <new_branch>/branch <origin_branch> <new_branch> Prepare or switch a branch using <origin_branch> as the source candidate.
For both forms, the bot then offers the source choices that actually exist: local/<branch> origin/<branch>
If only one of those exists, only that option is shown. If neither exists, the bot tells you the branch source is missing.
/current/current Show the active session for the current bot and chat.
/new [session_name]/new [session_name] Create a new session for the current project. If you omit the name, the bot uses the real session ID. If provider, project, or branch is missing, the bot guides you through the missing step.
/switch/switch Show the latest sessions, newest first. The list includes both bot-managed sessions and local Codex/Copilot CLI sessions for the current project.
/switch page <number>/switch page <number> Show another page of stored sessions.
/switch <session_id>/switch <session_id> Switch to a specific session by ID. If you choose a local CLI session, the bot imports it and continues from there.
/compact/compact Create a fresh compacted session from the active session and switch to it.
/commit <git commands>/commit <git commands> Run validated git commit-related commands inside the active session project. Available only when ENABLE_COMMIT_COMMAND=true. Mutating git commands require a trusted project.
/push/push Push origin <branch> for the current active session. The bot asks for confirmation before pushing.
/abort/abort Abort the current agent run for the current project. If queued questions are waiting, the bot asks whether to continue them.
@@ -260,7 +295,7 @@ The bot currently accepts: - + @@ -277,7 +312,7 @@ The bot currently accepts:
WORKSPACE_ROOTWORKSPACE_ROOT Parent folder that contains your project directories.
- + @@ -352,6 +387,24 @@ The bot currently accepts:
APP_LOCALEAPP_LOCALE UI locale for shared bot messages and command descriptions. Supported values: en, de, fr, ja, ko, nl, th, vi, zh-CN, zh-HK, zh-TW.
+

Speech to Text

+ + + + + + + + + + + + + +
ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXTDefault: false. If true, it enables the audio messages capability. System will check the prerequisites regarding required binaries or libraries on startup.
OPENAI_WHISPER_MODELModel for the Whisper SST. Default: base
Available models: tiny about 72 MB, base about 139 MB, large-v3-turbo about 1.5 GB
+ Models will be automatically downloaded on your first voice message. Recommended: base for general usage. If you want better accuracy and quality, you can try with turbo +
OPENAI_WHISPER_TIMEOUT_SECONDSDefault: 120Timeout for the STT process. Usually the STT processing is fast enough.
+

State and Logs

diff --git a/README.nl.md b/README.nl.md index 953102e..150c1a0 100644 --- a/README.nl.md +++ b/README.nl.md @@ -38,7 +38,7 @@ - ✅ Gebruik Telegram om Codex / Copilot CLI te bedienen - ✅ Antwoorden en gewijzigde bestanden eenvoudig beoordelen in codeblokken - ✅ Vervolgvragen kunnen in de wachtrij terwijl de agent werkt - - ✅ Ondersteunt tekst- en afbeeldingsinvoer + - ✅ Accepteert ✏️ tekst-, 🌄 afbeelding- en 🎙️ spraakberichten ## 🔁 Naadloos wisselen tussen apparaten en sessies @@ -49,7 +49,7 @@ ## 🛠️ Typische lokale flow ```bash - coding-agent-telegram # or run ./startup.sh + coding-agent-telegram # of voer ./startup.sh uit ``` ##### In Telegram: @@ -99,6 +99,7 @@ Voordat je de server start, zorg dat je hebt: - Codex CLI en/of Copilot CLI lokaal geïnstalleerd - [Codex CLI installatie](https://developers.openai.com/codex/cli) - [Copilot CLI installatie](https://github.com/features/copilot/cli) +- [Optioneel] Whisper, ffmpeg
@@ -108,35 +109,61 @@ Openclaw biedt zeer uitgebreide mogelijkheden en heeft al een geïntegreerde age ## 🚀 Snel starten -### Option A: Bootstrapscript in één regel +### Optie A: Bootstrapscript in één regel ```bash curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/install.sh | bash ``` -### Option B: Installeren vanaf PyPI met `pip` +### Optie B: Installeren vanaf PyPI met `pip` ```bash pip install coding-agent-telegram coding-agent-telegram ``` -### Option C: Uitvoeren vanuit een gekloonde repository +### Optie C: Uitvoeren vanuit een gekloonde repository ```bash git clone https://github.com/daocha/coding-agent-telegram cd coding-agent-telegram ./startup.sh ``` -### Botserver starten +### 🌐 Botserver starten ##### Bij de eerste start maakt de app het env-bestand aan en vertelt welke velden je moet invullen. ##### Start na het bijwerken van het env-bestand opnieuw: ```bash -# if you follow Option A or Option B, then run +# als je optie A of optie B volgt, voer dan uit coding-agent-telegram -# if you follow Option C, then run this again +# als je optie C volgt, voer dit dan opnieuw uit ./startup.sh ``` +## 🎙️ [Optioneel] Spraak-naar-tekstfunctie: lokale OpenAI-Whisper-vereisten voorbereiden + +Hiermee schakel je optionele lokale Whisper-gebaseerde spraak-naar-tekst in voor Telegram-spraaknotities. Audiobestanden zijn beperkt tot maximaal `20 MB`. + +```bash +# als je via pip hebt geïnstalleerd +coding-agent-telegram-stt-install + +# als je vanuit een gekloonde repository werkt +./install-stt.sh +``` + +Aanbevolen env-instellingen: + +```text +ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true +OPENAI_WHISPER_MODEL=base +OPENAI_WHISPER_TIMEOUT_SECONDS=120 +``` + +Opmerkingen: + +- Whisper downloadt het gekozen model automatisch bij het eerste gebruik naar `~/.cache/whisper`. +- Als je `OPENAI_WHISPER_MODEL=turbo` kiest, is de kans groter dat de eerste spraaktranscriptie de time-out raakt terwijl `large-v3-turbo.pt` nog wordt gedownload. +- Nadat een spraakbericht is getranscribeerd, stuurt de bot eerst het herkende transcript terug naar Telegram en daarna pas naar de agent. Dat helpt om herkenningsfouten te controleren. + ## 🔑 Telegram-instelling ### Een Bot Token krijgen @@ -171,59 +198,66 @@ Opmerkingen: ## 📨 Ondersteunde berichttypen -## 🤖 Telegram-commando’s +De bot accepteert momenteel: + +- tekstberichten +- foto’s +- spraakberichten en audiobestanden wanneer `ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true` is ingesteld en de lokale Whisper-vereisten zijn geïnstalleerd +- Codex en Copilot ondersteunen momenteel alleen tekst en afbeeldingen, geen video + +## 🤖 Telegram-commando's - - + + - + - + - - + + - + - - + + - + - + - + - - + + - - + + - - + + - +
/providerKies de provider voor nieuwe sessies. De keuze wordt per bot en chat bewaard totdat je die wijzigt./providerKies de aanbieder voor nieuwe sessies. Die keuze wordt per bot en chat bewaard totdat je die wijzigt.
/project <project_folder>/project <project_folder> Stel de huidige projectmap in. Bestaat de map niet, dan maakt de app die aan en markeert hem trusted. Bestaat hij al maar is hij nog untrusted, dan vraagt de app expliciet om trust.
/branch <new_branch>/branch <new_branch> Bereid een branch voor of wissel ernaar voor het huidige project. Als de branch al bestaat, behandelt de bot die als source candidate. Anders gebruikt hij de standaard-branch van de repository als source candidate.
/branch <origin_branch> <new_branch>Bereid een branch voor of wissel ernaar met `` als source candidate. Voor beide vormen biedt de bot daarna alleen de source choices aan die echt bestaan: `local/` en `origin/`. Als er maar één bestaat, zie je alleen die. Als geen van beide bestaat, meldt de bot dat de branch-source ontbreekt./branch <origin_branch> <new_branch>Bereid een branch voor of wissel ernaar met <origin_branch> als source candidate. Voor beide vormen biedt de bot daarna alleen de source choices aan die echt bestaan: local/<branch> en origin/<branch>. Als er maar één bestaat, zie je alleen die. Als geen van beide bestaat, meldt de bot dat de branch-source ontbreekt.
/current/current Toon de actieve sessie voor de huidige bot en chat.
/new [session_name]Maak een nieuwe sessie voor het huidige project. Als je geen naam opgeeft, gebruikt de bot de echte session ID. Als provider, project of branch ontbreekt, begeleidt de bot je door de ontbrekende stap./new [session_name]Maak een nieuwe sessie voor het huidige project. Als je geen naam opgeeft, gebruikt de bot de echte sessie-ID. Als aanbieder, project of branch ontbreekt, begeleidt de bot je door de ontbrekende stap.
/switch/switch Toon de nieuwste sessies, nieuwste eerst. De lijst bevat zowel bot-managed sessies als lokale Codex/Copilot CLI-sessies voor het huidige project.
/switch page <number>/switch page <number> Toon een andere pagina met opgeslagen sessies.
/switch <session_id>/switch <session_id> Schakel naar een specifieke sessie via ID. Kies je een lokale CLI-sessie, dan importeert de bot die en gaat daar verder.
/compactMaak vanuit de actieve session een nieuwe compacte session en schakel daarheen over./compactMaak vanuit de actieve sessie een nieuwe compacte sessie en schakel daarheen over.
/commit <git commands>Voer gevalideerde `git commit`-gerelateerde commando’s uit binnen het project van de actieve sessie. Alleen beschikbaar als `ENABLE_COMMIT_COMMAND=true`. Muterende Git-commando’s vereisen een trusted project./commit <git commands>Voer gevalideerde git commit-gerelateerde commando’s uit binnen het project van de actieve sessie. Alleen beschikbaar als ENABLE_COMMIT_COMMAND=true. Muterende Git-commando’s vereisen een trusted project.
/pushPush `origin ` voor de huidige actieve sessie. De bot vraagt om bevestiging voordat hij pusht./pushPush origin <branch> voor de huidige actieve sessie. De bot vraagt om bevestiging voordat hij pusht.
/abort/abort Breek de huidige agent-run voor het huidige project af. Als er vragen in de wachtrij staan, vraagt de bot of die verder verwerkt moeten worden.
@@ -251,15 +285,15 @@ Opmerkingen: - + - + - +
WORKSPACE_ROOTWORKSPACE_ROOT Bovenliggende map die je projectmappen bevat.
TELEGRAM_BOT_TOKENSTELEGRAM_BOT_TOKENS Door komma's gescheiden Telegram bot tokens.
ALLOWED_CHAT_IDSALLOWED_CHAT_IDS Door komma's gescheiden Telegram chat-ID's van privéchats die de bot mogen gebruiken.
@@ -268,85 +302,105 @@ Opmerkingen: - + - + - + - + - + - + - + - + - + - + - - + + - + - + - + - + - +
APP_LOCALEAPP_LOCALE UI-locale voor gedeelde botmeldingen en commandobeschrijvingen. Ondersteunde waarden: en, de, fr, ja, ko, nl, th, vi, zh-CN, zh-HK, zh-TW.
CODEX_BINCODEX_BIN Commando om Codex CLI te starten. Standaard: codex.
COPILOT_BINCOPILOT_BIN Commando om Copilot CLI te starten. Standaard: copilot.
CODEX_MODELCODEX_MODEL Optionele Codex-modeloverride. Laat leeg om het standaardmodel van Codex CLI te gebruiken. Voorbeeld: gpt-5.4 OpenAI Codex/OpenAI-modellen
COPILOT_MODELCOPILOT_MODEL Optionele Copilot-modeloverride. Laat leeg om het standaardmodel van Copilot CLI te gebruiken. Voorbeelden: gpt-5.4, claude-sonnet-4.6 Ondersteunde GitHub Copilot-modellen
CODEX_APPROVAL_POLICYCODEX_APPROVAL_POLICY Goedkeuringsmodus die aan Codex wordt doorgegeven. Standaard: never.
CODEX_SANDBOX_MODECODEX_SANDBOX_MODE Sandboxmodus die aan Codex wordt doorgegeven. Standaard: workspace-write.
CODEX_SKIP_GIT_REPO_CHECKCODEX_SKIP_GIT_REPO_CHECK Als dit is ingeschakeld, worden trusted-repo-checks van Codex altijd overgeslagen.
ENABLE_COMMIT_COMMANDENABLE_COMMIT_COMMAND Schakelt het Telegram-commando /commit in. Standaard: false.
AGENT_HARD_TIMEOUT_SECONDSAGENT_HARD_TIMEOUT_SECONDS Harde timeout voor één agent-run. Standaard: 0 (uitgeschakeld).
SNAPSHOT_TEXT_FILE_MAX_BYTESMaximale bestandsgrootte die de bot als tekst leest voor de before/after-snapshot voor per-run diffs. Standaard: 200000.SNAPSHOT_TEXT_FILE_MAX_BYTESMaximale bestandsgrootte die de bot als tekst leest voor de voor/na-momentopname voor per-run diffs. Standaard: 200000.
MAX_TELEGRAM_MESSAGE_LENGTHMAX_TELEGRAM_MESSAGE_LENGTH Maximale berichtgrootte voordat de app antwoorden splitst. Standaard: 3000.
ENABLE_SENSITIVE_DIFF_FILTERENABLE_SENSITIVE_DIFF_FILTER Verberg diffs voor gevoelige bestanden. Standaard: true.
ENABLE_SECRET_SCRUB_FILTERENABLE_SECRET_SCRUB_FILTER Maskeer tokens, sleutels, .env-waarden, certificaten en vergelijkbare geheime uitvoer voordat die naar Telegram wordt gestuurd. Standaard: true (sterk aanbevolen).
SNAPSHOT_INCLUDE_PATH_GLOBSSNAPSHOT_INCLUDE_PATH_GLOBS Forceer dat overeenkomende paden in diffs worden opgenomen. Voorbeeld: .github/*,.profile.test,.profile.prod
SNAPSHOT_EXCLUDE_PATH_GLOBSSNAPSHOT_EXCLUDE_PATH_GLOBS Voeg extra diff-exclusies toe boven op de pakketstandaard. Voorbeeld: .*,personal/*,sensitive*.txt Opmerking: .* matcht verborgen paden, inclusief bestanden in verborgen mappen.
+ + + +

Spraak naar tekst

+ + + + + + + + + + + + + + +
ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXTStandaard: false. Als dit op true staat, worden spraakberichten en audiobestanden herkend. Het systeem controleert de vereiste binaries of bibliotheken en vraagt de gebruiker om ze te installeren als ze ontbreken.
OPENAI_WHISPER_MODELModel voor Whisper STT. Standaard: base
Beschikbare modellen: tiny ongeveer 72 MB, base ongeveer 139 MB, large-v3-turbo ongeveer 1.5 GB
Modellen worden automatisch gedownload bij je eerste spraakbericht. Aanbevolen: base voor algemeen gebruik. Als je betere nauwkeurigheid en kwaliteit wilt, kun je turbo proberen.
OPENAI_WHISPER_TIMEOUT_SECONDSStandaard: 120. Time-out voor het STT-proces. Meestal is de verwerking snel genoeg. Maar als je turbo kiest, kan het eerste spraakbericht door het downloaden van het model de time-out overschrijden, afhankelijk van je internetsnelheid.
+

Status en logs

- + - + - +
~/.coding-agent-telegram/state.jsonHauptdatei für den Session-Status.Hoofdbestand voor de sessiestatus.
~/.coding-agent-telegram/state.json.bakBackup-Datei für den Status.Back-upbestand voor de status.
~/.coding-agent-telegram/logsLog-Verzeichnis.Logmap.
@@ -384,7 +438,7 @@ Voorbeeld: De actieve sessie is ook gekoppeld aan: - projectmap -- provider +- aanbieder - branch-naam wanneer beschikbaar
@@ -393,17 +447,17 @@ De actieve sessie is ook gekoppeld aan: - sessienaam - projectmap - branch-naam -- provider +- aanbieder - tijdstempels - actieve sessiekeuze voor die bot/chat-scope
-### 🔓 Workspace concurrency lock +### 🔓 Workspace-vergrendeling voor gelijktijdigheid Er kan maar één agent-run tegelijk actief zijn per **projectmap**, ongeacht welke chat of welke Telegram-bot die heeft gestart. -- **project is busy**: er draait al een agent-run in die workspace -- **agent is busy**: die ene run verwerkt de huidige aanvraag nog +- **project is bezig**: er draait al een agent-run in die workspace +- **agent is bezig**: die ene run verwerkt de huidige aanvraag nog De bot dwingt dit af zodat twee agents niet tegelijk naar dezelfde workspace schrijven. Dat verkleint de kans op conflicterende wijzigingen en datacorruptie. @@ -425,23 +479,23 @@ Wordt de huidige run afgebroken terwijl er nog vragen wachten, dan gaat de bot n ## ⚠️ Diff (bestandswijzigingen) -_Tijdens elke agent-run maakt de bot ook een lichte before/after-snapshot van het project, zodat gewijzigde bestanden kunnen worden samengevat en diffs naar Telegram kunnen worden gestuurd. Deze snapshot wordt door de bot-app zelf gemaakt, niet door Codex of Copilot._ +_Tijdens elke agent-run maakt de bot ook een lichte voor/na-momentopname van het project, zodat gewijzigde bestanden kunnen worden samengevat en diffs naar Telegram kunnen worden gestuurd. Deze momentopname wordt door de bot-app zelf gemaakt, niet door Codex of Copilot._ **Snapshot-opmerkingen:** - de app loopt de projectmap door vóór en na de run -- voor normale tekstbestanden heeft de per-run snapshot-diff voorrang op een git-head-diff -- gebruikelijke dependency-, cache- en runtime-mappen worden ook overgeslagen +- voor normale tekstbestanden heeft de per-run momentopnameverschil voorrang op een git-head-diff +- gebruikelijke afhankelijkheids-, cache- en runtime-mappen worden ook overgeslagen - binaire bestanden en bestanden groter dan `SNAPSHOT_TEXT_FILE_MAX_BYTES` worden niet als tekst geladen - bij erg grote projecten kan deze extra scan merkbare I/O- en geheugenbelasting toevoegen -- als de snapshot een bestand niet als tekst kan weergeven, valt de app waar mogelijk terug op `git diff` +- als de momentopname een bestand niet als tekst kan weergeven, valt de app waar mogelijk terug op `git diff` - voor grote of niet-tekstbestanden kan de diff alsnog worden weggelaten en vervangen door een kort bericht -De snapshot-uitsluitingsregels staan in package resources: +De momentopname-uitsluitingsregels staan in pakketresources: -- `src/coding_agent_telegram/resources/snapshot_excluded_dir_names.txt` -- `src/coding_agent_telegram/resources/snapshot_excluded_dir_globs.txt` -- `src/coding_agent_telegram/resources/snapshot_excluded_file_globs.txt` +- `src/coding_agent_telegram/resources/momentopname_excluded_dir_names.txt` +- `src/coding_agent_telegram/resources/momentopname_excluded_dir_globs.txt` +- `src/coding_agent_telegram/resources/momentopname_excluded_file_globs.txt` Je kunt deze standaardwaarden in het env-bestand overschrijven zonder het geïnstalleerde package te wijzigen: @@ -466,8 +520,8 @@ De bot behandelt project en branch als één geheel. Wanneer je een branch maakt of wisselt, begeleidt de bot je expliciet bij de bron: -- `local/` betekent de lokale branch als bron gebruiken -- `origin/` betekent eerst vanaf de remote branch verversen en daarna wisselen +- local/<branch> betekent de lokale branch als bron gebruiken +- origin/<branch> betekent eerst vanaf de remote branch verversen en daarna wisselen Als de bot ziet dat de in de sessie opgeslagen branch niet overeenkomt met de huidige repository-branch, gaat hij niet blind verder. Hij vraagt welke branch gebruikt moet worden: @@ -485,7 +539,7 @@ Als je voorkeursbron-branch ontbreekt, biedt de bot fallback-bronnen aan op basi - `/commit` kan volledig worden uitgeschakeld met `ENABLE_COMMIT_COMMAND` - muterende `/commit`-bewerkingen zijn alleen toegestaan voor trusted projecten -## 🪵 Logs +## 🪵 Logboeken Logs worden **zowel naar stdout als naar een roterend logbestand** geschreven onder: @@ -521,11 +575,11 @@ Logs worden **zowel naar stdout als naar een roterend logbestand** geschreven on canonieke omgevingssjabloon gebruikt door zowel repo-start als package-installaties - `pyproject.toml` - packaging- en dependencyconfiguratie + verpakkings- en dependencyconfiguratie -## 📦 Release-versiebeheer +## 📦 Uitgaveversiebeheer -Packageversies worden afgeleid van Git-tags. +Pakketversies worden afgeleid van Git-tags. - TestPyPI/testen: `v2026.3.26.dev1` - PyPI-prerelease: `v2026.3.26rc1` diff --git a/README.th.md b/README.th.md index 7ff78a6..6f70406 100644 --- a/README.th.md +++ b/README.th.md @@ -38,7 +38,7 @@ - ✅ ใช้ Telegram เพื่อควบคุม Codex / Copilot CLI - ✅ ตรวจคำตอบและไฟล์ที่ถูกแก้ได้ง่ายใน code block - ✅ ส่งคำถามต่อคิวไว้ได้ระหว่างที่ agent กำลังทำงาน - - ✅ รองรับข้อความและรูปภาพ + - ✅ รองรับ ✏️ ข้อความ, 🌄 รูปภาพ และ 🎙️ ข้อความเสียง ## 🔁 สลับอุปกรณ์และเซสชันได้ลื่นไหล @@ -49,7 +49,7 @@ ## 🛠️ ตัวอย่าง flow การใช้งานบนเครื่อง ```bash - coding-agent-telegram # or run ./startup.sh + coding-agent-telegram # หรือรัน ./startup.sh ``` ##### ใน Telegram: @@ -99,6 +99,7 @@ curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/i - ติดตั้ง Codex CLI และ/หรือ Copilot CLI ไว้ในเครื่องแล้ว - [ติดตั้ง Codex CLI](https://developers.openai.com/codex/cli) - [ติดตั้ง Copilot CLI](https://github.com/features/copilot/cli) +- [ทางเลือก] Whisper, ffmpeg @@ -108,35 +109,61 @@ Openclaw มีความสามารถครบมาก และมี ## 🚀 เริ่มต้นอย่างรวดเร็ว -### Option A: สคริปต์ bootstrap แบบบรรทัดเดียว +### วิธีที่ A: สคริปต์ bootstrap แบบบรรทัดเดียว ```bash curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/install.sh | bash ``` -### Option B: ติดตั้งจาก PyPI ด้วย `pip` +### วิธีที่ B: ติดตั้งจาก PyPI ด้วย `pip` ```bash pip install coding-agent-telegram coding-agent-telegram ``` -### Option C: รันจาก repository ที่ clone มา +### วิธีที่ C: รันจาก repository ที่ clone มา ```bash git clone https://github.com/daocha/coding-agent-telegram cd coding-agent-telegram ./startup.sh ``` -### เริ่ม Bot Server +### 🌐 เริ่ม Bot Server ##### ครั้งแรกแอปจะสร้างไฟล์ env และบอกว่าต้องกรอกค่าใดบ้าง ##### หลังแก้ไฟล์ env แล้ว ให้รันอีกครั้ง: ```bash -# if you follow Option A or Option B, then run +# หากคุณทำตามวิธีที่ A หรือ วิธีที่ B ให้รัน coding-agent-telegram -# if you follow Option C, then run this again +# หากคุณทำตามวิธีที่ C ให้รันสิ่งนี้อีกครั้ง ./startup.sh ``` +## 🎙️ [ทางเลือก] ฟีเจอร์เสียงเป็นข้อความ: เตรียมส่วนที่ OpenAI-Whisper ต้องใช้ในเครื่อง + +ส่วนนี้ใช้เปิดการแปลงข้อความจากข้อความเสียง Telegram ด้วย Whisper แบบโลคัลตามตัวเลือกของคุณ ไฟล์เสียงถูกจำกัดไว้ที่สูงสุด `20 MB` + +```bash +# ถ้าติดตั้งด้วย pip +coding-agent-telegram-stt-install + +# ถ้าใช้งานจาก repository ที่ clone มา +./install-stt.sh +``` + +ค่า env ที่แนะนำ: + +```text +ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true +OPENAI_WHISPER_MODEL=base +OPENAI_WHISPER_TIMEOUT_SECONDS=120 +``` + +หมายเหตุ: + +- Whisper จะดาวน์โหลดโมเดลที่เลือกโดยอัตโนมัติครั้งแรกไปยัง `~/.cache/whisper` +- หากเลือก `OPENAI_WHISPER_MODEL=turbo` การถอดข้อความจากเสียงครั้งแรกมีโอกาสหมดเวลามากขึ้น ขณะ `large-v3-turbo.pt` ยังดาวน์โหลดไม่เสร็จ +- หลังจากถอดข้อความจากเสียงแล้ว บอตจะส่งข้อความที่รู้จำได้กลับไปใน Telegram ก่อน แล้วจึงส่งต่อให้เอเจนต์ เพื่อช่วยตรวจสอบความคลาดเคลื่อนของการรู้จำ + ## 🔑 ตั้งค่า Telegram ### รับ Bot Token @@ -171,60 +198,67 @@ https://api.telegram.org/bot/getUpdates ## 📨 ประเภทข้อความที่รองรับ +บอตรองรับสิ่งต่อไปนี้ในตอนนี้: + +- ข้อความตัวอักษร +- รูปภาพ +- ข้อความเสียงและไฟล์เสียง เมื่อกำหนด `ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true` และติดตั้งส่วนที่ Whisper ต้องใช้ในเครื่องแล้ว +- ปัจจุบัน Codex และ Copilot รองรับเฉพาะข้อความและรูปภาพ ยังไม่รองรับวิดีโอ + ## 🤖 คำสั่ง Telegram - - + + - + - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + +
/providerเลือก provider สำหรับ session ใหม่ โดยค่าที่เลือกจะถูกเก็บแยกตาม bot และ chat จนกว่าคุณจะเปลี่ยน/ผู้ให้บริการเลือกผู้ให้บริการสำหรับเซสชันใหม่ โดยค่าที่เลือกจะถูกเก็บแยกตาม bot และ chat จนกว่าคุณจะเปลี่ยน
/project <project_folder>/project <project_folder> ตั้งค่าโฟลเดอร์ project ปัจจุบัน หากโฟลเดอร์ยังไม่มี แอปจะสร้างและทำเครื่องหมายว่า trusted หากมีอยู่แล้วแต่ยัง untrusted แอปจะถามยืนยัน trust ก่อน
/branch <new_branch>/branch <new_branch> เตรียมหรือสลับ branch สำหรับ project ปัจจุบัน หาก branch มีอยู่แล้ว บอตจะถือ branch นั้นเป็น source candidate หากยังไม่มี บอตจะใช้ default branch ของ repository เป็น source candidate
/branch <origin_branch> <new_branch>เตรียมหรือสลับ branch โดยใช้ `` เป็น source candidate สำหรับทั้งสองรูปแบบ บอตจะแสดงเฉพาะ source choices ที่มีอยู่จริงเท่านั้น: `local/` และ `origin/` หากมีเพียงตัวเดียวก็จะแสดงเพียงตัวนั้น หากไม่มีเลย บอตจะแจ้งว่าไม่พบ branch source/branch <origin_branch> <new_branch>เตรียมหรือสลับ branch โดยใช้ <origin_branch> เป็น source candidate สำหรับทั้งสองรูปแบบ บอตจะแสดงเฉพาะ source choices ที่มีอยู่จริงเท่านั้น: local/<branch> และ origin/<branch> หากมีเพียงตัวเดียวก็จะแสดงเพียงตัวนั้น หากไม่มีเลย บอตจะแจ้งว่าไม่พบ branch source
/currentแสดง active session ของ bot และ chat ปัจจุบัน/currentแสดง เซสชันที่ใช้งานอยู่ ของ bot และ chat ปัจจุบัน
/new [session_name]สร้าง session ใหม่สำหรับ project ปัจจุบัน หากไม่ระบุชื่อ บอตจะใช้ session ID จริง หากยังไม่มี provider, project หรือ branch บอตจะพาคุณไปยังขั้นตอนที่ขาดอยู่/new [session_name]สร้างเซสชันใหม่สำหรับ project ปัจจุบัน หากไม่ระบุชื่อ บอตจะใช้รหัสเซสชันจริง หากยังไม่มีผู้ให้บริการ, project หรือ branch บอตจะพาคุณไปยังขั้นตอนที่ขาดอยู่
/switchแสดง session ล่าสุด โดยเรียงจากใหม่ไปเก่า รายการนี้รวมทั้ง bot-managed sessions และ local Codex/Copilot CLI sessions ของ project ปัจจุบัน/switchแสดงเซสชันล่าสุด โดยเรียงจากใหม่ไปเก่า รายการนี้รวมทั้งเซสชันที่ bot ดูแลและ local Codex/Copilot CLI เซสชันของ project ปัจจุบัน
/switch page <number>แสดงหน้าถัดไปของ sessions ที่จัดเก็บไว้/switch page <number>แสดงหน้าถัดไปของเซสชันที่จัดเก็บไว้
/switch <session_id>สลับไปยัง session ที่ระบุด้วย ID หากเลือก local CLI session บอตจะ import เข้าสู่ state แล้วทำงานต่อจากตรงนั้น/switch <session_id>สลับไปยังเซสชันที่ระบุด้วย ID หากเลือก local CLI เซสชัน บอตจะ import เข้าสู่ state แล้วทำงานต่อจากตรงนั้น
/compactสร้าง session แบบย่อใหม่จาก session ที่กำลังใช้งาน แล้วสลับไปที่ session นั้น/compactสร้างเซสชันแบบย่อใหม่จากเซสชันที่กำลังใช้งาน แล้วสลับไปที่เซสชันนั้น
/commit <git commands>รันคำสั่งที่เกี่ยวข้องกับ `git commit` ซึ่งผ่านการตรวจสอบแล้วภายใน project ของ active session ใช้ได้เมื่อ `ENABLE_COMMIT_COMMAND=true` เท่านั้น คำสั่ง Git ที่มีการแก้ไขต้องใช้ project ที่ trusted/commit <git commands>รันคำสั่งที่เกี่ยวข้องกับ git commit ซึ่งผ่านการตรวจสอบแล้วภายใน project ของ เซสชันที่ใช้งานอยู่ ใช้ได้เมื่อ ENABLE_COMMIT_COMMAND=true เท่านั้น คำสั่ง Git ที่มีการแก้ไขต้องใช้ project ที่ trusted
/pushpush `origin ` สำหรับ active session ปัจจุบัน โดยบอตจะขอการยืนยันก่อน push/pushpush origin <branch> สำหรับ เซสชันที่ใช้งานอยู่ ปัจจุบัน โดยบอตจะขอการยืนยันก่อน push
/abortยกเลิก agent run ปัจจุบันของ project นี้ หากมี queued questions รออยู่ บอตจะถามว่าจะให้ประมวลผลต่อหรือไม่/abortยกเลิก การรันของเอเจนต์ ปัจจุบันของ project นี้ หากมี คำถามที่เข้าคิว รออยู่ บอตจะถามว่าจะให้ประมวลผลต่อหรือไม่
@@ -251,15 +285,15 @@ https://api.telegram.org/bot/getUpdates - + - + - +
WORKSPACE_ROOTWORKSPACE_ROOT โฟลเดอร์หลักที่เก็บโฟลเดอร์โปรเจกต์ของคุณ
TELEGRAM_BOT_TOKENSTELEGRAM_BOT_TOKENS Telegram bot tokens แบบคั่นด้วย comma
ALLOWED_CHAT_IDSALLOWED_CHAT_IDS Telegram private chat IDs แบบคั่นด้วย comma ที่ได้รับอนุญาตให้ใช้บอต
@@ -268,85 +302,109 @@ https://api.telegram.org/bot/getUpdates - + - + - + - + - + - + - + - + - + - - + + - - + + - + - + - + - + - - + + + +
APP_LOCALEAPP_LOCALE ภาษา UI สำหรับข้อความของบอตและคำอธิบายคำสั่งที่ใช้ร่วมกัน ค่าที่รองรับ: en, de, fr, ja, ko, nl, th, vi, zh-CN, zh-HK, zh-TW
CODEX_BINCODEX_BIN คำสั่งที่ใช้เรียก Codex CLI ค่าเริ่มต้น: codex
COPILOT_BINCOPILOT_BIN คำสั่งที่ใช้เรียก Copilot CLI ค่าเริ่มต้น: copilot
CODEX_MODELCODEX_MODEL กำหนด model ของ Codex เพิ่มเติมได้แบบ optional หากปล่อยว่างจะใช้ model เริ่มต้นของ Codex CLI ตัวอย่าง: gpt-5.4 OpenAI Codex/OpenAI models
COPILOT_MODELCOPILOT_MODEL กำหนด model ของ Copilot เพิ่มเติมได้แบบ optional หากปล่อยว่างจะใช้ model เริ่มต้นของ Copilot CLI ตัวอย่าง: gpt-5.4, claude-sonnet-4.6 GitHub Copilot supported models
CODEX_APPROVAL_POLICYCODEX_APPROVAL_POLICY โหมด approval ที่ส่งให้ Codex ค่าเริ่มต้น: never
CODEX_SANDBOX_MODECODEX_SANDBOX_MODE โหมด sandbox ที่ส่งให้ Codex ค่าเริ่มต้น: workspace-write
CODEX_SKIP_GIT_REPO_CHECKCODEX_SKIP_GIT_REPO_CHECK หากเปิดไว้ จะข้ามการตรวจ trusted-repo ของ Codex เสมอ
ENABLE_COMMIT_COMMANDENABLE_COMMIT_COMMAND เปิดใช้งานคำสั่ง Telegram /commit ค่าเริ่มต้น: false
AGENT_HARD_TIMEOUT_SECONDSฮาร์ดไทม์เอาต์สำหรับ agent run หนึ่งครั้ง ค่าเริ่มต้น: 0 (ปิดใช้งาน)AGENT_HARD_TIMEOUT_SECONDSฮาร์ดไทม์เอาต์สำหรับ การรันของเอเจนต์ หนึ่งครั้ง ค่าเริ่มต้น: 0 (ปิดใช้งาน)
SNAPSHOT_TEXT_FILE_MAX_BYTESขนาดไฟล์สูงสุดที่บอตจะอ่านเป็นข้อความเพื่อสร้าง before/after snapshot สำหรับ diff ของแต่ละ run ค่าเริ่มต้น: 200000SNAPSHOT_TEXT_FILE_MAX_BYTESขนาดไฟล์สูงสุดที่บอตจะอ่านเป็นข้อความเพื่อสร้าง สแนปช็อตก่อนและหลังการรัน สำหรับ diff ของแต่ละ run ค่าเริ่มต้น: 200000
MAX_TELEGRAM_MESSAGE_LENGTHMAX_TELEGRAM_MESSAGE_LENGTH ขนาดข้อความสูงสุดก่อนที่แอปจะแบ่งการตอบกลับ ค่าเริ่มต้น: 3000
ENABLE_SENSITIVE_DIFF_FILTERENABLE_SENSITIVE_DIFF_FILTER ซ่อน diff สำหรับไฟล์ที่มีข้อมูลอ่อนไหว ค่าเริ่มต้น: true
ENABLE_SECRET_SCRUB_FILTERENABLE_SECRET_SCRUB_FILTER ปิดบัง tokens, keys, ค่า .env, certificates และข้อมูลลักษณะคล้ายความลับก่อนส่งไปยัง Telegram ค่าเริ่มต้น: true (แนะนำอย่างยิ่ง)
SNAPSHOT_INCLUDE_PATH_GLOBSSNAPSHOT_INCLUDE_PATH_GLOBS บังคับรวม path ที่ตรงเงื่อนไขเข้าใน diff ตัวอย่าง: .github/*,.profile.test,.profile.prod
SNAPSHOT_EXCLUDE_PATH_GLOBSเพิ่มกฎยกเว้น diff เพิ่มเติมทับบนค่าเริ่มต้นของแพ็กเกจ ตัวอย่าง: .*,personal/*,sensitive*.txt หมายเหตุ: .* จะตรงกับ path ที่ซ่อนอยู่ รวมถึงไฟล์ใน hidden directorySNAPSHOT_EXCLUDE_PATH_GLOBSเพิ่มกฎยกเว้น diff เพิ่มเติมทับบนค่าเริ่มต้นของแพ็กเกจ ตัวอย่าง: .*,personal/*,sensitive*.txt หมายเหตุ: .* จะตรงกับ path ที่ซ่อนอยู่ รวมถึงไฟล์ใน ไดเรกทอรีที่ซ่อนอยู่
+ + + + + + + + +

เสียงเป็นข้อความ

+ + + + + + + + + + + + +
ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXTค่าเริ่มต้น: false หากเป็น true จะเปิดใช้การรู้จำข้อความเสียงและไฟล์เสียง ระบบจะตรวจสอบไบนารีหรือไลบรารีที่จำเป็น และแจ้งให้ผู้ใช้ติดตั้งหากยังขาดอยู่
OPENAI_WHISPER_MODELโมเดลสำหรับ Whisper STT ค่าเริ่มต้น: base
โมเดลที่ใช้ได้: tiny ประมาณ 72 MB, base ประมาณ 139 MB, large-v3-turbo ประมาณ 1.5 GB
โมเดลจะถูกดาวน์โหลดอัตโนมัติเมื่อคุณส่งข้อความเสียงครั้งแรก แนะนำให้ใช้ base สำหรับการใช้งานทั่วไป หากต้องการความแม่นยำและคุณภาพที่ดีขึ้นสามารถลอง turbo ได้
OPENAI_WHISPER_TIMEOUT_SECONDSค่าเริ่มต้น: 120 ระยะหมดเวลาของกระบวนการ STT โดยทั่วไปการประมวลผลเร็วพออยู่แล้ว แต่หากเลือก turbo การส่งข้อความเสียงครั้งแรกอาจใช้เวลานานเกินกำหนดระหว่างดาวน์โหลดโมเดล ขึ้นอยู่กับความเร็วอินเทอร์เน็ตของคุณ
-

State และ Logs

+

สถานะและบันทึก

- + - + - +
~/.coding-agent-telegram/state.jsonHauptdatei für den Session-Status.ไฟล์สถานะเซสชันหลัก
~/.coding-agent-telegram/state.json.bakBackup-Datei für den Status.ไฟล์สำรองของสถานะ
~/.coding-agent-telegram/logsLog-Verzeichnis.ไดเรกทอรีบันทึก
@@ -366,14 +424,14 @@ ENABLE_SENSITIVE_DIFF_FILTER=true ENABLE_SECRET_SCRUB_FILTER=true ``` -## 🧠 การจัดการ Session +## 🧠 การจัดการเซสชัน -Session ถูกแยกตาม: +เซสชันถูกแยกตาม: - Telegram bot - Telegram chat -ดังนั้นบัญชี Telegram เดียวกันสามารถใช้หลาย bot ได้โดยไม่ทำให้ session ปะปนกัน +ดังนั้นบัญชี Telegram เดียวกันสามารถใช้หลาย bot ได้โดยไม่ทำให้เซสชันปะปนกัน ตัวอย่าง: @@ -381,29 +439,29 @@ Session ถูกแยกตาม: - Bot B + chat ของคุณ -> งาน frontend - Bot C + chat ของคุณ -> งาน infra -active session ยังผูกกับสิ่งต่อไปนี้ด้วย: +เซสชันที่ใช้งานอยู่ ยังผูกกับสิ่งต่อไปนี้ด้วย: -- project folder -- provider +- โฟลเดอร์โปรเจ็กต์ +- ผู้ให้บริการ - ชื่อ branch หากมี
-แต่ละ session จะเก็บข้อมูล: +แต่ละเซสชันจะเก็บข้อมูล: -- ชื่อ session -- project folder +- ชื่อเซสชัน +- โฟลเดอร์โปรเจ็กต์ - ชื่อ branch -- provider +- ผู้ให้บริการ - timestamps -- การเลือก active session ภายใต้ขอบเขต bot/chat นั้น +- การเลือก เซสชันที่ใช้งานอยู่ ภายใต้ขอบเขต bot/chat นั้น
-### 🔓 Workspace concurrency lock +### 🔓 ล็อกการทำงานพร้อมกันของเวิร์กสเปซ -จะมี agent run ที่ active ได้พร้อมกันเพียงหนึ่งตัวต่อ **project folder** ไม่ว่า chat หรือ Telegram bot ตัวใดจะเป็นผู้เริ่มก็ตาม +จะมี การรันของเอเจนต์ ที่ active ได้พร้อมกันเพียงหนึ่งตัวต่อ **โฟลเดอร์โปรเจ็กต์** ไม่ว่า chat หรือ Telegram bot ตัวใดจะเป็นผู้เริ่มก็ตาม -- **project is busy**: ใน workspace นั้นมี agent run ทำงานอยู่แล้ว -- **agent is busy**: run ตัวนั้นยังประมวลผลคำขอปัจจุบันไม่เสร็จ +- **โปรเจ็กต์กำลังถูกใช้งาน**: ใน workspace นั้นมี การรันของเอเจนต์ ทำงานอยู่แล้ว +- **เอเจนต์กำลังทำงานอยู่**: run ตัวนั้นยังประมวลผลคำขอปัจจุบันไม่เสร็จ บอตบังคับกติกานี้เพื่อไม่ให้มีสอง agent เขียนลง workspace เดียวกันพร้อมกัน ช่วยลดการแก้ไขชนกันและลดโอกาสข้อมูลเสียหาย @@ -415,29 +473,29 @@ lock นี้อยู่ในหน่วยความจำ ไม่ไ ### 💬 คำถามที่เข้าคิว -หาก project ปัจจุบันมี agent run ทำงานอยู่แล้ว ข้อความตัวอักษรที่ส่งมาภายหลังจะไม่ถูกปฏิเสธ แต่จะถูกนำไปเข้าคิวแทน +หาก project ปัจจุบันมี การรันของเอเจนต์ ทำงานอยู่แล้ว ข้อความตัวอักษรที่ส่งมาภายหลังจะไม่ถูกปฏิเสธ แต่จะถูกนำไปเข้าคิวแทน - คำถามใหม่จะถูกต่อท้ายในไฟล์ queued-questions บนดิสก์ - agent ปัจจุบันยังคงทำงานกับคำขอเดิมต่อไป - เมื่อ run นั้นจบแบบปกติ บอตจะเริ่มประมวลผลคำถามในคิวโดยอัตโนมัติ -หาก run ปัจจุบันถูก abort และยังมี queued questions เหลืออยู่ บอตจะไม่ทำต่ออัตโนมัติ แต่จะถามว่าต้องการประมวลผลคำถามที่เหลือต่อหรือไม่ แบบรวมกันหรือทีละข้อ +หาก run ปัจจุบันถูก abort และยังมี คำถามที่เข้าคิว เหลืออยู่ บอตจะไม่ทำต่ออัตโนมัติ แต่จะถามว่าต้องการประมวลผลคำถามที่เหลือต่อหรือไม่ แบบรวมกันหรือทีละข้อ ## ⚠️ Diff (การเปลี่ยนไฟล์) -_ในแต่ละ agent run บอตจะสร้าง before/after snapshot แบบเบาของโปรเจกต์ด้วย เพื่อสรุปไฟล์ที่เปลี่ยนและส่ง diff กลับไปยัง Telegram ได้ Snapshot นี้ถูกสร้างโดยตัวบอตเอง ไม่ใช่โดย Codex หรือ Copilot._ +_ในแต่ละ การรันของเอเจนต์ บอตจะสร้าง สแนปช็อตก่อนและหลังการรัน แบบเบาของโปรเจกต์ด้วย เพื่อสรุปไฟล์ที่เปลี่ยนและส่ง diff กลับไปยัง Telegram ได้ สแนปช็อต นี้ถูกสร้างโดยตัวบอตเอง ไม่ใช่โดย Codex หรือ Copilot._ -**สิ่งที่ควรรู้เกี่ยวกับ snapshot:** +**สิ่งที่ควรรู้เกี่ยวกับ สแนปช็อต:** -- แอปจะสแกน project directory ก่อนและหลังการรัน -- สำหรับไฟล์ข้อความทั่วไป แอปจะใช้ diff จาก snapshot ของ run นั้นก่อน diff เทียบกับ git head -- โฟลเดอร์ dependency, cache และ runtime ที่พบบ่อยจะถูกข้ามเช่นกัน +- แอปจะสแกน ไดเรกทอรีโปรเจ็กต์ ก่อนและหลังการรัน +- สำหรับไฟล์ข้อความทั่วไป แอปจะใช้ diff จาก สแนปช็อต ของ run นั้นก่อน diff เทียบกับ git head +- โฟลเดอร์การพึ่งพา แคช และรันไทม์ที่พบบ่อยจะถูกข้ามเช่นกัน - ไฟล์ binary และไฟล์ที่ใหญ่กว่า `SNAPSHOT_TEXT_FILE_MAX_BYTES` จะไม่ถูกอ่านเป็นข้อความ - สำหรับโปรเจกต์ขนาดใหญ่มาก การสแกนเพิ่มนี้อาจเพิ่มภาระด้าน I/O และ memory ได้อย่างเห็นได้ชัด -- หาก snapshot ไม่สามารถแทนไฟล์เป็นข้อความได้ แอปจะ fallback ไปใช้ `git diff` เมื่อทำได้ +- หาก สแนปช็อต ไม่สามารถแทนไฟล์เป็นข้อความได้ แอปจะ fallback ไปใช้ `git diff` เมื่อทำได้ - สำหรับไฟล์ขนาดใหญ่หรือไม่ใช่ข้อความ diff อาจถูกละไว้และแทนด้วยข้อความสั้น ๆ -กฎการยกเว้น snapshot อยู่ใน package resources: +กฎการยกเว้น สแนปช็อต อยู่ในทรัพยากรของแพ็กเกจ: - `src/coding_agent_telegram/resources/snapshot_excluded_dir_names.txt` - `src/coding_agent_telegram/resources/snapshot_excluded_dir_globs.txt` @@ -452,7 +510,7 @@ _ในแต่ละ agent run บอตจะสร้าง before/after sna - `SNAPSHOT_EXCLUDE_PATH_GLOBS` เพิ่มกฎยกเว้น diff เพิ่มเติมทับบนค่าเริ่มต้นของ package ตัวอย่าง: `.*,personal/*,sensitive*.txt` - หมายเหตุ: `.*` จะตรงกับ hidden path รวมถึงไฟล์ใน hidden directory + หมายเหตุ: `.*` จะตรงกับ path ที่ซ่อนอยู่ รวมถึงไฟล์ใน ไดเรกทอรีที่ซ่อนอยู่ หาก include และ exclude ตรงพร้อมกัน include จะมีผลก่อน @@ -462,16 +520,16 @@ _ในแต่ละ agent run บอตจะสร้าง before/after sna - การเลือก project จะไม่แอบเลือก branch ที่ไม่เกี่ยวข้องให้อัตโนมัติ - หากต้องใช้ branch บอตจะถามให้คุณเลือก -- เมื่อมีการแสดงข้อมูล branch ในข้อความที่เกี่ยวกับ session จะโชว์ project และ branch ควบคู่กัน +- เมื่อมีการแสดงข้อมูล branch ในข้อความที่เกี่ยวกับเซสชัน จะโชว์ project และ branch ควบคู่กัน เมื่อคุณสร้างหรือสลับ branch บอตจะพาคุณเลือก source อย่างชัดเจน: -- `local/` คือใช้ local branch เป็นต้นทาง -- `origin/` คืออัปเดตจาก remote branch ก่อน แล้วค่อยสลับ +- local/<branch> คือใช้ local branch เป็นต้นทาง +- origin/<branch> คืออัปเดตจาก remote branch ก่อน แล้วค่อยสลับ -ถ้าบอตพบว่า branch ที่เก็บไว้ใน session ไม่ตรงกับ branch ปัจจุบันของ repository บอตจะไม่ทำต่อแบบเดาสุ่ม แต่จะถามว่าต้องการใช้ branch ใด: +ถ้าบอตพบว่า branch ที่เก็บไว้ในเซสชันไม่ตรงกับ branch ปัจจุบันของ repository บอตจะไม่ทำต่อแบบเดาสุ่ม แต่จะถามว่าต้องการใช้ branch ใด: -- ใช้ branch ที่เก็บไว้ใน session +- ใช้ branch ที่เก็บไว้ในเซสชัน - ใช้ branch ปัจจุบันของ repository หาก source branch ที่คุณต้องการหายไป บอตจะเสนอ fallback source ตาม default branch และ current branch แทนที่จะปล่อยให้คุณเจอ Git error ตรง ๆ @@ -485,7 +543,7 @@ _ในแต่ละ agent run บอตจะสร้าง before/after sna - สามารถปิด `/commit` ได้ทั้งหมดด้วย `ENABLE_COMMIT_COMMAND` - การทำ `/commit` ที่มีการแก้ไขจริงจะอนุญาตเฉพาะกับ trusted project เท่านั้น -## 🪵 Logs +## 🪵 บันทึก log จะถูกเขียน **ทั้งไปที่ stdout และไฟล์ log แบบหมุนเวียน** ใต้ path นี้: @@ -498,11 +556,11 @@ log จะถูกเขียน **ทั้งไปที่ stdout แล - การเริ่มต้น bot และเริ่ม polling - การเลือก project -- การสร้าง session -- การสลับ session -- การรายงาน active session +- การสร้างเซสชัน +- การสลับเซสชัน +- การรายงาน เซสชันที่ใช้งานอยู่ - การรันงานแบบปกติ (รวม audit log line ที่มี prompt แบบตัดทอน) -- การแทนที่ session หลัง resume ล้มเหลว +- การแทนที่เซสชันหลัง resume ล้มเหลว - warnings และ runtime errors @@ -521,7 +579,7 @@ log จะถูกเขียน **ทั้งไปที่ stdout แล template สภาพแวดล้อมหลักที่ใช้ทั้งตอนเริ่มจาก repo และตอนติดตั้งเป็น package - `pyproject.toml` - การตั้งค่า packaging และ dependencies + การตั้งค่า แพ็กเกจจิง และ dependencies ## 📦 การกำหนดเวอร์ชัน release @@ -535,4 +593,4 @@ log จะถูกเขียน **ทั้งไปที่ stdout แล - โปรเจกต์นี้ออกแบบมาสำหรับผู้ใช้ที่รัน agents แบบ local บนเครื่องของตนเอง - Telegram bot เป็น control surface ไม่ใช่ execution environment -- หากคุณรันหลาย bot ก็ยังจัดการทั้งหมดได้ด้วย server process เดียว +- หากคุณรันหลาย bot ก็ยังจัดการทั้งหมดได้ด้วย เซิร์ฟเวอร์โพรเซส เดียว diff --git a/README.vi.md b/README.vi.md index 96e6e04..f8324e1 100644 --- a/README.vi.md +++ b/README.vi.md @@ -36,9 +36,9 @@ - ✅ Nhẹ: không cần framework nặng, minh bạch hoàn toàn - ✅ Nhiều bot: nhiều cuộc chat, nhiều phiên - ✅ Dùng Telegram để điều khiển Codex / Copilot CLI - - ✅ Dễ xem câu trả lời và các file đã thay đổi trong code block + - ✅ Dễ xem câu trả lời và các tệp đã thay đổi trong code block - ✅ Có thể xếp hàng câu hỏi tiếp theo khi agent đang làm việc - - ✅ Hỗ trợ đầu vào văn bản và hình ảnh + - ✅ Chấp nhận tin nhắn ✏️ văn bản, 🌄 hình ảnh và 🎙️ thoại ## 🔁 Chuyển thiết bị/phiên liền mạch @@ -49,7 +49,7 @@ ## 🛠️ Luồng làm việc cục bộ điển hình ```bash - coding-agent-telegram # or run ./startup.sh + coding-agent-telegram # hoặc chạy ./startup.sh ``` ##### Trong Telegram: @@ -80,7 +80,7 @@ curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/i - Danh sách trắng cho chat riêng qua `ALLOWED_CHAT_IDS` - Chỉ cho phép một agent hoạt động trên mỗi project để giảm xung đột ghi -- Ẩn diff của các file nhạy cảm +- Ẩn diff của các tệp nhạy cảm - API keys, tokens, giá trị `.env`, certificates, SSH keys và các đầu ra mang tính bí mật sẽ được che trước khi gửi lại Telegram - Dữ liệu runtime của app nằm dưới `~/.coding-agent-telegram` - Các thư mục có sẵn có thể yêu cầu xác nhận trust trước khi chạy Git operation có thay đổi @@ -99,6 +99,7 @@ Trước khi khởi động server, hãy chuẩn bị: - Codex CLI và/hoặc Copilot CLI đã được cài cục bộ - [Cài Codex CLI](https://developers.openai.com/codex/cli) - [Cài Copilot CLI](https://github.com/features/copilot/cli) +- [Tùy chọn] Whisper, ffmpeg @@ -108,35 +109,61 @@ Openclaw cung cấp bộ tính năng rất đầy đủ và đã có sẵn agent ## 🚀 Bắt đầu nhanh -### Option A: Script bootstrap một dòng +### Cách A: Script bootstrap một dòng ```bash curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/install.sh | bash ``` -### Option B: Cài từ PyPI bằng `pip` +### Cách B: Cài từ PyPI bằng `pip` ```bash pip install coding-agent-telegram coding-agent-telegram ``` -### Option C: Chạy từ repository đã clone +### Cách C: Chạy từ repository đã clone ```bash git clone https://github.com/daocha/coding-agent-telegram cd coding-agent-telegram ./startup.sh ``` -### Khởi động bot server -##### Ở lần chạy đầu, app sẽ tạo file env và cho bạn biết cần điền trường nào. -##### Sau khi cập nhật file env, hãy chạy lại: +### 🌐 Khởi động bot server +##### Ở lần chạy đầu, app sẽ tạo tệp env và cho bạn biết cần điền trường nào. +##### Sau khi cập nhật tệp env, hãy chạy lại: ```bash -# if you follow Option A or Option B, then run +# nếu bạn làm theo Tùy chọn A hoặc Tùy chọn B, hãy chạy coding-agent-telegram -# if you follow Option C, then run this again +# nếu bạn làm theo Tùy chọn C, hãy chạy lại lệnh này ./startup.sh ``` +## 🎙️ [Tùy chọn] Tính năng chuyển giọng nói thành văn bản: chuẩn bị các điều kiện cần cục bộ của OpenAI-Whisper + +Phần này dùng để bật tùy chọn chuyển tin nhắn thoại Telegram thành văn bản bằng Whisper chạy cục bộ. Tệp âm thanh được giới hạn tối đa `20 MB`. + +```bash +# nếu bạn cài bằng pip +coding-agent-telegram-stt-install + +# nếu bạn chạy từ repository đã clone +./install-stt.sh +``` + +Thiết lập env được khuyến nghị: + +```text +ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true +OPENAI_WHISPER_MODEL=base +OPENAI_WHISPER_TIMEOUT_SECONDS=120 +``` + +Lưu ý: + +- Whisper sẽ tự động tải model đã chọn vào `~/.cache/whisper` ở lần dùng đầu tiên. +- Nếu bạn chọn `OPENAI_WHISPER_MODEL=turbo`, lần chuyển giọng nói đầu tiên có khả năng chạm timeout cao hơn khi `large-v3-turbo.pt` vẫn đang được tải. +- Sau khi một tin nhắn thoại được chép lại, bot sẽ gửi lại bản transcript đã nhận dạng vào Telegram trước rồi mới chuyển cho tác nhân. Điều này giúp kiểm tra lỗi nhận dạng dễ hơn. + ## 🔑 Thiết lập Telegram ### Lấy Bot Token @@ -171,79 +198,86 @@ Lưu ý: ## 📨 Loại tin nhắn được hỗ trợ +Hiện tại bot chấp nhận: + +- tin nhắn văn bản +- ảnh +- tin nhắn thoại và tệp âm thanh khi `ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true` và các điều kiện cần cục bộ của Whisper đã được cài đặt +- hiện tại Codex và Copilot chỉ hỗ trợ văn bản và hình ảnh, chưa hỗ trợ video + ## 🤖 Lệnh Telegram - - + + - + - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + +
/providerChọn provider cho các session mới. Lựa chọn này được lưu theo từng bot và chat cho đến khi bạn thay đổi./providerChọn nhà cung cấp cho các phiên mới. Lựa chọn này được lưu theo từng bot và chat cho đến khi bạn thay đổi.
/project <project_folder>/project <project_folder> Đặt thư mục project hiện tại. Nếu thư mục chưa tồn tại, app sẽ tạo và đánh dấu là trusted. Nếu đã tồn tại nhưng vẫn untrusted, app sẽ yêu cầu xác nhận trust rõ ràng.
/branch <new_branch>/branch <new_branch> Chuẩn bị hoặc chuyển branch cho project hiện tại. Nếu branch đã tồn tại, bot coi branch đó là source candidate. Nếu chưa có, bot dùng default branch của repository làm source candidate.
/branch <origin_branch> <new_branch>Chuẩn bị hoặc chuyển branch bằng cách dùng `` làm source candidate. Với cả hai dạng, bot chỉ đưa ra các source choice thật sự tồn tại: `local/` và `origin/`. Nếu chỉ có một lựa chọn thì chỉ hiện lựa chọn đó. Nếu không có lựa chọn nào, bot sẽ báo thiếu branch source./branch <origin_branch> <new_branch>Chuẩn bị hoặc chuyển branch bằng cách dùng <origin_branch> làm source candidate. Với cả hai dạng, bot chỉ đưa ra các source choice thật sự tồn tại: local/<branch>origin/<branch>. Nếu chỉ có một lựa chọn thì chỉ hiện lựa chọn đó. Nếu không có lựa chọn nào, bot sẽ báo thiếu branch source.
/currentHiển thị active session cho bot và chat hiện tại./currentHiển thị phiên hoạt động cho bot và chat hiện tại.
/new [session_name]Tạo session mới cho project hiện tại. Nếu bỏ qua tên, bot sẽ dùng session ID thật. Nếu thiếu provider, project hoặc branch, bot sẽ hướng dẫn bước còn thiếu./new [session_name]Tạo phiên mới cho project hiện tại. Nếu bỏ qua tên, bot sẽ dùng mã định danh phiên thật. Nếu thiếu nhà cung cấp, project hoặc branch, bot sẽ hướng dẫn bước còn thiếu.
/switchHiển thị các session mới nhất, mới nhất trước. Danh sách bao gồm cả session do bot quản lý và local Codex/Copilot CLI session của project hiện tại./switchHiển thị các phiên mới nhất, mới nhất trước. Danh sách bao gồm cả phiên do bot quản lý và phiên CLI Codex/Copilot cục bộ của project hiện tại.
/switch page <number>Hiển thị trang khác của các session đã lưu./switch page <number>Hiển thị trang khác của các phiên đã lưu.
/switch <session_id>Chuyển sang một session cụ thể bằng ID. Nếu bạn chọn local CLI session, bot sẽ import nó và tiếp tục từ đó./switch <session_id>Chuyển sang một phiên cụ thể bằng ID. Nếu bạn chọn phiên CLI cục bộ, bot sẽ import nó và tiếp tục từ đó.
/compactTạo một session rút gọn mới từ session đang hoạt động rồi chuyển sang session đó./compactTạo một phiên rút gọn mới từ phiên đang hoạt động rồi chuyển sang phiên đó.
/commit <git commands>Chạy các lệnh liên quan đến `git commit` đã được kiểm tra trong project của active session. Chỉ có khi `ENABLE_COMMIT_COMMAND=true`. Các lệnh Git có thay đổi yêu cầu project đã trusted./commit <git commands>Chạy các lệnh liên quan đến git commit đã được kiểm tra trong project của phiên hoạt động. Chỉ có khi ENABLE_COMMIT_COMMAND=true. Các lệnh Git có thay đổi yêu cầu project đã trusted.
/pushPush `origin ` cho active session hiện tại. Bot sẽ hỏi xác nhận trước khi push./pushPush origin <branch> cho phiên hoạt động hiện tại. Bot sẽ hỏi xác nhận trước khi push.
/abortHủy agent run hiện tại của project hiện tại. Nếu còn queued questions chờ xử lý, bot sẽ hỏi có tiếp tục hay không./abortHủy lần chạy tác nhân hiện tại của project hiện tại. Nếu còn các câu hỏi trong hàng đợi chờ xử lý, bot sẽ hỏi có tiếp tục hay không.

⚙️ Biến môi trường

-

Đường dẫn file env chính:

+

Đường dẫn tệp env chính:

- + - + - +
CODING_AGENT_TELEGRAM_ENV_FILEDùng khi bạn muốn app trỏ tới một file env cụ thể.Dùng khi bạn muốn app trỏ tới một tệp env cụ thể.
~/.coding-agent-telegram/.env_coding_agent_telegramVị trí file env mặc định.Vị trí tệp env mặc định.
./.env_coding_agent_telegramChỉ dùng khi file local này đã tồn tại.Chỉ dùng khi tệp local này đã tồn tại.
@@ -251,15 +285,15 @@ Lưu ý: - + - + - +
WORKSPACE_ROOTWORKSPACE_ROOT Thư mục cha chứa các thư mục project của bạn.
TELEGRAM_BOT_TOKENSTELEGRAM_BOT_TOKENS Các Telegram bot token, ngăn cách bằng dấu phẩy.
ALLOWED_CHAT_IDSALLOWED_CHAT_IDS Các Telegram private chat ID được phép dùng bot, ngăn cách bằng dấu phẩy.
@@ -268,68 +302,86 @@ Lưu ý: - + - + - + - + - + - + - + - + - + - - + + - - + + - + - - + + - + - + - - + + + +
APP_LOCALEAPP_LOCALE Ngôn ngữ UI cho các thông điệp bot dùng chung và mô tả lệnh. Giá trị hỗ trợ: en, de, fr, ja, ko, nl, th, vi, zh-CN, zh-HK, zh-TW.
CODEX_BINCODEX_BIN Lệnh dùng để chạy Codex CLI. Mặc định: codex.
COPILOT_BINCOPILOT_BIN Lệnh dùng để chạy Copilot CLI. Mặc định: copilot.
CODEX_MODELCODEX_MODEL Ghi đè model Codex nếu cần. Để trống để dùng model mặc định của Codex CLI. Ví dụ: gpt-5.4 OpenAI Codex/OpenAI models
COPILOT_MODELCOPILOT_MODEL Ghi đè model Copilot nếu cần. Để trống để dùng model mặc định của Copilot CLI. Ví dụ: gpt-5.4, claude-sonnet-4.6 GitHub Copilot supported models
CODEX_APPROVAL_POLICYCODEX_APPROVAL_POLICY Chế độ approval truyền cho Codex. Mặc định: never.
CODEX_SANDBOX_MODECODEX_SANDBOX_MODE Chế độ sandbox truyền cho Codex. Mặc định: workspace-write.
CODEX_SKIP_GIT_REPO_CHECKCODEX_SKIP_GIT_REPO_CHECK Nếu bật, luôn bỏ qua trusted-repo check của Codex.
ENABLE_COMMIT_COMMANDENABLE_COMMIT_COMMAND Bật lệnh Telegram /commit. Mặc định: false.
AGENT_HARD_TIMEOUT_SECONDSTimeout cứng cho một lần agent run. Mặc định: 0 (tắt).AGENT_HARD_TIMEOUT_SECONDSTimeout cứng cho một lần lần chạy tác nhân. Mặc định: 0 (tắt).
SNAPSHOT_TEXT_FILE_MAX_BYTESKích thước file tối đa mà bot sẽ đọc dưới dạng văn bản khi tạo before/after snapshot cho diff của từng run. Mặc định: 200000.SNAPSHOT_TEXT_FILE_MAX_BYTESKích thước tệp tối đa mà bot sẽ đọc dưới dạng văn bản khi tạo ảnh chụp nhanh trước/sau cho diff của từng run. Mặc định: 200000.
MAX_TELEGRAM_MESSAGE_LENGTHMAX_TELEGRAM_MESSAGE_LENGTH Kích thước tin nhắn tối đa trước khi app tách phản hồi. Mặc định: 3000.
ENABLE_SENSITIVE_DIFF_FILTERẨn diff của các file nhạy cảm. Mặc định: true.ENABLE_SENSITIVE_DIFF_FILTERẨn diff của các tệp nhạy cảm. Mặc định: true.
ENABLE_SECRET_SCRUB_FILTERENABLE_SECRET_SCRUB_FILTER Che tokens, keys, giá trị .env, certificates và các đầu ra giống bí mật trước khi gửi về Telegram. Mặc định: true (rất nên bật).
SNAPSHOT_INCLUDE_PATH_GLOBSSNAPSHOT_INCLUDE_PATH_GLOBS Luôn đưa các path khớp điều kiện vào diff. Ví dụ: .github/*,.profile.test,.profile.prod
SNAPSHOT_EXCLUDE_PATH_GLOBSThêm các rule loại trừ diff ngoài bộ mặc định của package. Ví dụ: .*,personal/*,sensitive*.txt Lưu ý: .* khớp cả path ẩn, gồm cả file trong thư mục ẩn.SNAPSHOT_EXCLUDE_PATH_GLOBSThêm các rule loại trừ diff ngoài bộ mặc định của package. Ví dụ: .*,personal/*,sensitive*.txt Lưu ý: .* khớp cả path ẩn, gồm cả tệp trong thư mục ẩn.
+ + +

Chuyển giọng nói thành văn bản

+ + + + + + + + + + + + +
ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXTMặc định: false. Nếu bật true, hệ thống sẽ nhận dạng tin nhắn thoại và tệp âm thanh. Hệ thống sẽ kiểm tra các binary hoặc thư viện cần thiết và nhắc người dùng cài đặt nếu còn thiếu.
OPENAI_WHISPER_MODELMô hình dùng cho Whisper STT. Mặc định: base
Các mô hình khả dụng: tiny khoảng 72 MB, base khoảng 139 MB, large-v3-turbo khoảng 1.5 GB
Mô hình sẽ được tự động tải xuống khi bạn gửi tin nhắn thoại đầu tiên. Khuyến nghị: base cho nhu cầu chung. Nếu muốn độ chính xác và chất lượng tốt hơn, bạn có thể thử turbo.
OPENAI_WHISPER_TIMEOUT_SECONDSMặc định: 120. Thời gian chờ cho tiến trình STT. Thông thường STT đủ nhanh, nhưng nếu bạn chọn turbo, lần gửi tin nhắn thoại đầu tiên có thể vượt quá thời gian chờ do phải tải mô hình, tùy theo tốc độ mạng.
@@ -338,15 +390,15 @@ Lưu ý: - + - + - +
~/.coding-agent-telegram/state.jsonHauptdatei für den Session-Status.Tệp trạng thái phiên chính.
~/.coding-agent-telegram/state.json.bakBackup-Datei für den Status.Tệp sao lưu trạng thái.
~/.coding-agent-telegram/logsLog-Verzeichnis.Thư mục log.
@@ -366,14 +418,14 @@ ENABLE_SENSITIVE_DIFF_FILTER=true ENABLE_SECRET_SCRUB_FILTER=true ``` -## 🧠 Quản lý Session +## 🧠 Quản lý phiên -Session được tách theo: +Phiên được tách theo: - Telegram bot - Telegram chat -Vì vậy cùng một tài khoản Telegram có thể dùng nhiều bot mà không làm lẫn session. +Vì vậy cùng một tài khoản Telegram có thể dùng nhiều bot mà không làm lẫn phiên. Ví dụ: @@ -381,29 +433,29 @@ Ví dụ: - Bot B + chat của bạn -> việc frontend - Bot C + chat của bạn -> việc infra -active session cũng gắn với: +phiên hoạt động cũng gắn với: -- project folder -- provider +- thư mục dự án +- nhà cung cấp - branch name nếu có
-Mỗi session lưu: +Mỗi phiên lưu: -- tên session -- project folder +- tên phiên +- thư mục dự án - branch name -- provider +- nhà cung cấp - timestamps -- active session được chọn cho phạm vi bot/chat đó +- phiên hoạt động được chọn cho phạm vi bot/chat đó
-### 🔓 Workspace concurrency lock +### 🔓 Khóa đồng thời workspace -Chỉ có thể có một agent run hoạt động trên mỗi **project folder** tại một thời điểm, bất kể chat hay Telegram bot nào khởi chạy. +Chỉ có thể có một lần chạy tác nhân hoạt động trên mỗi **thư mục dự án** tại một thời điểm, bất kể chat hay Telegram bot nào khởi chạy. -- **project is busy**: workspace đó đã có một agent run đang chạy -- **agent is busy**: chính run đó vẫn đang xử lý yêu cầu hiện tại +- **dự án đang bận**: workspace đó đã có một lần chạy tác nhân đang chạy +- **tác nhân đang bận**: chính run đó vẫn đang xử lý yêu cầu hiện tại Bot cố ý áp dụng giới hạn này để hai agent không ghi vào cùng một workspace cùng lúc. Điều đó giúp tránh sửa đổi xung đột và giảm nguy cơ hỏng dữ liệu. @@ -415,33 +467,33 @@ Lock được giữ trong bộ nhớ, không phải trên đĩa, nên sẽ tự ### 💬 Câu hỏi trong hàng đợi -Nếu project hiện tại đã có agent run đang chạy, các tin nhắn văn bản gửi sau sẽ không bị từ chối mà được đưa vào queue. +Nếu project hiện tại đã có lần chạy tác nhân đang chạy, các tin nhắn văn bản gửi sau sẽ không bị từ chối mà được đưa vào queue. -- câu hỏi mới được nối vào file queued-questions trên đĩa +- câu hỏi mới được nối vào tệp queued-questions trên đĩa - agent hiện tại tiếp tục làm yêu cầu trước đó - khi run đó kết thúc bình thường, bot tự động bắt đầu xử lý các câu hỏi trong hàng đợi -Nếu run hiện tại bị abort và vẫn còn queued questions, bot sẽ không tự tiếp tục. Bot sẽ hỏi có muốn tiếp tục xử lý phần còn lại theo dạng gộp hay từng câu một hay không. +Nếu run hiện tại bị abort và vẫn còn các câu hỏi trong hàng đợi, bot sẽ không tự tiếp tục. Bot sẽ hỏi có muốn tiếp tục xử lý phần còn lại theo dạng gộp hay từng câu một hay không. -## ⚠️ Diff (thay đổi file) +## ⚠️ Diff (thay đổi tệp) -_Trong mỗi agent run, bot cũng tạo một snapshot before/after nhẹ của project để có thể tóm tắt các file thay đổi và gửi diff về Telegram. Snapshot này do chính bot app tạo ra, không phải bởi Codex hay Copilot._ +_Trong mỗi lần chạy tác nhân, bot cũng tạo một ảnh chụp nhanh trước/sau nhẹ của project để có thể tóm tắt các tệp thay đổi và gửi diff về Telegram. Bản chụp nhanh này do chính bot app tạo ra, không phải bởi Codex hay Copilot._ -**Ghi chú về snapshot:** +**Ghi chú về ảnh chụp nhanh:** -- app quét project directory trước và sau mỗi run -- với file văn bản thông thường, app ưu tiên snapshot diff theo từng run hơn là diff so với git head -- các thư mục dependency, cache và runtime phổ biến cũng bị bỏ qua -- file nhị phân và file lớn hơn `SNAPSHOT_TEXT_FILE_MAX_BYTES` sẽ không được đọc như văn bản +- app quét thư mục dự án trước và sau mỗi run +- với tệp văn bản thông thường, app ưu tiên diff ảnh chụp nhanh theo từng run hơn là diff so với git head +- các thư mục phụ thuộc, bộ đệm và runtime phổ biến cũng bị bỏ qua +- tệp nhị phân và tệp lớn hơn `SNAPSHOT_TEXT_FILE_MAX_BYTES` sẽ không được đọc như văn bản - với project rất lớn, lần quét bổ sung này có thể làm tăng đáng kể I/O và bộ nhớ -- nếu snapshot không thể biểu diễn file dưới dạng văn bản, app sẽ fallback sang `git diff` khi có thể -- với file lớn hoặc không phải văn bản, diff vẫn có thể bị bỏ qua và thay bằng thông báo ngắn +- nếu ảnh chụp nhanh không thể biểu diễn tệp dưới dạng văn bản, app sẽ fallback sang `git diff` khi có thể +- với tệp lớn hoặc không phải văn bản, diff vẫn có thể bị bỏ qua và thay bằng thông báo ngắn -Các rule loại trừ snapshot nằm trong package resources: +Các rule loại trừ ảnh chụp nhanh nằm trong tài nguyên gói: -- `src/coding_agent_telegram/resources/snapshot_excluded_dir_names.txt` -- `src/coding_agent_telegram/resources/snapshot_excluded_dir_globs.txt` -- `src/coding_agent_telegram/resources/snapshot_excluded_file_globs.txt` +- `src/coding_agent_telegram/resources/ảnh chụp nhanh_excluded_dir_names.txt` +- `src/coding_agent_telegram/resources/ảnh chụp nhanh_excluded_dir_globs.txt` +- `src/coding_agent_telegram/resources/ảnh chụp nhanh_excluded_file_globs.txt` Bạn có thể override các giá trị mặc định này trong file env mà không cần sửa package đã cài: @@ -452,7 +504,7 @@ Bạn có thể override các giá trị mặc định này trong file env mà k - `SNAPSHOT_EXCLUDE_PATH_GLOBS` Thêm các diff exclusion ngoài bộ mặc định của package. Ví dụ: `.*,personal/*,sensitive*.txt` - Lưu ý: `.*` khớp cả hidden path, kể cả file trong hidden directory. + Lưu ý: `.*` khớp cả đường dẫn ẩn, kể cả tệp trong thư mục ẩn. Nếu include và exclude cùng khớp, include sẽ được ưu tiên. @@ -462,16 +514,16 @@ Bot coi project và branch là một cặp đi cùng nhau. - việc chọn project sẽ không âm thầm chọn một branch không liên quan - nếu cần branch, bot sẽ yêu cầu bạn chọn -- khi thông tin branch được hiển thị trong các thông báo liên quan đến session, project và branch sẽ được hiển thị cùng nhau +- khi thông tin branch được hiển thị trong các thông báo liên quan đến phiên, project và branch sẽ được hiển thị cùng nhau Khi bạn tạo hoặc đổi branch, bot sẽ hướng dẫn rõ source: -- `local/` nghĩa là dùng local branch làm source -- `origin/` nghĩa là cập nhật từ remote branch trước rồi mới chuyển +- local/<branch> nghĩa là dùng local branch làm source +- origin/<branch> nghĩa là cập nhật từ remote branch trước rồi mới chuyển -Nếu bot phát hiện branch lưu trong session không khớp với branch hiện tại của repository, bot sẽ không tiếp tục một cách mù quáng. Bot sẽ hỏi bạn muốn dùng branch nào: +Nếu bot phát hiện branch lưu trong phiên không khớp với branch hiện tại của repository, bot sẽ không tiếp tục một cách mù quáng. Bot sẽ hỏi bạn muốn dùng branch nào: -- giữ branch đã lưu trong session +- giữ branch đã lưu trong phiên - giữ branch hiện tại của repository Nếu source branch bạn muốn không còn, bot sẽ đưa ra các fallback source dựa trên default branch và current branch thay vì để bạn đối mặt với Git error thô. @@ -481,11 +533,11 @@ Nếu source branch bạn muốn không còn, bot sẽ đưa ra các fallback so - thư mục đã tồn tại sẽ tuân theo `CODEX_SKIP_GIT_REPO_CHECK` - thư mục được tạo qua `/project ` sẽ được app này đánh dấu là trusted - thư mục đã có sẵn được chọn qua `/project ` sẽ vẫn là untrusted cho đến khi bạn xác nhận trust trong Telegram -- vì vậy các project folder mới tạo có thể dùng ngay +- vì vậy các thư mục dự án mới tạo có thể dùng ngay - có thể tắt hoàn toàn `/commit` bằng `ENABLE_COMMIT_COMMAND` - các thao tác `/commit` có sửa đổi chỉ được phép trên trusted project -## 🪵 Logs +## 🪵 Nhật ký Log được ghi **cả ra stdout và vào file log quay vòng** dưới: @@ -498,11 +550,11 @@ Log được ghi **cả ra stdout và vào file log quay vòng** dưới: - bot khởi động và bắt đầu polling - chọn project -- tạo session -- chuyển session -- báo cáo active session +- tạo phiên +- chuyển phiên +- báo cáo phiên hoạt động - chạy bình thường (bao gồm audit log line với prompt đã được rút gọn) -- thay session sau khi resume thất bại +- thay phiên sau khi resume thất bại - warnings và runtime errors @@ -521,7 +573,7 @@ Log được ghi **cả ra stdout và vào file log quay vòng** dưới: mẫu environment chính được dùng cả khi chạy từ repo và khi cài dưới dạng package - `pyproject.toml` - cấu hình packaging và dependencies + cấu hình đóng gói và dependencies ## 📦 Quy ước phiên bản release diff --git a/README.zh-CN.md b/README.zh-CN.md index 3b3206c..201eb5e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -38,7 +38,7 @@ - ✅ 使用 Telegram 控制 Codex / Copilot CLI - ✅ 可以在代码块中轻松查看 agent 回复和改动文件 - ✅ agent 工作时也能继续排队后续问题 - - ✅ 支持文本和图片输入 + - ✅ 支持 ✏️ 文本、🌄 图片和 🎙️ 语音消息 ## 🔁 设备与会话无缝切换 @@ -49,7 +49,7 @@ ## 🛠️ 典型本地流程 ```bash - coding-agent-telegram # or run ./startup.sh + coding-agent-telegram # 或运行 ./startup.sh ``` ##### 在 Telegram 中: @@ -99,6 +99,7 @@ curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/i - 已在本地安装 Codex CLI 和/或 Copilot CLI - [安装 Codex CLI](https://developers.openai.com/codex/cli) - [安装 Copilot CLI](https://github.com/features/copilot/cli) +- [可选] Whisper、ffmpeg @@ -108,35 +109,61 @@ Openclaw 功能非常完整,也内置了名为 Pi-Agent 的 agent loop,适 ## 🚀 快速开始 -### Option A:一行 bootstrap 脚本 +### 方案 A:一行启动脚本 ```bash curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/install.sh | bash ``` -### Option B:通过 `pip` 从 PyPI 安装 +### 方案 B:通过 `pip` 从 PyPI 安装 ```bash pip install coding-agent-telegram coding-agent-telegram ``` -### Option C:从克隆的仓库运行 +### 方案 C:从克隆的仓库运行 ```bash git clone https://github.com/daocha/coding-agent-telegram cd coding-agent-telegram ./startup.sh ``` -### 启动 Bot Server +### 🌐 启动 Bot Server ##### 首次运行时,应用会创建 env 文件,并告诉你需要填写哪些字段。 ##### 更新 env 文件后,再次运行: ```bash -# if you follow Option A or Option B, then run +# 如果你使用方案 A 或方案 B,则运行 coding-agent-telegram -# if you follow Option C, then run this again +# 如果你使用方案 C,则再次运行此命令 ./startup.sh ``` +## 🎙️ [可选] 语音转文字功能:准备本地 OpenAI-Whisper 依赖 + +这部分用于可选启用 Telegram 语音消息的本地 Whisper 语音转文字功能。音频文件最大限制为 `20 MB`。 + +```bash +# 如果你是通过 pip 安装 +coding-agent-telegram-stt-install + +# 如果你是从克隆的仓库运行 +./install-stt.sh +``` + +推荐的 env 设置: + +```text +ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true +OPENAI_WHISPER_MODEL=base +OPENAI_WHISPER_TIMEOUT_SECONDS=120 +``` + +说明: + +- Whisper 会在首次使用时自动把所选模型下载到 `~/.cache/whisper`。 +- 如果你选择 `OPENAI_WHISPER_MODEL=turbo`,第一次语音转写更容易在 `large-v3-turbo.pt` 仍在下载时触发超时。 +- 语音消息转写完成后,bot 会先把识别出的文本回传到 Telegram,再把它交给 agent。这样更方便排查识别错误。 + ## 🔑 Telegram 设置 ### 获取 Bot Token @@ -171,60 +198,67 @@ https://api.telegram.org/bot/getUpdates ## 📨 支持的消息类型 +bot 当前接受: + +- 文本消息 +- 图片 +- 当 `ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true` 且已安装本地 Whisper 依赖时的语音消息和音频文件 +- Codex 和 Copilot 当前只支持文本和图片,不支持视频 + ## 🤖 Telegram 命令 - - + + - + - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + +
/provider为新 session 选择 provider。该选择会按 bot 和 chat 保存,直到你手动修改。/provider为新会话选择提供方。该选择会按 bot 和 chat 保存,直到你手动修改。
/project <project_folder>/project <project_folder> 设置当前 project 文件夹。如果文件夹不存在,应用会创建并标记为 trusted;如果已存在但仍是 untrusted,应用会明确要求确认 trust。
/branch <new_branch>/branch <new_branch> 为当前 project 准备或切换 branch。如果 branch 已存在,bot 会把它当作 source candidate;否则会使用 repository 的 default branch 作为 source candidate。
/branch <origin_branch> <new_branch>使用 `` 作为 source candidate 来准备或切换 branch。无论哪种形式,bot 之后只会提供实际存在的 source choices:`local/` 和 `origin/`。如果只存在其中一个,就只显示那个;如果两个都不存在,bot 会提示缺少 branch source。/branch <origin_branch> <new_branch>使用 <origin_branch> 作为 source candidate 来准备或切换 branch。无论哪种形式,bot 之后只会提供实际存在的 source choices:local/<branch>origin/<branch>。如果只存在其中一个,就只显示那个;如果两个都不存在,bot 会提示缺少 branch source。
/current显示当前 bot 和 chat 的 active session。/current显示当前 bot 和 chat 的活动会话。
/new [session_name]为当前 project 创建新 session。如果省略名称,bot 会使用真实的 session ID。若缺少 provider、project 或 branch,bot 会引导你完成缺失步骤。/new [session_name]为当前项目创建新会话。如果省略名称,bot 会使用真实的会话 ID。若缺少提供方、项目或 branch,bot 会引导你完成缺失步骤。
/switch显示最新的 session,按从新到旧排序。列表同时包含 bot-managed sessions 和当前 project 的本地 Codex/Copilot CLI sessions。/switch显示最新的会话,按从新到旧排序。列表同时包含 bot 管理的会话和当前项目的本地 Codex/Copilot CLI 会话。
/switch page <number>显示已保存 sessions 的其他页。/switch page <number>显示已保存会话的其他页。
/switch <session_id>通过 ID 切换到指定 session。如果你选择本地 CLI session,bot 会把它导入 state 并从那里继续。/switch <session_id>通过 ID 切换到指定会话。如果你选择本地 CLI 会话,bot 会把它导入状态并从那里继续。
/compact从当前活动 session 创建一个新的压缩 session,并切换到该 session。/compact从当前活动会话创建一个新的压缩会话,并切换到该会话。
/commit <git commands>在 active session 的 project 内执行已校验的 `git commit` 相关命令。仅当 `ENABLE_COMMIT_COMMAND=true` 时可用。会修改内容的 Git 命令要求 project 已 trusted。/commit <git commands>在活动会话的项目内执行已校验的 git commit 相关命令。仅当 ENABLE_COMMIT_COMMAND=true 时可用。会修改内容的 Git 命令要求项目已 trusted。
/push为当前 active session 执行 `origin ` push。push 前 bot 会要求确认。/push为当前活动会话执行 origin <branch> push。push 前 bot 会要求确认。
/abort中止当前 project 的 agent run。如果还有 queued questions 在等待,bot 会询问是否继续处理。/abort中止当前 project 的 代理运行。如果还有 排队问题 在等待,bot 会询问是否继续处理。
@@ -251,15 +285,15 @@ https://api.telegram.org/bot/getUpdates - + - + - +
WORKSPACE_ROOTWORKSPACE_ROOT 包含你的项目目录的父文件夹。
TELEGRAM_BOT_TOKENSTELEGRAM_BOT_TOKENS 以逗号分隔的 Telegram bot token。
ALLOWED_CHAT_IDSALLOWED_CHAT_IDS 允许使用该 bot 的 Telegram 私聊 chat ID,使用逗号分隔。
@@ -268,85 +302,103 @@ https://api.telegram.org/bot/getUpdates - + - + - + - + - + - + - + - + - + - - + + - + - + - + - + - + - +
APP_LOCALEAPP_LOCALE 共享 bot 消息和命令说明所使用的 UI 语言。支持值:endefrjakonlthvizh-CNzh-HKzh-TW
CODEX_BINCODEX_BIN 用于启动 Codex CLI 的命令。默认:codex
COPILOT_BINCOPILOT_BIN 用于启动 Copilot CLI 的命令。默认:copilot
CODEX_MODELCODEX_MODEL 可选的 Codex model 覆盖。留空则使用 Codex CLI 默认 model。示例:gpt-5.4 OpenAI Codex/OpenAI models
COPILOT_MODELCOPILOT_MODEL 可选的 Copilot model 覆盖。留空则使用 Copilot CLI 默认 model。示例:gpt-5.4claude-sonnet-4.6 GitHub Copilot supported models
CODEX_APPROVAL_POLICYCODEX_APPROVAL_POLICY 传递给 Codex 的 approval mode。默认:never
CODEX_SANDBOX_MODECODEX_SANDBOX_MODE 传递给 Codex 的 sandbox mode。默认:workspace-write
CODEX_SKIP_GIT_REPO_CHECKCODEX_SKIP_GIT_REPO_CHECK 如果启用,将始终跳过 Codex 的 trusted-repo 检查。
ENABLE_COMMIT_COMMANDENABLE_COMMIT_COMMAND 启用 Telegram 的 /commit 命令。默认:false
AGENT_HARD_TIMEOUT_SECONDS单次 agent run 的硬超时。默认:0(关闭)。AGENT_HARD_TIMEOUT_SECONDS单次 代理运行 的硬超时。默认:0(关闭)。
SNAPSHOT_TEXT_FILE_MAX_BYTESSNAPSHOT_TEXT_FILE_MAX_BYTES 构建每次运行的前后快照 diff 时,bot 会按文本读取的最大文件大小。默认:200000
MAX_TELEGRAM_MESSAGE_LENGTHMAX_TELEGRAM_MESSAGE_LENGTH 应用拆分回复前使用的最大消息长度。默认:3000
ENABLE_SENSITIVE_DIFF_FILTERENABLE_SENSITIVE_DIFF_FILTER 隐藏敏感文件的 diff。默认:true
ENABLE_SECRET_SCRUB_FILTERENABLE_SECRET_SCRUB_FILTER 在发送到 Telegram 之前,对 tokens、keys、.env 值、certificates 以及类似秘密输出做脱敏。默认:true(强烈建议开启)。
SNAPSHOT_INCLUDE_PATH_GLOBSSNAPSHOT_INCLUDE_PATH_GLOBS 强制把匹配的路径包含进 diff。示例:.github/*,.profile.test,.profile.prod
SNAPSHOT_EXCLUDE_PATH_GLOBSSNAPSHOT_EXCLUDE_PATH_GLOBS 在打包默认值之外额外添加 diff 排除规则。示例:.*,personal/*,sensitive*.txt 说明:.* 会匹配隐藏路径,包括隐藏目录中的文件。
+ +

语音转文字

+ + + + + + + + + + + + + + +
ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT默认:false。如果为 true,则启用语音消息和音频文件识别。系统会检查所需的二进制或库依赖,缺失时提示用户安装。
OPENAI_WHISPER_MODELWhisper STT 使用的模型。默认:base
可用模型:tiny72 MBbase139 MBlarge-v3-turbo1.5 GB
模型会在你第一次发送语音消息时自动下载。一般使用推荐 base。如果你想要更好的准确率和质量,可以尝试 turbo
OPENAI_WHISPER_TIMEOUT_SECONDS默认:120。STT 进程的超时时间。通常处理速度已经足够快,但如果你选择 turbo,第一次语音消息可能会因为下载模型而根据网速超过超时限制。
+

状态与日志

- + - + - +
~/.coding-agent-telegram/state.jsonHauptdatei für den Session-Status.会话状态主文件。
~/.coding-agent-telegram/state.json.bakBackup-Datei für den Status.状态备份文件。
~/.coding-agent-telegram/logsLog-Verzeichnis.日志目录。
@@ -366,14 +418,14 @@ ENABLE_SENSITIVE_DIFF_FILTER=true ENABLE_SECRET_SCRUB_FILTER=true ``` -## 🧠 Session 管理 +## 🧠 会话管理 -Session 按以下范围区分: +会话按以下范围区分: - Telegram bot - Telegram chat -这意味着同一个 Telegram 账号可以同时使用多个 bot,而不会把 session 混在一起。 +这意味着同一个 Telegram 账号可以同时使用多个 bot,而不会把会话混在一起。 示例: @@ -381,78 +433,78 @@ Session 按以下范围区分: - Bot B + 你的 chat -> frontend 工作 - Bot C + 你的 chat -> infra 工作 -active session 还会绑定到: +当前活动会话还会绑定到: -- project folder -- provider +- 项目文件夹 +- 提供方 - 如果有的话,branch 名称
-每个 session 会保存: +每个会话会保存: -- session 名称 -- project folder +- 会话名称 +- 项目文件夹 - branch 名称 -- provider +- 提供方 - timestamps -- 该 bot/chat 范围下的 active session 选择 +- 该 bot/chat 范围下的活动会话选择
-### 🔓 Workspace concurrency lock +### 🔓 工作区并发锁 -同一时间,每个 **project folder** 只能有一个 agent run 在执行,不管它是由哪个 chat 或 Telegram bot 触发的。 +同一时间,每个**项目文件夹**只能有一个代理运行实例在执行,不管它是由哪个 chat 或 Telegram bot 触发的。 -- **project is busy**:该 workspace 里已经有一个 agent run 在运行 -- **agent is busy**:那个 run 仍在处理当前请求 +- **项目忙碌中**:该工作区里已经有一个代理运行实例在执行 +- **代理忙碌中**:该运行实例仍在处理当前请求 bot 会强制这个限制,避免两个 agent 同时写入同一个 workspace,从而减少冲突修改和数据损坏的风险。 如果同一个 project 已经有 agent 在运行,又收到新的消息,bot 会立即回复: -> ⏳ 这个 project 上已经有 agent 在运行。请等待它完成。 +> ⏳ 项目上已有代理正在运行。请等待其完成。 这个 lock 只保存在内存中,不写入磁盘,所以当 agent 完成、失败或 server 重启时会自动释放。 ### 💬 排队问题 -如果当前 project 已经有一个 agent run 在执行,后续文本消息不会被拒绝,而是会进入队列。 +如果当前 project 已经有一个 代理运行 在执行,后续文本消息不会被拒绝,而是会进入队列。 - 新问题会追加到磁盘上的 queued-questions file - 当前 agent 会继续处理之前的请求 - 当该 run 正常结束后,bot 会自动开始处理队列中的问题 -如果当前 run 被 abort 且仍有 queued questions 在等待,bot 不会自动继续。它会询问你是否继续处理剩余问题,以及是打包处理还是逐个处理。 +如果当前 run 被 abort 且仍有 排队问题 在等待,bot 不会自动继续。它会询问你是否继续处理剩余问题,以及是打包处理还是逐个处理。 ## ⚠️ Diff(文件变更) -_在每次 agent run 期间,bot 也会为项目生成轻量的 before/after snapshot,用来汇总改动文件并把 diff 发回 Telegram。这个 snapshot 由 bot 应用自己生成,而不是由 Codex 或 Copilot 生成。_ +_在每次 代理运行 期间,bot 也会为项目生成轻量的 前后快照,用来汇总改动文件并把 diff 发回 Telegram。这个 快照 由 bot 应用自己生成,而不是由 Codex 或 Copilot 生成。_ -**Snapshot 说明:** +**快照说明:** -- app 会在 run 前后扫描 project directory -- 对于普通文本文件,app 会优先使用本次 run 的 snapshot diff,而不是 git head diff -- 常见的 dependency、cache 和 runtime directory 也会被跳过 -- binary file 以及大于 `SNAPSHOT_TEXT_FILE_MAX_BYTES` 的 file 不会按文本读取 +- app 会在 run 前后扫描 项目目录 +- 对于普通文本文件,app 会优先使用本次 run 的 快照 diff,而不是 git head diff +- 常见的 依赖、缓存 和 运行时目录 也会被跳过 +- 二进制文件 以及大于 `SNAPSHOT_TEXT_FILE_MAX_BYTES` 的 文件 不会按文本读取 - 对于很大的 project,这次额外扫描可能带来明显的 I/O 和内存开销 -- 如果 snapshot 无法把某个 file 表示成文本,app 会在可能时 fallback 到 `git diff` +- 如果 快照 无法把某个 文件 表示成文本,app 会在可能时 fallback 到 `git diff` - 对于大文件或非文本文件,diff 仍可能被省略,并替换为一条简短说明 -Snapshot 排除规则位于 package resources 中: +快照排除规则位于套件资源中: - `src/coding_agent_telegram/resources/snapshot_excluded_dir_names.txt` - `src/coding_agent_telegram/resources/snapshot_excluded_dir_globs.txt` - `src/coding_agent_telegram/resources/snapshot_excluded_file_globs.txt` -你可以在 env file 中覆盖这些默认值,而无需修改已安装的 package: +你可以在 环境文件 中覆盖这些默认值,而无需修改已安装的套件: - `SNAPSHOT_INCLUDE_PATH_GLOBS` 强制把匹配的 path 包含进 diff。 示例:`.github/*,.profile.test,.profile.prod` - `SNAPSHOT_EXCLUDE_PATH_GLOBS` - 在 package 默认值之外增加额外的 diff 排除规则。 + 在 套件 默认值之外增加额外的 diff 排除规则。 示例:`.*,personal/*,sensitive*.txt` - 说明:`.*` 会匹配隐藏 path,包括隐藏目录中的 file。 + 说明:`.*` 会匹配隐藏路径,包括隐藏目录中的文件。 如果 include 和 exclude 同时命中,则 include 优先。 @@ -462,16 +514,16 @@ bot 会把 project 和 branch 当成一组信息来处理。 - 选择 project 时不会悄悄切到无关的 branch - 如果需要 branch 输入,bot 会提示你选择 -- 在 session 相关消息里显示 branch 信息时,project 和 branch 会一起展示 +- 在会话相关消息里显示 branch 信息时,项目和 branch 会一起展示 当你创建或切换 branch 时,bot 会明确引导你选择 source: -- `local/` 表示使用本地 branch 作为 source -- `origin/` 表示先从远端 branch 更新,再切换 +- local/<branch> 表示使用本地 branch 作为 source +- origin/<branch> 表示先从远端 branch 更新,再切换 -如果 bot 发现 session 里保存的 branch 与当前 repository branch 不一致,它不会盲目继续,而是会询问你要使用哪一个 branch: +如果 bot 发现会话里保存的 branch 与当前仓库 branch 不一致,它不会盲目继续,而是会询问你要使用哪一个 branch: -- 保留 session 中保存的 branch +- 保留会话中保存的 branch - 保留当前 repository branch 如果你偏好的 source branch 已缺失,bot 会基于 default branch 和 current branch 提供 fallback source,而不是直接把你丢给原始 Git error。 @@ -481,11 +533,11 @@ bot 会把 project 和 branch 当成一组信息来处理。 - 已存在的 folder 遵循 `CODEX_SKIP_GIT_REPO_CHECK` - 通过 `/project ` 创建的 folder 会被此 app 标记为 trusted - 通过 `/project ` 选择的已有 folder,在你于 Telegram 中确认 trust 之前仍然保持 untrusted -- 因此,新建的 project folder 可以直接使用 +- 因此,新建的项目文件夹可以直接使用 - 可以通过 `ENABLE_COMMIT_COMMAND` 完全禁用 `/commit` - 会修改内容的 `/commit` 操作只允许在 trusted project 上执行 -## 🪵 Logs +## 🪵 日志 log 会**同时写入 stdout 和轮转日志文件**,路径为: @@ -498,11 +550,11 @@ log 会**同时写入 stdout 和轮转日志文件**,路径为: - bot 启动与 polling 启动 - project 选择 -- session 创建 -- session 切换 -- active session 报告 +- 会话创建 +- 会话切换 +- 活动会话报告 - 正常 run 执行(包含截断后的 prompt 审计日志行) -- resume 失败后的 session 替换 +- resume 失败后的会话替换 - warnings 与 runtime errors @@ -515,17 +567,17 @@ log 会**同时写入 stdout 和轮转日志文件**,路径为: 测试套件 - `startup.sh` - 本地 bootstrap 与 startup 入口 + 本地启动与运行入口 - `src/coding_agent_telegram/resources/.env.example` - 标准环境模板,同时用于 repo 启动和 package 安装 + 标准环境模板,同时用于仓库启动和套件安装 - `pyproject.toml` - packaging 与依赖配置 + 打包 与依赖配置 -## 📦 Release 版本规则 +## 📦 发布版本规则 -package 版本由 Git tags 推导而来。 +套件版本由 Git tags 推导而来。 - TestPyPI/testing: `v2026.3.26.dev1` - PyPI prerelease: `v2026.3.26rc1` diff --git a/README.zh-HK.md b/README.zh-HK.md index 24a199e..c0a693d 100644 --- a/README.zh-HK.md +++ b/README.zh-HK.md @@ -38,7 +38,7 @@ - ✅ 使用 Telegram 控制 Codex / Copilot CLI - ✅ 可在 code block 中輕鬆查看 agent 回覆及改動檔案 - ✅ agent 執行中仍可把後續問題排入佇列 - - ✅ 支援文字與圖片輸入 + - ✅ 支援 ✏️ 文字、🌄 圖片和 🎙️ 語音訊息 ## 🔁 裝置與工作階段無縫切換 @@ -49,7 +49,7 @@ ## 🛠️ 典型本機流程 ```bash - coding-agent-telegram # or run ./startup.sh + coding-agent-telegram # 或執行 ./startup.sh ``` ##### 在 Telegram: @@ -89,7 +89,7 @@ curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/i - ## ✅ 需求 + ## ✅ 運行要求 在啟動 server 之前,請先準備: @@ -99,6 +99,7 @@ curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/i - 已在本機安裝 Codex CLI 及/或 Copilot CLI - [安裝 Codex CLI](https://developers.openai.com/codex/cli) - [安裝 Copilot CLI](https://github.com/features/copilot/cli) +- [可選] Whisper、ffmpeg @@ -108,35 +109,61 @@ Openclaw 提供非常完整的能力,也內建了名為 Pi-Agent 的 agent loo ## 🚀 快速開始 -### Option A:一行 bootstrap script +### 方案 A:一行啟動腳本 ```bash curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/install.sh | bash ``` -### Option B:使用 `pip` 從 PyPI 安裝 +### 方案 B:使用 `pip` 從 PyPI 安裝 ```bash pip install coding-agent-telegram coding-agent-telegram ``` -### Option C:從 clone 下來的 repository 執行 +### 方案 C:從 clone 下來的 repository 執行 ```bash git clone https://github.com/daocha/coding-agent-telegram cd coding-agent-telegram ./startup.sh ``` -### 啟動 Bot Server +### 🌐 啟動 Bot Server ##### 第一次執行時,app 會建立 env 檔案,並告訴你需要填寫哪些欄位。 ##### 更新 env 檔案後,再次執行: ```bash -# if you follow Option A or Option B, then run +# 如果你使用方案 A 或方案 B,則執行 coding-agent-telegram -# if you follow Option C, then run this again +# 如果你使用方案 C,則再次執行此指令 ./startup.sh ``` +## 🎙️ [可選] 語音轉文字功能:準備本機 OpenAI-Whisper 依賴 + +這部分可選啟用 Telegram 語音訊息的本機 Whisper 語音轉文字功能。音訊檔案最大限制為 `20 MB`。 + +```bash +# 如果你是用 pip 安裝 +coding-agent-telegram-stt-install + +# 如果你是從 clone 的 repository 執行 +./install-stt.sh +``` + +建議的 env 設定: + +```text +ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true +OPENAI_WHISPER_MODEL=base +OPENAI_WHISPER_TIMEOUT_SECONDS=120 +``` + +說明: + +- Whisper 會在首次使用時自動把所選模型下載到 `~/.cache/whisper`。 +- 如果你選擇 `OPENAI_WHISPER_MODEL=turbo`,第一次語音轉錄更容易在 `large-v3-turbo.pt` 尚在下載時觸發逾時。 +- 語音訊息轉錄完成後,bot 會先把辨識出的文字回傳到 Telegram,再把它交給 agent。這樣更方便排查辨識錯誤。 + ## 🔑 Telegram 設定 ### 取得 Bot Token @@ -171,60 +198,67 @@ https://api.telegram.org/bot/getUpdates ## 📨 支援的訊息類型 +bot 目前接受: + +- 文字訊息 +- 圖片 +- 當 `ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true` 且已安裝本機 Whisper 依賴時的語音訊息與音訊檔案 +- Codex 與 Copilot 目前只支援文字與圖片,不支援影片 + ## 🤖 Telegram 指令 - - + + - + - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + +
/provider為新 session 選擇 provider。這個選擇會按 bot 與 chat 儲存,直到你手動修改。/provider為新的工作階段選擇提供者。這個選擇會按 bot 與 chat 儲存,直到你手動修改。
/project <project_folder>/project <project_folder> 設定目前的 project 資料夾。如果資料夾不存在,app 會建立並標記為 trusted;如果已存在但仍是 untrusted,app 會明確要求確認 trust。
/branch <new_branch>/branch <new_branch> 為目前的 project 準備或切換 branch。如果 branch 已存在,bot 會把它當作 source candidate;否則會使用 repository 的 default branch 作為 source candidate。
/branch <origin_branch> <new_branch>使用 `` 作為 source candidate 來準備或切換 branch。無論哪種形式,bot 之後只會提供實際存在的 source choices:`local/` 和 `origin/`。若只存在其中一個,就只顯示那個;若兩個都不存在,bot 會提示缺少 branch source。/branch <origin_branch> <new_branch>使用 <origin_branch> 作為 source candidate 來準備或切換 branch。無論哪種形式,bot 之後只會提供實際存在的 source choices:local/<branch>origin/<branch>。若只存在其中一個,就只顯示那個;若兩個都不存在,bot 會提示缺少 branch source。
/current顯示目前 bot 與 chat 的 active session。/current顯示目前 bot 與 chat 的作用中工作階段。
/new [session_name]為目前的 project 建立新 session。如果省略名稱,bot 會使用真實 session ID。若缺少 provider、project 或 branch,bot 會引導你完成缺少的步驟。/new [session_name]為目前的專案建立新工作階段。如果省略名稱,bot 會使用真實工作階段 ID。若缺少提供者、專案或 branch,bot 會引導你完成缺少的步驟。
/switch顯示最新的 session,按由新到舊排序。列表同時包含 bot-managed sessions 以及目前 project 的本機 Codex/Copilot CLI sessions。/switch顯示最新的工作階段,按由新到舊排序。列表同時包含 bot 管理的工作階段以及目前專案的本機 Codex/Copilot CLI 工作階段。
/switch page <number>顯示已儲存 sessions 的其他頁面。/switch page <number>顯示已儲存工作階段的其他頁面。
/switch <session_id>透過 ID 切換到指定 session。如果你選擇本機 CLI session,bot 會把它匯入 state 並從那裡繼續。/switch <session_id>透過 ID 切換到指定工作階段。如果你選擇本機 CLI 工作階段,bot 會把它匯入狀態並從那裡繼續。
/compact從目前使用中的 session 建立新的壓縮 session,並切換到該 session。/compact從目前使用中的工作階段建立新的壓縮工作階段,並切換到該工作階段。
/commit <git commands>在 active session 的 project 內執行已驗證的 `git commit` 相關指令。只在 `ENABLE_COMMIT_COMMAND=true` 時可用。會修改內容的 Git 指令要求 project 已 trusted。/commit <git commands>在作用中工作階段的專案內執行已驗證的 git commit 相關指令。只在 ENABLE_COMMIT_COMMAND=true 時可用。會修改內容的 Git 指令要求專案已 trusted。
/push為目前 active session 執行 `origin ` push。push 前 bot 會要求確認。/push為目前作用中工作階段執行 origin <branch> push。push 前 bot 會要求確認。
/abort中止目前 project 的 agent run。如果還有 queued questions 等候,bot 會詢問是否繼續處理。/abort中止目前 project 的 代理執行。如果還有 排隊問題 等候,bot 會詢問是否繼續處理。
@@ -251,15 +285,15 @@ https://api.telegram.org/bot/getUpdates - + - + - +
WORKSPACE_ROOTWORKSPACE_ROOT 包含你各個 project 目錄的父資料夾。
TELEGRAM_BOT_TOKENSTELEGRAM_BOT_TOKENS 以逗號分隔的 Telegram bot token。
ALLOWED_CHAT_IDSALLOWED_CHAT_IDS 允許使用此 bot 的 Telegram 私人 chat ID,使用逗號分隔。
@@ -268,85 +302,109 @@ https://api.telegram.org/bot/getUpdates - + - + - + - + - + - + - + - + - + - - + + - - + + - + - + - + - + - +
APP_LOCALEAPP_LOCALE 共用 bot 訊息與指令說明所使用的 UI 語言。支援值:endefrjakonlthvizh-CNzh-HKzh-TW
CODEX_BINCODEX_BIN 用來啟動 Codex CLI 的指令。預設:codex
COPILOT_BINCOPILOT_BIN 用來啟動 Copilot CLI 的指令。預設:copilot
CODEX_MODELCODEX_MODEL 可選的 Codex model override。留空則使用 Codex CLI 預設 model。例子:gpt-5.4 OpenAI Codex/OpenAI models
COPILOT_MODELCOPILOT_MODEL 可選的 Copilot model override。留空則使用 Copilot CLI 預設 model。例子:gpt-5.4claude-sonnet-4.6 GitHub Copilot supported models
CODEX_APPROVAL_POLICYCODEX_APPROVAL_POLICY 傳遞給 Codex 的 approval mode。預設:never
CODEX_SANDBOX_MODECODEX_SANDBOX_MODE 傳遞給 Codex 的 sandbox mode。預設:workspace-write
CODEX_SKIP_GIT_REPO_CHECKCODEX_SKIP_GIT_REPO_CHECK 如果啟用,會一直略過 Codex 的 trusted-repo 檢查。
ENABLE_COMMIT_COMMANDENABLE_COMMIT_COMMAND 啟用 Telegram 的 /commit 指令。預設:false
AGENT_HARD_TIMEOUT_SECONDS單次 agent run 的硬性 timeout。預設:0(停用)。AGENT_HARD_TIMEOUT_SECONDS單次 代理執行 的硬性 timeout。預設:0(停用)。
SNAPSHOT_TEXT_FILE_MAX_BYTES建立每次執行的前後 snapshot diff 時,bot 會以文字讀取的最大檔案大小。預設:200000SNAPSHOT_TEXT_FILE_MAX_BYTES建立每次執行的前後 快照 diff 時,bot 會以文字讀取的最大檔案大小。預設:200000
MAX_TELEGRAM_MESSAGE_LENGTHMAX_TELEGRAM_MESSAGE_LENGTH app 分割回覆前使用的最大訊息長度。預設:3000
ENABLE_SENSITIVE_DIFF_FILTERENABLE_SENSITIVE_DIFF_FILTER 隱藏敏感檔案的 diff。預設:true
ENABLE_SECRET_SCRUB_FILTERENABLE_SECRET_SCRUB_FILTER 在送往 Telegram 之前,對 tokens、keys、.env 值、certificates 及類似秘密輸出做遮罩。預設:true(強烈建議啟用)。
SNAPSHOT_INCLUDE_PATH_GLOBSSNAPSHOT_INCLUDE_PATH_GLOBS 強制把符合條件的 path 納入 diff。例子:.github/*,.profile.test,.profile.prod
SNAPSHOT_EXCLUDE_PATH_GLOBSSNAPSHOT_EXCLUDE_PATH_GLOBS 在套件預設值之外額外加入 diff 排除規則。例子:.*,personal/*,sensitive*.txt 說明:.* 會比對隱藏 path,包括隱藏資料夾內的檔案。
-

State 與 Logs

+ + + + + + + +

語音轉文字

+ + + + + + + + + + + + + + +
ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT預設:false。如果為 true,就會啟用語音訊息與音訊檔案識別。系統會檢查所需的 binary 或 library 依賴,缺少時會提示使用者安裝。
OPENAI_WHISPER_MODELWhisper STT 使用的模型。預設:base
可用模型:tiny72 MBbase139 MBlarge-v3-turbo1.5 GB
模型會在你第一次傳送語音訊息時自動下載。建議一般使用選 base。如果你想要更好的準確率與品質,可以嘗試 turbo
OPENAI_WHISPER_TIMEOUT_SECONDS預設:120。STT 進程的逾時時間。一般來說處理速度已足夠快,但如果你選擇 turbo,首次下載可能會視乎網速而超出逾時限制。
+ +

狀態與日誌

- + - + - +
~/.coding-agent-telegram/state.jsonHauptdatei für den Session-Status.工作階段狀態主檔。
~/.coding-agent-telegram/state.json.bakBackup-Datei für den Status.狀態備份檔。
~/.coding-agent-telegram/logsLog-Verzeichnis.日誌目錄。
@@ -366,14 +424,14 @@ ENABLE_SENSITIVE_DIFF_FILTER=true ENABLE_SECRET_SCRUB_FILTER=true ``` -## 🧠 Session 管理 +## 🧠 工作階段管理 -Session 會按以下範圍分開: +工作階段會按以下範圍分開: - Telegram bot - Telegram chat -這表示同一個 Telegram 帳號可以同時使用多個 bot,而不會把 session 混在一起。 +這表示同一個 Telegram 帳號可以同時使用多個 bot,而不會把工作階段混在一起。 例子: @@ -381,78 +439,78 @@ Session 會按以下範圍分開: - Bot B + 你的 chat -> frontend 工作 - Bot C + 你的 chat -> infra 工作 -active session 亦會綁定到: +目前作用中的工作階段亦會綁定到: -- project folder -- provider +- 專案資料夾 +- 提供者 - 如有的話,branch 名稱
-每個 session 會儲存: +每個工作階段會儲存: -- session 名稱 -- project folder +- 工作階段名稱 +- 專案資料夾 - branch 名稱 -- provider +- 提供者 - timestamps -- 該 bot/chat 範圍下的 active session 選擇 +- 該 bot/chat 範圍下的作用中工作階段選擇
-### 🔓 Workspace concurrency lock +### 🔓 工作區並行鎖定 -同一時間,每個 **project folder** 只能有一個 agent run 在執行,不論它是由哪個 chat 或 Telegram bot 觸發。 +同一時間,每個**專案資料夾**只能有一個代理執行在運作,不論它是由哪個 chat 或 Telegram bot 觸發。 -- **project is busy**:該 workspace 裡已經有一個 agent run 在運行 -- **agent is busy**:那個 run 仍在處理目前的請求 +- **專案忙碌中**:該工作區裡已經有一個代理執行在運作 +- **代理忙碌中**:該執行仍在處理目前的請求 bot 會強制這個限制,避免兩個 agent 同時寫入同一個 workspace,從而減少衝突修改和資料損壞風險。 如果同一個 project 已經有 agent 在運行,又收到新訊息,bot 會立即回覆: -> ⏳ 這個 project 上已經有 agent 在運行。請等待它完成。 +> ⏳ 專案上已有代理正在執行。請等待其完成。 這個 lock 只保存在記憶體中,不會寫入磁碟,所以當 agent 完成、失敗或 server 重新啟動時會自動釋放。 ### 💬 排隊問題 -如果目前的 project 已經有一個 agent run 在執行,之後的文字訊息不會被拒絕,而是會進入佇列。 +如果目前的 project 已經有一個 代理執行 在執行,之後的文字訊息不會被拒絕,而是會進入佇列。 - 新問題會追加到磁碟上的 queued-questions file - 目前的 agent 會繼續處理先前的請求 - 當該 run 正常結束後,bot 會自動開始處理佇列中的問題 -如果目前的 run 被 abort,而仍有 queued questions 在等待,bot 不會自動繼續。它會詢問你是否要繼續處理剩餘問題,以及要分批還是逐個處理。 +如果目前的 run 被 abort,而仍有 排隊問題 在等待,bot 不會自動繼續。它會詢問你是否要繼續處理剩餘問題,以及要分批還是逐個處理。 ## ⚠️ Diff(檔案變更) -_在每次 agent run 期間,bot 也會為 project 產生輕量的 before/after snapshot,用來總結已變更檔案並把 diff 傳回 Telegram。這個 snapshot 是由 bot app 自己建立,不是由 Codex 或 Copilot 建立。_ +_在每次 代理執行 期間,bot 也會為 project 產生輕量的 前後快照,用來總結已變更檔案並把 diff 傳回 Telegram。這個 快照 是由 bot app 自己建立,不是由 Codex 或 Copilot 建立。_ -**Snapshot 說明:** +**快照說明:** -- app 會在 run 前後掃描 project directory -- 對一般文字檔,app 會優先使用本次 run 的 snapshot diff,而不是 git head diff -- 常見的 dependency、cache 和 runtime directory 也會被略過 -- binary file 以及大於 `SNAPSHOT_TEXT_FILE_MAX_BYTES` 的 file 不會以文字方式讀取 +- app 會在 run 前後掃描 專案目錄 +- 對一般文字檔,app 會優先使用本次 run 的 快照 diff,而不是 git head diff +- 常見的 依賴、快取 和 執行時目錄 也會被略過 +- 二進位檔案 以及大於 `SNAPSHOT_TEXT_FILE_MAX_BYTES` 的 檔案 不會以文字方式讀取 - 對非常大的 project,這次額外掃描可能增加明顯的 I/O 和記憶體負擔 -- 如果 snapshot 無法把 file 表示為文字,app 會在可行時 fallback 到 `git diff` +- 如果 快照 無法把 檔案 表示為文字,app 會在可行時 fallback 到 `git diff` - 對大檔案或非文字檔,diff 仍可能被省略,並以簡短訊息代替 -Snapshot 排除規則位於 package resources: +快照排除規則位於套件資源: - `src/coding_agent_telegram/resources/snapshot_excluded_dir_names.txt` - `src/coding_agent_telegram/resources/snapshot_excluded_dir_globs.txt` - `src/coding_agent_telegram/resources/snapshot_excluded_file_globs.txt` -你可以在 env file 中覆蓋這些預設值,而不用修改已安裝的 package: +你可以在 環境檔案 中覆蓋這些預設值,而不用修改已安裝的套件: - `SNAPSHOT_INCLUDE_PATH_GLOBS` 強制把符合的 path 納入 diff。 例子:`.github/*,.profile.test,.profile.prod` - `SNAPSHOT_EXCLUDE_PATH_GLOBS` - 在 package 預設值之外加入額外的 diff 排除規則。 + 在 套件 預設值之外加入額外的 diff 排除規則。 例子:`.*,personal/*,sensitive*.txt` - 說明:`.*` 會比對 hidden path,包括 hidden directory 內的 file。 + 說明:`.*` 會比對 隱藏路徑,包括隱藏目錄內的檔案。 如果 include 和 exclude 同時命中,include 會優先。 @@ -462,16 +520,16 @@ bot 會把 project 和 branch 當成一組來處理。 - 選擇 project 時不會靜默切到無關 branch - 如果需要 branch 輸入,bot 會要求你選擇 -- 在 session 相關訊息中顯示 branch 資訊時,project 和 branch 會一起顯示 +- 在工作階段相關訊息中顯示 branch 資訊時,專案和 branch 會一起顯示 當你建立或切換 branch 時,bot 會明確引導你選擇 source: -- `local/`:使用本地 branch 作為 source -- `origin/`:先從遠端 branch 更新,再切換 +- local/<branch>:使用本地 branch 作為 source +- origin/<branch>:先從遠端 branch 更新,再切換 -如果 bot 發現 session 中儲存的 branch 與目前 repository branch 不一致,它不會盲目繼續,而會詢問你想使用哪個 branch: +如果 bot 發現工作階段中儲存的 branch 與目前儲存庫 branch 不一致,它不會盲目繼續,而會詢問你想使用哪個 branch: -- 保留 session 中儲存的 branch +- 保留工作階段中儲存的 branch - 保留目前 repository branch 如果你偏好的 source branch 已不存在,bot 會根據 default branch 和 current branch 提供 fallback source,而不是直接丟出原始 Git error。 @@ -481,28 +539,28 @@ bot 會把 project 和 branch 當成一組來處理。 - 已存在的 folder 會遵循 `CODEX_SKIP_GIT_REPO_CHECK` - 透過 `/project ` 建立的 folder 會被這個 app 標記為 trusted - 透過 `/project ` 選取的既有 folder,在你於 Telegram 確認 trust 前仍然保持 untrusted -- 因此,新建立的 project folder 可以立即使用 +- 因此,新建立的專案資料夾可以立即使用 - 可以用 `ENABLE_COMMIT_COMMAND` 完全停用 `/commit` - 會修改內容的 `/commit` 操作只允許在 trusted project 上執行 -## 🪵 Logs +## 🪵 日誌 -log 會**同時寫入 stdout 和輪轉 log file**,路徑如下: +log 會**同時寫入 stdout 和輪轉 日誌檔案**,路徑如下: - `~/.coding-agent-telegram/logs`(10 MB 輪轉,保留 3 份備份) -> **注意:**如果你同時看 terminal 又去 tail log file,每條訊息都會出現兩次。這是正常行為。請只看其中一邊,不要同時看兩邊。 +> **注意:**如果你同時看 terminal 又去 tail 日誌檔案,每條訊息都會出現兩次。這是正常行為。請只看其中一邊,不要同時看兩邊。
常見記錄事件 - bot 啟動與 polling 開始 - project 選擇 -- session 建立 -- session 切換 -- active session 報告 +- 工作階段建立 +- 工作階段切換 +- 作用中工作階段報告 - 正常 run 執行(包含被截短的 prompt audit log 行) -- resume 失敗後的 session 替換 +- resume 失敗後的工作階段替換 - warnings 與 runtime errors
@@ -515,17 +573,17 @@ log 會**同時寫入 stdout 和輪轉 log file**,路徑如下: 測試套件 - `startup.sh` - 本地 bootstrap 與 startup 入口 + 本地啟動與執行入口 - `src/coding_agent_telegram/resources/.env.example` - 標準環境範本,同時用於 repo 啟動與 package 安裝 + 標準環境範本,同時用於儲存庫啟動與套件安裝 - `pyproject.toml` - packaging 與 dependency 設定 + 封裝 與 依賴 設定 -## 📦 Release 版本規則 +## 📦 發行版本規則 -package 版本由 Git tags 推導而來。 +套件版本由 Git tags 推導而來。 - TestPyPI/testing: `v2026.3.26.dev1` - PyPI prerelease: `v2026.3.26rc1` diff --git a/README.zh-TW.md b/README.zh-TW.md index 689d0b0..d78cd0f 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -38,7 +38,7 @@ - ✅ 使用 Telegram 控制 Codex / Copilot CLI - ✅ 可以在 code block 中輕鬆檢視 agent 回覆與改動檔案 - ✅ agent 執行期間也能把後續問題排入佇列 - - ✅ 支援文字與圖片輸入 + - ✅ 支援 ✏️ 文字、🌄 圖片和 🎙️ 語音訊息 ## 🔁 裝置與工作階段無縫切換 @@ -49,7 +49,7 @@ ## 🛠️ 典型本機流程 ```bash - coding-agent-telegram # or run ./startup.sh + coding-agent-telegram # 或執行 ./startup.sh ``` ##### 在 Telegram: @@ -89,7 +89,7 @@ curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/i - ## ✅ 需求 + ## ✅ 執行需求 啟動 server 前,請先準備: @@ -99,6 +99,7 @@ curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/i - 已在本機安裝 Codex CLI 及/或 Copilot CLI - [安裝 Codex CLI](https://developers.openai.com/codex/cli) - [安裝 Copilot CLI](https://github.com/features/copilot/cli) +- [可選] Whisper、ffmpeg @@ -108,35 +109,61 @@ Openclaw 提供非常完整的能力,也內建了名為 Pi-Agent 的 agent loo ## 🚀 快速開始 -### Option A:一行 bootstrap script +### 方案 A:一行啟動腳本 ```bash curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/install.sh | bash ``` -### Option B:使用 `pip` 從 PyPI 安裝 +### 方案 B:使用 `pip` 從 PyPI 安裝 ```bash pip install coding-agent-telegram coding-agent-telegram ``` -### Option C:從 clone 下來的 repository 執行 +### 方案 C:從 clone 下來的 repository 執行 ```bash git clone https://github.com/daocha/coding-agent-telegram cd coding-agent-telegram ./startup.sh ``` -### 啟動 Bot Server +### 🌐 啟動 Bot Server ##### 第一次執行時,app 會建立 env 檔案,並告訴你需要填寫哪些欄位。 ##### 更新 env 檔案後,再次執行: ```bash -# if you follow Option A or Option B, then run +# 如果你使用方案 A 或方案 B,則執行 coding-agent-telegram -# if you follow Option C, then run this again +# 如果你使用方案 C,則再次執行此指令 ./startup.sh ``` +## 🎙️ [可選] 語音轉文字功能:準備本機 OpenAI-Whisper 依賴 + +這部分可選啟用 Telegram 語音訊息的本機 Whisper 語音轉文字功能。音訊檔案最大限制為 `20 MB`。 + +```bash +# 如果你是用 pip 安裝 +coding-agent-telegram-stt-install + +# 如果你是從 clone 的 repository 執行 +./install-stt.sh +``` + +建議的 env 設定: + +```text +ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true +OPENAI_WHISPER_MODEL=base +OPENAI_WHISPER_TIMEOUT_SECONDS=120 +``` + +說明: + +- Whisper 會在首次使用時自動把所選模型下載到 `~/.cache/whisper`。 +- 如果你選擇 `OPENAI_WHISPER_MODEL=turbo`,第一次語音轉錄更容易在 `large-v3-turbo.pt` 尚在下載時觸發逾時。 +- 語音訊息轉錄完成後,bot 會先把辨識出的文字回傳到 Telegram,再把它交給 agent。這樣更方便排查辨識錯誤。 + ## 🔑 Telegram 設定 ### 取得 Bot Token @@ -171,60 +198,67 @@ https://api.telegram.org/bot/getUpdates ## 📨 支援的訊息類型 +bot 目前接受: + +- 文字訊息 +- 圖片 +- 當 `ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true` 且已安裝本機 Whisper 依賴時的語音訊息與音訊檔案 +- Codex 與 Copilot 目前只支援文字與圖片,不支援影片 + ## 🤖 Telegram 指令 - - + + - + - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + +
/provider為新的 session 選擇 provider。這個選擇會依 bot 與 chat 儲存,直到你手動修改。/provider為新的工作階段選擇提供者。這個選擇會依 bot 與 chat 儲存,直到你手動修改。
/project <project_folder>/project <project_folder> 設定目前的 project 資料夾。如果資料夾不存在,app 會建立並標記為 trusted;如果已存在但仍是 untrusted,app 會明確要求確認 trust。
/branch <new_branch>/branch <new_branch> 為目前的 project 準備或切換 branch。如果 branch 已存在,bot 會把它視為 source candidate;否則會使用 repository 的 default branch 作為 source candidate。
/branch <origin_branch> <new_branch>使用 `` 作為 source candidate 來準備或切換 branch。無論哪種形式,bot 之後只會提供實際存在的 source choices:`local/` 和 `origin/`。若只存在其中一個,就只顯示那個;若兩個都不存在,bot 會提示缺少 branch source。/branch <origin_branch> <new_branch>使用 <origin_branch> 作為 source candidate 來準備或切換 branch。無論哪種形式,bot 之後只會提供實際存在的 source choices:local/<branch>origin/<branch>。若只存在其中一個,就只顯示那個;若兩個都不存在,bot 會提示缺少 branch source。
/current顯示目前 bot 與 chat 的 active session。/current顯示目前 bot 與 chat 的作用中工作階段。
/new [session_name]為目前的 project 建立新的 session。如果省略名稱,bot 會使用真實 session ID。若缺少 provider、project 或 branch,bot 會引導你完成缺少的步驟。/new [session_name]為目前的專案建立新的工作階段。如果省略名稱,bot 會使用真實工作階段 ID。若缺少提供者、專案或 branch,bot 會引導你完成缺少的步驟。
/switch顯示最新的 sessions,依新到舊排序。列表同時包含 bot-managed sessions 與目前 project 的本機 Codex/Copilot CLI sessions。/switch顯示最新的工作階段,依新到舊排序。列表同時包含 bot 管理的工作階段與目前專案的本機 Codex/Copilot CLI 工作階段。
/switch page <number>顯示已儲存 sessions 的其他頁面。/switch page <number>顯示已儲存工作階段的其他頁面。
/switch <session_id>透過 ID 切換到指定 session。如果你選擇本機 CLI session,bot 會把它匯入 state 並從那裡繼續。/switch <session_id>透過 ID 切換到指定工作階段。如果你選擇本機 CLI 工作階段,bot 會把它匯入狀態並從那裡繼續。
/compact從目前使用中的 session 建立新的壓縮 session,並切換到該 session。/compact從目前使用中的工作階段建立新的壓縮工作階段,並切換到該工作階段。
/commit <git commands>在 active session 的 project 內執行已驗證的 `git commit` 相關指令。僅在 `ENABLE_COMMIT_COMMAND=true` 時可用。會修改內容的 Git 指令要求 project 已 trusted。/commit <git commands>在作用中工作階段的專案內執行已驗證的 git commit 相關指令。僅在 ENABLE_COMMIT_COMMAND=true 時可用。會修改內容的 Git 指令要求專案已 trusted。
/push為目前 active session 執行 `origin ` push。push 前 bot 會要求確認。/push為目前作用中工作階段執行 origin <branch> push。push 前 bot 會要求確認。
/abort中止目前 project 的 agent run。如果還有 queued questions 在等待,bot 會詢問是否繼續處理。/abort中止目前 project 的 代理執行。如果還有 排隊問題 在等待,bot 會詢問是否繼續處理。
@@ -251,15 +285,15 @@ https://api.telegram.org/bot/getUpdates - + - + - +
WORKSPACE_ROOTWORKSPACE_ROOT 包含你各個 project 目錄的父資料夾。
TELEGRAM_BOT_TOKENSTELEGRAM_BOT_TOKENS 以逗號分隔的 Telegram bot token。
ALLOWED_CHAT_IDSALLOWED_CHAT_IDS 允許使用此 bot 的 Telegram 私人 chat ID,使用逗號分隔。
@@ -268,85 +302,109 @@ https://api.telegram.org/bot/getUpdates - + - + - + - + - + - + - + - + - + - - + + - - + + - + - + - + - + - +
APP_LOCALEAPP_LOCALE 共用 bot 訊息與指令說明所使用的 UI 語言。支援值:endefrjakonlthvizh-CNzh-HKzh-TW
CODEX_BINCODEX_BIN 用來啟動 Codex CLI 的指令。預設:codex
COPILOT_BINCOPILOT_BIN 用來啟動 Copilot CLI 的指令。預設:copilot
CODEX_MODELCODEX_MODEL 可選的 Codex model override。留空則使用 Codex CLI 預設 model。例子:gpt-5.4 OpenAI Codex/OpenAI models
COPILOT_MODELCOPILOT_MODEL 可選的 Copilot model override。留空則使用 Copilot CLI 預設 model。例子:gpt-5.4claude-sonnet-4.6 GitHub Copilot supported models
CODEX_APPROVAL_POLICYCODEX_APPROVAL_POLICY 傳遞給 Codex 的 approval mode。預設:never
CODEX_SANDBOX_MODECODEX_SANDBOX_MODE 傳遞給 Codex 的 sandbox mode。預設:workspace-write
CODEX_SKIP_GIT_REPO_CHECKCODEX_SKIP_GIT_REPO_CHECK 如果啟用,會永遠略過 Codex 的 trusted-repo 檢查。
ENABLE_COMMIT_COMMANDENABLE_COMMIT_COMMAND 啟用 Telegram 的 /commit 指令。預設:false
AGENT_HARD_TIMEOUT_SECONDS單次 agent run 的硬性 timeout。預設:0(停用)。AGENT_HARD_TIMEOUT_SECONDS單次 代理執行 的硬性 timeout。預設:0(停用)。
SNAPSHOT_TEXT_FILE_MAX_BYTES建立每次執行的前後 snapshot diff 時,bot 會以文字讀取的最大檔案大小。預設:200000SNAPSHOT_TEXT_FILE_MAX_BYTES建立每次執行的前後 快照 diff 時,bot 會以文字讀取的最大檔案大小。預設:200000
MAX_TELEGRAM_MESSAGE_LENGTHMAX_TELEGRAM_MESSAGE_LENGTH app 分割回覆前使用的最大訊息長度。預設:3000
ENABLE_SENSITIVE_DIFF_FILTERENABLE_SENSITIVE_DIFF_FILTER 隱藏敏感檔案的 diff。預設:true
ENABLE_SECRET_SCRUB_FILTERENABLE_SECRET_SCRUB_FILTER 在送往 Telegram 之前,對 tokens、keys、.env 值、certificates 及類似秘密輸出做遮罩。預設:true(強烈建議啟用)。
SNAPSHOT_INCLUDE_PATH_GLOBSSNAPSHOT_INCLUDE_PATH_GLOBS 強制把符合條件的 path 納入 diff。例子:.github/*,.profile.test,.profile.prod
SNAPSHOT_EXCLUDE_PATH_GLOBSSNAPSHOT_EXCLUDE_PATH_GLOBS 在套件預設值之外額外加入 diff 排除規則。例子:.*,personal/*,sensitive*.txt 說明:.* 會比對隱藏 path,包括隱藏資料夾內的檔案。
-

State 與 Logs

+ + + + + + + +

語音轉文字

+ + + + + + + + + + + + + + +
ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT預設:false。如果為 true,就會啟用語音訊息與音訊檔案識別。系統會檢查所需的 binary 或 library 依賴,缺少時會提示使用者安裝。
OPENAI_WHISPER_MODELWhisper STT 使用的模型。預設:base
可用模型:tiny72 MBbase139 MBlarge-v3-turbo1.5 GB
模型會在你第一次傳送語音訊息時自動下載。建議一般使用選 base。如果你想要更好的準確率與品質,可以嘗試 turbo
OPENAI_WHISPER_TIMEOUT_SECONDS預設:120。STT 進程的逾時時間。一般來說處理速度已足夠快,但如果你選擇 turbo,首次下載可能會視乎網速而超出逾時限制。
+ +

狀態與日誌

- + - + - +
~/.coding-agent-telegram/state.jsonHauptdatei für den Session-Status.工作階段狀態主檔。
~/.coding-agent-telegram/state.json.bakBackup-Datei für den Status.狀態備份檔。
~/.coding-agent-telegram/logsLog-Verzeichnis.日誌目錄。
@@ -366,14 +424,14 @@ ENABLE_SENSITIVE_DIFF_FILTER=true ENABLE_SECRET_SCRUB_FILTER=true ``` -## 🧠 Session 管理 +## 🧠 工作階段管理 -Session 會依以下範圍分開: +工作階段會依以下範圍分開: - Telegram bot - Telegram chat -這表示同一個 Telegram 帳號可以同時使用多個 bot,而不會把 session 混在一起。 +這表示同一個 Telegram 帳號可以同時使用多個 bot,而不會把工作階段混在一起。 範例: @@ -381,78 +439,78 @@ Session 會依以下範圍分開: - Bot B + 你的 chat -> frontend 工作 - Bot C + 你的 chat -> infra 工作 -active session 也會綁定到: +目前作用中的工作階段也會綁定到: -- project folder -- provider +- 專案資料夾 +- 提供者 - 如果有的話,branch 名稱
-每個 session 會儲存: +每個工作階段會儲存: -- session 名稱 -- project folder +- 工作階段名稱 +- 專案資料夾 - branch 名稱 -- provider +- 提供者 - timestamps -- 該 bot/chat 範圍下的 active session 選擇 +- 該 bot/chat 範圍下的作用中工作階段選擇
-### 🔓 Workspace concurrency lock +### 🔓 工作區並行鎖定 -同一時間,每個 **project folder** 只能有一個 agent run 在執行,不論它是由哪個 chat 或 Telegram bot 觸發。 +同一時間,每個**專案資料夾**只能有一個代理執行在運作,不論它是由哪個 chat 或 Telegram bot 觸發。 -- **project is busy**:該 workspace 裡已經有一個 agent run 在運行 -- **agent is busy**:那個 run 仍在處理目前的請求 +- **專案忙碌中**:該工作區裡已經有一個代理執行在運作 +- **代理忙碌中**:該執行仍在處理目前的請求 bot 會強制這個限制,避免兩個 agent 同時寫入同一個 workspace,從而減少衝突修改與資料損壞的風險。 如果同一個 project 已經有 agent 在運行,又收到新的訊息,bot 會立即回覆: -> ⏳ 這個 project 上已經有 agent 在運行。請等待它完成。 +> ⏳ 專案上已有代理正在執行。請等待其完成。 這個 lock 只保存在記憶體中,不會寫入磁碟,所以當 agent 完成、失敗或 server 重新啟動時會自動釋放。 ### 💬 排隊問題 -如果目前的 project 已經有一個 agent run 在執行,之後的文字訊息不會被拒絕,而是會進入佇列。 +如果目前的 project 已經有一個 代理執行 在執行,之後的文字訊息不會被拒絕,而是會進入佇列。 - 新問題會追加到磁碟上的 queued-questions file - 目前的 agent 會繼續處理先前的請求 - 當該 run 正常結束後,bot 會自動開始處理佇列中的問題 -如果目前的 run 被 abort,而仍有 queued questions 在等待,bot 不會自動繼續。它會詢問你是否要繼續處理剩餘問題,以及要分批還是逐個處理。 +如果目前的 run 被 abort,而仍有 排隊問題 在等待,bot 不會自動繼續。它會詢問你是否要繼續處理剩餘問題,以及要分批還是逐個處理。 ## ⚠️ Diff(檔案變更) -_在每次 agent run 期間,bot 也會為 project 產生輕量的 before/after snapshot,用來彙整已變更檔案並把 diff 傳回 Telegram。這個 snapshot 是由 bot app 自己建立,不是由 Codex 或 Copilot 建立。_ +_在每次 代理執行 期間,bot 也會為 project 產生輕量的 前後快照,用來彙整已變更檔案並把 diff 傳回 Telegram。這個 快照 是由 bot app 自己建立,不是由 Codex 或 Copilot 建立。_ -**Snapshot 說明:** +**快照說明:** -- app 會在 run 前後掃描 project directory -- 對一般文字檔,app 會優先使用本次 run 的 snapshot diff,而不是 git head diff -- 常見的 dependency、cache 與 runtime directory 也會被略過 -- binary file 以及大於 `SNAPSHOT_TEXT_FILE_MAX_BYTES` 的 file 不會以文字方式讀取 +- app 會在 run 前後掃描 專案目錄 +- 對一般文字檔,app 會優先使用本次 run 的 快照 diff,而不是 git head diff +- 常見的 依賴、快取 與 執行時目錄 也會被略過 +- 二進位檔案 以及大於 `SNAPSHOT_TEXT_FILE_MAX_BYTES` 的 檔案 不會以文字方式讀取 - 對非常大的 project,這次額外掃描可能增加明顯的 I/O 與記憶體負擔 -- 如果 snapshot 無法把 file 表示為文字,app 會在可行時 fallback 到 `git diff` +- 如果 快照 無法把 檔案 表示為文字,app 會在可行時 fallback 到 `git diff` - 對大檔案或非文字檔,diff 仍可能被省略,並改用簡短訊息代替 -Snapshot 排除規則位於 package resources: +快照排除規則位於套件資源: - `src/coding_agent_telegram/resources/snapshot_excluded_dir_names.txt` - `src/coding_agent_telegram/resources/snapshot_excluded_dir_globs.txt` - `src/coding_agent_telegram/resources/snapshot_excluded_file_globs.txt` -你可以在 env file 中覆蓋這些預設值,而不用修改已安裝的 package: +你可以在 環境檔案 中覆蓋這些預設值,而不用修改已安裝的套件: - `SNAPSHOT_INCLUDE_PATH_GLOBS` 強制把符合的 path 納入 diff。 範例:`.github/*,.profile.test,.profile.prod` - `SNAPSHOT_EXCLUDE_PATH_GLOBS` - 在 package 預設值之外加入額外的 diff 排除規則。 + 在 套件 預設值之外加入額外的 diff 排除規則。 範例:`.*,personal/*,sensitive*.txt` - 說明:`.*` 會比對 hidden path,包括 hidden directory 內的 file。 + 說明:`.*` 會比對 隱藏路徑,包括隱藏目錄內的檔案。 如果 include 和 exclude 同時命中,include 會優先。 @@ -462,16 +520,16 @@ bot 會把 project 和 branch 當成一組資訊來處理。 - 選擇 project 時不會悄悄切到無關 branch - 如果需要 branch 輸入,bot 會要求你選擇 -- 在 session 相關訊息中顯示 branch 資訊時,project 和 branch 會一起顯示 +- 在工作階段相關訊息中顯示 branch 資訊時,專案和 branch 會一起顯示 當你建立或切換 branch 時,bot 會明確引導你選擇 source: -- `local/`:使用本地 branch 作為 source -- `origin/`:先從遠端 branch 更新,再切換 +- local/<branch>:使用本地 branch 作為 source +- origin/<branch>:先從遠端 branch 更新,再切換 -如果 bot 發現 session 中儲存的 branch 與目前 repository branch 不一致,它不會盲目繼續,而會詢問你想使用哪個 branch: +如果 bot 發現工作階段中儲存的 branch 與目前儲存庫 branch 不一致,它不會盲目繼續,而會詢問你想使用哪個 branch: -- 保留 session 中儲存的 branch +- 保留工作階段中儲存的 branch - 保留目前 repository branch 如果你偏好的 source branch 已不存在,bot 會根據 default branch 和 current branch 提供 fallback source,而不是直接丟出原始 Git error。 @@ -481,28 +539,28 @@ bot 會把 project 和 branch 當成一組資訊來處理。 - 已存在的 folder 會遵循 `CODEX_SKIP_GIT_REPO_CHECK` - 透過 `/project ` 建立的 folder 會被這個 app 標記為 trusted - 透過 `/project ` 選取的既有 folder,在你於 Telegram 確認 trust 前仍然保持 untrusted -- 因此,新建立的 project folder 可以立即使用 +- 因此,新建立的專案資料夾可以立即使用 - 可以用 `ENABLE_COMMIT_COMMAND` 完全停用 `/commit` - 會修改內容的 `/commit` 操作只允許在 trusted project 上執行 -## 🪵 Logs +## 🪵 日誌 -log 會**同時寫入 stdout 與輪轉 log file**,路徑如下: +log 會**同時寫入 stdout 與輪轉 日誌檔案**,路徑如下: - `~/.coding-agent-telegram/logs`(10 MB 輪轉,保留 3 份備份) -> **注意:**如果你同時看 terminal 又去 tail log file,每條訊息都會出現兩次。這是正常行為。請只看其中一邊,不要同時看兩邊。 +> **注意:**如果你同時看 terminal 又去 tail 日誌檔案,每條訊息都會出現兩次。這是正常行為。請只看其中一邊,不要同時看兩邊。
常見記錄事件 - bot 啟動與 polling 開始 - project 選擇 -- session 建立 -- session 切換 -- active session 報告 +- 工作階段建立 +- 工作階段切換 +- 作用中工作階段報告 - 正常 run 執行(包含被截短的 prompt audit log 行) -- resume 失敗後的 session 替換 +- resume 失敗後的工作階段替換 - warnings 與 runtime errors
@@ -515,17 +573,17 @@ log 會**同時寫入 stdout 與輪轉 log file**,路徑如下: 測試套件 - `startup.sh` - 本地 bootstrap 與 startup 入口 + 本地啟動與執行入口 - `src/coding_agent_telegram/resources/.env.example` - 標準環境範本,同時用於 repo 啟動與 package 安裝 + 標準環境範本,同時用於儲存庫啟動與套件安裝 - `pyproject.toml` - packaging 與 dependency 設定 + 封裝 與 依賴 設定 -## 📦 Release 版本規則 +## 📦 發行版本規則 -package 版本由 Git tags 推導而來。 +套件版本由 Git tags 推導而來。 - TestPyPI/testing: `v2026.3.26.dev1` - PyPI prerelease: `v2026.3.26rc1` diff --git a/install-stt.sh b/install-stt.sh new file mode 100755 index 0000000..a74dba7 --- /dev/null +++ b/install-stt.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +PYTHON_BIN="${PYTHON_BIN:-python3}" +VENV_DIR="${VENV_DIR:-.venv}" +ENV_FILE="${ENV_FILE:-}" +LOCAL_PRETEND_VERSION="${SETUPTOOLS_SCM_PRETEND_VERSION_FOR_CODING_AGENT_TELEGRAM:-0.0.dev0}" + +if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then + echo "Error: $PYTHON_BIN was not found in PATH." >&2 + exit 1 +fi + +if [[ ! -d "$VENV_DIR" ]]; then + "$PYTHON_BIN" -m venv "$VENV_DIR" +fi + +source "$VENV_DIR/bin/activate" +python -m pip install --upgrade pip >/dev/null + +if ! python -c "import coding_agent_telegram" >/dev/null 2>&1; then + echo "Installing local package into $VENV_DIR so the shared STT installer is available." + SETUPTOOLS_SCM_PRETEND_VERSION_FOR_CODING_AGENT_TELEGRAM="$LOCAL_PRETEND_VERSION" \ + python -m pip install -e . +fi + +ARGS=("install") +if [[ -n "$ENV_FILE" ]]; then + ARGS+=("--env-file" "$ENV_FILE") +fi +ARGS+=("--python-bin" "$(command -v python)") + +exec python -m coding_agent_telegram.stt_setup "${ARGS[@]}" diff --git a/install.sh b/install.sh index 4ff3b92..4424b95 100644 --- a/install.sh +++ b/install.sh @@ -28,7 +28,4 @@ if [[ -z "$COMMAND_PATH" && ":$PATH:" != *":$SCRIPT_DIR:"* ]]; then fi echo "Starting coding-agent-telegram..." -if [[ -n "$COMMAND_PATH" ]]; then - exec "$COMMAND_PATH" -fi exec "$PYTHON_BIN" -m coding_agent_telegram diff --git a/pyproject.toml b/pyproject.toml index 20ff289..59db972 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ [project.scripts] coding-agent-telegram = "coding_agent_telegram.cli:main" +coding-agent-telegram-stt-install = "coding_agent_telegram.stt_setup:main" [tool.setuptools] package-dir = {"" = "src"} diff --git a/src/coding_agent_telegram/bot.py b/src/coding_agent_telegram/bot.py index c0fd0aa..0cad645 100644 --- a/src/coding_agent_telegram/bot.py +++ b/src/coding_agent_telegram/bot.py @@ -20,6 +20,25 @@ TELEGRAM_GET_UPDATES_CONNECTION_POOL_SIZE = 2 +def _describe_message_types(message) -> list[str]: + types: list[str] = [] + for field_name in ( + "text", + "photo", + "voice", + "audio", + "document", + "video", + "video_note", + "animation", + "sticker", + ): + value = getattr(message, field_name, None) + if value: + types.append(field_name) + return types + + def default_bot_commands(*, enable_commit_command: bool, locale: str = DEFAULT_LOCALE) -> list[BotCommand]: commands = [ BotCommand("provider", translate(locale, "bot.command.provider")), @@ -106,9 +125,22 @@ def build_application(token: str, router: CommandRouter, *, allowed_chat_ids: se | tg_filters.Sticker.ALL | tg_filters.VIDEO | tg_filters.VIDEO_NOTE - | tg_filters.VOICE ) + async def log_incoming_private_message(update, _context) -> None: + message = getattr(update, "message", None) + chat = getattr(update, "effective_chat", None) + if message is None or chat is None: + return + logger.info( + "Incoming Telegram message chat=%s message_id=%s types=%s text_preview=%.120r", + chat.id, + getattr(message, "message_id", None), + ",".join(_describe_message_types(message)) or "unknown", + getattr(message, "text", None) or "", + ) + + app.add_handler(MessageHandler(allowed_private, log_incoming_private_message, block=False), group=-1) app.add_handler(CommandHandler("provider", router.handle_provider, filters=allowed_private)) app.add_handler(CommandHandler("project", router.handle_project, filters=allowed_private)) app.add_handler(CommandHandler("branch", router.handle_branch, filters=allowed_private)) @@ -122,11 +154,13 @@ def build_application(token: str, router: CommandRouter, *, allowed_chat_ids: se app.add_handler(CallbackQueryHandler(router.handle_provider_callback, pattern=r"^provider:set:(codex|copilot)$", block=False)) app.add_handler(CallbackQueryHandler(router.handle_queue_batch_callback, pattern=r"^queuebatch:(group|single|cancel)$", block=False)) app.add_handler(CallbackQueryHandler(router.handle_queue_continue_callback, pattern=r"^queuecontinue:(yes|no)$", block=False)) - app.add_handler(CallbackQueryHandler(router.handle_branch_source_callback, pattern=r"^branchsource:(local|origin):", block=False)) + app.add_handler(CallbackQueryHandler(router.handle_branch_source_callback, pattern=r"^branchsource:[0-9a-f]{12}$", block=False)) app.add_handler(CallbackQueryHandler(router.handle_branch_discrepancy_callback, pattern=r"^branchdiscrepancy:(stored|current)$", block=False)) app.add_handler(CallbackQueryHandler(router.handle_push_callback, pattern=r"^push:(confirm|cancel)$")) app.add_handler(CallbackQueryHandler(router.handle_trust_project_callback, pattern=r"^trustproject:(yes|no):")) app.add_handler(MessageHandler(allowed_private & tg_filters.PHOTO, router.handle_photo, block=False)) + app.add_handler(MessageHandler(allowed_private & tg_filters.AUDIO, router.handle_audio, block=False)) + app.add_handler(MessageHandler(allowed_private & tg_filters.VOICE, router.handle_voice, block=False)) app.add_handler(MessageHandler(allowed_private & tg_filters.TEXT & ~tg_filters.COMMAND, router.handle_message, block=False)) app.add_handler(MessageHandler(allowed_private & unsupported_media, router.handle_unsupported_message)) app.add_error_handler(build_error_handler(router.deps.cfg.locale)) diff --git a/src/coding_agent_telegram/cli.py b/src/coding_agent_telegram/cli.py index 2debcd3..3cf2299 100644 --- a/src/coding_agent_telegram/cli.py +++ b/src/coding_agent_telegram/cli.py @@ -14,6 +14,7 @@ from coding_agent_telegram.i18n import translate from coding_agent_telegram.logging_utils import setup_logging from coding_agent_telegram.session_store import SessionStore +from coding_agent_telegram.stt_setup import ensure_stt_runtime_or_exit, offer_stt_install_for_new_env logger = logging.getLogger(__name__) @@ -123,6 +124,11 @@ def main() -> None: ), file=sys.stderr, ) + offer_stt_install_for_new_env( + env_file=str(env_path), + python_bin=sys.executable, + installer_label="coding-agent-telegram-stt-install", + ) try: cfg = load_config(env_path) except ValueError as exc: @@ -140,6 +146,11 @@ def main() -> None: log_file = setup_logging(cfg.log_level, cfg.log_dir) logger.info("Logging to %s", log_file) + try: + ensure_stt_runtime_or_exit(cfg.enable_openai_whisper_speech_to_text) + except SystemExit as exc: + logger.error("%s", exc) + raise store = SessionStore(cfg.state_file, cfg.state_backup_file) runner = MultiAgentRunner( diff --git a/src/coding_agent_telegram/config.py b/src/coding_agent_telegram/config.py index 59c435a..be8eb09 100644 --- a/src/coding_agent_telegram/config.py +++ b/src/coding_agent_telegram/config.py @@ -21,6 +21,8 @@ DEFAULT_ENV_FILE_NAME = ".env_coding_agent_telegram" # 0 = disabled. Set to a positive value to kill runaway agent processes. DEFAULT_AGENT_HARD_TIMEOUT_SECONDS = 0 +DEFAULT_OPENAI_WHISPER_MODEL = "base" +DEFAULT_OPENAI_WHISPER_TIMEOUT_SECONDS = 120 @dataclass(frozen=True) @@ -51,6 +53,9 @@ class AppConfig: max_telegram_message_length: int enable_sensitive_diff_filter: bool enable_secret_scrub_filter: bool + enable_openai_whisper_speech_to_text: bool + openai_whisper_model: str + openai_whisper_timeout_seconds: int default_agent_provider: str agent_hard_timeout_seconds: int app_internal_root: Path @@ -227,6 +232,15 @@ def load_config(env_file: Optional[Path] = None) -> AppConfig: ), enable_sensitive_diff_filter=_parse_bool(os.getenv("ENABLE_SENSITIVE_DIFF_FILTER", "true"), default=True), enable_secret_scrub_filter=_parse_bool(os.getenv("ENABLE_SECRET_SCRUB_FILTER", "true"), default=True), + enable_openai_whisper_speech_to_text=_parse_bool( + os.getenv("ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT", "false") + ), + openai_whisper_model=os.getenv("OPENAI_WHISPER_MODEL", DEFAULT_OPENAI_WHISPER_MODEL).strip() + or DEFAULT_OPENAI_WHISPER_MODEL, + openai_whisper_timeout_seconds=max( + 1, + int(os.getenv("OPENAI_WHISPER_TIMEOUT_SECONDS", str(DEFAULT_OPENAI_WHISPER_TIMEOUT_SECONDS))), + ), default_agent_provider=provider, agent_hard_timeout_seconds=int( os.getenv("AGENT_HARD_TIMEOUT_SECONDS", str(DEFAULT_AGENT_HARD_TIMEOUT_SECONDS)) diff --git a/src/coding_agent_telegram/resources/.env.example b/src/coding_agent_telegram/resources/.env.example index eac4569..582d866 100644 --- a/src/coding_agent_telegram/resources/.env.example +++ b/src/coding_agent_telegram/resources/.env.example @@ -90,6 +90,22 @@ ENABLE_SENSITIVE_DIFF_FILTER=true # Strongly recommended: keep this set to true. ENABLE_SECRET_SCRUB_FILTER=true +# If true, enable Telegram voice-message speech-to-text through local openai-whisper. +# Default: false. Run coding-agent-telegram-stt-install (pip install) or ./install-stt.sh (repo clone) first. +# Estimated local footprint: openai-whisper package ~50 MB, ffmpeg ~50 MB, plus Whisper model downloads. +# Example model cache sizes: tiny ~72 MB, base ~139 MB, large-v3-turbo ~1.5 GB. +ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=false + +# Whisper model name to use for Telegram voice-message speech-to-text. +# Recommended default: base. `turbo` downloads the large-v3-turbo model (~1.5 GB). +# Models download automatically on first use into ~/.cache/whisper. +# If the selected model is not cached yet, the first voice transcription may take longer. +# With `turbo`, that first call is more likely to hit OPENAI_WHISPER_TIMEOUT_SECONDS before the download finishes. +OPENAI_WHISPER_MODEL=base + +# Timeout for a single Whisper transcription call, in seconds. +OPENAI_WHISPER_TIMEOUT_SECONDS=120 + # Default agent provider for new sessions: codex or copilot. DEFAULT_AGENT_PROVIDER=codex diff --git a/src/coding_agent_telegram/resources/locales/de.json b/src/coding_agent_telegram/resources/locales/de.json index 0efee11..1d48a75 100644 --- a/src/coding_agent_telegram/resources/locales/de.json +++ b/src/coding_agent_telegram/resources/locales/de.json @@ -26,7 +26,8 @@ "git.usage_push": "Verwendung: /push", "message.photo_only_codex": "Fotoanhänge werden derzeit nur für Codex-Sitzungen unterstützt.", "message.question_queued": "Frage als Q{question_number} in die Warteschlange gestellt. Sie wird verarbeitet, sobald die aktuelle Agent-Aufgabe abgeschlossen ist.", - "message.unsupported_message_type": "Nicht unterstützter Nachrichtentyp.\nDieser Bot akzeptiert derzeit nur Textnachrichten und Fotos.", + "message.voice_speech_to_text_disabled": "Sprachnachrichten sind nicht aktiviert.\nSetze ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true und installiere zuerst die lokalen Whisper-Voraussetzungen.", + "message.unsupported_message_type": "Nicht unterstützter Nachrichtentyp.\nDieser Bot akzeptiert derzeit Textnachrichten, Fotos, Sprachnachrichten und Audiodateien.", "queue.button_group": "Fragen gruppieren", "queue.button_no": "Nein", "queue.button_single": "Einzeln verarbeiten", @@ -54,6 +55,11 @@ "runtime.resume_created_new": "Das Fortsetzen ist fehlgeschlagen, daher wurde eine neue Sitzung erstellt.\nNeue Sitzungs-ID: {session_id}\nNeuer Sitzungsname: {session_name}", "runtime.resume_id_changed": "Das Fortsetzen war erfolgreich, aber die Sitzungs-ID hat sich geändert.\nNeue Sitzungs-ID: {session_id}\nNeuer Sitzungsname: {session_name}", "runtime.sensitive_diff_omitted": "{path}\nDiese Datei enthält sensible Inhalte und wurde ausgelassen.", + "runtime.voice_conversion_failed": "Sprachumwandlung fehlgeschlagen.", + "runtime.voice_conversion_timed_out": "Zeitlimit für Sprachumwandlung erreicht.", + "runtime.voice_model_initial_download_note": "Das gewählte Whisper-Modell wird beim ersten Aufruf möglicherweise noch heruntergeladen. Größere Modelle wie turbo erreichen dieses Zeitlimit eher.", + "runtime.voice_transcript_preview": "Erkanntes Sprachtranskript:\n{transcript}\n\nWird bearbeitet...", + "runtime.voice_transcript_queued_preview": "Erkanntes Sprachtranskript:\n{transcript}\n\nAls Q{question_number} in die Warteschlange gestellt. Es wird verarbeitet, sobald die aktuelle Agent-Aufgabe abgeschlossen ist.", "runtime.working_on_it": "Wird bearbeitet...", "status.abort_signal_sent": "Abbruchsignal für den aktuellen Projektlauf gesendet.", "status.no_running_agent": "Für das aktuelle Projekt wurde kein laufender Agent-Prozess gefunden.", diff --git a/src/coding_agent_telegram/resources/locales/en.json b/src/coding_agent_telegram/resources/locales/en.json index 7748e71..9115cea 100644 --- a/src/coding_agent_telegram/resources/locales/en.json +++ b/src/coding_agent_telegram/resources/locales/en.json @@ -11,6 +11,7 @@ "bot.error.command_failed": "⚠️ Command failed. Check the server log for details.", "bot.error.session_store": "⚠️ {error}", "common.agent_already_running": "⏳ An agent is already running on project '{project_folder}'. Please wait for it to finish.", + "common.button_expired": "⚠️ This button has expired. Please retry the command.", "common.current_project_not_git": "⚠️ Current project is not a git repository.", "common.no_active_session": "No active session.\nPlease run /project and /new first.", "common.no_project_selected": "No project selected.\nPlease run /project first.", @@ -26,7 +27,8 @@ "git.usage_push": "Usage: /push", "message.photo_only_codex": "Photo attachments are currently supported only for codex sessions.", "message.question_queued": "Question queued as Q{question_number}. It will run after the current agent task finishes.", - "message.unsupported_message_type": "Unsupported message type.\nThis bot currently accepts only text messages and photos.", + "message.voice_speech_to_text_disabled": "Voice messages are not enabled.\nSet ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true and install the local Whisper prerequisites first.", + "message.unsupported_message_type": "Unsupported message type.\nThis bot currently accepts text messages, photos, voice messages, and audio files.", "queue.button_group": "Group the questions", "queue.button_cancel": "Cancel", "queue.button_no": "No", @@ -56,6 +58,12 @@ "runtime.resume_created_new": "Resume failed, so a new session was created.\nNew session ID: {session_id}\nNew session name: {session_name}", "runtime.resume_id_changed": "Resume succeeded, but the session ID changed.\nNew session ID: {session_id}\nNew session name: {session_name}", "runtime.sensitive_diff_omitted": "{path}\nThis file contains sensitive content and was omitted.", + "runtime.voice_conversion_failed": "Voice conversion failed.", + "runtime.voice_conversion_timed_out": "Voice conversion timed out.", + "runtime.voice_audio_too_large": "Audio is too large for local speech-to-text. The maximum supported size is {max_size_mb} MB.", + "runtime.voice_model_initial_download_note": "The selected Whisper model may still be downloading on first use. Larger models such as turbo are more likely to hit this timeout.", + "runtime.voice_transcript_preview": "Recognized voice transcript:\n{transcript}\n\nWorking on it...", + "runtime.voice_transcript_queued_preview": "Recognized voice transcript:\n{transcript}\n\nQueued as Q{question_number}. It will run after the current agent task finishes.", "runtime.working_on_it": "Working on it...", "status.abort_signal_sent": "Abort signal sent for the current project run.", "status.no_running_agent": "No running agent process was found for the current project.", diff --git a/src/coding_agent_telegram/resources/locales/fr.json b/src/coding_agent_telegram/resources/locales/fr.json index 2fb4187..9700b88 100644 --- a/src/coding_agent_telegram/resources/locales/fr.json +++ b/src/coding_agent_telegram/resources/locales/fr.json @@ -26,7 +26,8 @@ "git.usage_push": "Utilisation : /push", "message.photo_only_codex": "Les pièces jointes photo sont actuellement prises en charge uniquement pour les sessions Codex.", "message.question_queued": "Question mise en file d’attente sous Q{question_number}. Elle sera traitée une fois la tâche actuelle terminée.", - "message.unsupported_message_type": "Type de message non pris en charge.\nCe bot accepte actuellement uniquement les messages texte et les photos.", + "message.voice_speech_to_text_disabled": "Les messages vocaux ne sont pas activés.\nDéfinissez ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true et installez d'abord les prérequis locaux de Whisper.", + "message.unsupported_message_type": "Type de message non pris en charge.\nCe bot accepte actuellement les messages texte, les photos, les messages vocaux et les fichiers audio.", "queue.button_group": "Regrouper les questions", "queue.button_no": "Non", "queue.button_single": "Traiter une par une", @@ -54,6 +55,11 @@ "runtime.resume_created_new": "La reprise a échoué, donc une nouvelle session a été créée.\nNouvel ID de session : {session_id}\nNouveau nom de session : {session_name}", "runtime.resume_id_changed": "La reprise a réussi, mais l’ID de session a changé.\nNouvel ID de session : {session_id}\nNouveau nom de session : {session_name}", "runtime.sensitive_diff_omitted": "{path}\nCe fichier contient des données sensibles et a été omis.", + "runtime.voice_conversion_failed": "La conversion vocale a échoué.", + "runtime.voice_conversion_timed_out": "La conversion vocale a dépassé le délai.", + "runtime.voice_model_initial_download_note": "Le modèle Whisper sélectionné est peut-être encore en cours de téléchargement lors du premier usage. Les modèles plus volumineux comme turbo risquent davantage d’atteindre ce délai.", + "runtime.voice_transcript_preview": "Transcription vocale reconnue :\n{transcript}\n\nTraitement en cours...", + "runtime.voice_transcript_queued_preview": "Transcription vocale reconnue :\n{transcript}\n\nMise en file d’attente sous Q{question_number}. Elle sera traitée une fois la tâche actuelle terminée.", "runtime.working_on_it": "Traitement en cours...", "status.abort_signal_sent": "Signal d’arrêt envoyé pour l’exécution actuelle du projet.", "status.no_running_agent": "Aucun processus d’agent en cours n’a été trouvé pour le projet actuel.", diff --git a/src/coding_agent_telegram/resources/locales/ja.json b/src/coding_agent_telegram/resources/locales/ja.json index c9aa776..6524730 100644 --- a/src/coding_agent_telegram/resources/locales/ja.json +++ b/src/coding_agent_telegram/resources/locales/ja.json @@ -26,7 +26,8 @@ "git.usage_push": "使い方: /push", "message.photo_only_codex": "写真添付は現在 Codex セッションでのみサポートされています。", "message.question_queued": "質問は Q{question_number} としてキューに追加されました。現在のエージェント処理が終わった後に実行されます。", - "message.unsupported_message_type": "未対応のメッセージ種類です。\nこのボットは現在、テキストメッセージと写真のみ受け付けます。", + "message.voice_speech_to_text_disabled": "音声メッセージは有効になっていません。\nENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true を設定し、先にローカル Whisper の前提条件をインストールしてください。", + "message.unsupported_message_type": "未対応のメッセージ種類です。\nこのボットは現在、テキストメッセージ、写真、音声メッセージ、音声ファイルを受け付けます。", "queue.button_group": "質問をまとめる", "queue.button_no": "いいえ", "queue.button_single": "1つずつ処理", @@ -54,6 +55,11 @@ "runtime.resume_created_new": "再開に失敗したため、新しいセッションを作成しました。\n新しいセッション ID: {session_id}\n新しいセッション名: {session_name}", "runtime.resume_id_changed": "再開には成功しましたが、セッション ID が変わりました。\n新しいセッション ID: {session_id}\n新しいセッション名: {session_name}", "runtime.sensitive_diff_omitted": "{path}\nこのファイルには機密内容が含まれているため省略されました。", + "runtime.voice_conversion_failed": "音声の変換に失敗しました。", + "runtime.voice_conversion_timed_out": "音声の変換がタイムアウトしました。", + "runtime.voice_model_initial_download_note": "選択した Whisper モデルは初回利用時にまだダウンロード中の可能性があります。turbo のような大きなモデルはこのタイムアウトに達しやすくなります。", + "runtime.voice_transcript_preview": "認識された音声文字起こし:\n{transcript}\n\n処理中です...", + "runtime.voice_transcript_queued_preview": "認識された音声文字起こし:\n{transcript}\n\nQ{question_number} としてキューに追加されました。現在のエージェント処理が終わった後に実行されます。", "runtime.working_on_it": "処理中です...", "status.abort_signal_sent": "現在のプロジェクト実行に中止シグナルを送信しました。", "status.no_running_agent": "現在のプロジェクトで実行中のエージェントプロセスは見つかりませんでした。", diff --git a/src/coding_agent_telegram/resources/locales/ko.json b/src/coding_agent_telegram/resources/locales/ko.json index 5a18c0d..2680cd3 100644 --- a/src/coding_agent_telegram/resources/locales/ko.json +++ b/src/coding_agent_telegram/resources/locales/ko.json @@ -26,7 +26,8 @@ "git.usage_push": "사용법: /push", "message.photo_only_codex": "사진 첨부는 현재 Codex 세션에서만 지원됩니다.", "message.question_queued": "질문이 Q{question_number} 로 대기열에 추가되었습니다. 현재 에이전트 작업이 끝난 뒤 처리됩니다.", - "message.unsupported_message_type": "지원되지 않는 메시지 유형입니다.\n이 봇은 현재 텍스트 메시지와 사진만 받습니다.", + "message.voice_speech_to_text_disabled": "음성 메시지가 활성화되어 있지 않습니다.\nENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true 를 설정하고 먼저 로컬 Whisper 필수 요소를 설치하세요.", + "message.unsupported_message_type": "지원되지 않는 메시지 유형입니다.\n이 봇은 현재 텍스트 메시지, 사진, 음성 메시지, 오디오 파일을 받습니다.", "queue.button_group": "질문 묶기", "queue.button_no": "아니요", "queue.button_single": "하나씩 처리", @@ -54,6 +55,11 @@ "runtime.resume_created_new": "재개에 실패하여 새 세션이 생성되었습니다.\n새 세션 ID: {session_id}\n새 세션 이름: {session_name}", "runtime.resume_id_changed": "재개에는 성공했지만 세션 ID가 변경되었습니다.\n새 세션 ID: {session_id}\n새 세션 이름: {session_name}", "runtime.sensitive_diff_omitted": "{path}\n이 파일에는 민감한 내용이 포함되어 있어 생략되었습니다.", + "runtime.voice_conversion_failed": "음성 변환에 실패했습니다.", + "runtime.voice_conversion_timed_out": "음성 변환 시간이 초과되었습니다.", + "runtime.voice_model_initial_download_note": "선택한 Whisper 모델이 첫 사용 시 아직 다운로드 중일 수 있습니다. turbo 같은 큰 모델은 이 시간 제한에 더 걸리기 쉽습니다.", + "runtime.voice_transcript_preview": "인식된 음성 전사:\n{transcript}\n\n처리 중입니다...", + "runtime.voice_transcript_queued_preview": "인식된 음성 전사:\n{transcript}\n\nQ{question_number} 로 대기열에 추가되었습니다. 현재 에이전트 작업이 끝난 뒤 처리됩니다.", "runtime.working_on_it": "처리 중...", "status.abort_signal_sent": "현재 프로젝트 실행에 중단 신호를 보냈습니다.", "status.no_running_agent": "현재 프로젝트에서 실행 중인 에이전트 프로세스를 찾지 못했습니다.", diff --git a/src/coding_agent_telegram/resources/locales/nl.json b/src/coding_agent_telegram/resources/locales/nl.json index ca860b9..401388b 100644 --- a/src/coding_agent_telegram/resources/locales/nl.json +++ b/src/coding_agent_telegram/resources/locales/nl.json @@ -26,7 +26,8 @@ "git.usage_push": "Gebruik: /push", "message.photo_only_codex": "Foto-bijlagen worden momenteel alleen ondersteund voor Codex-sessies.", "message.question_queued": "Vraag in de wachtrij geplaatst als Q{question_number}. Deze wordt verwerkt nadat de huidige agenttaak is voltooid.", - "message.unsupported_message_type": "Niet-ondersteund berichttype.\nDeze bot accepteert momenteel alleen tekstberichten en foto's.", + "message.voice_speech_to_text_disabled": "Spraakberichten zijn niet ingeschakeld.\nZet ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true en installeer eerst de lokale Whisper-vereisten.", + "message.unsupported_message_type": "Niet-ondersteund berichttype.\nDeze bot accepteert momenteel tekstberichten, foto's, spraakberichten en audiobestanden.", "queue.button_group": "Vragen groeperen", "queue.button_no": "Nee", "queue.button_single": "Eén voor één verwerken", @@ -54,6 +55,11 @@ "runtime.resume_created_new": "Hervatten is mislukt, daarom is een nieuwe sessie gemaakt.\nNieuwe sessie-ID: {session_id}\nNieuwe sessienaam: {session_name}", "runtime.resume_id_changed": "Hervatten is gelukt, maar de sessie-ID is gewijzigd.\nNieuwe sessie-ID: {session_id}\nNieuwe sessienaam: {session_name}", "runtime.sensitive_diff_omitted": "{path}\nDit bestand bevat gevoelige inhoud en is weggelaten.", + "runtime.voice_conversion_failed": "Spraakconversie mislukt.", + "runtime.voice_conversion_timed_out": "Time-out tijdens spraakconversie.", + "runtime.voice_model_initial_download_note": "Het gekozen Whisper-model wordt bij het eerste gebruik mogelijk nog gedownload. Grotere modellen zoals turbo lopen eerder tegen deze time-out aan.", + "runtime.voice_transcript_preview": "Herkend spraaktranscript:\n{transcript}\n\nBezig...", + "runtime.voice_transcript_queued_preview": "Herkend spraaktranscript:\n{transcript}\n\nIn de wachtrij geplaatst als Q{question_number}. Dit wordt verwerkt nadat de huidige agenttaak is voltooid.", "runtime.working_on_it": "Bezig...", "status.abort_signal_sent": "Afbreeksignaal verzonden voor de huidige projectrun.", "status.no_running_agent": "Er is geen draaiend agentproces gevonden voor het huidige project.", diff --git a/src/coding_agent_telegram/resources/locales/th.json b/src/coding_agent_telegram/resources/locales/th.json index 9c4416e..449a3e0 100644 --- a/src/coding_agent_telegram/resources/locales/th.json +++ b/src/coding_agent_telegram/resources/locales/th.json @@ -26,7 +26,8 @@ "git.usage_push": "วิธีใช้: /push", "message.photo_only_codex": "ขณะนี้รองรับไฟล์แนบรูปภาพเฉพาะสำหรับเซสชัน Codex เท่านั้น", "message.question_queued": "จัดคิวคำถามเป็น Q{question_number} แล้ว จะประมวลผลหลังจากงานเอเจนต์ปัจจุบันเสร็จสิ้น", - "message.unsupported_message_type": "ประเภทข้อความไม่รองรับ\nขณะนี้บอตนี้รองรับเฉพาะข้อความตัวอักษรและรูปภาพเท่านั้น", + "message.voice_speech_to_text_disabled": "ยังไม่ได้เปิดใช้งานข้อความเสียง\nตั้งค่า ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true และติดตั้งส่วนที่ Whisper ต้องใช้ในเครื่องก่อน", + "message.unsupported_message_type": "ประเภทข้อความไม่รองรับ\nขณะนี้บอตนี้รองรับข้อความตัวอักษร รูปภาพ ข้อความเสียง และไฟล์เสียง", "queue.button_group": "รวมคำถาม", "queue.button_no": "ไม่", "queue.button_single": "ประมวลผลทีละข้อ", @@ -54,6 +55,11 @@ "runtime.resume_created_new": "กลับมาทำงานต่อไม่สำเร็จ จึงสร้างเซสชันใหม่แทน\nSession ID ใหม่: {session_id}\nชื่อเซสชันใหม่: {session_name}", "runtime.resume_id_changed": "กลับมาทำงานต่อได้สำเร็จ แต่ session ID เปลี่ยนไป\nSession ID ใหม่: {session_id}\nชื่อเซสชันใหม่: {session_name}", "runtime.sensitive_diff_omitted": "{path}\nไฟล์นี้มีข้อมูลสำคัญจึงถูกละไว้", + "runtime.voice_conversion_failed": "แปลงเสียงเป็นข้อความไม่สำเร็จ", + "runtime.voice_conversion_timed_out": "การแปลงเสียงเป็นข้อความหมดเวลา", + "runtime.voice_model_initial_download_note": "โมเดล Whisper ที่เลือกอาจกำลังดาวน์โหลดอยู่ในการใช้งานครั้งแรก โมเดลขนาดใหญ่เช่น turbo มีโอกาสเจอ timeout นี้มากกว่า", + "runtime.voice_transcript_preview": "ข้อความที่ถอดจากเสียง:\n{transcript}\n\nกำลังดำเนินการ...", + "runtime.voice_transcript_queued_preview": "ข้อความที่ถอดจากเสียง:\n{transcript}\n\nจัดคิวเป็น Q{question_number} แล้ว จะประมวลผลหลังจากงานเอเจนต์ปัจจุบันเสร็จสิ้น", "runtime.working_on_it": "กำลังดำเนินการ...", "status.abort_signal_sent": "ส่งสัญญาณยกเลิกสำหรับการทำงานของโปรเจ็กต์ปัจจุบันแล้ว", "status.no_running_agent": "ไม่พบโปรเซสเอเจนต์ที่กำลังทำงานสำหรับโปรเจ็กต์ปัจจุบัน", diff --git a/src/coding_agent_telegram/resources/locales/vi.json b/src/coding_agent_telegram/resources/locales/vi.json index 6553de1..80e7fd9 100644 --- a/src/coding_agent_telegram/resources/locales/vi.json +++ b/src/coding_agent_telegram/resources/locales/vi.json @@ -26,7 +26,8 @@ "git.usage_push": "Cách dùng: /push", "message.photo_only_codex": "Hiện tại tệp đính kèm ảnh chỉ được hỗ trợ cho các phiên Codex.", "message.question_queued": "Câu hỏi đã được xếp hàng dưới dạng Q{question_number}. Nó sẽ được xử lý sau khi tác vụ hiện tại của tác nhân hoàn tất.", - "message.unsupported_message_type": "Loại tin nhắn không được hỗ trợ.\nBot này hiện chỉ chấp nhận tin nhắn văn bản và ảnh.", + "message.voice_speech_to_text_disabled": "Tin nhắn thoại chưa được bật.\nHãy đặt ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true và cài đặt trước các điều kiện cần cục bộ của Whisper.", + "message.unsupported_message_type": "Loại tin nhắn không được hỗ trợ.\nBot này hiện chấp nhận tin nhắn văn bản, ảnh, tin nhắn thoại và tệp âm thanh.", "queue.button_group": "Gộp các câu hỏi", "queue.button_no": "Không", "queue.button_single": "Xử lý từng câu một", @@ -54,6 +55,11 @@ "runtime.resume_created_new": "Tiếp tục thất bại, vì vậy một phiên mới đã được tạo.\nID phiên mới: {session_id}\nTên phiên mới: {session_name}", "runtime.resume_id_changed": "Tiếp tục thành công, nhưng ID phiên đã thay đổi.\nID phiên mới: {session_id}\nTên phiên mới: {session_name}", "runtime.sensitive_diff_omitted": "{path}\nTệp này chứa nội dung nhạy cảm và đã bị lược bỏ.", + "runtime.voice_conversion_failed": "Chuyển giọng nói thành văn bản thất bại.", + "runtime.voice_conversion_timed_out": "Chuyển giọng nói thành văn bản đã hết thời gian chờ.", + "runtime.voice_model_initial_download_note": "Model Whisper đã chọn có thể vẫn đang được tải xuống ở lần dùng đầu tiên. Các model lớn như turbo dễ chạm mốc timeout này hơn.", + "runtime.voice_transcript_preview": "Bản chép lời giọng nói đã nhận dạng:\n{transcript}\n\nĐang xử lý...", + "runtime.voice_transcript_queued_preview": "Bản chép lời giọng nói đã nhận dạng:\n{transcript}\n\nCâu hỏi đã được xếp hàng dưới dạng Q{question_number}. Nó sẽ được xử lý sau khi tác vụ hiện tại của tác nhân hoàn tất.", "runtime.working_on_it": "Đang xử lý...", "status.abort_signal_sent": "Đã gửi tín hiệu hủy cho lần chạy hiện tại của dự án.", "status.no_running_agent": "Không tìm thấy tiến trình tác nhân đang chạy cho dự án hiện tại.", diff --git a/src/coding_agent_telegram/resources/locales/zh-CN.json b/src/coding_agent_telegram/resources/locales/zh-CN.json index 8268d6d..65c2959 100644 --- a/src/coding_agent_telegram/resources/locales/zh-CN.json +++ b/src/coding_agent_telegram/resources/locales/zh-CN.json @@ -26,7 +26,8 @@ "git.usage_push": "用法:/push", "message.photo_only_codex": "当前仅 Codex 会话支持图片附件。", "message.question_queued": "问题已加入队列,编号为 Q{question_number}。当前代理任务完成后将开始处理。", - "message.unsupported_message_type": "不支持的消息类型。\n此 bot 当前仅接受文本消息和图片。", + "message.voice_speech_to_text_disabled": "语音消息功能尚未启用。\n请先设置 ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true,并安装本地 Whisper 依赖。", + "message.unsupported_message_type": "不支持的消息类型。\n此 bot 当前接受文本消息、图片、语音消息和音频文件。", "queue.button_group": "合并问题", "queue.button_no": "否", "queue.button_single": "逐个处理", @@ -54,6 +55,11 @@ "runtime.resume_created_new": "恢复失败,因此已创建一个新会话。\n新的会话 ID:{session_id}\n新的会话名称:{session_name}", "runtime.resume_id_changed": "恢复成功,但会话 ID 已更改。\n新的会话 ID:{session_id}\n新的会话名称:{session_name}", "runtime.sensitive_diff_omitted": "{path}\n此文件包含敏感内容,已省略。", + "runtime.voice_conversion_failed": "语音转换失败。", + "runtime.voice_conversion_timed_out": "语音转换超时。", + "runtime.voice_model_initial_download_note": "所选 Whisper 模型在首次使用时可能仍在下载。像 turbo 这样更大的模型更容易触发这个超时。", + "runtime.voice_transcript_preview": "识别出的语音文本:\n{transcript}\n\n正在处理...", + "runtime.voice_transcript_queued_preview": "识别出的语音文本:\n{transcript}\n\n问题已加入队列,编号为 Q{question_number}。当前代理任务完成后将开始处理。", "runtime.working_on_it": "正在处理...", "status.abort_signal_sent": "已向当前项目运行发送中止信号。", "status.no_running_agent": "当前项目未找到正在运行的代理进程。", diff --git a/src/coding_agent_telegram/resources/locales/zh-HK.json b/src/coding_agent_telegram/resources/locales/zh-HK.json index 5aaf6b5..8d67c56 100644 --- a/src/coding_agent_telegram/resources/locales/zh-HK.json +++ b/src/coding_agent_telegram/resources/locales/zh-HK.json @@ -10,7 +10,7 @@ "bot.command.switch": "列出工作階段或切換", "bot.error.command_failed": "⚠️ 指令失敗。請檢查伺服器日誌。", "bot.error.session_store": "⚠️ {error}", - "common.agent_already_running": "⏳ 專案 '{project_folder}' 已有代理正在執行。請等待其完成。", + "common.agent_already_running": "⏳ 專案 '{project_folder}' 上已有代理正在執行。請等待其完成。", "common.current_project_not_git": "⚠️ 目前專案不是 Git 儲存庫。", "common.no_active_session": "沒有使用中的工作階段。\n請先執行 /project 和 /new。", "common.no_project_selected": "尚未選擇專案。\n請先執行 /project 。", @@ -26,7 +26,8 @@ "git.usage_push": "用法:/push", "message.photo_only_codex": "目前只有 Codex 工作階段支援圖片附件。", "message.question_queued": "問題已加入佇列,編號為 Q{question_number}。目前代理工作完成後會開始處理。", - "message.unsupported_message_type": "不支援的訊息類型。\n此 bot 目前只接受文字訊息與圖片。", + "message.voice_speech_to_text_disabled": "語音訊息功能尚未啟用。\n請先設定 ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true,並安裝本機 Whisper 依賴。", + "message.unsupported_message_type": "不支援的訊息類型。\n此 bot 目前接受文字訊息、圖片、語音訊息與音訊檔案。", "queue.button_group": "合併問題", "queue.button_no": "否", "queue.button_single": "逐一處理", @@ -54,6 +55,11 @@ "runtime.resume_created_new": "恢復失敗,因此已建立新的工作階段。\n新的工作階段 ID:{session_id}\n新的工作階段名稱:{session_name}", "runtime.resume_id_changed": "恢復成功,但工作階段 ID 已變更。\n新的工作階段 ID:{session_id}\n新的工作階段名稱:{session_name}", "runtime.sensitive_diff_omitted": "{path}\n此檔案包含敏感內容,已省略。", + "runtime.voice_conversion_failed": "語音轉換失敗。", + "runtime.voice_conversion_timed_out": "語音轉換逾時。", + "runtime.voice_model_initial_download_note": "所選的 Whisper 模型在首次使用時可能仍在下載。像 turbo 這類較大的模型更容易觸發這個逾時。", + "runtime.voice_transcript_preview": "辨識出的語音文字:\n{transcript}\n\n處理中...", + "runtime.voice_transcript_queued_preview": "辨識出的語音文字:\n{transcript}\n\n問題已加入佇列,編號為 Q{question_number}。目前代理工作完成後會開始處理。", "runtime.working_on_it": "處理中...", "status.abort_signal_sent": "已向目前專案執行送出中止訊號。", "status.no_running_agent": "目前專案找不到正在執行的代理程序。", diff --git a/src/coding_agent_telegram/resources/locales/zh-TW.json b/src/coding_agent_telegram/resources/locales/zh-TW.json index 399a65a..5055e6c 100644 --- a/src/coding_agent_telegram/resources/locales/zh-TW.json +++ b/src/coding_agent_telegram/resources/locales/zh-TW.json @@ -10,7 +10,7 @@ "bot.command.switch": "列出工作階段或切換", "bot.error.command_failed": "⚠️ 指令失敗。請檢查伺服器日誌。", "bot.error.session_store": "⚠️ {error}", - "common.agent_already_running": "⏳ 專案 '{project_folder}' 已有代理正在執行。請等待其完成。", + "common.agent_already_running": "⏳ 專案 '{project_folder}' 上已有代理正在執行。請等待其完成。", "common.current_project_not_git": "⚠️ 目前專案不是 Git 儲存庫。", "common.no_active_session": "沒有使用中的工作階段。\n請先執行 /project 和 /new。", "common.no_project_selected": "尚未選擇專案。\n請先執行 /project 。", @@ -26,7 +26,8 @@ "git.usage_push": "用法:/push", "message.photo_only_codex": "目前只有 Codex 工作階段支援圖片附件。", "message.question_queued": "問題已加入佇列,編號為 Q{question_number}。目前代理工作完成後會開始處理。", - "message.unsupported_message_type": "不支援的訊息類型。\n此 bot 目前只接受文字訊息與圖片。", + "message.voice_speech_to_text_disabled": "語音訊息功能尚未啟用。\n請先設定 ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true,並安裝本機 Whisper 依賴。", + "message.unsupported_message_type": "不支援的訊息類型。\n此 bot 目前接受文字訊息、圖片、語音訊息與音訊檔案。", "queue.button_group": "合併問題", "queue.button_no": "否", "queue.button_single": "逐一處理", @@ -54,6 +55,11 @@ "runtime.resume_created_new": "恢復失敗,因此已建立新的工作階段。\n新的工作階段 ID:{session_id}\n新的工作階段名稱:{session_name}", "runtime.resume_id_changed": "恢復成功,但工作階段 ID 已變更。\n新的工作階段 ID:{session_id}\n新的工作階段名稱:{session_name}", "runtime.sensitive_diff_omitted": "{path}\n此檔案包含敏感內容,已省略。", + "runtime.voice_conversion_failed": "語音轉換失敗。", + "runtime.voice_conversion_timed_out": "語音轉換逾時。", + "runtime.voice_model_initial_download_note": "所選的 Whisper 模型在首次使用時可能仍在下載。像 turbo 這類較大的模型更容易觸發這個逾時。", + "runtime.voice_transcript_preview": "辨識出的語音文字:\n{transcript}\n\n處理中...", + "runtime.voice_transcript_queued_preview": "辨識出的語音文字:\n{transcript}\n\n問題已加入佇列,編號為 Q{question_number}。目前代理工作完成後會開始處理。", "runtime.working_on_it": "處理中...", "status.abort_signal_sent": "已向目前專案執行送出中止訊號。", "status.no_running_agent": "目前專案找不到正在執行的代理程序。", diff --git a/src/coding_agent_telegram/router/base.py b/src/coding_agent_telegram/router/base.py index 16e7817..68e263d 100644 --- a/src/coding_agent_telegram/router/base.py +++ b/src/coding_agent_telegram/router/base.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import hashlib import html import logging import os @@ -25,6 +26,7 @@ from coding_agent_telegram.i18n import translate from coding_agent_telegram.session_runtime import PhotoAttachmentStore, SessionRuntime from coding_agent_telegram.session_store import SessionStore +from coding_agent_telegram.speech_to_text import WhisperSpeechToText from coding_agent_telegram.telegram_sender import send_text @@ -108,6 +110,7 @@ def __init__(self, deps: RouterDeps) -> None: self.deps = deps self.git = GitWorkspaceManager() self.photo_attachments = PhotoAttachmentStore(deps.cfg.app_internal_root) + self.speech_to_text = WhisperSpeechToText(deps.cfg) self.runtime = SessionRuntime( cfg=deps.cfg, store=deps.store, @@ -128,6 +131,16 @@ def __init__(self, deps: RouterDeps) -> None: self._chat_next_queue_file_index: dict[int, int] = {} self._chat_message_queue_draining: set[int] = set() self._last_run_results: dict[int, object] = {} + self._branch_source_tokens: dict[str, tuple[str, str, str]] = {} + + def _register_branch_source_token(self, source_kind: str, source_branch: str, new_branch: str) -> str: + key = f"{source_kind}:{source_branch}:{new_branch}" + token = hashlib.sha256(key.encode()).hexdigest()[:12] + self._branch_source_tokens[token] = (source_kind, source_branch, new_branch) + return token + + def _lookup_branch_source_token(self, token: str) -> tuple[str, str, str] | None: + return self._branch_source_tokens.get(token) def _sorted_sessions(self, sessions: dict[str, dict[str, str]]) -> list[tuple[str, dict[str, str]]]: indexed_sessions = list(enumerate(sessions.items())) @@ -224,6 +237,7 @@ async def _notify_if_current_project_busy(self, update: Update, context: Context update, context, self._t(update, "common.project_busy", project_folder=project_folder), + reply_to_message_id=getattr(update.message, "message_id", None), ) return True @@ -245,6 +259,7 @@ async def _run_with_typing(self, update: Update, context: ContextTypes.DEFAULT_T update, context, self._t(update, "common.agent_already_running", project_folder=workspace_lock_key), + reply_to_message_id=getattr(update.message, "message_id", None), ) return None async with lock: @@ -350,8 +365,18 @@ async def publish(info: AgentProgressInfo) -> None: text=message_text, ) except BadRequest: + previous_message_id = progress_state["message_id"] message = await context.bot.send_message(chat_id=chat.id, text=message_text) message_id = getattr(message, "message_id", None) + if ( + previous_message_id is not None + and previous_message_id != message_id + and hasattr(context.bot, "delete_message") + ): + try: + await context.bot.delete_message(chat_id=chat.id, message_id=previous_message_id) + except BadRequest: + pass if progress_state.get("closed") and message_id is not None and hasattr(context.bot, "delete_message"): try: await context.bot.delete_message(chat_id=chat.id, message_id=message_id) diff --git a/src/coding_agent_telegram/router/message_commands.py b/src/coding_agent_telegram/router/message_commands.py index a514c35..10e95a8 100644 --- a/src/coding_agent_telegram/router/message_commands.py +++ b/src/coding_agent_telegram/router/message_commands.py @@ -1,34 +1,59 @@ from __future__ import annotations +import logging +import tempfile +from pathlib import Path + from telegram import Update from telegram.ext import ContextTypes from coding_agent_telegram.session_runtime import PhotoAttachmentError +from coding_agent_telegram.speech_to_text import SpeechToTextError from coding_agent_telegram.telegram_sender import send_text from .base import require_allowed_chat +logger = logging.getLogger(__name__) +MAX_STT_AUDIO_BYTES = 20 * 1024 * 1024 + + class MessageCommandMixin: - @require_allowed_chat() - async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if update.message is None or not update.message.text: - return - user_message = update.message.text + async def _process_user_message( + self, + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user_message: str, + *, + suppress_working_notice: bool = False, + ) -> None: chat_id = update.effective_chat.id - if self._is_project_busy(chat_id) or self._has_pending_queue_decision(chat_id): - _queue_file, question_number = self._enqueue_chat_message(chat_id, user_message) + if self._should_queue_incoming_message(chat_id): + _queue_file, question_number = self._enqueue_chat_message( + chat_id, + user_message, + reply_to_message_id=getattr(update.message, "message_id", None), + ) + logger.info( + "Queued user message for chat %s as Q%s. Preview: %.120r", + chat_id, + question_number, + user_message, + ) await send_text( update, context, self._t(update, "message.question_queued", question_number=question_number), + reply_to_message_id=getattr(update.message, "message_id", None), ) return + logger.info("Processing user message immediately for chat %s. Preview: %.120r", chat_id, user_message) self._store_pending_action( chat_id, { "kind": "message", "user_message": user_message, + "suppress_working_notice": suppress_working_notice, }, ) try: @@ -37,6 +62,12 @@ async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYP finally: await self._drain_chat_message_queue(chat_id, context) + @require_allowed_chat() + async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if update.message is None or not update.message.text: + return + await self._process_user_message(update, context, update.message.text) + @require_allowed_chat() async def handle_photo(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.message is None or not update.message.photo: @@ -60,8 +91,184 @@ async def handle_photo(self, update: Update, context: ContextTypes.DEFAULT_TYPE) prompt = self.photo_attachments.build_prompt(attachment_path, project_path, caption) await self.runtime.run_active_session(update, context, user_message=prompt, image_paths=(attachment_path,)) + async def _handle_audio_like( + self, + update: Update, + context: ContextTypes.DEFAULT_TYPE, + telegram_media, + *, + media_kind: str, + ) -> None: + if update.message is None or telegram_media is None: + return + logger.info( + "Received Telegram %s message for speech-to-text in chat %s.", + media_kind, + update.effective_chat.id if update.effective_chat is not None else "unknown", + ) + if not self.speech_to_text.enabled: + await send_text(update, context, self._t(update, "message.voice_speech_to_text_disabled")) + return + + suffix = Path( + getattr(telegram_media, "file_name", "") or getattr(telegram_media, "file_unique_id", "") or media_kind + ).suffix or ".ogg" + telegram_file = await telegram_media.get_file() + logger.debug( + "Speech-to-text input prepared for chat %s: media_kind=%s file_path=%r initial_suffix=%r model=%s timeout=%ss", + update.effective_chat.id if update.effective_chat is not None else "unknown", + media_kind, + getattr(telegram_file, "file_path", None), + suffix, + self.speech_to_text.model, + self.speech_to_text.timeout_seconds, + ) + if suffix == ".ogg" and getattr(telegram_file, "file_path", None): + resolved_suffix = Path(telegram_file.file_path).suffix.lower() + if resolved_suffix: + suffix = resolved_suffix + + declared_size = getattr(telegram_media, "file_size", None) + if isinstance(declared_size, int) and declared_size > MAX_STT_AUDIO_BYTES: + await send_text( + update, + context, + self._t( + update, + "runtime.voice_audio_too_large", + max_size_mb=MAX_STT_AUDIO_BYTES // (1024 * 1024), + ), + ) + return + + with tempfile.NamedTemporaryFile(prefix="coding-agent-telegram-voice-", suffix=suffix, delete=False) as handle: + temp_path = Path(handle.name) + try: + content = bytes(await telegram_file.download_as_bytearray()) + if len(content) > MAX_STT_AUDIO_BYTES: + await send_text( + update, + context, + self._t( + update, + "runtime.voice_audio_too_large", + max_size_mb=MAX_STT_AUDIO_BYTES // (1024 * 1024), + ), + ) + return + temp_path.write_bytes(content) + logger.debug( + "Downloaded Telegram %s message for chat %s to %s (%s bytes).", + media_kind, + update.effective_chat.id if update.effective_chat is not None else "unknown", + temp_path, + len(content), + ) + result = await self._run_with_typing( + update, + context, + self.speech_to_text.transcribe_file, + temp_path, + ) + except SpeechToTextError as exc: + logger.warning( + "Telegram %s speech-to-text failed for chat %s: code=%s detail=%s", + media_kind, + update.effective_chat.id if update.effective_chat is not None else "unknown", + exc.code, + exc.detail or "(none)", + ) + if exc.code == "timeout": + message = self._t(update, "runtime.voice_conversion_timed_out") + else: + message = self._t(update, "runtime.voice_conversion_failed") + if exc.likely_first_download: + message = f"{message}\n\n{self._t(update, 'runtime.voice_model_initial_download_note')}" + await send_text(update, context, message) + return + except Exception: + logger.exception( + "Unexpected Telegram %s speech-to-text failure for chat %s.", + media_kind, + update.effective_chat.id if update.effective_chat is not None else "unknown", + ) + await send_text(update, context, self._t(update, "runtime.voice_conversion_failed")) + return + finally: + temp_path.unlink(missing_ok=True) + + if result is None: + return + chat_id = update.effective_chat.id + logger.info( + "Speech-to-text succeeded for Telegram %s message in chat %s. Transcript preview: %.120r", + media_kind, + chat_id, + result.text, + ) + logger.debug( + "Transcript metadata for chat %s: media_kind=%s chars=%s reply_to_message_id=%s", + chat_id, + media_kind, + len(result.text), + getattr(update.message, "message_id", None), + ) + if self._should_queue_incoming_message(chat_id): + _queue_file, question_number = self._enqueue_chat_message( + chat_id, + result.text, + reply_to_message_id=getattr(update.message, "message_id", None), + ) + logger.info( + "Queued transcript from Telegram %s message for chat %s as Q%s.", + media_kind, + chat_id, + question_number, + ) + await send_text( + update, + context, + self._t( + update, + "runtime.voice_transcript_queued_preview", + transcript=result.text, + question_number=question_number, + ), + ) + return + logger.info("Dispatching transcript from Telegram %s message immediately for chat %s.", media_kind, chat_id) + await send_text( + update, + context, + self._t(update, "runtime.voice_transcript_preview", transcript=result.text), + ) + await self._process_user_message(update, context, result.text, suppress_working_notice=True) + + @require_allowed_chat() + async def handle_voice(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if update.message is None or not update.message.voice: + return + await self._handle_audio_like(update, context, update.message.voice, media_kind="voice") + + @require_allowed_chat() + async def handle_audio(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if update.message is None or not update.message.audio: + return + await self._handle_audio_like(update, context, update.message.audio, media_kind="audio") + @require_allowed_chat() async def handle_unsupported_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if update.message is not None: + unsupported_types = [ + field_name + for field_name in ("animation", "audio", "document", "sticker", "video", "video_note") + if getattr(update.message, field_name, None) is not None + ] + logger.info( + "Unsupported Telegram message type from chat %s: %s", + update.effective_chat.id if update.effective_chat is not None else "unknown", + ", ".join(unsupported_types) or "unknown", + ) await send_text( update, context, diff --git a/src/coding_agent_telegram/router/project_commands.py b/src/coding_agent_telegram/router/project_commands.py index 6e92516..fd9a39b 100644 --- a/src/coding_agent_telegram/router/project_commands.py +++ b/src/coding_agent_telegram/router/project_commands.py @@ -28,17 +28,19 @@ def _branch_source_keyboard( ) -> InlineKeyboardMarkup: buttons: list[InlineKeyboardButton] = [] if allow_local: + token = self._register_branch_source_token("local", source_branch, new_branch) buttons.append( InlineKeyboardButton( f"local/{source_branch}", - callback_data=f"branchsource:local:{source_branch}:{new_branch}", + callback_data=f"branchsource:{token}", ) ) if allow_origin: + token = self._register_branch_source_token("origin", source_branch, new_branch) buttons.append( InlineKeyboardButton( f"origin/{source_branch}", - callback_data=f"branchsource:origin:{source_branch}:{new_branch}", + callback_data=f"branchsource:{token}", ) ) return InlineKeyboardMarkup([buttons]) @@ -59,20 +61,22 @@ def _multi_branch_source_keyboard( if self.git.local_branch_exists(project_path, source_branch): key = ("local", source_branch) if key not in seen: + token = self._register_branch_source_token("local", source_branch, new_branch) row.append( InlineKeyboardButton( f"local/{source_branch}", - callback_data=f"branchsource:local:{source_branch}:{new_branch}", + callback_data=f"branchsource:{token}", ) ) seen.add(key) if self.git.remote_branch_exists(project_path, source_branch): key = ("origin", source_branch) if key not in seen: + token = self._register_branch_source_token("origin", source_branch, new_branch) row.append( InlineKeyboardButton( f"origin/{source_branch}", - callback_data=f"branchsource:origin:{source_branch}:{new_branch}", + callback_data=f"branchsource:{token}", ) ) seen.add(key) @@ -339,7 +343,7 @@ async def handle_project(self, update: Update, context: ContextTypes.DEFAULT_TYP reply_markup=keyboard, ) if (not is_git_repo or not switched_project) and hasattr(self, "_continue_pending_action"): - await self._continue_pending_action(update, context) + await self._continue_pending_action(update, context, drain_queue_after_completion=True) @require_allowed_chat(answer_callback=True) async def handle_trust_project_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -445,12 +449,12 @@ async def handle_branch_source_callback(self, update: Update, context: ContextTy return await query.answer() - parts = query.data.split(":", 3) - if len(parts) != 4: - return - _, source_kind, source_branch, new_branch = parts - if source_kind not in {"local", "origin"}: + token = query.data.partition(":")[2] + entry = self._lookup_branch_source_token(token) + if entry is None: + await query.edit_message_text(self._t(update, "common.button_expired")) return + source_kind, source_branch, new_branch = entry chat_id = update.effective_chat.id chat_state = self.deps.store.get_chat_state(self.deps.bot_id, chat_id) @@ -514,4 +518,4 @@ async def handle_branch_source_callback(self, update: Update, context: ContextTy ) ) if hasattr(self, "_continue_pending_action"): - await self._continue_pending_action(update, context) + await self._continue_pending_action(update, context, drain_queue_after_completion=True) diff --git a/src/coding_agent_telegram/router/queue_processing.py b/src/coding_agent_telegram/router/queue_processing.py index 00a44b5..cd4cc87 100644 --- a/src/coding_agent_telegram/router/queue_processing.py +++ b/src/coding_agent_telegram/router/queue_processing.py @@ -1,7 +1,10 @@ from __future__ import annotations +import base64 +import logging import re from collections import deque +from dataclasses import dataclass from pathlib import Path from types import SimpleNamespace @@ -12,6 +15,13 @@ QUEUED_QUESTIONS_DIR = "queued_questions" +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class QueuedQuestion: + text: str + reply_to_message_id: int | None = None class QueueProcessingMixin: @@ -38,38 +48,98 @@ def _next_queue_file_path(self, chat_id: int) -> Path: session_id = self._sanitize_queue_session_id(str(chat_state.get("active_session_id") or "session")) return queue_dir / f"{session_id}-queue-{next_index}.txt" - def _read_queue_questions(self, queue_file: Path) -> list[str]: + def _encode_queue_body(self, text: str) -> str: + return "[Encoding:base64]\n" + base64.b64encode(text.encode("utf-8")).decode("ascii") + + def _decode_queue_body(self, body: str) -> str: + prefix = "[Encoding:base64]\n" + if body.startswith(prefix): + return base64.b64decode(body[len(prefix):].encode("ascii")).decode("utf-8") + return body + + def _read_queue_questions(self, queue_file: Path) -> list[QueuedQuestion]: if not queue_file.exists(): return [] raw = queue_file.read_text(encoding="utf-8") - pattern = re.compile(r"^\[Question (\d+)\]\n(.*?)\n\[End Question \1\]\s*$", re.MULTILINE | re.DOTALL) - return [match.group(2).strip() for match in pattern.finditer(raw) if match.group(2).strip()] + pattern = re.compile( + r"^\[Question (\d+)\]\n(?:\[ReplyToMessageId (\d+)\]\n)?(.*?)\n\[End Question \1\]\s*$", + re.MULTILINE | re.DOTALL, + ) + questions: list[QueuedQuestion] = [] + for match in pattern.finditer(raw): + text = self._decode_queue_body(match.group(3).strip()) + if not text: + continue + questions.append( + QueuedQuestion( + text=text, + reply_to_message_id=int(match.group(2)) if match.group(2) else None, + ) + ) + logger.debug("Loaded %s queued question(s) from %s.", len(questions), queue_file) + return questions - def _append_question_to_queue_file(self, queue_file: Path, user_message: str) -> int: + def _append_question_to_queue_file( + self, + queue_file: Path, + user_message: str, + *, + reply_to_message_id: int | None = None, + ) -> int: questions = self._read_queue_questions(queue_file) next_number = len(questions) + 1 with queue_file.open("a", encoding="utf-8") as fh: if queue_file.stat().st_size > 0: fh.write("\n") - fh.write(f"[Question {next_number}]\n{user_message.strip()}\n[End Question {next_number}]\n") + fh.write(f"[Question {next_number}]\n") + if reply_to_message_id is not None: + fh.write(f"[ReplyToMessageId {reply_to_message_id}]\n") + fh.write(f"{self._encode_queue_body(user_message.strip())}\n[End Question {next_number}]\n") + logger.debug( + "Appended queued question Q%s to %s with reply_to_message_id=%s.", + next_number, + queue_file, + reply_to_message_id, + ) return next_number - def _write_queue_questions(self, queue_file: Path, questions: list[str]) -> None: + def _write_queue_questions(self, queue_file: Path, questions: list[QueuedQuestion]) -> None: with queue_file.open("w", encoding="utf-8") as fh: for index, question in enumerate(questions, start=1): if index > 1: fh.write("\n") - fh.write(f"[Question {index}]\n{question.strip()}\n[End Question {index}]\n") + fh.write(f"[Question {index}]\n") + if question.reply_to_message_id is not None: + fh.write(f"[ReplyToMessageId {question.reply_to_message_id}]\n") + fh.write(f"{self._encode_queue_body(question.text.strip())}\n[End Question {index}]\n") + logger.debug("Rewrote %s queued question(s) to %s.", len(questions), queue_file) - def _enqueue_chat_message(self, chat_id: int, user_message: str) -> tuple[Path, int]: + def _enqueue_chat_message( + self, + chat_id: int, + user_message: str, + *, + reply_to_message_id: int | None = None, + ) -> tuple[Path, int]: queue = self._chat_message_queue_files.setdefault(chat_id, deque()) queue_file = queue[-1] if queue else self._next_queue_file_path(chat_id) if not queue: queue.append(queue_file) - question_number = self._append_question_to_queue_file(queue_file, user_message) + question_number = self._append_question_to_queue_file( + queue_file, + user_message, + reply_to_message_id=reply_to_message_id, + ) + logger.debug( + "Queued message for chat %s in %s as Q%s with reply_to_message_id=%s.", + chat_id, + queue_file, + question_number, + reply_to_message_id, + ) return queue_file, question_number - def _dequeue_chat_message_file(self, chat_id: int) -> tuple[Path | None, list[str]]: + def _dequeue_chat_message_file(self, chat_id: int) -> tuple[Path | None, list[QueuedQuestion]]: queue = self._chat_message_queue_files.get(chat_id) if not queue: return None, [] @@ -81,12 +151,13 @@ def _dequeue_chat_message_file(self, chat_id: int) -> tuple[Path | None, list[st return None, [] if not queue: self._chat_message_queue_files.pop(chat_id, None) + logger.debug("Dequeued %s queued question(s) for chat %s from %s.", len(questions), chat_id, queue_file) return queue_file, questions - def _queued_batch_prompt(self, queued_messages: list[str]) -> str: + def _queued_batch_prompt(self, queued_messages: list[QueuedQuestion]) -> str: lines = ["Answer the following queued user questions in order."] for index, message in enumerate(queued_messages, start=1): - lines.extend(["", f"[Question {index}]", message.strip(), f"[End Question {index}]"]) + lines.extend(["", f"[Question {index}]", message.text.strip(), f"[End Question {index}]"]) return "\n".join(lines) def _preview_queued_message(self, message: str, *, max_chars: int = 100) -> str: @@ -97,10 +168,10 @@ def _preview_queued_message(self, message: str, *, max_chars: int = 100) -> str: return stripped[:max_chars] return f"{stripped[: max_chars - 3]}..." - def _queued_batch_notice(self, chat_id: int, queued_messages: list[str]) -> str: + def _queued_batch_notice(self, chat_id: int, queued_messages: list[QueuedQuestion]) -> str: lines = [translate(self._chat_locale(chat_id), "queue.working_on_queued")] for index, message in enumerate(queued_messages, start=1): - lines.append(f"{index}. {self._preview_queued_message(message)}") + lines.append(f"{index}. {self._preview_queued_message(message.text)}") return "\n".join(lines) def _has_pending_queue_decision(self, chat_id: int) -> bool: @@ -134,7 +205,7 @@ async def _prompt_queue_batch_decision( self, chat_id: int, context: ContextTypes.DEFAULT_TYPE, - queued_messages: list[str], + queued_messages: list[QueuedQuestion], ) -> None: if not hasattr(context.bot, "send_message"): return @@ -147,7 +218,7 @@ async def _prompt_queue_batch_decision( translate(locale, "queue.here_are_questions"), ] for index, message in enumerate(queued_messages, start=1): - lines.append(f"Q{index}: {self._preview_queued_message(message)}") + lines.append(f"Q{index}: {self._preview_queued_message(message.text)}") lines.extend( [ "", @@ -190,7 +261,7 @@ async def _dispatch_queued_questions( context: ContextTypes.DEFAULT_TYPE, *, queue_file: Path, - queued_messages: list[str], + queued_messages: list[QueuedQuestion], grouped: bool, ) -> bool: self._chat_processing_queue_files[chat_id] = queue_file @@ -199,16 +270,25 @@ async def _dispatch_queued_questions( queued_notice = self._queued_batch_notice(chat_id, current_batch) queued_update = SimpleNamespace( effective_chat=SimpleNamespace(id=chat_id, type="private"), - message=SimpleNamespace(text=queued_notice, photo=None, caption=None), + message=SimpleNamespace(text=queued_notice, photo=None, caption=None, message_id=None), ) await send_text(queued_update, context, queued_notice) if grouped: user_message = self._queued_batch_prompt(queued_messages) + reply_to_message_id = None else: - user_message = queued_messages[0] + user_message = queued_messages[0].text + reply_to_message_id = queued_messages[0].reply_to_message_id + logger.debug( + "Dispatching queued question(s) for chat %s grouped=%s count=%s reply_to_message_id=%s.", + chat_id, + grouped, + len(queued_messages), + reply_to_message_id, + ) queued_update = SimpleNamespace( effective_chat=SimpleNamespace(id=chat_id, type="private"), - message=SimpleNamespace(text=user_message, photo=None, caption=None), + message=SimpleNamespace(text=user_message, photo=None, caption=None, message_id=reply_to_message_id), ) self.deps.store.set_pending_action( self.deps.bot_id, @@ -218,7 +298,11 @@ async def _dispatch_queued_questions( "user_message": user_message, }, ) - continued = await self._continue_pending_action(queued_update, context) + continued = await self._continue_pending_action( + queued_update, + context, + drain_queue_after_completion=False, + ) if not continued: self._queue_lock_path(queue_file).unlink(missing_ok=True) self._chat_processing_queue_files.pop(chat_id, None) @@ -234,11 +318,19 @@ async def _dispatch_queued_questions( async def _drain_chat_message_queue(self, chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> None: if chat_id in self._chat_message_queue_draining: + logger.debug("Queue drain already active for chat %s; skipping nested call.", chat_id) return self._chat_message_queue_draining.add(chat_id) try: while True: if self._is_project_busy(chat_id): + logger.debug("Stopping queue drain for chat %s because project is busy.", chat_id) + return + if self._pending_action(chat_id): + logger.debug("Stopping queue drain for chat %s because a pending action is unresolved.", chat_id) + return + if self._has_pending_queue_decision(chat_id): + logger.debug("Stopping queue drain for chat %s because a queue batch decision is pending.", chat_id) return last_result = self._last_run_results.pop(chat_id, None) if self._run_result_was_aborted(last_result) and self._has_pending_queue_files(chat_id): @@ -256,6 +348,7 @@ async def _drain_chat_message_queue(self, chat_id: int, context: ContextTypes.DE self._chat_processing_queue_files.pop(chat_id, None) queue_file, queued_messages = self._dequeue_chat_message_file(chat_id) if queue_file is None or not queued_messages: + logger.debug("No queued messages remain for chat %s.", chat_id) if chat_id not in self._chat_processing_queue_files and chat_id not in self._chat_message_queue_files: self._chat_queue_batch_modes.pop(chat_id, None) self._chat_next_queue_file_index.pop(chat_id, None) diff --git a/src/coding_agent_telegram/router/session_branch_resolution.py b/src/coding_agent_telegram/router/session_branch_resolution.py index 8b0b14b..906ec1f 100644 --- a/src/coding_agent_telegram/router/session_branch_resolution.py +++ b/src/coding_agent_telegram/router/session_branch_resolution.py @@ -42,20 +42,22 @@ def _multi_branch_source_keyboard( if self.git.local_branch_exists(project_path, source_branch): key = ("local", source_branch) if key not in seen: + token = self._register_branch_source_token("local", source_branch, new_branch) row.append( InlineKeyboardButton( f"local/{source_branch}", - callback_data=f"branchsource:local:{source_branch}:{new_branch}", + callback_data=f"branchsource:{token}", ) ) seen.add(key) if self.git.remote_branch_exists(project_path, source_branch): key = ("origin", source_branch) if key not in seen: + token = self._register_branch_source_token("origin", source_branch, new_branch) row.append( InlineKeyboardButton( f"origin/{source_branch}", - callback_data=f"branchsource:origin:{source_branch}:{new_branch}", + callback_data=f"branchsource:{token}", ) ) seen.add(key) @@ -226,7 +228,7 @@ async def handle_branch_discrepancy_callback(self, update: Update, context: Cont pending_action.pop("branch_resolution", None) self._store_pending_action(chat_id, pending_action) await query.edit_message_text(self._t(update, "branch_resolution.using_current_branch", branch_name=current_branch)) - await self._continue_pending_action(update, context) + await self._continue_pending_action(update, context, drain_queue_after_completion=True) return allow_local = self.git.local_branch_exists(project_path, stored_branch) diff --git a/src/coding_agent_telegram/router/session_common.py b/src/coding_agent_telegram/router/session_common.py index 70db93b..5381a3c 100644 --- a/src/coding_agent_telegram/router/session_common.py +++ b/src/coding_agent_telegram/router/session_common.py @@ -43,6 +43,14 @@ def _pending_action(self, chat_id: int) -> dict[str, object] | None: def _store_pending_action(self, chat_id: int, pending_action: dict[str, object] | None) -> None: self.deps.store.set_pending_action(self.deps.bot_id, chat_id, pending_action) + def _should_queue_incoming_message(self, chat_id: int) -> bool: + pending_action = self._pending_action(chat_id) + return ( + self._is_project_busy(chat_id) + or self._has_pending_queue_decision(chat_id) + or isinstance(pending_action, dict) + ) + def _auto_session_name(self, project_folder: str, branch_name: str, provider: str, chat_id: int) -> str: branch_label = (branch_name or "current").replace("/", "-") base_name = f"{project_folder}-{branch_label}-{provider}" diff --git a/src/coding_agent_telegram/router/session_lifecycle_commands.py b/src/coding_agent_telegram/router/session_lifecycle_commands.py index 92dde64..704f65f 100644 --- a/src/coding_agent_telegram/router/session_lifecycle_commands.py +++ b/src/coding_agent_telegram/router/session_lifecycle_commands.py @@ -167,59 +167,81 @@ async def _create_session_for_context( ) return True - async def _continue_pending_action(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool: + async def _continue_pending_action( + self, + update: Update, + context: ContextTypes.DEFAULT_TYPE, + *, + drain_queue_after_completion: bool = False, + ) -> bool: chat_id = update.effective_chat.id pending_action = self._pending_action(chat_id) if not pending_action: return False + completed = False - resolved = await self._resolve_session_prerequisites(update, context, pending_action=pending_action) - if resolved is None: - return False - provider, project_folder, branch_name, project_path = resolved - kind = str(pending_action.get("kind") or "") - - if kind == "new_session": - if await self._create_session_for_context( - update, - context, - session_name=str(pending_action.get("session_name") or "").strip() or None, - use_session_id_as_name=bool(pending_action.get("use_session_id_as_name")), - provider=provider, - project_folder=project_folder, - branch_name=branch_name, - project_path=project_path, - ): - self._store_pending_action(chat_id, None) - return True - return False - - if kind == "message": - user_message = str(pending_action.get("user_message") or "").strip() - if not user_message: - self._store_pending_action(chat_id, None) + try: + resolved = await self._resolve_session_prerequisites(update, context, pending_action=pending_action) + if resolved is None: return False - chat_state = self.deps.store.get_chat_state(self.deps.bot_id, chat_id) - if not self._active_session_matches_current_context(chat_state): - if not await self._create_session_for_context( + provider, project_folder, branch_name, project_path = resolved + kind = str(pending_action.get("kind") or "") + + if kind == "new_session": + if await self._create_session_for_context( update, context, session_name=str(pending_action.get("session_name") or "").strip() or None, - use_session_id_as_name=False, + use_session_id_as_name=bool(pending_action.get("use_session_id_as_name")), provider=provider, project_folder=project_folder, branch_name=branch_name, project_path=project_path, ): - return False - if not await self._ensure_active_session_ready_for_run(update, context): + self._store_pending_action(chat_id, None) + completed = True + return True return False - self._store_pending_action(chat_id, None) - self._last_run_results[chat_id] = await self.runtime.run_active_session(update, context, user_message=user_message) - return True - self._store_pending_action(chat_id, None) - return False + if kind == "message": + user_message = str(pending_action.get("user_message") or "").strip() + if not user_message: + self._store_pending_action(chat_id, None) + completed = True + return False + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, chat_id) + if not self._active_session_matches_current_context(chat_state): + if not await self._create_session_for_context( + update, + context, + session_name=str(pending_action.get("session_name") or "").strip() or None, + use_session_id_as_name=False, + provider=provider, + project_folder=project_folder, + branch_name=branch_name, + project_path=project_path, + ): + return False + if not await self._ensure_active_session_ready_for_run(update, context): + return False + try: + self._last_run_results[chat_id] = await self.runtime.run_active_session( + update, + context, + user_message=user_message, + suppress_working_notice=bool(pending_action.get("suppress_working_notice")), + ) + finally: + self._store_pending_action(chat_id, None) + completed = True + return True + + self._store_pending_action(chat_id, None) + completed = True + return False + finally: + if completed and drain_queue_after_completion: + await self._drain_chat_message_queue(chat_id, context) async def _ensure_active_session_ready_for_run(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool: chat_id = update.effective_chat.id @@ -283,4 +305,4 @@ async def handle_new(self, update: Update, context: ContextTypes.DEFAULT_TYPE) - "use_session_id_as_name": not bool(session_name), }, ) - await self._continue_pending_action(update, context) + await self._continue_pending_action(update, context, drain_queue_after_completion=True) diff --git a/src/coding_agent_telegram/router/session_provider_commands.py b/src/coding_agent_telegram/router/session_provider_commands.py index aa57183..8031030 100644 --- a/src/coding_agent_telegram/router/session_provider_commands.py +++ b/src/coding_agent_telegram/router/session_provider_commands.py @@ -125,4 +125,4 @@ async def handle_provider_callback(self, update: Update, context: ContextTypes.D "use_session_id_as_name": True, }, ) - await self._continue_pending_action(update, context) + await self._continue_pending_action(update, context, drain_queue_after_completion=True) diff --git a/src/coding_agent_telegram/session_runtime.py b/src/coding_agent_telegram/session_runtime.py index e5eea62..bab9478 100644 --- a/src/coding_agent_telegram/session_runtime.py +++ b/src/coding_agent_telegram/session_runtime.py @@ -58,6 +58,11 @@ _ABSOLUTE_PATH_RE = re.compile(r"(?:^|(?<=\s)|(?<=[\"'(]))((?:/[^\s\"',;)]+)+|[A-Za-z]:\\[^\s\"',;)]+)") +def _reply_to_message_id(update: Update) -> int | None: + message = getattr(update, "message", None) + return getattr(message, "message_id", None) + + def _load_secret_scrub_patterns() -> tuple[tuple[re.Pattern[str], str], ...]: resource = importlib.resources.files("coding_agent_telegram").joinpath("resources/secret_scrub_patterns.properties") compiled: list[tuple[re.Pattern[str], str]] = [] @@ -183,6 +188,11 @@ def _locale(self, update: Update | None) -> str: def _t(self, update: Update | None, key: str, **kwargs) -> str: return translate(self._locale(update), key, **kwargs) + def _take_reply_to_message_id(self, reply_state: dict[str, int | None]) -> int | None: + reply_to_message_id = reply_state.get("reply_to_message_id") + reply_state["reply_to_message_id"] = None + return reply_to_message_id + def _next_rotated_session_name(self, chat_id: int, base_name: str) -> str: existing = { data.get("name", "").strip().lower() @@ -234,6 +244,7 @@ async def run_active_session( *, user_message: str, image_paths: Sequence[Path] = (), + suppress_working_notice: bool = False, ) -> AgentRunResult | None: chat_id = update.effective_chat.id active_id, session, project_path = await self._active_session_or_notify(update, context) @@ -264,7 +275,14 @@ async def run_active_session( max_text_file_bytes=self.cfg.snapshot_text_file_max_bytes, ) before = set(changed_files(project_path)) - await send_text(update, context, self._t(update, "runtime.working_on_it")) + reply_to_message_id = _reply_to_message_id(update) + if not suppress_working_notice: + await send_text( + update, + context, + self._t(update, "runtime.working_on_it"), + reply_to_message_id=reply_to_message_id, + ) result = await self.run_with_typing( update, context, @@ -366,6 +384,7 @@ async def run_active_session( result=result, before_snapshot=before_snapshot, before=before, + reply_to_message_id=reply_to_message_id, ) return result @@ -589,6 +608,7 @@ async def _send_run_results( result, before_snapshot: dict[str, str | None], before: set[str], + reply_to_message_id: int | None, ) -> None: after_snapshot = snapshot_project_files( project_path, @@ -603,8 +623,15 @@ async def _send_run_results( for file_diff in collect_snapshot_diffs(before_snapshot, after_snapshot, files) } diffs = self._merge_snapshot_diffs(diffs, snapshot_diffs_by_path) + reply_state = {"reply_to_message_id": reply_to_message_id} - await self._send_assistant_chunks(update, context, result.assistant_text, provider=provider) + await self._send_assistant_chunks( + update, + context, + result.assistant_text, + provider=provider, + reply_state=reply_state, + ) logger.info( "Completed run for chat %s on session '%s' (%s); %d changed file(s).", update.effective_chat.id, @@ -622,8 +649,9 @@ async def _send_run_results( branch_name=branch_name or None, locale=self._locale(update), ), + reply_to_message_id=self._take_reply_to_message_id(reply_state), ) - await self._send_diffs(update, context, diffs) + await self._send_diffs(update, context, diffs, reply_state=reply_state) def _merge_snapshot_diffs(self, diffs, snapshot_diffs_by_path): if not snapshot_diffs_by_path: @@ -650,6 +678,7 @@ async def _send_assistant_chunks( assistant_text: str, *, provider: str, + reply_state: dict[str, int | None], ) -> None: if self.cfg.enable_secret_scrub_filter: assistant_text = _scrub_secrets(assistant_text) @@ -666,6 +695,7 @@ async def _send_assistant_chunks( f"{segment.header} ({index}/{total})", segment.text, language=segment.language, + reply_to_message_id=self._take_reply_to_message_id(reply_state), ) continue @@ -682,7 +712,12 @@ async def _send_assistant_chunks( ) ) for message in self._chunk_assistant_prose(title_prefix, segment.text): - await send_html_text(update, context, message) + await send_html_text( + update, + context, + message, + reply_to_message_id=self._take_reply_to_message_id(reply_state), + ) def _chunk_assistant_prose(self, title_prefix: str, text: str) -> list[str]: normalized = text.strip() @@ -726,10 +761,22 @@ def _split_assistant_body(self, body: str) -> tuple[str, str]: left = body[:-1].rstrip() or body[:1] return left, right - async def _send_diffs(self, update: Update, context: ContextTypes.DEFAULT_TYPE, diffs) -> None: + async def _send_diffs( + self, + update: Update, + context: ContextTypes.DEFAULT_TYPE, + diffs, + *, + reply_state: dict[str, int | None], + ) -> None: for file_diff in diffs: if self.cfg.enable_sensitive_diff_filter and is_sensitive_path(file_diff.path): - await send_text(update, context, self._t(update, "runtime.sensitive_diff_omitted", path=file_diff.path)) + await send_text( + update, + context, + self._t(update, "runtime.sensitive_diff_omitted", path=file_diff.path), + reply_to_message_id=self._take_reply_to_message_id(reply_state), + ) continue for chunk in chunk_fenced_diff( file_diff.path, @@ -737,4 +784,11 @@ async def _send_diffs(self, update: Update, context: ContextTypes.DEFAULT_TYPE, self.cfg.max_telegram_message_length, locale=self._locale(update), ): - await send_code_block(update, context, chunk.header, chunk.code, language=chunk.language) + await send_code_block( + update, + context, + chunk.header, + chunk.code, + language=chunk.language, + reply_to_message_id=self._take_reply_to_message_id(reply_state), + ) diff --git a/src/coding_agent_telegram/speech_to_text.py b/src/coding_agent_telegram/speech_to_text.py new file mode 100644 index 0000000..6c55105 --- /dev/null +++ b/src/coding_agent_telegram/speech_to_text.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import json +import logging +import os +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from coding_agent_telegram.config import AppConfig, DEFAULT_OPENAI_WHISPER_MODEL + + +logger = logging.getLogger(__name__) +_MODEL_CACHE_FILENAMES = { + "tiny": "tiny.pt", + "tiny.en": "tiny.en.pt", + "base": "base.pt", + "base.en": "base.en.pt", + "small": "small.pt", + "small.en": "small.en.pt", + "medium": "medium.pt", + "medium.en": "medium.en.pt", + "large": "large-v3.pt", + "large-v1": "large-v1.pt", + "large-v2": "large-v2.pt", + "large-v3": "large-v3.pt", + "large-v3-turbo": "large-v3-turbo.pt", + "turbo": "large-v3-turbo.pt", +} + + +class SpeechToTextError(RuntimeError): + def __init__(self, code: str, *, likely_first_download: bool = False, detail: str | None = None) -> None: + super().__init__(code) + self.code = code + self.likely_first_download = likely_first_download + self.detail = detail + + +@dataclass(frozen=True) +class SpeechToTextResult: + text: str + model: str + + +class WhisperSpeechToText: + def __init__(self, cfg: AppConfig) -> None: + self.enabled = cfg.enable_openai_whisper_speech_to_text + self.model = cfg.openai_whisper_model or DEFAULT_OPENAI_WHISPER_MODEL + self.timeout_seconds = cfg.openai_whisper_timeout_seconds + + def _model_cache_path(self) -> Path: + cache_root = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache")).expanduser() + file_name = _MODEL_CACHE_FILENAMES.get(self.model, f"{self.model}.pt") + return cache_root / "whisper" / file_name + + def _likely_first_download(self) -> bool: + return not self._model_cache_path().exists() + + def _summarize_process_output(self, result: subprocess.CompletedProcess[str]) -> str: + parts: list[str] = [f"whisper exited with status {result.returncode}"] + stderr = (result.stderr or "").strip() + stdout = (result.stdout or "").strip() + if stderr: + parts.append(f"stderr: {stderr[:500]}") + if stdout: + parts.append(f"stdout: {stdout[:500]}") + return "; ".join(parts) + + def transcribe_file(self, audio_path: Path) -> SpeechToTextResult: + likely_first_download = self._likely_first_download() + + with tempfile.TemporaryDirectory(prefix="coding-agent-telegram-whisper-") as output_dir: + command = [ + sys.executable, + "-m", + "whisper", + str(audio_path), + "--model", + self.model, + "--task", + "transcribe", + "--output_format", + "json", + "--output_dir", + output_dir, + "--verbose", + "False", + "--fp16", + "False", + "--condition_on_previous_text", + "False", + ] + try: + result = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=self.timeout_seconds, + ) + except subprocess.TimeoutExpired as exc: + raise SpeechToTextError( + "timeout", + likely_first_download=likely_first_download, + detail=f"whisper timed out after {self.timeout_seconds} seconds", + ) from exc + + if result.returncode != 0: + detail = self._summarize_process_output(result) + logger.warning("Whisper transcription failed for %s using model %s: %s", audio_path, self.model, detail) + raise SpeechToTextError("failed", likely_first_download=likely_first_download, detail=detail) + + transcript_path = Path(output_dir) / f"{audio_path.stem}.json" + if not transcript_path.exists(): + raise SpeechToTextError( + "failed", + likely_first_download=likely_first_download, + detail=f"whisper finished without writing transcript json for {audio_path.name}", + ) + + try: + payload = json.loads(transcript_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SpeechToTextError( + "failed", + likely_first_download=likely_first_download, + detail=f"failed to parse whisper transcript json for {audio_path.name}: {exc}", + ) from exc + + text = str(payload.get("text") or "").strip() + if not text: + raise SpeechToTextError( + "empty", + likely_first_download=likely_first_download, + detail=f"whisper returned an empty transcript for {audio_path.name}", + ) + return SpeechToTextResult(text=text, model=self.model) diff --git a/src/coding_agent_telegram/stt_setup.py b/src/coding_agent_telegram/stt_setup.py new file mode 100644 index 0000000..85ea0d8 --- /dev/null +++ b/src/coding_agent_telegram/stt_setup.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +import argparse +import importlib +import importlib.util +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from coding_agent_telegram.config import ( + DEFAULT_OPENAI_WHISPER_MODEL, + DEFAULT_OPENAI_WHISPER_TIMEOUT_SECONDS, + create_initial_env_file, + resolve_env_file_path, +) + + +ENABLE_STT_ENV = "ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT" +STT_INSTALL_HINT_ENV = "CODING_AGENT_TELEGRAM_STT_INSTALL_HINT" +STT_SIZE_GUIDANCE = ( + "Estimated local footprint: openai-whisper package about 50 MB, ffmpeg about 50 MB, " + "and Whisper model downloads vary by model size " + "(tiny about 72 MB, base about 139 MB, large-v3-turbo about 1.5 GB)." +) + + +@dataclass(frozen=True) +class SttPrereqStatus: + ffmpeg: bool + whisper_module: bool + + @property + def missing(self) -> list[str]: + missing: list[str] = [] + if not self.ffmpeg: + missing.append("ffmpeg") + if not self.whisper_module: + missing.append("openai-whisper (Python module)") + return missing + + @property + def ready(self) -> bool: + return not self.missing + + +def _has_whisper_module(python_bin: str | None = None) -> bool: + if python_bin is None: + return importlib.util.find_spec("whisper") is not None + result = subprocess.run( + [python_bin, "-c", "import importlib.util, sys; raise SystemExit(0 if importlib.util.find_spec('whisper') else 1)"], + check=False, + capture_output=True, + text=True, + ) + return result.returncode == 0 + + +def detect_stt_prereqs(*, python_bin: str | None = None) -> SttPrereqStatus: + importlib.invalidate_caches() + return SttPrereqStatus( + ffmpeg=shutil.which("ffmpeg") is not None, + whisper_module=_has_whisper_module(python_bin), + ) + + +def ensure_stt_runtime_or_exit(enabled: bool, *, install_hint: Optional[str] = None) -> None: + if not enabled: + return + + status = detect_stt_prereqs() + if status.ready: + return + + resolved_hint = (install_hint or os.getenv(STT_INSTALL_HINT_ENV, "")).strip() or "coding-agent-telegram-stt-install" + missing_text = ", ".join(status.missing) + raise SystemExit( + "\n".join( + [ + f"Error: {ENABLE_STT_ENV}=true but speech-to-text prerequisites are missing: {missing_text}", + f"Run: {resolved_hint}", + STT_SIZE_GUIDANCE, + ] + ) + ) + + +def _resolve_env_path(explicit: str | None = None) -> Path: + env_path = resolve_env_file_path(Path(explicit).expanduser() if explicit else None) + env_path.parent.mkdir(parents=True, exist_ok=True) + if not env_path.exists(): + create_initial_env_file(env_path) + return env_path + + +def _set_env_flag(env_path: Path, enabled: bool) -> None: + lines = [] + if env_path.exists(): + lines = env_path.read_text(encoding="utf-8").splitlines() + + def upsert(key: str, value: str, comments: list[str] | None = None, overwrite: bool = True) -> None: + replacement = f"{key}={value}" + for index, line in enumerate(lines): + if line.startswith(f"{key}="): + if overwrite: + lines[index] = replacement + return + if lines and lines[-1].strip(): + lines.append("") + if comments: + lines.extend(comments) + lines.append(replacement) + + upsert( + ENABLE_STT_ENV, + "true" if enabled else "false", + comments=[ + "# If true, enable Telegram voice-message speech-to-text with local openai-whisper.", + "# Estimated local footprint: package ~50 MB, ffmpeg ~50 MB, model downloads vary by model size.", + ], + ) + upsert( + "OPENAI_WHISPER_MODEL", + DEFAULT_OPENAI_WHISPER_MODEL, + comments=[ + "# Whisper model name for Telegram voice-message speech-to-text.", + "# `turbo` downloads the large-v3-turbo model (~1.5 GB) on first use into ~/.cache/whisper.", + "# If turbo is not cached yet, the first voice transcription is more likely to hit the timeout.", + ], + overwrite=False, + ) + upsert( + "OPENAI_WHISPER_TIMEOUT_SECONDS", + str(DEFAULT_OPENAI_WHISPER_TIMEOUT_SECONDS), + comments=["# Timeout for a single Whisper transcription call, in seconds."], + overwrite=False, + ) + + env_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _prompt_yes_no(prompt: str, *, default: bool = True) -> bool: + suffix = "[Y/n]" if default else "[y/N]" + while True: + try: + answer = input(f"{prompt} {suffix} ").strip().lower() + except EOFError: + return default + if not answer: + return default + if answer in {"y", "yes"}: + return True + if answer in {"n", "no"}: + return False + print("Please answer yes or no.") + + +def _package_manager() -> tuple[str, list[str]] | tuple[None, None]: + if sys.platform == "darwin" and shutil.which("brew"): + return "brew", ["brew", "install", "ffmpeg"] + if sys.platform.startswith("linux"): + if shutil.which("apt-get"): + prefix = ["sudo"] if hasattr(os, "geteuid") and os.geteuid() != 0 and shutil.which("sudo") else [] + return "apt-get", [*prefix, "apt-get", "update", "&&", *prefix, "apt-get", "install", "-y", "ffmpeg"] + if shutil.which("dnf"): + prefix = ["sudo"] if hasattr(os, "geteuid") and os.geteuid() != 0 and shutil.which("sudo") else [] + return "dnf", [*prefix, "dnf", "install", "-y", "ffmpeg"] + if shutil.which("yum"): + prefix = ["sudo"] if hasattr(os, "geteuid") and os.geteuid() != 0 and shutil.which("sudo") else [] + return "yum", [*prefix, "yum", "install", "-y", "ffmpeg"] + return None, None + + +def _run_shell_command(command: str) -> bool: + print(f"Running: {command}") + result = subprocess.run(command, shell=True, check=False) + return result.returncode == 0 + + +def _ensure_ffmpeg_installed() -> bool: + while True: + status = detect_stt_prereqs() + if status.ffmpeg: + return True + + print("Missing required system binary: ffmpeg") + + manager, command_parts = _package_manager() + if manager == "apt-get": + install_command = " ".join(command_parts) + elif command_parts is not None: + install_command = " ".join(command_parts) + else: + install_command = "" + + if install_command: + if not _prompt_yes_no(f"Install ffmpeg now using {manager}?"): + return False + if _run_shell_command(install_command): + continue + print("Automatic ffmpeg installation did not complete successfully.") + if not _prompt_yes_no("Retry ffmpeg installation?"): + return False + continue + + print("Automatic ffmpeg installation is not available on this OS/package-manager combination.") + print("Install ffmpeg manually, then return here and choose continue.") + if not _prompt_yes_no("Continue after manual installation?", default=False): + return False + + +def _ensure_whisper_installed(python_bin: str) -> bool: + while True: + status = detect_stt_prereqs(python_bin=python_bin) + if status.whisper_module: + return True + + print("Missing required Python package: openai-whisper") + if not _prompt_yes_no(f"Install openai-whisper with {python_bin} -m pip?"): + return False + command = f"{python_bin} -m pip install --upgrade openai-whisper" + if _run_shell_command(command): + continue + print("openai-whisper installation did not complete successfully.") + if not _prompt_yes_no("Retry openai-whisper installation?"): + return False + + +def install_stt_dependencies(*, env_file: str | None = None, python_bin: str | None = None) -> int: + env_path = _resolve_env_path(env_file) + resolved_python = python_bin or sys.executable + + print(STT_SIZE_GUIDANCE) + print(f"Using env file: {env_path}") + + if not _ensure_ffmpeg_installed(): + print("Speech-to-text installation aborted before ffmpeg prerequisites were satisfied.") + return 1 + if not _ensure_whisper_installed(resolved_python): + print("Speech-to-text installation aborted before openai-whisper was installed.") + return 1 + + _set_env_flag(env_path, True) + print(f"Speech-to-text prerequisites are ready. Enabled {ENABLE_STT_ENV}=true in {env_path}.") + return 0 + + +def offer_stt_install_for_new_env( + *, + env_file: str | None = None, + python_bin: str | None = None, + installer_label: str, +) -> int: + env_path = _resolve_env_path(env_file) + print("A new env file was created for coding-agent-telegram.") + print(STT_SIZE_GUIDANCE) + if not _prompt_yes_no( + f"Do you want to enable local Whisper speech-to-text now? This will run {installer_label}.", + default=False, + ): + print(f"Keeping {ENABLE_STT_ENV}=false in {env_path}.") + return 0 + + result = install_stt_dependencies(env_file=str(env_path), python_bin=python_bin) + if result != 0: + print(f"Speech-to-text setup did not complete. Keeping {ENABLE_STT_ENV}=false unless you enable it later.") + _set_env_flag(env_path, False) + return 0 + return 0 + + +def main(argv: Optional[list[str]] = None) -> int: + if argv is None: + argv = sys.argv[1:] + if not argv: + argv = ["install"] + + parser = argparse.ArgumentParser(description="Install or validate local Whisper speech-to-text support.") + subparsers = parser.add_subparsers(dest="command", required=True) + + install_parser = subparsers.add_parser("install", help="Install missing speech-to-text prerequisites.") + install_parser.add_argument("--env-file", help="Explicit env file path to update.") + install_parser.add_argument("--python-bin", help="Python executable to use for pip installation.") + offer_parser = subparsers.add_parser("offer", help="Prompt whether to enable speech-to-text for a new env file.") + offer_parser.add_argument("--env-file", help="Explicit env file path to update.") + offer_parser.add_argument("--python-bin", help="Python executable to use for pip installation.") + offer_parser.add_argument("--installer-label", required=True, help="User-facing installer command label.") + + args = parser.parse_args(argv) + + if args.command == "install": + return install_stt_dependencies(env_file=args.env_file, python_bin=args.python_bin) + if args.command == "offer": + return offer_stt_install_for_new_env( + env_file=args.env_file, + python_bin=args.python_bin, + installer_label=args.installer_label, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/coding_agent_telegram/telegram_sender.py b/src/coding_agent_telegram/telegram_sender.py index e8a243b..127835d 100644 --- a/src/coding_agent_telegram/telegram_sender.py +++ b/src/coding_agent_telegram/telegram_sender.py @@ -1,6 +1,7 @@ from __future__ import annotations import html +import logging import re from dataclasses import dataclass from typing import Optional @@ -56,6 +57,7 @@ ) SHELL_LANGUAGES = {"bash", "console", "shell", "sh", "zsh"} DEFAULT_TELEGRAM_MESSAGE_LENGTH = 3000 +logger = logging.getLogger(__name__) @dataclass(frozen=True) @@ -75,45 +77,92 @@ def _max_telegram_message_length(context: ContextTypes.DEFAULT_TYPE) -> int: return DEFAULT_TELEGRAM_MESSAGE_LENGTH -async def send_text(update: Update, context: ContextTypes.DEFAULT_TYPE, text: str) -> None: +def _default_reply_to_message_id(update: Update, explicit_reply_to_message_id: Optional[int] = None) -> Optional[int]: + return explicit_reply_to_message_id + + +async def send_text( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + text: str, + *, + reply_to_message_id: Optional[int] = None, +) -> None: if update.effective_chat is None: return max_length = _max_telegram_message_length(context) - for chunk in _split_text_chunks(text, max_length=max_length): + resolved_reply_to_message_id = _default_reply_to_message_id(update, reply_to_message_id) + chunks = _split_text_chunks(text, max_length=max_length) + logger.debug( + "Sending Telegram text message chat=%s chunks=%s reply_to_message_id=%s preview=%.120r", + update.effective_chat.id, + len(chunks), + resolved_reply_to_message_id, + text, + ) + for index, chunk in enumerate(chunks): await context.bot.send_message( chat_id=update.effective_chat.id, text=html.escape(chunk), parse_mode=ParseMode.HTML, + reply_to_message_id=resolved_reply_to_message_id if index == 0 else None, ) -async def send_markdown_text(update: Update, context: ContextTypes.DEFAULT_TYPE, text: str) -> None: +async def send_markdown_text( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + text: str, + *, + reply_to_message_id: Optional[int] = None, +) -> None: if update.effective_chat is None: return + logger.debug( + "Sending Telegram markdown message chat=%s reply_to_message_id=%s preview=%.120r", + update.effective_chat.id, + reply_to_message_id, + text, + ) await context.bot.send_message( chat_id=update.effective_chat.id, text=text, parse_mode=ParseMode.MARKDOWN, + reply_to_message_id=_default_reply_to_message_id(update, reply_to_message_id), ) -async def send_html_text(update: Update, context: ContextTypes.DEFAULT_TYPE, text: str) -> None: +async def send_html_text( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + text: str, + *, + reply_to_message_id: Optional[int] = None, +) -> None: if update.effective_chat is None: return max_length = _max_telegram_message_length(context) + logger.debug( + "Sending Telegram HTML message chat=%s reply_to_message_id=%s length=%s preview=%.120r", + update.effective_chat.id, + reply_to_message_id, + len(text), + text, + ) if len(text) > max_length: - await send_text(update, context, _strip_html_tags(text)) + await send_text(update, context, _strip_html_tags(text), reply_to_message_id=reply_to_message_id) return try: await context.bot.send_message( chat_id=update.effective_chat.id, text=text, parse_mode=ParseMode.HTML, + reply_to_message_id=_default_reply_to_message_id(update, reply_to_message_id), ) except BadRequest as exc: if "Can't parse entities" not in str(exc): raise - await send_text(update, context, _strip_html_tags(text)) + await send_text(update, context, _strip_html_tags(text), reply_to_message_id=reply_to_message_id) def markdownish_to_html(text: str) -> str: @@ -141,7 +190,7 @@ def markdownish_to_html(text: str) -> str: def _format_plain_markdownish(text: str) -> str: escaped = html.escape(text) - return BOLD_RE.sub(lambda match: f"{html.escape(match.group(1))}", escaped) + return BOLD_RE.sub(lambda match: f"{match.group(1)}", escaped) def _strip_html_tags(text: str) -> str: @@ -276,12 +325,22 @@ async def send_code_block( code: str, *, language: Optional[str] = None, + reply_to_message_id: Optional[int] = None, ) -> None: if update.effective_chat is None: return max_length = _max_telegram_message_length(context) chunks = _split_code_chunks(code, language, max_length=max_length) total = len(chunks) + resolved_reply_to_message_id = _default_reply_to_message_id(update, reply_to_message_id) + logger.debug( + "Sending Telegram code block chat=%s header=%r chunks=%s reply_to_message_id=%s language=%r", + update.effective_chat.id, + header, + total, + resolved_reply_to_message_id, + language, + ) for index, chunk in enumerate(chunks, start=1): current_header = header if total == 1 else f"{header} ({index}/{total})" escaped_code = html.escape(chunk) @@ -289,6 +348,7 @@ async def send_code_block( chat_id=update.effective_chat.id, text=html.escape(current_header), parse_mode=ParseMode.HTML, + reply_to_message_id=resolved_reply_to_message_id if index == 1 else None, ) if language: text = f"
{escaped_code}
" @@ -298,4 +358,5 @@ async def send_code_block( chat_id=update.effective_chat.id, text=text, parse_mode=ParseMode.HTML, + reply_to_message_id=None, ) diff --git a/startup.sh b/startup.sh index 1e212cc..9b61be4 100755 --- a/startup.sh +++ b/startup.sh @@ -67,6 +67,7 @@ if [[ -z "$ENV_FILE" ]]; then fi fi +NEW_ENV_CREATED=0 if [[ ! -f "$ENV_FILE" ]]; then if [[ -f "$ENV_TEMPLATE_FILE" ]]; then ENV_FILE_TARGET="$ENV_FILE" ENV_TEMPLATE_SOURCE="$ENV_TEMPLATE_FILE" PYTHONPATH="$SCRIPT_DIR/src${PYTHONPATH:+:$PYTHONPATH}" "$PYTHON_BIN" - <<'PY' @@ -81,16 +82,13 @@ app_locale = create_initial_env_file(env_path, template_path) print(translate(app_locale, "bootstrap.env_created_locale_line", env_path=env_path, app_locale=app_locale)) print(translate(app_locale, "bootstrap.env_created_change_line", env_path=env_path)) PY + NEW_ENV_CREATED=1 else echo "Error: $ENV_FILE is missing and $ENV_TEMPLATE_FILE was not found." >&2 exit 1 fi fi -set -a -source "$ENV_FILE" -set +a - STATE_FILE="$STATE_FILE_DEFAULT" STATE_BACKUP_FILE="$STATE_BACKUP_FILE_DEFAULT" if [[ -f "$APP_HOME_DIR/state.json" ]]; then @@ -108,6 +106,49 @@ LOG_DIR="$LOG_DIR_DEFAULT" mkdir -p "$(dirname "$STATE_FILE")" "$(dirname "$STATE_BACKUP_FILE")" "$LOG_DIR" touch "$STATE_FILE" "$STATE_BACKUP_FILE" +if [[ ! -d "$VENV_DIR" ]]; then + "$PYTHON_BIN" -m venv "$VENV_DIR" +fi + +source "$VENV_DIR/bin/activate" + +python -m pip install --upgrade pip >/dev/null +INSTALL_STATE_FILE="$VENV_DIR/$INSTALL_STATE_FILE_NAME" +CURRENT_INSTALL_FINGERPRINT="$(compute_install_fingerprint)" +STORED_INSTALL_FINGERPRINT="" +if [[ -f "$INSTALL_STATE_FILE" ]]; then + STORED_INSTALL_FINGERPRINT="$(<"$INSTALL_STATE_FILE")" +fi + +NEEDS_REINSTALL=0 +if [[ "$FORCE_REINSTALL" == "1" ]]; then + NEEDS_REINSTALL=1 +elif ! python -c "import coding_agent_telegram" >/dev/null 2>&1; then + NEEDS_REINSTALL=1 +elif [[ "$CURRENT_INSTALL_FINGERPRINT" != "$STORED_INSTALL_FINGERPRINT" ]]; then + NEEDS_REINSTALL=1 +fi + +if [[ "$NEEDS_REINSTALL" == "1" ]]; then + echo "Installing local package into $VENV_DIR." + SETUPTOOLS_SCM_PRETEND_VERSION_FOR_CODING_AGENT_TELEGRAM="$LOCAL_PRETEND_VERSION" \ + python -m pip install -e . + printf '%s\n' "$CURRENT_INSTALL_FINGERPRINT" > "$INSTALL_STATE_FILE" +else + echo "Existing editable install detected; skipping reinstall." +fi + +if [[ "$NEW_ENV_CREATED" == "1" ]]; then + python -m coding_agent_telegram.stt_setup offer \ + --env-file "$ENV_FILE" \ + --python-bin "$VENV_DIR/bin/python" \ + --installer-label "./install-stt.sh" +fi + +set -a +source "$ENV_FILE" +set +a + required_vars=( WORKSPACE_ROOT TELEGRAM_BOT_TOKENS @@ -159,43 +200,13 @@ case "$DEFAULT_AGENT_PROVIDER" in ;; esac -if [[ ! -d "$VENV_DIR" ]]; then - "$PYTHON_BIN" -m venv "$VENV_DIR" -fi - -source "$VENV_DIR/bin/activate" - -python -m pip install --upgrade pip >/dev/null -INSTALL_STATE_FILE="$VENV_DIR/$INSTALL_STATE_FILE_NAME" -CURRENT_INSTALL_FINGERPRINT="$(compute_install_fingerprint)" -STORED_INSTALL_FINGERPRINT="" -if [[ -f "$INSTALL_STATE_FILE" ]]; then - STORED_INSTALL_FINGERPRINT="$(<"$INSTALL_STATE_FILE")" -fi - -NEEDS_REINSTALL=0 -if [[ "$FORCE_REINSTALL" == "1" ]]; then - NEEDS_REINSTALL=1 -elif ! python -c "import coding_agent_telegram" >/dev/null 2>&1; then - NEEDS_REINSTALL=1 -elif [[ "$CURRENT_INSTALL_FINGERPRINT" != "$STORED_INSTALL_FINGERPRINT" ]]; then - NEEDS_REINSTALL=1 -fi - -if [[ "$NEEDS_REINSTALL" == "1" ]]; then - echo "Installing local package into $VENV_DIR." - SETUPTOOLS_SCM_PRETEND_VERSION_FOR_CODING_AGENT_TELEGRAM="$LOCAL_PRETEND_VERSION" \ - python -m pip install -e . - printf '%s\n' "$CURRENT_INSTALL_FINGERPRINT" > "$INSTALL_STATE_FILE" -else - echo "Existing editable install detected; skipping reinstall." -fi - echo "Post-installation guide:" echo "1. Confirm $ENV_FILE contains WORKSPACE_ROOT, TELEGRAM_BOT_TOKENS, and ALLOWED_CHAT_IDS." echo "2. State files are ready at $STATE_FILE and $STATE_BACKUP_FILE." echo "3. Application logs will be written under $LOG_DIR." -echo "4. Start the server with: ./startup.sh" -echo "5. In Telegram, start conversations." +echo "4. Optional voice-to-text: run ./install-stt.sh if you want local Whisper support." +echo "5. Start the server with: ./startup.sh" +echo "6. In Telegram, start conversations." echo "Starting coding-agent-telegram..." +export CODING_AGENT_TELEGRAM_STT_INSTALL_HINT="./install-stt.sh" exec python -m coding_agent_telegram diff --git a/tests/test_command_router.py b/tests/test_command_router.py index 2fdcae1..239acaa 100644 --- a/tests/test_command_router.py +++ b/tests/test_command_router.py @@ -2,6 +2,7 @@ import asyncio import html +import logging import sqlite3 import shlex import sys @@ -14,6 +15,8 @@ from coding_agent_telegram.command_router import CommandRouter, RouterDeps from coding_agent_telegram.config import AppConfig from coding_agent_telegram.session_store import SessionStore +from coding_agent_telegram.speech_to_text import SpeechToTextError +from telegram.error import BadRequest class DummyRunner: @@ -316,13 +319,23 @@ def resume_session( class FakeBot: def __init__(self): self.messages = [] + self.sent_messages = [] self.actions = [] self.deleted_messages = [] self.send_count = 0 self.edit_count = 0 - async def send_message(self, chat_id, text, parse_mode=None, reply_markup=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_markup=None, reply_to_message_id=None): self.send_count += 1 + self.sent_messages.append( + { + "chat_id": chat_id, + "text": text, + "parse_mode": parse_mode, + "reply_markup": reply_markup, + "reply_to_message_id": reply_to_message_id, + } + ) self.messages.append((chat_id, text, parse_mode, reply_markup)) return SimpleNamespace(message_id=len(self.messages)) @@ -338,10 +351,21 @@ async def send_chat_action(self, chat_id, action): class SlowProgressBot(FakeBot): - async def send_message(self, chat_id, text, parse_mode=None, reply_markup=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_markup=None, reply_to_message_id=None): if "Live agent output" in text: await asyncio.sleep(0.2) - return await super().send_message(chat_id, text, parse_mode=parse_mode, reply_markup=reply_markup) + return await super().send_message( + chat_id, + text, + parse_mode=parse_mode, + reply_markup=reply_markup, + reply_to_message_id=reply_to_message_id, + ) + + +class EditFailingProgressBot(FakeBot): + async def edit_message_text(self, chat_id, message_id, text, parse_mode=None, reply_markup=None): + raise BadRequest("message can't be edited") class FakeGitManager: @@ -436,6 +460,24 @@ async def download_as_bytearray(self): return bytearray(self._content) +class FakeVoiceMessage: + def __init__( + self, + telegram_file: FakeTelegramFile, + *, + file_unique_id: str = "voice.ogg", + file_size=None, + file_name: str | None = None, + ): + self.telegram_file = telegram_file + self.file_unique_id = file_unique_id + self.file_size = file_size if file_size is not None else len(getattr(telegram_file, "_content", b"")) + self.file_name = file_name + + async def get_file(self): + return self.telegram_file + + class FakePhotoSize: def __init__(self, telegram_file: FakeTelegramFile, *, file_size=None): self.telegram_file = telegram_file @@ -445,10 +487,10 @@ async def get_file(self): return self.telegram_file -def make_update(chat_id=123, chat_type="private", text="hello"): +def make_update(chat_id=123, chat_type="private", text="hello", message_id=1): return SimpleNamespace( effective_chat=SimpleNamespace(id=chat_id, type=chat_type), - message=SimpleNamespace(text=text, photo=None, caption=None), + message=SimpleNamespace(text=text, photo=None, caption=None, message_id=message_id), ) @@ -480,6 +522,9 @@ def make_config(tmp_path: Path, *, locale: str = "en") -> AppConfig: max_telegram_message_length=3000, enable_sensitive_diff_filter=True, enable_secret_scrub_filter=True, + enable_openai_whisper_speech_to_text=False, + openai_whisper_model="base", + openai_whisper_timeout_seconds=120, default_agent_provider="codex", agent_hard_timeout_seconds=0, app_internal_root=tmp_path / ".coding-agent-telegram", @@ -900,8 +945,9 @@ def test_branch_command_uses_default_branch_when_origin_not_provided(tmp_path: P reply_markup = bot.messages[-1][3] assert reply_markup is not None + token = router._register_branch_source_token("origin", "main", "feature-1") query = SimpleNamespace( - data="branchsource:origin:main:feature-1", + data=f"branchsource:{token}", answer=None, edit_message_text=None, ) @@ -959,8 +1005,9 @@ def test_branch_command_is_localized_in_zh_tw(tmp_path: Path): assert "請選擇 branch 來源:" in message assert "目標 branch:feature-1" in message + token = router._register_branch_source_token("origin", "main", "feature-1") query = SimpleNamespace( - data="branchsource:origin:main:feature-1", + data=f"branchsource:{token}", answer=None, edit_message_text=None, ) @@ -1048,8 +1095,9 @@ def test_branch_command_switches_to_existing_branch(tmp_path: Path): assert "Switching branch to main requires choosing a source first." in bot.messages[-1][1] assert "Choose the branch source:" in bot.messages[-1][1] + token = router._register_branch_source_token("local", "main", "main") query = SimpleNamespace( - data="branchsource:local:main:main", + data=f"branchsource:{token}", answer=None, edit_message_text=None, ) @@ -1592,6 +1640,118 @@ async def fake_edit(text): assert state["sessions"][state["active_session_id"]]["provider"] == "copilot" +def test_text_message_is_queued_while_new_session_prerequisites_are_pending(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router._provider_available = lambda provider: True + + async def exercise(): + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + await router.handle_new(make_update(text="/new my-session"), SimpleNamespace(args=["my-session"], bot=bot)) + state = store.get_chat_state("bot-a", 123) + assert state["pending_action"]["kind"] == "new_session" + + await router.handle_message(make_update(text="follow-up question", message_id=202), context) + + state = store.get_chat_state("bot-a", 123) + assert state["pending_action"]["kind"] == "new_session" + assert any("Question queued as Q1." in entry["text"] for entry in bot.sent_messages) + assert runner.resume_calls == [] + + query = SimpleNamespace(data="provider:set:codex", answer=None, edit_message_text=None) + callback_update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=SimpleNamespace(text=None, photo=None, caption=None, message_id=None), + ) + + async def fake_answer(): + return None + + async def fake_edit(_text, reply_markup=None): + return None + + query.answer = fake_answer + query.edit_message_text = fake_edit + + await router.handle_provider_callback(callback_update, context) + + assert len(runner.create_calls) == 1 + assert len(runner.resume_calls) == 1 + assert runner.resume_calls[0]["user_message"] == "follow-up question" + + asyncio.run(exercise()) + + +def test_voice_message_is_queued_while_new_session_prerequisites_are_pending(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router._provider_available = lambda provider: True + router.speech_to_text.enabled = True + router.speech_to_text.transcribe_file = lambda _path: SimpleNamespace(text="voice follow-up") + + async def exercise(): + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + await router.handle_new(make_update(text="/new my-session"), SimpleNamespace(args=["my-session"], bot=bot)) + state = store.get_chat_state("bot-a", 123) + assert state["pending_action"]["kind"] == "new_session" + + voice_update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace( + text=None, + photo=None, + caption=None, + message_id=303, + voice=FakeVoiceMessage(FakeTelegramFile(b"voice-bytes", "voice/note.ogg")), + ), + ) + await router.handle_voice(voice_update, context) + + state = store.get_chat_state("bot-a", 123) + assert state["pending_action"]["kind"] == "new_session" + assert any("Queued as Q1." in entry["text"] for entry in bot.sent_messages) + assert runner.resume_calls == [] + + query = SimpleNamespace(data="provider:set:codex", answer=None, edit_message_text=None) + callback_update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=SimpleNamespace(text=None, photo=None, caption=None, message_id=None), + ) + + async def fake_answer(): + return None + + async def fake_edit(_text, reply_markup=None): + return None + + query.answer = fake_answer + query.edit_message_text = fake_edit + + await router.handle_provider_callback(callback_update, context) + + assert len(runner.create_calls) == 1 + assert len(runner.resume_calls) == 1 + assert runner.resume_calls[0]["user_message"] == "voice follow-up" + + asyncio.run(exercise()) + + def test_provider_switch_auto_creates_session_named_by_session_id(tmp_path: Path): backend = tmp_path / "backend" backend.mkdir() @@ -2078,6 +2238,336 @@ def test_photo_message_rejected_for_copilot_session(tmp_path: Path): assert "Photo attachments are currently supported only for codex sessions." in bot.messages[-1][1] +def test_voice_message_sends_transcript_preview_before_running_agent(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_voice", "voice-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + router.speech_to_text.enabled = True + router.speech_to_text.transcribe_file = lambda _path: SimpleNamespace(text="fix the flaky test") + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace( + text=None, + photo=None, + caption=None, + voice=FakeVoiceMessage(FakeTelegramFile(b"voice-bytes", "voice/note.ogg")), + ), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_voice(update, context)) + + assert bot.messages[0][1] == "Recognized voice transcript:\nfix the flaky test\n\nWorking on it..." + assert runner.resume_calls[-1]["user_message"] == "fix the flaky test" + working_entries = [entry for entry in bot.sent_messages if "Working on it..." in entry["text"]] + assert len(working_entries) == 1 + + +def test_voice_message_sends_queued_transcript_notice_when_project_busy(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + runner.has_running_process = lambda _project_path: True + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_voice", "voice-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + router.speech_to_text.enabled = True + router.speech_to_text.transcribe_file = lambda _path: SimpleNamespace(text="fix the flaky test") + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace( + text=None, + photo=None, + caption=None, + voice=FakeVoiceMessage(FakeTelegramFile(b"voice-bytes", "voice/note.ogg")), + ), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_voice(update, context)) + + assert "Recognized voice transcript:\nfix the flaky test\n\nQueued as Q1." in bot.messages[0][1] + assert runner.resume_calls == [] + + +def test_audio_message_is_transcribed_and_forwarded(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_audio", "audio-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + router.speech_to_text.enabled = True + router.speech_to_text.transcribe_file = lambda _path: SimpleNamespace(text="summarize this meeting note") + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace( + text=None, + photo=None, + caption=None, + voice=None, + audio=FakeVoiceMessage(FakeTelegramFile(b"audio-bytes", "audio/clip.mp3"), file_unique_id="clip.mp3"), + ), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_audio(update, context)) + + assert runner.resume_calls[-1]["user_message"] == "summarize this meeting note" + + +def test_voice_message_logs_stt_error_details(tmp_path: Path, caplog: pytest.LogCaptureFixture): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_voice", "voice-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + router.speech_to_text.enabled = True + + def fail_transcription(_path): + raise SpeechToTextError("failed", detail="ffmpeg exited with status 1") + + router.speech_to_text.transcribe_file = fail_transcription + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace( + text=None, + photo=None, + caption=None, + voice=FakeVoiceMessage(FakeTelegramFile(b"voice-bytes", "voice/note.ogg")), + ), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + with caplog.at_level(logging.WARNING): + asyncio.run(router.handle_voice(update, context)) + + assert bot.messages[-1][1] == "Voice conversion failed." + assert "ffmpeg exited with status 1" in caplog.text + + +def test_voice_message_is_queued_when_message_pending_before_runner_busy(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = BlockingRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_voice_pending", "voice-pending-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + router.speech_to_text.enabled = True + router.speech_to_text.transcribe_file = lambda _path: SimpleNamespace(text="queued via voice") + + async def exercise(): + bot = FakeBot() + first_update = make_update(text="first text", message_id=101) + voice_update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace( + text=None, + photo=None, + caption=None, + message_id=202, + voice=FakeVoiceMessage(FakeTelegramFile(b"voice-bytes", "voice/note.ogg")), + ), + ) + + first_task = asyncio.create_task(router.handle_message(first_update, SimpleNamespace(args=[], bot=bot))) + await asyncio.sleep(0) + await router.handle_voice(voice_update, SimpleNamespace(args=[], bot=bot)) + + assert any("Queued as Q1." in entry["text"] for entry in bot.sent_messages) + assert not any( + entry["text"] == "Recognized voice transcript:\nqueued via voice\n\nWorking on it..." + for entry in bot.sent_messages + ) + + runner.release_next() + started_second = await asyncio.to_thread(runner.wait_started, 2, 1.0) + assert started_second is True + runner.release_next() + await first_task + + assert runner.resume_calls[0]["user_message"] == "first text" + assert runner.resume_calls[1]["user_message"] == "queued via voice" + + asyncio.run(exercise()) + + +def test_audio_message_rejected_when_declared_size_exceeds_stt_limit(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_audio_limit", "audio-limit-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + router.speech_to_text.enabled = True + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace( + text=None, + photo=None, + caption=None, + voice=None, + audio=FakeVoiceMessage( + FakeTelegramFile(b"small-audio", "audio/clip.mp3"), + file_unique_id="clip.mp3", + file_size=(20 * 1024 * 1024) + 1, + file_name="clip.mp3", + ), + ), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_audio(update, context)) + + assert runner.resume_calls == [] + assert bot.messages[-1][1] == "Audio is too large for local speech-to-text. The maximum supported size is 20 MB." + + +def test_text_message_is_processed_after_voice_triggered_run_finishes(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = BlockingRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_voice", "voice-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + router.speech_to_text.enabled = True + router.speech_to_text.transcribe_file = lambda _path: SimpleNamespace(text="first via voice") + + async def exercise(): + bot = FakeBot() + voice_update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace( + text=None, + photo=None, + caption=None, + voice=FakeVoiceMessage(FakeTelegramFile(b"voice-bytes", "voice/note.ogg")), + ), + ) + text_update = make_update(text="second via text") + + voice_task = asyncio.create_task(router.handle_voice(voice_update, SimpleNamespace(args=[], bot=bot))) + started = await asyncio.to_thread(runner.wait_started, 1, 1.0) + assert started is True + + await router.handle_message(text_update, SimpleNamespace(args=[], bot=bot)) + assert any("Question queued as Q1." in message for _, message, _, _ in bot.messages) + + runner.release_next() + started_second = await asyncio.to_thread(runner.wait_started, 2, 1.0) + assert started_second is True + runner.release_next() + await voice_task + + assert len(runner.resume_calls) == 2 + assert runner.resume_calls[0]["user_message"] == "first via voice" + assert runner.resume_calls[1]["user_message"] == "second via text" + + asyncio.run(exercise()) + + +def test_busy_queue_and_final_output_reply_to_original_message(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = BlockingRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_reply", "reply-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + async def exercise(): + bot = FakeBot() + first_update = make_update(text="first question", message_id=101) + second_update = make_update(text="second question", message_id=202) + + first_task = asyncio.create_task(router.handle_message(first_update, SimpleNamespace(args=[], bot=bot))) + started = await asyncio.to_thread(runner.wait_started, 1, 1.0) + assert started is True + + await router.handle_message(second_update, SimpleNamespace(args=[], bot=bot)) + queued_entries = [entry for entry in bot.sent_messages if "Question queued as Q1." in entry["text"]] + assert queued_entries + assert queued_entries[-1]["reply_to_message_id"] == 202 + + runner.release_next() + started_second = await asyncio.to_thread(runner.wait_started, 2, 1.0) + assert started_second is True + runner.release_next() + await first_task + + working_entries = [entry for entry in bot.sent_messages if "Working on it..." in entry["text"]] + assert working_entries + assert working_entries[0]["reply_to_message_id"] == 101 + assert working_entries[-1]["reply_to_message_id"] == 202 + + final_entries = [ + entry + for entry in bot.sent_messages + if "Codex output" in entry["text"] or "Task completed." in entry["text"] + ] + assert final_entries + reply_targets = {entry["reply_to_message_id"] for entry in final_entries} + assert 101 in reply_targets + assert 202 in reply_targets + + asyncio.run(exercise()) + + +def test_final_output_replies_only_on_first_message(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = CommandBlockRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_final_reply", "final-reply-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + bot = FakeBot() + update = make_update(text="show me the result", message_id=777) + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + final_entries = [ + entry + for entry in bot.sent_messages + if "Codex output" in entry["text"] or "Command" in entry["text"] or "Task completed." in entry["text"] + ] + assert len(final_entries) >= 3 + assert final_entries[0]["reply_to_message_id"] == 777 + assert all(entry["reply_to_message_id"] is None for entry in final_entries[1:]) + + def test_photo_message_rejected_when_declared_size_exceeds_limit(tmp_path: Path): backend = tmp_path / "backend" backend.mkdir() @@ -2236,6 +2726,86 @@ def test_message_prompts_for_provider_when_not_selected(tmp_path: Path): assert store.get_chat_state("bot-a", 123)["pending_action"]["kind"] == "message" +def test_pending_action_blocks_queue_drain_until_prerequisites_are_resolved(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + async def exercise(): + bot = FakeBot() + first_update = make_update(text="first question", message_id=101) + second_update = make_update(text="second question", message_id=202) + context = SimpleNamespace(args=[], bot=bot) + + await router.handle_message(first_update, context) + await router.handle_message(second_update, context) + + state = store.get_chat_state("bot-a", 123) + assert state["pending_action"]["kind"] == "message" + assert state["pending_action"]["user_message"] == "first question" + assert any("Question queued as Q1." in entry["text"] for entry in bot.sent_messages) + assert runner.resume_calls == [] + + asyncio.run(exercise()) + + +def test_provider_callback_drains_queued_messages_after_pending_message_runs(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router._provider_available = lambda provider: True + + async def exercise(): + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + await router.handle_message(make_update(text="first question", message_id=101), context) + await router.handle_message(make_update(text="second question", message_id=202), context) + + query = SimpleNamespace( + data="provider:set:codex", + answer=None, + edit_message_text=None, + ) + callback_update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=SimpleNamespace(text=None, photo=None, caption=None, message_id=None), + ) + edited = [] + + async def fake_answer(): + return None + + async def fake_edit(text, reply_markup=None): + edited.append((text, reply_markup)) + + query.answer = fake_answer + query.edit_message_text = fake_edit + + await router.handle_provider_callback(callback_update, context) + + assert edited[-1][0] == "Current provider set to: codex" + assert len(runner.create_calls) == 1 + assert len(runner.resume_calls) == 2 + assert runner.resume_calls[0]["user_message"] == "first question" + assert runner.resume_calls[1]["user_message"] == "second question" + + state = store.get_chat_state("bot-a", 123) + assert state.get("pending_action") is None + assert not router._has_pending_queue_files(123) + + asyncio.run(exercise()) + + def test_message_prompts_for_branch_discrepancy_before_running_bot_managed_session(tmp_path: Path): backend = tmp_path / "backend" backend.mkdir() @@ -2484,9 +3054,10 @@ def test_branch_discrepancy_fallback_branch_source_resumes_pending_run(tmp_path: ), ) + token = router._register_branch_source_token("origin", "main", "enhancements") edited = [] query = SimpleNamespace( - data="branchsource:origin:main:enhancements", + data=f"branchsource:{token}", answer=None, edit_message_text=None, ) @@ -2562,9 +3133,10 @@ def test_branch_discrepancy_fallback_source_options_resume_pending_run( ), ) + token = router._register_branch_source_token(source_kind, source_branch, "enhancements") edited = [] query = SimpleNamespace( - data=f"branchsource:{source_kind}:{source_branch}:enhancements", + data=f"branchsource:{token}", answer=None, edit_message_text=None, ) @@ -2629,9 +3201,10 @@ def test_branch_source_failure_during_discrepancy_offers_fallback_prompt(tmp_pat ), ) + token = router._register_branch_source_token("origin", "enhancements", "enhancements") edited = [] query = SimpleNamespace( - data="branchsource:origin:enhancements:enhancements", + data=f"branchsource:{token}", answer=None, edit_message_text=None, ) @@ -2972,6 +3545,27 @@ def test_active_session_deletes_live_progress_message_even_if_progress_send_is_s assert len(bot.deleted_messages) == 1 +def test_active_session_deletes_previous_live_progress_message_when_edit_falls_back_to_send(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = RapidProgressRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_progress", "progress-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update(text="continue") + bot = EditFailingProgressBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + assert len(bot.deleted_messages) == 2 + deleted_ids = [message_id for chat_id, message_id in bot.deleted_messages if chat_id == 123] + assert len(set(deleted_ids)) == 2 + + def test_second_message_is_queued_while_first_run_is_still_running(tmp_path: Path): backend = tmp_path / "backend" backend.mkdir() @@ -3015,6 +3609,42 @@ async def exercise(): asyncio.run(exercise()) +def test_second_message_is_queued_even_before_runner_reports_busy(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = BlockingRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_queue", "queue-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + async def exercise(): + bot = FakeBot() + first_update = make_update(text="first question", message_id=101) + second_update = make_update(text="second question", message_id=202) + + first_task = asyncio.create_task(router.handle_message(first_update, SimpleNamespace(args=[], bot=bot))) + await asyncio.sleep(0) + await router.handle_message(second_update, SimpleNamespace(args=[], bot=bot)) + + assert any("Question queued as Q1." in message for _, message, _, _ in bot.messages) + + started = await asyncio.to_thread(runner.wait_started, 1, 1.0) + assert started is True + runner.release_next() + started_second = await asyncio.to_thread(runner.wait_started, 2, 1.0) + assert started_second is True + runner.release_next() + await first_task + + assert len(runner.resume_calls) == 2 + assert runner.resume_calls[0]["user_message"] == "first question" + assert runner.resume_calls[1]["user_message"] == "second question" + + asyncio.run(exercise()) + + def test_grouped_queue_batch_requires_user_decision_then_processes_remaining_queue(tmp_path: Path): backend = tmp_path / "backend" backend.mkdir() @@ -3027,10 +3657,10 @@ def test_grouped_queue_batch_requires_user_decision_then_processes_remaining_que async def exercise(): bot = FakeBot() - first_update = make_update(text="first question") - second_update = make_update(text="two") - third_update = make_update(text="three") - fourth_update = make_update(text="four four four four four four four") + first_update = make_update(text="first question", message_id=101) + second_update = make_update(text="two", message_id=202) + third_update = make_update(text="three", message_id=303) + fourth_update = make_update(text="four four four four four four four", message_id=404) first_context = SimpleNamespace(args=[], bot=bot) first_task = asyncio.create_task(router.handle_message(first_update, first_context)) @@ -3088,6 +3718,17 @@ async def fake_edit(text): queued_notices = [message for _, message, _, _ in bot.messages if "Working on queued questions:" in message] assert any("1. two" in message and "2. three" in message for message in queued_notices) assert any("1. four four four four four four four" in message for message in queued_notices) + working_entries = [entry for entry in bot.sent_messages if "Working on it..." in entry["text"]] + assert [entry["reply_to_message_id"] for entry in working_entries] == [101, None, 404] + final_entries = [ + entry + for entry in bot.sent_messages + if "Codex output" in entry["text"] or "Task completed." in entry["text"] + ] + reply_targets = {entry["reply_to_message_id"] for entry in final_entries} + assert 101 in reply_targets + assert None in reply_targets + assert 404 in reply_targets asyncio.run(exercise()) @@ -3447,7 +4088,7 @@ def test_unsupported_message_type_is_rejected(tmp_path: Path): asyncio.run(router.handle_unsupported_message(update, context)) - assert "This bot currently accepts only text messages and photos." in bot.messages[-1][1] + assert "This bot currently accepts text messages, photos, voice messages, and audio files." in bot.messages[-1][1] def _make_commit_router(tmp_path: Path, *, git_manager=None, trusted: bool = True) -> tuple[CommandRouter, Path]: @@ -4948,9 +5589,10 @@ def test_origin_branch_prepare_failure_offers_fallback_prompt(tmp_path: Path): assert "Choose the branch source:" in bot.messages[-1][1] + token = router._register_branch_source_token("origin", "main", "feature-new") edited = [] query = SimpleNamespace( - data="branchsource:origin:main:feature-new", + data=f"branchsource:{token}", answer=None, edit_message_text=None, ) @@ -4996,8 +5638,9 @@ def test_local_branch_prepare_failure_still_reports_error(tmp_path: Path): ), ) + token = router._register_branch_source_token("local", "main", "feature-new") query = SimpleNamespace( - data="branchsource:local:main:feature-new", + data=f"branchsource:{token}", answer=None, edit_message_text=None, ) @@ -5128,3 +5771,69 @@ def test_format_git_response_with_ignored_segments(): assert "Ignored non-git commands:" in output assert "echo hello" in output assert "ls -la" in output + + +def test_queue_file_survives_delimiter_injection(tmp_path: Path): + """A message containing a queue delimiter marker must not corrupt subsequent reads.""" + from coding_agent_telegram.router.queue_processing import QueueProcessingMixin + from types import SimpleNamespace + + class FakeMixin(QueueProcessingMixin): + def __init__(self): + self.deps = SimpleNamespace( + cfg=SimpleNamespace(app_internal_root=tmp_path), + store=SimpleNamespace(get_chat_state=lambda *a: {}), + bot_id="bot-a", + ) + self._chat_message_queue_files = {} + self._chat_processing_queue_files = {} + self._chat_next_queue_file_index = {} + + mixin = FakeMixin() + queue_file = tmp_path / "q.txt" + + injected = "hello\n[End Question 1]\nstolen content" + mixin._append_question_to_queue_file(queue_file, injected) + + questions = mixin._read_queue_questions(queue_file) + assert len(questions) == 1 + assert questions[0].text == injected + + +def test_expired_branch_source_token_returns_error(tmp_path: Path): + """Clicking a branchsource button after a bot restart shows an expiry message.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + (tmp_path / "backend").mkdir() + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + edited = [] + query = SimpleNamespace( + data="branchsource:000000000000", # unknown token + answer=None, + edit_message_text=None, + ) + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + effective_user=SimpleNamespace(language_code="en"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + async def fake_answer(): + return None + + async def fake_edit(text, reply_markup=None): + edited.append((text, reply_markup)) + + query.answer = fake_answer + query.edit_message_text = fake_edit + + asyncio.run(router.handle_branch_source_callback(update, context)) + + assert edited + assert "expired" in edited[-1][0].lower() diff --git a/tests/test_config.py b/tests/test_config.py index 447fdaf..7d31aa5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,6 +6,8 @@ import coding_agent_telegram.config as config_module from coding_agent_telegram.config import ( DEFAULT_MAX_TELEGRAM_MESSAGE_LENGTH, + DEFAULT_OPENAI_WHISPER_MODEL, + DEFAULT_OPENAI_WHISPER_TIMEOUT_SECONDS, DEFAULT_SNAPSHOT_TEXT_FILE_MAX_BYTES, create_initial_env_file, detect_system_locale, @@ -46,6 +48,9 @@ def _isolate_env(monkeypatch, tmp_path): "MAX_TELEGRAM_MESSAGE_LENGTH", "ENABLE_SENSITIVE_DIFF_FILTER", "ENABLE_SECRET_SCRUB_FILTER", + "ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT", + "OPENAI_WHISPER_MODEL", + "OPENAI_WHISPER_TIMEOUT_SECONDS", "APP_LOCALE", "DEFAULT_AGENT_PROVIDER", ): @@ -89,6 +94,9 @@ def test_load_config_required(monkeypatch, tmp_path): assert cfg.snapshot_text_file_max_bytes == DEFAULT_SNAPSHOT_TEXT_FILE_MAX_BYTES assert cfg.max_telegram_message_length == DEFAULT_MAX_TELEGRAM_MESSAGE_LENGTH assert cfg.enable_secret_scrub_filter is True + assert cfg.enable_openai_whisper_speech_to_text is False + assert cfg.openai_whisper_model == DEFAULT_OPENAI_WHISPER_MODEL + assert cfg.openai_whisper_timeout_seconds == DEFAULT_OPENAI_WHISPER_TIMEOUT_SECONDS assert cfg.locale == "en" assert cfg.default_agent_provider == "codex" assert cfg.log_dir.name == "logs" @@ -148,6 +156,32 @@ def test_load_config_secret_scrub_filter_can_be_disabled(monkeypatch, tmp_path): assert cfg.enable_secret_scrub_filter is False +def test_load_config_whisper_speech_to_text_can_be_enabled(monkeypatch, tmp_path): + _isolate_env(monkeypatch, tmp_path) + monkeypatch.setenv("WORKSPACE_ROOT", "~/git") + monkeypatch.setenv("TELEGRAM_BOT_TOKENS", "token-a") + monkeypatch.setenv("ALLOWED_CHAT_IDS", "123") + monkeypatch.setenv("ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT", "true") + + cfg = load_config() + + assert cfg.enable_openai_whisper_speech_to_text is True + + +def test_load_config_whisper_model_and_timeout_override(monkeypatch, tmp_path): + _isolate_env(monkeypatch, tmp_path) + monkeypatch.setenv("WORKSPACE_ROOT", "~/git") + monkeypatch.setenv("TELEGRAM_BOT_TOKENS", "token-a") + monkeypatch.setenv("ALLOWED_CHAT_IDS", "123") + monkeypatch.setenv("OPENAI_WHISPER_MODEL", "turbo") + monkeypatch.setenv("OPENAI_WHISPER_TIMEOUT_SECONDS", "300") + + cfg = load_config() + + assert cfg.openai_whisper_model == "turbo" + assert cfg.openai_whisper_timeout_seconds == 300 + + def test_load_config_locale_override(monkeypatch, tmp_path): _isolate_env(monkeypatch, tmp_path) monkeypatch.setenv("WORKSPACE_ROOT", "~/git") diff --git a/tests/test_speech_to_text.py b/tests/test_speech_to_text.py new file mode 100644 index 0000000..e405d82 --- /dev/null +++ b/tests/test_speech_to_text.py @@ -0,0 +1,105 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from coding_agent_telegram.config import AppConfig +from coding_agent_telegram.speech_to_text import SpeechToTextError, WhisperSpeechToText + + +def _cfg(tmp_path: Path, *, model: str = "base", timeout: int = 120) -> AppConfig: + return AppConfig( + workspace_root=tmp_path, + state_file=tmp_path / "state.json", + state_backup_file=tmp_path / "state.json.bak", + log_level="INFO", + log_dir=tmp_path / "logs", + telegram_bot_tokens=("token",), + allowed_chat_ids={123}, + codex_bin="codex", + copilot_bin="copilot", + codex_model="", + copilot_model="", + copilot_autopilot=True, + copilot_no_ask_user=True, + copilot_allow_all=True, + copilot_allow_all_tools=False, + copilot_allow_tools=(), + copilot_deny_tools=(), + copilot_available_tools=(), + codex_approval_policy="never", + codex_sandbox_mode="workspace-write", + codex_skip_git_repo_check=False, + enable_commit_command=False, + snapshot_text_file_max_bytes=200000, + max_telegram_message_length=3000, + enable_sensitive_diff_filter=True, + enable_secret_scrub_filter=True, + enable_openai_whisper_speech_to_text=True, + openai_whisper_model=model, + openai_whisper_timeout_seconds=timeout, + default_agent_provider="codex", + agent_hard_timeout_seconds=0, + app_internal_root=tmp_path / ".coding-agent-telegram", + locale="en", + ) + + +def test_model_cache_path_maps_turbo_alias(tmp_path): + transcriber = WhisperSpeechToText(_cfg(tmp_path, model="turbo")) + + assert transcriber._model_cache_path().name == "large-v3-turbo.pt" + + +def test_transcribe_file_returns_text(monkeypatch, tmp_path): + audio_path = tmp_path / "voice.ogg" + audio_path.write_bytes(b"voice") + transcriber = WhisperSpeechToText(_cfg(tmp_path)) + + def fake_run(command, **kwargs): + output_dir = Path(command[command.index("--output_dir") + 1]) + (output_dir / "voice.json").write_text(json.dumps({"text": "hello world"}), encoding="utf-8") + return subprocess.CompletedProcess(command, 0, "", "") + + monkeypatch.setattr("coding_agent_telegram.speech_to_text.subprocess.run", fake_run) + + result = transcriber.transcribe_file(audio_path) + + assert result.text == "hello world" + assert result.model == "base" + + +def test_transcribe_file_timeout_marks_likely_first_download(monkeypatch, tmp_path): + audio_path = tmp_path / "voice.ogg" + audio_path.write_bytes(b"voice") + transcriber = WhisperSpeechToText(_cfg(tmp_path, model="turbo", timeout=1)) + monkeypatch.setattr(WhisperSpeechToText, "_likely_first_download", lambda self: True) + + def fake_run(command, **kwargs): + raise subprocess.TimeoutExpired(command, timeout=1) + + monkeypatch.setattr("coding_agent_telegram.speech_to_text.subprocess.run", fake_run) + + with pytest.raises(SpeechToTextError) as exc: + transcriber.transcribe_file(audio_path) + + assert exc.value.code == "timeout" + assert exc.value.likely_first_download is True + + +def test_transcribe_file_includes_process_detail_on_failure(monkeypatch, tmp_path): + audio_path = tmp_path / "voice.ogg" + audio_path.write_bytes(b"voice") + transcriber = WhisperSpeechToText(_cfg(tmp_path)) + + def fake_run(command, **kwargs): + return subprocess.CompletedProcess(command, 1, "stdout note", "stderr note") + + monkeypatch.setattr("coding_agent_telegram.speech_to_text.subprocess.run", fake_run) + + with pytest.raises(SpeechToTextError) as exc: + transcriber.transcribe_file(audio_path) + + assert exc.value.code == "failed" + assert "stderr note" in (exc.value.detail or "") diff --git a/tests/test_stt_setup.py b/tests/test_stt_setup.py new file mode 100644 index 0000000..d2f24cc --- /dev/null +++ b/tests/test_stt_setup.py @@ -0,0 +1,99 @@ +from pathlib import Path + +import pytest + +from coding_agent_telegram import stt_setup + + +def test_detect_stt_prereqs_reports_missing(monkeypatch): + monkeypatch.setattr(stt_setup.shutil, "which", lambda name: None) + monkeypatch.setattr(stt_setup.importlib.util, "find_spec", lambda name: None) + + status = stt_setup.detect_stt_prereqs() + + assert status.ready is False + assert status.missing == ["ffmpeg", "openai-whisper (Python module)"] + + +def test_detect_stt_prereqs_checks_target_python_when_provided(monkeypatch): + monkeypatch.setattr(stt_setup.shutil, "which", lambda name: "/usr/bin/ffmpeg") + monkeypatch.setattr( + stt_setup.subprocess, + "run", + lambda *args, **kwargs: type("Result", (), {"returncode": 0})(), + ) + + status = stt_setup.detect_stt_prereqs(python_bin="/custom/python") + + assert status.ready is True + assert status.whisper_module is True + + +def test_ensure_stt_runtime_or_exit_uses_install_hint(monkeypatch): + monkeypatch.setattr( + stt_setup, + "detect_stt_prereqs", + lambda **kwargs: stt_setup.SttPrereqStatus(ffmpeg=True, whisper_module=False), + ) + + with pytest.raises(SystemExit) as exc: + stt_setup.ensure_stt_runtime_or_exit(True, install_hint="./install-stt.sh") + + assert "./install-stt.sh" in str(exc.value) + assert "openai-whisper" in str(exc.value) + + +def test_set_env_flag_appends_when_missing(tmp_path): + env_path = tmp_path / ".env_coding_agent_telegram" + env_path.write_text("WORKSPACE_ROOT=~/git\n", encoding="utf-8") + + stt_setup._set_env_flag(env_path, True) + + text = env_path.read_text(encoding="utf-8") + assert "ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true" in text + assert "openai-whisper" in text + + +def test_set_env_flag_replaces_existing_value(tmp_path): + env_path = tmp_path / ".env_coding_agent_telegram" + env_path.write_text("ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=false\n", encoding="utf-8") + + stt_setup._set_env_flag(env_path, True) + + text = env_path.read_text(encoding="utf-8") + assert "ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true" in text + assert "OPENAI_WHISPER_MODEL=base" in text + assert "OPENAI_WHISPER_TIMEOUT_SECONDS=120" in text + + +def test_set_env_flag_preserves_user_customised_model(tmp_path): + env_path = tmp_path / ".env_coding_agent_telegram" + env_path.write_text( + "ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true\n" + "OPENAI_WHISPER_MODEL=large-v3-turbo\n" + "OPENAI_WHISPER_TIMEOUT_SECONDS=300\n", + encoding="utf-8", + ) + + stt_setup._set_env_flag(env_path, True) + + text = env_path.read_text(encoding="utf-8") + assert "OPENAI_WHISPER_MODEL=large-v3-turbo" in text + assert "OPENAI_WHISPER_TIMEOUT_SECONDS=300" in text + assert "OPENAI_WHISPER_MODEL=base" not in text + assert "OPENAI_WHISPER_TIMEOUT_SECONDS=120" not in text + + +def test_offer_stt_install_for_new_env_keeps_false_when_declined(monkeypatch, tmp_path): + env_path = tmp_path / ".env_coding_agent_telegram" + env_path.write_text("ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=false\n", encoding="utf-8") + monkeypatch.setattr(stt_setup, "_prompt_yes_no", lambda *args, **kwargs: False) + + result = stt_setup.offer_stt_install_for_new_env( + env_file=str(env_path), + python_bin="python3", + installer_label="coding-agent-telegram-stt-install", + ) + + assert result == 0 + assert "ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=false" in env_path.read_text(encoding="utf-8") diff --git a/tests/test_telegram_sender.py b/tests/test_telegram_sender.py index b42fd14..fd768bf 100644 --- a/tests/test_telegram_sender.py +++ b/tests/test_telegram_sender.py @@ -36,7 +36,7 @@ def test_send_html_text_falls_back_to_plain_text_on_parse_error(): calls = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): calls.append((chat_id, text, parse_mode)) if len(calls) == 1: raise BadRequest("Can't parse entities: can't find end tag corresponding to start tag \"code\"") @@ -54,7 +54,7 @@ def test_send_text_chunks_long_messages(): calls = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): calls.append((chat_id, text, parse_mode)) update = SimpleNamespace(effective_chat=SimpleNamespace(id=123)) @@ -70,7 +70,7 @@ def test_send_html_text_chunks_long_messages_as_plain_text(): calls = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): calls.append((chat_id, text, parse_mode)) update = SimpleNamespace(effective_chat=SimpleNamespace(id=123)) @@ -86,7 +86,7 @@ def test_send_code_block_chunks_long_code_blocks(): calls = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): calls.append((chat_id, text, parse_mode)) update = SimpleNamespace(effective_chat=SimpleNamespace(id=123)) @@ -108,7 +108,7 @@ def test_send_text_does_nothing_when_effective_chat_is_none(): called = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): called.append(text) update = SimpleNamespace(effective_chat=None) @@ -122,7 +122,7 @@ def test_send_html_text_does_nothing_when_effective_chat_is_none(): called = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): called.append(text) update = SimpleNamespace(effective_chat=None) @@ -136,7 +136,7 @@ def test_send_code_block_does_nothing_when_effective_chat_is_none(): called = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): called.append(text) update = SimpleNamespace(effective_chat=None) @@ -156,7 +156,7 @@ def test_send_text_uses_default_length_when_no_bot_data(): calls = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): calls.append(text) update = SimpleNamespace(effective_chat=SimpleNamespace(id=1)) @@ -186,6 +186,14 @@ def test_markdownish_to_html_renders_bold_text(): assert "bold" in result +def test_markdownish_to_html_does_not_double_escape_html_in_bold(): + from coding_agent_telegram.telegram_sender import markdownish_to_html + + result = markdownish_to_html("Use **git add & commit** to stage.") + assert "git add & commit" in result + assert "&amp;" not in result + + # --------------------------------------------------------------------------- # _split_plain_text_chunk edge cases # --------------------------------------------------------------------------- @@ -222,7 +230,7 @@ def test_send_markdown_text_sends_message(): calls = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): calls.append((chat_id, text, parse_mode)) from telegram.constants import ParseMode @@ -262,7 +270,7 @@ def test_send_html_text_reraises_non_parse_bad_request(): from telegram.error import BadRequest class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): raise BadRequest("Message is too long") update = SimpleNamespace(effective_chat=SimpleNamespace(id=1)) @@ -379,7 +387,7 @@ def test_send_code_block_without_language(): calls = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): calls.append(text) update = SimpleNamespace(effective_chat=SimpleNamespace(id=7)) From 914a3be89cef2d8cdb791e72ed7c68ff243d5185 Mon Sep 17 00:00:00 2001 From: DCHA <426225+daocha@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:50:15 +0800 Subject: [PATCH 2/5] Update demo image in README.md (#37) --- README.de.md | 2 +- README.fr.md | 2 +- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- README.nl.md | 2 +- README.th.md | 2 +- README.vi.md | 2 +- README.zh-CN.md | 2 +- README.zh-HK.md | 2 +- README.zh-TW.md | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.de.md b/README.de.md index 2049d43..6c4b8c7 100644 --- a/README.de.md +++ b/README.de.md @@ -62,7 +62,7 @@ - + diff --git a/README.fr.md b/README.fr.md index 9d1b853..e2c87c2 100644 --- a/README.fr.md +++ b/README.fr.md @@ -62,7 +62,7 @@ - + diff --git a/README.ja.md b/README.ja.md index 30b183e..e1c2c86 100644 --- a/README.ja.md +++ b/README.ja.md @@ -62,7 +62,7 @@ - + diff --git a/README.ko.md b/README.ko.md index 5cf2365..8c0aa02 100644 --- a/README.ko.md +++ b/README.ko.md @@ -62,7 +62,7 @@ - + diff --git a/README.md b/README.md index 1a77861..e355714 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ - + diff --git a/README.nl.md b/README.nl.md index 150c1a0..0294598 100644 --- a/README.nl.md +++ b/README.nl.md @@ -62,7 +62,7 @@ - + diff --git a/README.th.md b/README.th.md index 6f70406..ee96a25 100644 --- a/README.th.md +++ b/README.th.md @@ -62,7 +62,7 @@ - + diff --git a/README.vi.md b/README.vi.md index f8324e1..22cbc12 100644 --- a/README.vi.md +++ b/README.vi.md @@ -62,7 +62,7 @@ - + diff --git a/README.zh-CN.md b/README.zh-CN.md index 201eb5e..075ef20 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -62,7 +62,7 @@ - + diff --git a/README.zh-HK.md b/README.zh-HK.md index c0a693d..2ac553e 100644 --- a/README.zh-HK.md +++ b/README.zh-HK.md @@ -62,7 +62,7 @@ - + diff --git a/README.zh-TW.md b/README.zh-TW.md index d78cd0f..af8c7ee 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -62,7 +62,7 @@ - + From dd1609077352473839137b91ecf21ef6406cd85d Mon Sep 17 00:00:00 2001 From: DCHA <426225+daocha@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:32:38 +0800 Subject: [PATCH 3/5] Fix bugs, remove dead code, improve test coverage to 86% (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes: - config.py: wrap int() in try/except for malformed ALLOWED_CHAT_IDS - session_lifecycle_commands.py: guard against None return from _run_with_typing - session_runtime.py: use locale_from_update instead of hardcoded 'en' in store_photo Maintainability: - session_branch_resolution.py: remove duplicate _multi_branch_source_keyboard (was dead code shadowed by ProjectCommandMixin in the MRO) Test coverage (81% → 86%, 457 → 605 tests): - 15 files now at 100%: i18n, session_store, session_common, telegram_sender, session_branch_resolution, session_commands, session_lifecycle_commands, session_status_commands, message_commands, project_commands, queue_processing, native_session_utils, native_codex_sessions, native_copilot_sessions, native_session_types, native_sessions, logging_utils - New test files: test_i18n.py, test_native_session_utils.py, test_native_sessions.py - Extended: test_command_router.py, test_config.py, test_session_store.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/coding_agent_telegram/config.py | 5 +- .../router/session_branch_resolution.py | 41 - .../router/session_lifecycle_commands.py | 3 + src/coding_agent_telegram/session_runtime.py | 6 +- tests/test_command_router.py | 3377 +++++++++++++++++ tests/test_config.py | 15 + tests/test_i18n.py | 137 + tests/test_native_session_utils.py | 217 ++ tests/test_native_sessions.py | 213 ++ tests/test_session_store.py | 76 + 10 files changed, 4045 insertions(+), 45 deletions(-) create mode 100644 tests/test_i18n.py create mode 100644 tests/test_native_session_utils.py create mode 100644 tests/test_native_sessions.py diff --git a/src/coding_agent_telegram/config.py b/src/coding_agent_telegram/config.py index be8eb09..919d25f 100644 --- a/src/coding_agent_telegram/config.py +++ b/src/coding_agent_telegram/config.py @@ -80,7 +80,10 @@ def _parse_allowed_chat_ids() -> set[int]: out: set[int] = set() for item in values: - out.add(int(item)) + try: + out.add(int(item)) + except ValueError: + raise ValueError(f"Invalid chat ID in ALLOWED_CHAT_IDS: {item!r}") from None return out diff --git a/src/coding_agent_telegram/router/session_branch_resolution.py b/src/coding_agent_telegram/router/session_branch_resolution.py index 906ec1f..b8b8660 100644 --- a/src/coding_agent_telegram/router/session_branch_resolution.py +++ b/src/coding_agent_telegram/router/session_branch_resolution.py @@ -26,47 +26,6 @@ def _branch_discrepancy_keyboard(self, update: Update, stored_branch: str, curre ] ) - def _multi_branch_source_keyboard( - self, - *, - new_branch: str, - source_branches: list[str], - project_path, - ) -> InlineKeyboardMarkup | None: - rows: list[list[InlineKeyboardButton]] = [] - seen: set[tuple[str, str]] = set() - for source_branch in source_branches: - if not source_branch: - continue - row: list[InlineKeyboardButton] = [] - if self.git.local_branch_exists(project_path, source_branch): - key = ("local", source_branch) - if key not in seen: - token = self._register_branch_source_token("local", source_branch, new_branch) - row.append( - InlineKeyboardButton( - f"local/{source_branch}", - callback_data=f"branchsource:{token}", - ) - ) - seen.add(key) - if self.git.remote_branch_exists(project_path, source_branch): - key = ("origin", source_branch) - if key not in seen: - token = self._register_branch_source_token("origin", source_branch, new_branch) - row.append( - InlineKeyboardButton( - f"origin/{source_branch}", - callback_data=f"branchsource:{token}", - ) - ) - seen.add(key) - if row: - rows.append(row) - if not rows: - return None - return InlineKeyboardMarkup(rows) - async def _offer_branch_source_fallback( self, query, diff --git a/src/coding_agent_telegram/router/session_lifecycle_commands.py b/src/coding_agent_telegram/router/session_lifecycle_commands.py index 704f65f..a9fc5be 100644 --- a/src/coding_agent_telegram/router/session_lifecycle_commands.py +++ b/src/coding_agent_telegram/router/session_lifecycle_commands.py @@ -129,6 +129,9 @@ async def _create_session_for_context( stall_message=self._t(update, "runtime.replacement_session_stall"), ) + if result is None: + return False + if not result.success or not result.session_id: await send_text(update, context, result.error_message or self._t(update, "lifecycle.failed_create_session")) return False diff --git a/src/coding_agent_telegram/session_runtime.py b/src/coding_agent_telegram/session_runtime.py index bab9478..4dcf2f7 100644 --- a/src/coding_agent_telegram/session_runtime.py +++ b/src/coding_agent_telegram/session_runtime.py @@ -28,7 +28,7 @@ ) from coding_agent_telegram.filters import is_sensitive_path, resolve_project_path from coding_agent_telegram.git_utils import GitWorkspaceManager -from coding_agent_telegram.i18n import translate +from coding_agent_telegram.i18n import locale_from_update, translate from coding_agent_telegram.session_store import SessionStore from coding_agent_telegram.telegram_sender import ( markdownish_to_html, @@ -129,12 +129,12 @@ async def store_photo(self, update: Update, project_folder: str) -> Path: telegram_photo = update.message.photo[-1] declared_size = getattr(telegram_photo, "file_size", None) if isinstance(declared_size, int) and declared_size > self.MAX_PHOTO_BYTES: - raise PhotoAttachmentError("photo_too_large", translate("en", "runtime.photo_too_large")) + raise PhotoAttachmentError("photo_too_large", translate(locale_from_update(update), "runtime.photo_too_large")) telegram_file = await telegram_photo.get_file() content = bytes(await telegram_file.download_as_bytearray()) if len(content) > self.MAX_PHOTO_BYTES: - raise PhotoAttachmentError("photo_too_large", translate("en", "runtime.photo_too_large")) + raise PhotoAttachmentError("photo_too_large", translate(locale_from_update(update), "runtime.photo_too_large")) suffix = Path(telegram_file.file_path or "image.jpg").suffix.lower() or ".jpg" if suffix not in {".jpg", ".jpeg", ".png", ".webp", ".gif"}: suffix = ".jpg" diff --git a/tests/test_command_router.py b/tests/test_command_router.py index 239acaa..87c8b35 100644 --- a/tests/test_command_router.py +++ b/tests/test_command_router.py @@ -5837,3 +5837,3380 @@ async def fake_edit(text, reply_markup=None): assert edited assert "expired" in edited[-1][0].lower() + + +# =========================================================================== +# session_common.py coverage +# =========================================================================== + + +def test_next_available_session_name_appends_suffix_on_collision(tmp_path: Path): + """_next_available_session_name must try suffix -1, -2 … until unique.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "s1", "backend-main-codex", "backend", "codex") + store.create_session("bot-a", 123, "s2", "backend-main-codex-1", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + result = router._next_available_session_name(123, "backend-main-codex") + assert result == "backend-main-codex-2" + + +def test_active_session_matches_current_context_false_when_session_not_dict(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + store.set_pending_action("bot-a", 123, {"active_session_id": "nonexistent"}) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + # active_session_id set but not pointing to a real session + store.set_pending_action("bot-a", 123, None) + import json, portalocker + lock = cfg.state_file.with_suffix(cfg.state_file.suffix + ".lock") + with portalocker.Lock(str(lock), timeout=5): + raw = json.loads(cfg.state_file.read_text()) + key = "bot-a:123" + if key in raw.get("chats", {}): + raw["chats"][key]["active_session_id"] = "ghost-session" + raw["chats"][key].setdefault("sessions", {}) + cfg.state_file.write_text(json.dumps(raw), encoding="utf-8") + + chat_state = store.get_chat_state("bot-a", 123) + result = router._active_session_matches_current_context(chat_state) + assert result is False + + +def test_auto_session_name_uses_timestamp_fallback_when_all_suffixes_taken(tmp_path: Path): + """If base name AND all numbered suffixes are taken, _auto_session_name + should fall back to a timestamp-based name.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + + # Occupy the base name and -1 suffix so the first pass needs a timestamp name + store.create_session("bot-a", 123, "s1", "proj-main-codex", "proj", "codex") + store.create_session("bot-a", 123, "s2", "proj-main-codex-1", "proj", "codex") + + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + # _auto_session_name normally returns base; with base+1 taken it tries timestamp path + name = router._auto_session_name("proj", "main", "codex", 123) + # Should be a unique name (not equal to any existing ones) + existing = {d["name"] for d in store.list_sessions("bot-a", 123).values()} + assert name not in existing + + +# =========================================================================== +# session_status_commands.py coverage +# =========================================================================== + + +def test_abort_command_with_args_sends_usage(tmp_path: Path): + """handle_abort with extra args should send a usage message.""" + (tmp_path / "backend").mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + bot = FakeBot() + update = make_update() + context = SimpleNamespace(args=["extra"], bot=bot) + + asyncio.run(router.handle_abort(update, context)) + + assert bot.messages + assert "usage" in bot.messages[-1][1].lower() or "/abort" in bot.messages[-1][1] + + +def test_abort_command_with_no_project_sends_no_project_message(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + bot = FakeBot() + update = make_update() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_abort(update, context)) + + assert bot.messages + assert "project" in bot.messages[-1][1].lower() + + +def test_abort_command_with_missing_project_folder_sends_error(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "nonexistent-folder") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + bot = FakeBot() + update = make_update() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_abort(update, context)) + + assert bot.messages + # Should mention the missing folder + assert "nonexistent-folder" in bot.messages[-1][1] or "missing" in bot.messages[-1][1].lower() + + +# =========================================================================== +# session_branch_resolution.py coverage +# =========================================================================== + + +def test_branch_discrepancy_callback_no_pending_action_sends_error(tmp_path: Path): + (tmp_path / "backend").mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + edited = [] + query = SimpleNamespace( + data="branchdiscrepancy:stored", + answer=None, + edit_message_text=None, + ) + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + effective_user=SimpleNamespace(language_code="en"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + async def fake_answer(): return None + async def fake_edit(text, reply_markup=None): edited.append(text) + query.answer = fake_answer + query.edit_message_text = fake_edit + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + + assert edited + assert "pending" in edited[-1].lower() or "decision" in edited[-1].lower() + + +def test_branch_discrepancy_callback_wrong_kind_sends_error(tmp_path: Path): + (tmp_path / "backend").mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + # Set pending action with wrong branch_resolution kind + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": {"kind": "switch_source"}, # not "discrepancy" + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + edited = [] + query = SimpleNamespace( + data="branchdiscrepancy:stored", + answer=None, + edit_message_text=None, + ) + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + effective_user=SimpleNamespace(language_code="en"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + async def fake_answer(): return None + async def fake_edit(text, reply_markup=None): edited.append(text) + query.answer = fake_answer + query.edit_message_text = fake_edit + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + + assert edited + + +def test_branch_discrepancy_callback_choose_current_updates_branch(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess1", "my-session", "backend", "codex", branch_name="stored-branch") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "continue", + "branch_resolution": { + "kind": "discrepancy", + "stored_branch": "stored-branch", + "current_branch": "current-branch", + }, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True) + + edited = [] + query = SimpleNamespace( + data="branchdiscrepancy:current", + answer=None, + edit_message_text=None, + ) + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + effective_user=SimpleNamespace(language_code="en"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + async def fake_answer(): return None + async def fake_edit(text, reply_markup=None): edited.append(text) + query.answer = fake_answer + query.edit_message_text = fake_edit + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + + state = store.get_chat_state("bot-a", 123) + assert state.get("current_branch") == "current-branch" + + +def test_branch_discrepancy_callback_stored_unavailable_no_fallback(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess1", "my-session", "backend", "codex", branch_name="ghost-branch") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "continue", + "branch_resolution": { + "kind": "discrepancy", + "stored_branch": "ghost-branch", + "current_branch": "main", + }, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager( + is_git_repo=True, + default_branch="main", + local_branches=[], # ghost-branch not available locally + # no remote either + ) + + edited = [] + query = SimpleNamespace( + data="branchdiscrepancy:stored", + answer=None, + edit_message_text=None, + ) + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + effective_user=SimpleNamespace(language_code="en"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + async def fake_answer(): return None + async def fake_edit(text, reply_markup=None): edited.append((text, reply_markup)) + query.answer = fake_answer + query.edit_message_text = fake_edit + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + + assert edited + + +# =========================================================================== +# session_lifecycle_commands.py: null result when workspace lock is held +# =========================================================================== + + +def test_create_session_returns_false_when_workspace_locked(tmp_path: Path): + """_create_session_for_context must return False (not crash) when + _run_with_typing returns None because the workspace lock is already held.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + store.set_current_provider("bot-a", 123, "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + # Patch _run_with_typing to return None (simulates workspace lock held) + async def _locked(*args, **kwargs): + return None + + router._run_with_typing = _locked + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._create_session_for_context( + update, context, + session_name=None, + use_session_id_as_name=False, + provider="codex", + project_folder="backend", + branch_name="", + project_path=backend, + )) + + assert result is False + + +async def _acquire_lock_helper(): + import asyncio + lock = asyncio.Lock() + await lock.acquire() + return lock + + +# =========================================================================== +# session_branch_resolution.py — _resolve_branch_discrepancy_if_needed paths +# =========================================================================== + + +def test_resolve_discrepancy_clears_action_when_no_active_session(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": {"kind": "discrepancy", "stored_branch": "a", "current_branch": "b"}, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._resolve_branch_discrepancy_if_needed(update, context)) + + assert result is False + assert store.get_chat_state("bot-a", 123).get("pending_action") is None + + +def test_resolve_discrepancy_clears_action_when_session_not_dict(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess1", "s", "backend", "codex") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": {"kind": "discrepancy", "stored_branch": "a", "current_branch": "b"}, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True) + + # Remove the session entry while keeping active_session_id pointing to it + import json, portalocker + lock = cfg.state_file.with_suffix(cfg.state_file.suffix + ".lock") + with portalocker.Lock(str(lock), timeout=5): + raw = json.loads(cfg.state_file.read_text()) + raw["chats"]["bot-a:123"]["sessions"].pop("sess1", None) + cfg.state_file.write_text(json.dumps(raw)) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._resolve_branch_discrepancy_if_needed(update, context)) + + assert result is False + + +def test_resolve_discrepancy_sends_error_when_project_folder_missing(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess1", "s", "gone-folder", "codex") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": {"kind": "discrepancy", "stored_branch": "a", "current_branch": "b"}, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._resolve_branch_discrepancy_if_needed(update, context)) + + assert result is False + assert bot.messages # Error message was sent + + +def test_resolve_discrepancy_prompts_when_kind_is_discrepancy(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess1", "my-session", "backend", "codex") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": { + "kind": "discrepancy", + "stored_branch": "feature-x", + "current_branch": "main", + }, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._resolve_branch_discrepancy_if_needed(update, context)) + + assert result is False + # A prompt message should have been sent + assert bot.messages + + +# =========================================================================== +# session_branch_resolution.py — _multi_branch_source_keyboard paths +# =========================================================================== + + +def test_multi_branch_source_keyboard_returns_none_when_no_branches_available(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True, local_branches=[]) + + result = router._multi_branch_source_keyboard( + new_branch="feature", + source_branches=["nonexistent"], + project_path=backend, + ) + + assert result is None + + +def test_multi_branch_source_keyboard_skips_empty_branch_names(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True, local_branches=["main"]) + + result = router._multi_branch_source_keyboard( + new_branch="feature", + source_branches=["", "main"], + project_path=backend, + ) + + assert result is not None + labels = [btn.text for row in result.inline_keyboard for btn in row] + assert any("main" in lbl for lbl in labels) + + +# =========================================================================== +# session_branch_resolution — _offer_branch_source_fallback +# =========================================================================== + + +def test_offer_branch_source_fallback_shows_keyboard_when_alternatives_exist(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager( + is_git_repo=True, + current_branch="main", + default_branch="main", + local_branches=["main"], + ) + + edited = [] + query = SimpleNamespace( + answer=None, + edit_message_text=None, + ) + + async def fake_edit(text, reply_markup=None): + edited.append((text, reply_markup)) + + query.edit_message_text = fake_edit + + result = asyncio.run(router._offer_branch_source_fallback( + query, + project_folder="backend", + project_path=backend, + source_kind="origin", + source_branch="deleted-branch", + new_branch="feature", + error_message="fatal: not found", + )) + + assert result is True + assert edited + + +def test_offer_branch_source_fallback_returns_false_for_local_source(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True) + + result = asyncio.run(router._offer_branch_source_fallback( + None, + project_folder="backend", + project_path=backend, + source_kind="local", # only origin triggers fallback + source_branch="branch", + new_branch="feature", + error_message="error", + )) + + assert result is False + + +# =========================================================================== +# session_lifecycle_commands.py — _resolve_session_prerequisites paths +# =========================================================================== + + +def test_resolve_session_prerequisites_returns_none_when_provider_unavailable(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_provider("bot-a", 123, "codex") + store.set_current_project_folder("bot-a", 123, "backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + async def unavailable(*a, **kw): + return False + + router._ensure_provider_available = unavailable + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._resolve_session_prerequisites(update, context, pending_action=None)) + assert result is None + + +def test_resolve_session_prerequisites_returns_none_when_no_branch_and_git_repo(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_provider("bot-a", 123, "codex") + store.set_current_project_folder("bot-a", 123, "backend") + # No branch set in state + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True, local_branches=["main"]) + + async def available(*a, **kw): + return True + + router._ensure_provider_available = available + + sent_messages = [] + + async def fake_send_branch_prompt(*a, **kw): + sent_messages.append("branch_prompt") + + router._send_branch_selection_prompt = fake_send_branch_prompt + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._resolve_session_prerequisites(update, context, pending_action=None)) + assert result is None + assert "branch_prompt" in sent_messages + + +# =========================================================================== +# session_lifecycle_commands.py — _create_session_for_context error paths +# =========================================================================== + + +def test_create_session_returns_false_when_agent_reports_failure(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + failed_result = SimpleNamespace(success=False, session_id=None, error_message="agent error") + + async def _failing(*args, **kwargs): + return failed_result + + router._run_with_typing = _failing + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._create_session_for_context( + update, context, + session_name=None, + use_session_id_as_name=False, + provider="codex", + project_folder="backend", + branch_name="main", + project_path=backend, + )) + + assert result is False + assert any("agent error" in m for m in bot.messages) + + +# =========================================================================== +# session_lifecycle_commands.py — _continue_pending_action paths +# =========================================================================== + + +def test_continue_pending_action_clears_empty_user_message(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_provider("bot-a", 123, "codex") + store.set_current_project_folder("bot-a", 123, "backend") + store.set_pending_action("bot-a", 123, {"kind": "message", "user_message": ""}) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + async def available(*a, **kw): + return True + + router._ensure_provider_available = available + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._continue_pending_action(update, context)) + assert result is False + assert store.get_chat_state("bot-a", 123).get("pending_action") is None + + +def test_continue_pending_action_handles_unknown_kind(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_provider("bot-a", 123, "codex") + store.set_current_project_folder("bot-a", 123, "backend") + store.set_pending_action("bot-a", 123, {"kind": "unknown_kind"}) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + async def available(*a, **kw): + return True + + router._ensure_provider_available = available + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._continue_pending_action(update, context)) + assert result is False + assert store.get_chat_state("bot-a", 123).get("pending_action") is None + + +# =========================================================================== +# session_lifecycle_commands.py — _ensure_active_session_ready_for_run paths +# =========================================================================== + + +def test_ensure_active_session_ready_returns_false_when_no_active_session(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._ensure_active_session_ready_for_run(update, context)) + assert result is False + + +def test_ensure_active_session_ready_returns_false_project_folder_missing(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess1", "s", "gone", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._ensure_active_session_ready_for_run(update, context)) + assert result is False + assert bot.messages # error message sent + + +def test_ensure_active_session_ready_returns_true_non_git_repo(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess1", "s", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._ensure_active_session_ready_for_run(update, context)) + assert result is True + + +def test_ensure_active_session_ready_prompts_branch_discrepancy(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess1", "s", "backend", "codex", branch_name="feature-x") + store.set_pending_action("bot-a", 123, {"kind": "message", "user_message": "hi"}) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True, current_branch="main") + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._ensure_active_session_ready_for_run(update, context)) + assert result is False + assert bot.sent_messages # discrepancy prompt sent + + +# =========================================================================== +# session_branch_resolution — handle_branch_discrepancy_callback paths +# =========================================================================== + + +def test_handle_branch_discrepancy_callback_shows_no_pending_when_none(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True) + + edited = [] + + async def fake_answer(): + pass + + async def fake_edit(text, reply_markup=None): + edited.append(text) + + query = SimpleNamespace(answer=fake_answer, edit_message_text=fake_edit, data="branchdiscrepancy:stored") + update = SimpleNamespace(effective_chat=SimpleNamespace(id=123, type="private"), message=None, callback_query=query) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + assert any("pending" in t.lower() or edited for t in edited) + + +def test_handle_branch_discrepancy_callback_shows_wrong_kind_message(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + # Set pending action with wrong kind + store.set_pending_action("bot-a", 123, {"kind": "message", "user_message": "hi"}) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True) + + edited = [] + + async def fake_answer(): + pass + + async def fake_edit(text, reply_markup=None): + edited.append(text) + + query = SimpleNamespace(answer=fake_answer, edit_message_text=fake_edit, data="branchdiscrepancy:stored") + update = SimpleNamespace(effective_chat=SimpleNamespace(id=123, type="private"), message=None, callback_query=query) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + assert edited + + +def test_handle_branch_discrepancy_callback_stored_no_branches_keyboard_none(tmp_path: Path): + """Stored branch chosen but local+remote unavailable and no fallback keyboard.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess1", "s", "backend", "codex", branch_name="feature-x") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": { + "kind": "discrepancy", + "stored_branch": "feature-x", + "current_branch": "main", + }, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + # No branches → keyboard will be None + # No local branches, no default branch → keyboard will be None for source fallback + router.git = FakeGitManager(is_git_repo=True, local_branches=[], current_branch=None, default_branch=None) + + edited = [] + + async def fake_answer(): + pass + + async def fake_edit(text, reply_markup=None): + edited.append((text, reply_markup)) + + query = SimpleNamespace(answer=fake_answer, edit_message_text=fake_edit, data="branchdiscrepancy:stored") + update = SimpleNamespace(effective_chat=SimpleNamespace(id=123, type="private"), message=None, callback_query=query) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + # Should show "no fallback" message (no keyboard available) + assert edited + assert any("no longer available" in t[0].lower() or "no fallback" in t[0].lower() for t in edited) + + +def test_handle_branch_discrepancy_callback_stored_branch_found_locally(tmp_path: Path): + """Stored branch chosen and it exists locally/remotely → show restore method keyboard.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess1", "s", "backend", "codex", branch_name="feature-x") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": { + "kind": "discrepancy", + "stored_branch": "feature-x", + "current_branch": "main", + }, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager( + is_git_repo=True, + local_branches=["feature-x", "main"], + current_branch="main", + default_branch="main", + ) + + edited = [] + + async def fake_answer(): + pass + + async def fake_edit(text, reply_markup=None): + edited.append((text, reply_markup)) + + query = SimpleNamespace(answer=fake_answer, edit_message_text=fake_edit, data="branchdiscrepancy:stored") + update = SimpleNamespace(effective_chat=SimpleNamespace(id=123, type="private"), message=None, callback_query=query) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + # Should show keyboard with restore options + assert edited + assert any(t[1] is not None for t in edited) + + +def test_handle_branch_discrepancy_callback_current_choice(tmp_path: Path): + """Choosing 'current' updates store and continues pending action.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess1", "s", "backend", "codex", branch_name="feature-x") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": { + "kind": "discrepancy", + "stored_branch": "feature-x", + "current_branch": "main", + }, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True, local_branches=["main"], current_branch="main") + + continued = [] + + async def fake_continue(*a, **kw): + continued.append(True) + + router._continue_pending_action = fake_continue + + edited = [] + + async def fake_answer(): + pass + + async def fake_edit(text, reply_markup=None): + edited.append(text) + + query = SimpleNamespace(answer=fake_answer, edit_message_text=fake_edit, data="branchdiscrepancy:current") + update = SimpleNamespace(effective_chat=SimpleNamespace(id=123, type="private"), message=None, callback_query=query) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + assert edited + assert continued + + +def test_handle_branch_discrepancy_callback_no_active_session(tmp_path: Path): + """Callback chosen but no active session → shows no_active_session message.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": { + "kind": "discrepancy", + "stored_branch": "feature-x", + "current_branch": "main", + }, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True) + + edited = [] + + async def fake_answer(): + pass + + async def fake_edit(text, reply_markup=None): + edited.append(text) + + query = SimpleNamespace(answer=fake_answer, edit_message_text=fake_edit, data="branchdiscrepancy:stored") + update = SimpleNamespace(effective_chat=SimpleNamespace(id=123, type="private"), message=None, callback_query=query) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + assert edited + + +def test_handle_branch_discrepancy_callback_project_folder_missing(tmp_path: Path): + """Callback chosen but project folder is gone → shows missing message.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess1", "s", "gone-folder", "codex") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": { + "kind": "discrepancy", + "stored_branch": "feature-x", + "current_branch": "main", + }, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True) + + edited = [] + + async def fake_answer(): + pass + + async def fake_edit(text, reply_markup=None): + edited.append(text) + + query = SimpleNamespace(answer=fake_answer, edit_message_text=fake_edit, data="branchdiscrepancy:stored") + update = SimpleNamespace(effective_chat=SimpleNamespace(id=123, type="private"), message=None, callback_query=query) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + assert edited + + +# =========================================================================== +# session_branch_resolution — _resolve_branch_discrepancy_if_needed early exits +# =========================================================================== + + +def test_resolve_discrepancy_returns_true_when_no_pending_action(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._resolve_branch_discrepancy_if_needed(update, context)) + assert result is True + + +def test_resolve_discrepancy_returns_true_when_no_branch_resolution(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_pending_action("bot-a", 123, {"kind": "message", "user_message": "hi"}) # no branch_resolution key + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._resolve_branch_discrepancy_if_needed(update, context)) + assert result is True + + +def test_resolve_discrepancy_returns_true_when_branch_resolution_not_dict(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_pending_action("bot-a", 123, {"kind": "message", "user_message": "hi", "branch_resolution": "invalid"}) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._resolve_branch_discrepancy_if_needed(update, context)) + assert result is True + + +def test_resolve_discrepancy_returns_true_when_unknown_kind(tmp_path: Path): + """branch_resolution dict with unknown kind → returns True (no action).""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess1", "s", "backend", "codex") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": {"kind": "other"}, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._resolve_branch_discrepancy_if_needed(update, context)) + assert result is True + + +def test_resolve_discrepancy_returns_true_for_empty_stored_or_current_branch(tmp_path: Path): + """branch_resolution discrepancy but with empty stored/current branch → True.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess1", "s", "backend", "codex") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": {"kind": "discrepancy", "stored_branch": "", "current_branch": ""}, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._resolve_branch_discrepancy_if_needed(update, context)) + assert result is True + + +# =========================================================================== +# session_status_commands.py — handle_compact missing lines (75, 77) +# =========================================================================== + + +def test_compact_returns_early_when_no_active_session(tmp_path: Path): + """handle_compact must return early (line 75) when there is no active session.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update(text="/compact") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_compact(update, context)) + + assert any("No active session" in msg[1] for msg in bot.messages) + + +def test_compact_returns_early_when_project_busy(tmp_path: Path): + """handle_compact must return early (line 77) when the project is busy.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_a", "session-a", "backend", "codex", branch_name="main") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True, current_branch="main") + + # Mark project as busy + import asyncio as _asyncio; _lock = _asyncio.Lock(); asyncio.run(_lock.acquire()); router._workspace_locks["backend"] = _lock + + update = make_update(text="/compact") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_compact(update, context)) + + # Message should include "busy" info (project is running) + assert any("busy" in msg[1].lower() or "running" in msg[1].lower() or "currently" in msg[1].lower() for msg in bot.messages) + + +# =========================================================================== +# session_status_commands.py — handle_queue_continue_callback (line 85) +# =========================================================================== + + +def test_queue_continue_callback_returns_early_when_query_data_is_none(tmp_path: Path): + """handle_queue_continue_callback must return silently when query.data is None (line 85).""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + query = SimpleNamespace(data=None, answer=None) + + async def fake_answer(): + return None + + query.answer = fake_answer + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_queue_continue_callback(update, context)) + # Should not crash and not send any message + assert bot.messages == [] + + +# =========================================================================== +# session_status_commands.py — handle_queue_batch_callback (lines 102, 109-110) +# =========================================================================== + + +def test_queue_batch_callback_returns_early_when_query_data_is_none(tmp_path: Path): + """handle_queue_batch_callback must return silently when query.data is None (line 102).""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + query = SimpleNamespace(data=None, answer=None) + + async def fake_answer(): + return None + + query.answer = fake_answer + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_queue_batch_callback(update, context)) + assert bot.messages == [] + + +def test_queue_batch_callback_sends_no_batch_pending_when_no_pending(tmp_path: Path): + """handle_queue_batch_callback must edit message with 'no pending' text when pending is None (lines 109-110).""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + edited = [] + query = SimpleNamespace(data="queuebatch:group", answer=None, edit_message_text=None) + + async def fake_answer(): + return None + + async def fake_edit(text, reply_markup=None): + edited.append(text) + + query.answer = fake_answer + query.edit_message_text = fake_edit + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_queue_batch_callback(update, context)) + assert edited + # Should say no batch pending + assert any("pending" in e.lower() or "batch" in e.lower() for e in edited) + + +# =========================================================================== +# session_lifecycle_commands.py — _resolve_session_prerequisites (line 50) +# =========================================================================== + + +def test_resolve_session_prerequisites_returns_none_when_provider_unavailable(tmp_path: Path): + """_resolve_session_prerequisites must return None (line 50) when provider is not available.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + store.set_current_provider("bot-a", 123, "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + # Provider is selected but NOT available + router._provider_available = lambda provider: False + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=["my-session"], bot=bot) + + asyncio.run(router.handle_new(update, context)) + + # Session should not have been created + assert runner.create_calls == [] + + +# =========================================================================== +# session_lifecycle_commands.py — _resolve_session_prerequisites (lines 70-78) +# =========================================================================== + + +def test_resolve_session_prerequisites_sends_branch_prompt_for_git_repo_without_branch(tmp_path: Path): + """_resolve_session_prerequisites must send branch selection prompt (lines 70-78) + when project is a git repo but no branch is selected.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + store.set_current_provider("bot-a", 123, "codex") + # No current_branch set in state + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True, current_branch="main", local_branches=["main"]) + router._provider_available = lambda provider: True + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=["my-session"], bot=bot) + + asyncio.run(router.handle_new(update, context)) + + assert runner.create_calls == [] + # Should have sent branch selection message + assert any("branch" in msg[1].lower() for msg in bot.messages) + + +# =========================================================================== +# session_lifecycle_commands.py — _create_session_for_context (lines 136-137) +# =========================================================================== + + +class FailingCreateRunner(DummyRunner): + def create_session( + self, + provider, + project_path, + user_message, + *, + skip_git_repo_check=False, + image_paths=(), + on_stall=None, + on_progress=None, + ): + from coding_agent_telegram.agent_runner import AgentRunResult + self.create_calls.append({"provider": provider, "project_path": project_path, "user_message": user_message}) + return AgentRunResult( + session_id=None, + success=False, + assistant_text="", + error_message="Backend unavailable", + raw_events=[], + ) + + +def test_create_session_for_context_returns_false_when_result_failed(tmp_path: Path): + """_create_session_for_context must return False (lines 136-137) when result.success is False.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = FailingCreateRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + store.set_current_provider("bot-a", 123, "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + router._provider_available = lambda provider: True + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=["my-session"], bot=bot) + + asyncio.run(router.handle_new(update, context)) + + assert runner.create_calls + assert any("Backend unavailable" in msg[1] or "failed" in msg[1].lower() for msg in bot.messages) + + +# =========================================================================== +# session_lifecycle_commands.py — _continue_pending_action kind="message" +# with empty user_message (lines 212-214) +# =========================================================================== + + +def test_continue_pending_action_clears_empty_message_and_returns_false(tmp_path: Path): + """_continue_pending_action must clear pending action and return False (lines 212-214) + when kind='message' and user_message is empty.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + store.set_current_provider("bot-a", 123, "codex") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "", # empty message + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + router._provider_available = lambda provider: True + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._continue_pending_action(update, context)) + + assert result is False + # Pending action should be cleared + assert store.get_chat_state("bot-a", 123).get("pending_action") is None + + +# =========================================================================== +# session_lifecycle_commands.py — _continue_pending_action kind="message" +# when _create_session_for_context fails (line 227) +# =========================================================================== + + +def test_continue_pending_action_returns_false_when_session_creation_fails(tmp_path: Path): + """_continue_pending_action must return False (line 227) when session creation fails + and no active session matches current context.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = FailingCreateRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + store.set_current_provider("bot-a", 123, "codex") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "do something", + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + router._provider_available = lambda provider: True + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._continue_pending_action(update, context)) + + assert result is False + + +# =========================================================================== +# session_lifecycle_commands.py — _continue_pending_action unknown kind +# (lines 242-244) +# =========================================================================== + + +def test_continue_pending_action_handles_unknown_kind(tmp_path: Path): + """_continue_pending_action must clear action and return False (lines 242-244) for unknown kinds.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + store.set_current_provider("bot-a", 123, "codex") + store.set_pending_action("bot-a", 123, { + "kind": "unknown_future_kind", + "data": "something", + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + router._provider_available = lambda provider: True + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._continue_pending_action(update, context)) + + assert result is False + assert store.get_chat_state("bot-a", 123).get("pending_action") is None + + +# =========================================================================== +# session_lifecycle_commands.py — _ensure_active_session_ready_for_run +# no active session (line 254), session not dict (line 257) +# =========================================================================== + + +def test_ensure_active_session_ready_returns_false_when_no_active_session(tmp_path: Path): + """_ensure_active_session_ready_for_run must return False (line 254) when no active session.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._ensure_active_session_ready_for_run(update, context)) + assert result is False + + +# =========================================================================== +# session_lifecycle_commands.py — _ensure_active_session_ready_for_run +# project folder missing (lines 262-263) +# =========================================================================== + + +def test_ensure_active_session_ready_returns_false_when_project_missing(tmp_path: Path): + """_ensure_active_session_ready_for_run must send error and return False (lines 262-263) + when project folder does not exist.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + # Create session with non-existent project folder + store.create_session("bot-a", 123, "sess_a", "session-a", "nonexistent", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._ensure_active_session_ready_for_run(update, context)) + assert result is False + assert any("missing" in msg[1].lower() or "not found" in msg[1].lower() or "nonexistent" in msg[1] for msg in bot.messages) + + +# =========================================================================== +# session_lifecycle_commands.py — _ensure_active_session_ready_for_run +# pending_action is None (line 274) +# =========================================================================== + + +def test_ensure_active_session_ready_returns_true_when_pending_action_is_none(tmp_path: Path): + """_ensure_active_session_ready_for_run must return True (line 274) when + there is a branch discrepancy but no pending action.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_a", "session-a", "backend", "codex", branch_name="feature") + # No pending action + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True, current_branch="main", local_branches=["main", "feature"]) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._ensure_active_session_ready_for_run(update, context)) + assert result is True + + +# =========================================================================== +# session_lifecycle_commands.py — _ensure_active_session_ready_for_run +# branch_resolution is discrepancy (line 277) +# =========================================================================== + + +def test_ensure_active_session_ready_calls_resolve_discrepancy_when_branch_resolution_is_set(tmp_path: Path): + """_ensure_active_session_ready_for_run must call _resolve_branch_discrepancy_if_needed + (line 277) when pending action has branch_resolution with kind=discrepancy.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_a", "session-a", "backend", "codex", branch_name="feature") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hello", + "branch_resolution": { + "kind": "discrepancy", + "stored_branch": "feature", + "current_branch": "main", + }, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True, current_branch="main", local_branches=["main", "feature"]) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + # Should invoke _resolve_branch_discrepancy_if_needed which will prompt for resolution + result = asyncio.run(router._ensure_active_session_ready_for_run(update, context)) + # Resolution prompts and returns False (branch discrepancy not yet resolved) + assert result is False + + +# =========================================================================== +# session_lifecycle_commands.py — handle_new busy (line 300) +# =========================================================================== + + +def test_handle_new_returns_early_when_project_busy(tmp_path: Path): + """handle_new must return early (line 300) when the current project is busy.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + store.set_current_provider("bot-a", 123, "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router._provider_available = lambda provider: True + + # Mark project as busy + import asyncio as _asyncio; _lock = _asyncio.Lock(); asyncio.run(_lock.acquire()); router._workspace_locks["backend"] = _lock + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=["my-session"], bot=bot) + + asyncio.run(router.handle_new(update, context)) + + assert runner.create_calls == [] + assert any("busy" in msg[1].lower() or "running" in msg[1].lower() or "currently" in msg[1].lower() for msg in bot.messages) + + +# =========================================================================== +# session_branch_resolution.py — _multi_branch_source_keyboard (via ProjectCommandMixin) +# =========================================================================== + + +def test_session_branch_resolution_multi_keyboard_skips_empty_source_branches(tmp_path: Path): + """_multi_branch_source_keyboard skips empty/None source branches.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True, local_branches=["main"], default_branch="main") + + result = router._multi_branch_source_keyboard( + new_branch="feature", + source_branches=["", "main"], + project_path=backend, + ) + + assert result is not None + + +# =========================================================================== +# session_branch_resolution.py — _offer_branch_source_fallback (line 92) +# =========================================================================== + + +def test_offer_branch_source_fallback_returns_false_when_keyboard_is_none(tmp_path: Path): + """_offer_branch_source_fallback must return False (line 92) when the keyboard is None.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + # No branches exist so _multi_branch_source_keyboard returns None + router.git = FakeGitManager(is_git_repo=True, current_branch="", default_branch="", local_branches=[]) + + result = asyncio.run(router._offer_branch_source_fallback( + None, + project_folder="backend", + project_path=backend, + source_kind="origin", + source_branch="deleted-branch", + new_branch="feature", + error_message="fatal: not found", + )) + + assert result is False + + +# =========================================================================== +# session_branch_resolution.py — _offer_branch_source_fallback (line 104) +# =========================================================================== + + +def test_offer_branch_source_fallback_adds_current_branch_line_when_different_from_default(tmp_path: Path): + """_offer_branch_source_fallback must append current_branch info (line 104) + when current_branch differs from default_branch.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + # current_branch != default_branch + router.git = FakeGitManager( + is_git_repo=True, + current_branch="develop", + default_branch="main", + local_branches=["main", "develop"], + ) + + edited = [] + + async def fake_edit(text, reply_markup=None): + edited.append(text) + + query = SimpleNamespace(edit_message_text=fake_edit) + + result = asyncio.run(router._offer_branch_source_fallback( + query, + project_folder="backend", + project_path=backend, + source_kind="origin", + source_branch="deleted-branch", + new_branch="feature", + error_message="fatal: not found", + )) + + assert result is True + assert edited + assert "develop" in edited[-1] + + +# =========================================================================== +# session_branch_resolution.py — _resolve_branch_discrepancy_if_needed +# missing paths (lines 143, 147, 173, 184) +# =========================================================================== + + +def test_resolve_branch_discrepancy_returns_true_when_no_pending_action(tmp_path: Path): + """_resolve_branch_discrepancy_if_needed returns True (line 143) when there is no pending action.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._resolve_branch_discrepancy_if_needed(update, context)) + assert result is True + + +def test_resolve_branch_discrepancy_returns_true_when_branch_resolution_not_dict(tmp_path: Path): + """_resolve_branch_discrepancy_if_needed returns True (line 147) when + branch_resolution is not a dict.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + store.set_pending_action("bot-a", 123, {"kind": "message", "user_message": "hi"}) + # No branch_resolution key → branch_resolution is None, not a dict + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._resolve_branch_discrepancy_if_needed(update, context)) + assert result is True + + +def test_resolve_branch_discrepancy_returns_true_when_discrepancy_branches_empty(tmp_path: Path): + """_resolve_branch_discrepancy_if_needed returns True (line 173) when + stored_branch or current_branch is empty in a discrepancy resolution.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_a", "session-a", "backend", "codex") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": { + "kind": "discrepancy", + "stored_branch": "", # empty + "current_branch": "main", + }, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._resolve_branch_discrepancy_if_needed(update, context)) + assert result is True + + +def test_resolve_branch_discrepancy_returns_true_when_kind_not_discrepancy(tmp_path: Path): + """_resolve_branch_discrepancy_if_needed returns True (line 184) when + branch_resolution kind is not 'discrepancy'.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_a", "session-a", "backend", "codex") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": { + "kind": "switch_source", # not "discrepancy" + "new_branch": "feature", + }, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._resolve_branch_discrepancy_if_needed(update, context)) + assert result is True + + +# =========================================================================== +# session_branch_resolution.py — handle_branch_discrepancy_callback +# missing paths (lines 190, 195, 211-212, 217-220, 244-247, 271-278) +# =========================================================================== + + +def test_branch_discrepancy_callback_returns_when_query_data_is_none(tmp_path: Path): + """handle_branch_discrepancy_callback returns silently (line 190) when query.data is None.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + query = SimpleNamespace(data=None, answer=None) + + async def fake_answer(): + return None + + query.answer = fake_answer + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + assert bot.messages == [] + + +def test_branch_discrepancy_callback_returns_on_invalid_choice(tmp_path: Path): + """handle_branch_discrepancy_callback returns silently (line 195) for invalid choices.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + query = SimpleNamespace(data="branchdiscrepancy:invalid", answer=None) + + async def fake_answer(): + return None + + query.answer = fake_answer + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + assert bot.messages == [] + + +def test_branch_discrepancy_callback_sends_no_pending_when_no_pending_action(tmp_path: Path): + """handle_branch_discrepancy_callback sends 'no pending' message when pending_action is None.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + edited = [] + query = SimpleNamespace(data="branchdiscrepancy:stored", answer=None, edit_message_text=None) + + async def fake_answer(): + return None + + async def fake_edit(text, reply_markup=None): + edited.append(text) + + query.answer = fake_answer + query.edit_message_text = fake_edit + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + assert edited + assert any("pending" in e.lower() for e in edited) + + +def test_branch_discrepancy_callback_sends_no_pending_discrepancy_for_wrong_kind(tmp_path: Path): + """handle_branch_discrepancy_callback sends 'no pending discrepancy' when + branch_resolution kind is not 'discrepancy'.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": {"kind": "switch_source"}, # not discrepancy + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + edited = [] + query = SimpleNamespace(data="branchdiscrepancy:stored", answer=None, edit_message_text=None) + + async def fake_answer(): + return None + + async def fake_edit(text, reply_markup=None): + edited.append(text) + + query.answer = fake_answer + query.edit_message_text = fake_edit + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + assert edited + assert any("discrepancy" in e.lower() or "pending" in e.lower() for e in edited) + + +def test_branch_discrepancy_callback_sends_no_session_when_session_missing(tmp_path: Path): + """handle_branch_discrepancy_callback sends 'no active session' (lines 211-212) + when active session is not found.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + # Set pending action with discrepancy but NO active session + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": { + "kind": "discrepancy", + "stored_branch": "feature", + "current_branch": "main", + }, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + edited = [] + query = SimpleNamespace(data="branchdiscrepancy:stored", answer=None, edit_message_text=None) + + async def fake_answer(): + return None + + async def fake_edit(text, reply_markup=None): + edited.append(text) + + query.answer = fake_answer + query.edit_message_text = fake_edit + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + assert edited + assert any("session" in e.lower() for e in edited) + + +def test_branch_discrepancy_callback_sends_error_when_project_missing(tmp_path: Path): + """handle_branch_discrepancy_callback edits message (lines 217-220) when + project folder does not exist.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_a", "session-a", "nonexistent-project", "codex", branch_name="feature") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": { + "kind": "discrepancy", + "stored_branch": "feature", + "current_branch": "main", + }, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + edited = [] + query = SimpleNamespace(data="branchdiscrepancy:stored", answer=None, edit_message_text=None) + + async def fake_answer(): + return None + + async def fake_edit(text, reply_markup=None): + edited.append(text) + + query.answer = fake_answer + query.edit_message_text = fake_edit + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + assert edited + assert any("missing" in e.lower() or "nonexistent" in e for e in edited) + + +def test_branch_discrepancy_callback_stored_unavailable_no_fallback(tmp_path: Path): + """handle_branch_discrepancy_callback sends 'no fallback' message (lines 244-247) + when stored branch is unavailable and there are no fallback sources.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_a", "session-a", "backend", "codex", branch_name="missing-branch") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": { + "kind": "discrepancy", + "stored_branch": "missing-branch", + "current_branch": "main", + }, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + # No local or remote branches at all (so no fallback either) + router.git = FakeGitManager( + is_git_repo=True, + current_branch="main", + default_branch="", + local_branches=[], + ) + + edited = [] + query = SimpleNamespace(data="branchdiscrepancy:stored", answer=None, edit_message_text=None) + + async def fake_answer(): + return None + + async def fake_edit(text, reply_markup=None): + edited.append((text, reply_markup)) + + query.answer = fake_answer + query.edit_message_text = fake_edit + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + assert edited + # Should show "stored branch unavailable, no fallback" message + assert any("unavailable" in e[0].lower() or "missing-branch" in e[0] for e in edited) + + +def test_branch_discrepancy_callback_stored_available_offers_restore_choice(tmp_path: Path): + """handle_branch_discrepancy_callback offers a restore choice (lines 271-278) + when stored branch exists locally or remotely.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_a", "session-a", "backend", "codex", branch_name="feature") + store.set_pending_action("bot-a", 123, { + "kind": "message", + "user_message": "hi", + "branch_resolution": { + "kind": "discrepancy", + "stored_branch": "feature", + "current_branch": "main", + }, + }) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + # "feature" exists locally + router.git = FakeGitManager( + is_git_repo=True, + current_branch="main", + default_branch="main", + local_branches=["main", "feature"], + ) + + edited = [] + query = SimpleNamespace(data="branchdiscrepancy:stored", answer=None, edit_message_text=None) + + async def fake_answer(): + return None + + async def fake_edit(text, reply_markup=None): + edited.append((text, reply_markup)) + + query.answer = fake_answer + query.edit_message_text = fake_edit + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch_discrepancy_callback(update, context)) + assert edited + # Should offer restore method with keyboard + assert edited[-1][1] is not None # has keyboard + + +def test_ensure_active_session_ready_returns_false_when_session_not_dict(tmp_path: Path): + """_ensure_active_session_ready_for_run returns False when session data is not a dict (line 257).""" + import json, portalocker + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess1", "s", "backend", "codex") + # Delete the session entry from the state file but leave active_session_id pointing to it + lock = cfg.state_file.with_suffix(cfg.state_file.suffix + ".lock") + with portalocker.Lock(str(lock), timeout=5): + raw = json.loads(cfg.state_file.read_text()) + raw["chats"]["bot-a:123"]["sessions"].pop("sess1", None) + cfg.state_file.write_text(json.dumps(raw)) + + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + result = asyncio.run(router._ensure_active_session_ready_for_run(update, context)) + assert result is False + + +# =========================================================================== +# queue_processing.py — uncovered utility paths +# =========================================================================== + + +def test_decode_queue_body_returns_raw_when_no_prefix(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + assert router._decode_queue_body("plain text") == "plain text" + + +def test_preview_queued_message_truncates_at_3_chars(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + result = router._preview_queued_message("hello world", max_chars=3) + assert result == "hel" + + +def test_preview_queued_message_appends_ellipsis_for_longer_truncation(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + result = router._preview_queued_message("a " * 60, max_chars=10) + assert result.endswith("...") + assert len(result) <= 10 + + +def test_append_question_to_queue_file_appends_newline_when_file_nonempty(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + qf = tmp_path / "q.txt" + router._append_question_to_queue_file(qf, "first message") + router._append_question_to_queue_file(qf, "second message") + questions = router._read_queue_questions(qf) + assert len(questions) == 2 + assert questions[0].text == "first message" + assert questions[1].text == "second message" + + +def test_dequeue_chat_message_file_returns_empty_when_file_empty(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + from collections import deque + + qf = tmp_path / "empty.txt" + qf.write_text("", encoding="utf-8") # empty file → no questions + router._chat_message_queue_files[123] = deque([qf]) + + file, questions = router._dequeue_chat_message_file(123) + assert file is None + assert questions == [] + + +def test_next_queue_file_path_starts_at_zero_for_new_chat(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + path = router._next_queue_file_path(999) + assert "queue-0" in path.name + + +def test_prompt_continue_queued_questions_early_exit_no_send_message(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + context = SimpleNamespace(bot=SimpleNamespace()) # no send_message attr + asyncio.run(router._prompt_continue_queued_questions(123, context)) # should not raise + + +def test_prompt_queue_batch_decision_early_exit_no_send_message(tmp_path: Path): + from coding_agent_telegram.router.queue_processing import QueuedQuestion + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + context = SimpleNamespace(bot=SimpleNamespace()) # no send_message attr + msgs = [QueuedQuestion(text="q1"), QueuedQuestion(text="q2")] + asyncio.run(router._prompt_queue_batch_decision(123, context, msgs)) # should not raise + + +def test_clear_chat_message_queue_removes_processing_and_pending(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + from collections import deque + + qf1 = tmp_path / "q1.txt" + qf2 = tmp_path / "q2.txt" + qf3 = tmp_path / "q3.txt" + for f in [qf1, qf2, qf3]: + f.write_text("", encoding="utf-8") + + router._chat_message_queue_files[123] = deque([qf1]) + router._chat_processing_queue_files[123] = qf2 + router._chat_pending_queue_decisions[123] = (qf3, []) + + router._clear_chat_message_queue(123) + + assert 123 not in router._chat_message_queue_files + assert 123 not in router._chat_processing_queue_files + assert 123 not in router._chat_pending_queue_decisions + + +def test_drain_queue_stops_when_project_busy(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + import asyncio as _asyncio + _lock = _asyncio.Lock() + asyncio.run(_lock.acquire()) + router._workspace_locks["backend"] = _lock + + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + asyncio.run(router._drain_chat_message_queue(123, context)) # should return immediately + + +def test_drain_queue_stops_when_pending_action_present(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_pending_action("bot-a", 123, {"kind": "message", "user_message": "hi"}) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + asyncio.run(router._drain_chat_message_queue(123, context)) # should return immediately + + +def test_drain_queue_stops_when_pending_queue_decision_present(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + qf = tmp_path / "q.txt" + qf.write_text("", encoding="utf-8") + router._chat_pending_queue_decisions[123] = (qf, []) + + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + asyncio.run(router._drain_chat_message_queue(123, context)) # should return immediately + + +def test_drain_queue_prompts_continue_when_last_result_aborted(tmp_path: Path): + from coding_agent_telegram.router.queue_processing import QueuedQuestion + from collections import deque + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + qf = tmp_path / "q.txt" + router._append_question_to_queue_file(qf, "waiting question") + router._chat_message_queue_files[123] = deque([qf]) + router._last_run_results[123] = SimpleNamespace(error_code="agent_aborted") + + sent = [] + + async def fake_send_message(**kwargs): + sent.append(kwargs) + + bot = SimpleNamespace(send_message=fake_send_message) + context = SimpleNamespace(args=[], bot=bot) + asyncio.run(router._drain_chat_message_queue(123, context)) + assert sent # _prompt_continue_queued_questions was called + + +def test_drain_queue_skips_nested_call(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router._chat_message_queue_draining.add(123) + + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + asyncio.run(router._drain_chat_message_queue(123, context)) # should return immediately without error + + +# =========================================================================== +# message_commands.py — _handle_audio_like missing lines +# =========================================================================== + + +def test_handle_audio_like_returns_early_when_message_is_none(tmp_path: Path): + """_handle_audio_like must return early (line 103) when update.message is None.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router._handle_audio_like(update, context, None, media_kind="voice")) + assert bot.messages == [] + + +def test_handle_audio_like_sends_disabled_message_when_stt_disabled(tmp_path: Path): + """_handle_audio_like must send STT disabled message (lines 110-111) when STT is off.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.speech_to_text.enabled = False + + fake_media = SimpleNamespace(file_unique_id="uid", file_size=100, file_name=None) + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace(text=None, photo=None, caption=None, voice=None, audio=fake_media), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router._handle_audio_like(update, context, fake_media, media_kind="voice")) + assert bot.messages + assert any("not enabled" in msg[1].lower() or "voice" in msg[1].lower() for msg in bot.messages) + + +def test_handle_audio_like_rejects_too_large_downloaded_content(tmp_path: Path): + """_handle_audio_like must reject content (lines 149-158) when downloaded bytes exceed limit.""" + import os + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_audio", "audio-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.speech_to_text.enabled = True + + # Content is large (over 20MB) but declared_size is explicitly set to 0 + # so the early check won't trigger; the post-download check will + from coding_agent_telegram.router.message_commands import MAX_STT_AUDIO_BYTES + large_content = b"x" * (MAX_STT_AUDIO_BYTES + 1) + fake_telegram_file = FakeTelegramFile(large_content, "voice.ogg") + fake_media = FakeVoiceMessage(fake_telegram_file, file_size=0) # 0 → early check skipped + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace(text=None, photo=None, caption=None, voice=fake_media, audio=None), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_voice(update, context)) + assert any("too large" in msg[1].lower() or "maximum" in msg[1].lower() for msg in bot.messages) + + +def test_handle_audio_like_sends_timeout_message_on_stt_timeout(tmp_path: Path): + """_handle_audio_like must send timeout message (line 182) on STT timeout error.""" + from coding_agent_telegram.speech_to_text import SpeechToTextError + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_stt", "stt-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.speech_to_text.enabled = True + router.speech_to_text.transcribe_file = lambda _path: (_ for _ in ()).throw( + SpeechToTextError(code="timeout", detail="timed out", likely_first_download=False) + ) + + fake_content = b"audio-data" + fake_telegram_file = FakeTelegramFile(fake_content, "voice.ogg") + fake_voice = FakeVoiceMessage(fake_telegram_file) + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace(text=None, photo=None, caption=None, voice=fake_voice, audio=None), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_voice(update, context)) + assert any("timed out" in msg[1].lower() or "timeout" in msg[1].lower() or "conversion timed out" in msg[1].lower() for msg in bot.messages) + + +def test_handle_audio_like_adds_download_note_on_first_download(tmp_path: Path): + """_handle_audio_like must add download note (line 186) when likely_first_download is True.""" + from coding_agent_telegram.speech_to_text import SpeechToTextError + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_stt", "stt-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.speech_to_text.enabled = True + router.speech_to_text.transcribe_file = lambda _path: (_ for _ in ()).throw( + SpeechToTextError(code="other", detail="failed", likely_first_download=True) + ) + + fake_content = b"audio-data" + fake_telegram_file = FakeTelegramFile(fake_content, "voice.ogg") + fake_voice = FakeVoiceMessage(fake_telegram_file) + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace(text=None, photo=None, caption=None, voice=fake_voice, audio=None), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_voice(update, context)) + assert any("download" in msg[1].lower() or "initial" in msg[1].lower() or "model" in msg[1].lower() for msg in bot.messages) + + +def test_handle_audio_like_sends_generic_error_on_unexpected_exception(tmp_path: Path): + """_handle_audio_like must send generic error (lines 189-196) on unexpected exception.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_stt", "stt-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.speech_to_text.enabled = True + router.speech_to_text.transcribe_file = lambda _path: (_ for _ in ()).throw( + RuntimeError("unexpected failure") + ) + + fake_content = b"audio-data" + fake_telegram_file = FakeTelegramFile(fake_content, "voice.ogg") + fake_voice = FakeVoiceMessage(fake_telegram_file) + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace(text=None, photo=None, caption=None, voice=fake_voice, audio=None), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_voice(update, context)) + assert any("failed" in msg[1].lower() or "error" in msg[1].lower() for msg in bot.messages) + + +def test_handle_audio_like_returns_early_when_result_is_none(tmp_path: Path): + """_handle_audio_like must return early (line 201) when result is None (workspace locked).""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_stt", "stt-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.speech_to_text.enabled = True + # Return None to simulate workspace lock + router.speech_to_text.transcribe_file = lambda _path: None + + fake_content = b"audio-data" + fake_telegram_file = FakeTelegramFile(fake_content, "voice.ogg") + fake_voice = FakeVoiceMessage(fake_telegram_file) + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace(text=None, photo=None, caption=None, voice=fake_voice, audio=None), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_voice(update, context)) + # Should not have any "transcript" messages - returned early + assert not any("transcript" in msg[1].lower() for msg in bot.messages) + + +def test_handle_voice_returns_early_when_no_voice_message(tmp_path: Path): + """handle_voice must return silently (line 250) when message has no voice.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace(text=None, photo=None, caption=None, voice=None, audio=None), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_voice(update, context)) + assert bot.messages == [] + + +def test_handle_audio_returns_early_when_no_audio_message(tmp_path: Path): + """handle_audio must return silently (line 256) when message has no audio.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace(text=None, photo=None, caption=None, voice=None, audio=None), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_audio(update, context)) + assert bot.messages == [] + + +# =========================================================================== +# project_commands.py — _prompt_for_branch_source keyboard None (lines 109-120) +# =========================================================================== + + +def test_branch_command_reports_missing_source_when_no_branches_exist(tmp_path: Path): + """_prompt_for_branch_source must send 'source missing' error (lines 109-120) + when no source branches are found and source_branches is provided.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager( + is_git_repo=True, + current_branch="main", + default_branch="", + local_branches=[], + ) + + update = make_update(text="/branch feature") + bot = FakeBot() + # New branch that doesn't exist, and no branches available + context = SimpleNamespace(args=["feature"], bot=bot) + + asyncio.run(router.handle_branch(update, context)) + + assert any("source" in msg[1].lower() or "branch" in msg[1].lower() for msg in bot.messages) + + +# =========================================================================== +# project_commands.py — branch source missing for single source (lines 126-136) +# =========================================================================== + + +def test_branch_command_reports_source_missing_for_nonexistent_origin_branch(tmp_path: Path): + """_prompt_for_branch_source must send 'source missing' error (lines 126-136) + when source_branch doesn't exist locally or remotely.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager( + is_git_repo=True, + current_branch="main", + default_branch="main", + local_branches=["main"], + ) + + update = make_update(text="/branch nonexistent feature") + bot = FakeBot() + # 2 args: source_branch=nonexistent, new_branch=feature + context = SimpleNamespace(args=["nonexistent", "feature"], bot=bot) + + asyncio.run(router.handle_branch(update, context)) + + assert any("source" in msg[1].lower() or "missing" in msg[1].lower() for msg in bot.messages) + + +# =========================================================================== +# project_commands.py — refresh_result.success False (lines 185-186) +# =========================================================================== + + +def test_branch_command_reports_refresh_failure(tmp_path: Path): + """_send_branch_selection_prompt must send error message (lines 185-186) + when refresh_current_branch returns a failed result.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True, current_branch="main", local_branches=["main"]) + router.git.refresh_result = SimpleNamespace(success=False, message="Could not fetch") + + update = make_update(text="/branch") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch(update, context)) + + assert any("Could not fetch" in msg[1] or "fetch" in msg[1].lower() for msg in bot.messages) + + +# =========================================================================== +# project_commands.py — handle_project busy (line 231) +# =========================================================================== + + +def test_project_command_returns_early_when_project_busy(tmp_path: Path): + """handle_project must return early (line 231) when project is busy.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + runner.has_running_process = lambda _project_path: True + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=["backend"], bot=bot) + + asyncio.run(router.handle_project(update, context)) + + assert any("busy" in msg[1].lower() or "running" in msg[1].lower() or "currently" in msg[1].lower() for msg in bot.messages) + + +# =========================================================================== +# project_commands.py — trust callback invalid decision (lines 361-362) +# =========================================================================== + + +def test_trust_callback_rejects_invalid_decision(tmp_path: Path): + """handle_trust_project_callback must send error (lines 361-362) + when decision is not 'yes' or 'no'.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + edited = [] + query = SimpleNamespace(data="trustproject:maybe:backend", answer=None, edit_message_text=None) + + async def fake_answer(): + return None + + async def fake_edit(text, reply_markup=None): + edited.append(text) + + query.answer = fake_answer + query.edit_message_text = fake_edit + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_trust_project_callback(update, context)) + assert edited + assert any("invalid" in e.lower() for e in edited) + + +# =========================================================================== +# project_commands.py — trust callback project missing (lines 367-368) +# =========================================================================== + + +def test_trust_callback_sends_error_when_project_missing(tmp_path: Path): + """handle_trust_project_callback must send error (lines 367-368) when project folder missing.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + edited = [] + query = SimpleNamespace(data="trustproject:yes:nonexistent-proj", answer=None, edit_message_text=None) + + async def fake_answer(): + return None + + async def fake_edit(text, reply_markup=None): + edited.append(text) + + query.answer = fake_answer + query.edit_message_text = fake_edit + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_trust_project_callback(update, context)) + assert edited + assert any("missing" in e.lower() or "nonexistent" in e for e in edited) + + +# =========================================================================== +# project_commands.py — handle_branch busy (line 384) +# =========================================================================== + + +def test_branch_command_returns_early_when_project_busy(tmp_path: Path): + """handle_branch must return early (line 384) when project is busy.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + runner.has_running_process = lambda _project_path: True + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update(text="/branch feature") + bot = FakeBot() + context = SimpleNamespace(args=["feature"], bot=bot) + + asyncio.run(router.handle_branch(update, context)) + + assert any("busy" in msg[1].lower() or "running" in msg[1].lower() or "currently" in msg[1].lower() for msg in bot.messages) + + +# =========================================================================== +# project_commands.py — handle_branch with 2 args (lines 415-417) +# =========================================================================== + + +def test_branch_command_with_two_args_shows_keyboard(tmp_path: Path): + """handle_branch with 2 args sets source_branch and new_branch directly (lines 415-417).""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager( + is_git_repo=True, + current_branch="main", + default_branch="main", + local_branches=["main"], + ) + + update = make_update(text="/branch main feature") + bot = FakeBot() + context = SimpleNamespace(args=["main", "feature"], bot=bot) + + asyncio.run(router.handle_branch(update, context)) + + # Should show branch source keyboard or message + assert bot.messages or bot.sent_messages + + +# =========================================================================== +# project_commands.py — default_branch_unknown (lines 429-430) +# =========================================================================== + + +def test_branch_command_reports_unknown_default_branch(tmp_path: Path): + """handle_branch must send 'default branch unknown' message (lines 429-430) + when new branch doesn't exist and no current/default branch is available.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager( + is_git_repo=True, + current_branch=None, # no current branch + default_branch=None, # no default branch + local_branches=[], + ) + + update = make_update(text="/branch new-feature") + bot = FakeBot() + context = SimpleNamespace(args=["new-feature"], bot=bot) + + asyncio.run(router.handle_branch(update, context)) + + assert any("default branch" in msg[1].lower() or "unknown" in msg[1].lower() for msg in bot.messages) + + +# =========================================================================== +# project_commands.py — handle_branch_source_callback no query (line 449) +# =========================================================================== + + +def test_branch_source_callback_returns_when_query_is_none(tmp_path: Path): + """handle_branch_source_callback must return silently (line 449) when query is None.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=None, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch_source_callback(update, context)) + assert bot.messages == [] + + +# =========================================================================== +# project_commands.py — handle_branch_source_callback no project (lines 463-464) +# =========================================================================== + + +def test_branch_source_callback_sends_error_when_no_project_selected(tmp_path: Path): + """handle_branch_source_callback sends 'no project selected' (lines 463-464) + when no project is currently selected.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True, local_branches=["main"]) + # Register a valid token + token = router._register_branch_source_token("local", "main", "feature") + + edited = [] + query = SimpleNamespace(data=f"branchsource:{token}", answer=None, edit_message_text=None) + + async def fake_answer(): + return None + + async def fake_edit(text, reply_markup=None): + edited.append(text) + + query.answer = fake_answer + query.edit_message_text = fake_edit + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch_source_callback(update, context)) + assert edited + assert any("project" in e.lower() for e in edited) + + +# =========================================================================== +# project_commands.py — handle_branch_source_callback project missing (lines 468-471) +# =========================================================================== + + +def test_branch_source_callback_sends_error_when_project_folder_missing(tmp_path: Path): + """handle_branch_source_callback sends project missing error (lines 468-471) + when the project folder doesn't exist.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "nonexistent-project") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True, local_branches=["main"]) + # Register a valid token + token = router._register_branch_source_token("local", "main", "feature") + + edited = [] + query = SimpleNamespace(data=f"branchsource:{token}", answer=None, edit_message_text=None) + + async def fake_answer(): + return None + + async def fake_edit(text, reply_markup=None): + edited.append(text) + + query.answer = fake_answer + query.edit_message_text = fake_edit + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_branch_source_callback(update, context)) + assert edited + assert any("missing" in e.lower() or "nonexistent" in e for e in edited) + + +# =========================================================================== +# queue_processing.py — various edge case paths +# =========================================================================== + + +def test_decode_queue_body_returns_raw_when_no_base64_prefix(tmp_path: Path): + """_decode_queue_body must return body unchanged (line 58) when no base64 prefix.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + result = router._decode_queue_body("raw text without prefix") + assert result == "raw text without prefix" + + +def test_preview_queued_message_truncates_at_three_chars(tmp_path: Path): + """_preview_queued_message handles max_chars <= 3 edge case (lines 167-169).""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + result = router._preview_queued_message("hello world", max_chars=2) + assert result == "he" + + +def test_next_queue_file_path_returns_index_zero_on_fresh_state(tmp_path: Path): + """_next_queue_file_path starts at index 0 (line 43) when neither queue exists.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + path = router._next_queue_file_path(123) + assert "-queue-0.txt" in path.name + + +def test_clear_chat_message_queue_removes_processing_and_pending_files(tmp_path: Path): + """_clear_chat_message_queue must unlink processing and pending files (lines 249-254).""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + # Create actual queue files in the expected location + queue_dir = router._queue_dir(123) + queue_dir.mkdir(parents=True, exist_ok=True) + + processing_file = queue_dir / "session-processing.txt" + processing_file.write_text("[Question 1]\nhello\n[End Question 1]\n") + router._chat_processing_queue_files[123] = processing_file + + pending_file = queue_dir / "session-pending.txt" + pending_file.write_text("[Question 1]\nhello\n[End Question 1]\n") + from types import SimpleNamespace as SN + router._chat_pending_queue_decisions[123] = (pending_file, []) + + router._clear_chat_message_queue(123) + + # Files should be gone or not tracked + assert 123 not in router._chat_processing_queue_files + assert 123 not in router._chat_pending_queue_decisions + + +def test_prompt_continue_queued_questions_skips_when_no_send_message(tmp_path: Path): + """_prompt_continue_queued_questions returns early (line 189) when bot lacks send_message.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + context = SimpleNamespace(bot=SimpleNamespace()) # no send_message attribute + + asyncio.run(router._prompt_continue_queued_questions(123, context)) + # Should not raise + + +def test_prompt_queue_batch_decision_skips_when_no_send_message(tmp_path: Path): + """_prompt_queue_batch_decision returns early (line 211) when bot lacks send_message.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + from coding_agent_telegram.router.queue_processing import QueuedQuestion + context = SimpleNamespace(bot=SimpleNamespace()) # no send_message attribute + + asyncio.run(router._prompt_queue_batch_decision( + 123, + context, + [QueuedQuestion(text="hello", reply_to_message_id=None)], + )) + # Should not raise + + +# =========================================================================== +# project_commands.py — project with active session in different project (line 292) +# =========================================================================== + + +def test_project_command_includes_active_session_info_when_project_changes(tmp_path: Path): + """handle_project must extend intro_lines (line 292) with active session details + when switching to a different project while an active session is in another project.""" + backend1 = tmp_path / "backend1" + backend1.mkdir() + backend2 = tmp_path / "backend2" + backend2.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + # Active session is for backend1 + store.create_session("bot-a", 123, "sess_a", "session-a", "backend1", "codex") + store.set_current_project_folder("bot-a", 123, "backend1") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True, current_branch="main", local_branches=["main"]) + + # Switch to backend2 (different project) + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=["backend2"], bot=bot) + + asyncio.run(router.handle_project(update, context)) + + # Should show branch selection prompt (git repo + switched project) + assert bot.messages or bot.sent_messages + + +# =========================================================================== +# project_commands.py — project with branch_name set (non-git or same project) (line 307) +# =========================================================================== + + +def test_project_command_shows_confirmation_when_branch_is_set(tmp_path: Path): + """handle_project must send confirmation (line 307) when branch_name is set + (non-git-repo or same project, branch already selected).""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + # Set up: current project is same, branch is set in state + store.set_current_project_folder("bot-a", 123, "backend") + store.set_current_branch("bot-a", 123, "main") + store.trust_project("backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + # Non-git repo with branch_name from state + router.git = FakeGitManager(is_git_repo=False, current_branch="main") + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=["backend"], bot=bot) + + asyncio.run(router.handle_project(update, context)) + + # Should show project confirmation with branch info + assert bot.messages or bot.sent_messages + assert any("project" in msg[1].lower() or "branch" in msg[1].lower() for msg in bot.messages + [(0, m, 0, 0) for m in []]) + + +# =========================================================================== +# project_commands.py — trust callback query is None (line 352) +# =========================================================================== + + +def test_trust_project_callback_returns_when_query_is_none(tmp_path: Path): + """handle_trust_project_callback must return silently (line 352) when query is None.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=None, + message=None, + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_trust_project_callback(update, context)) + assert bot.messages == [] + + +# =========================================================================== +# queue_processing.py — remaining edge cases (lines 72, 307-311, 339-341, 366, 377) +# =========================================================================== + + +def test_read_queue_questions_skips_empty_body(tmp_path: Path): + """_read_queue_questions must skip questions with empty body (line 72).""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + queue_dir = router._queue_dir(123) + queue_dir.mkdir(parents=True, exist_ok=True) + queue_file = queue_dir / "test-queue-0.txt" + # Write a question with empty body (blank line between headers) + queue_file.write_text( + "[Question 1]\n\n[End Question 1]\n", + encoding="utf-8", + ) + + result = router._read_queue_questions(queue_file) + assert result == [] + + +def test_drain_chat_message_queue_skips_when_already_draining(tmp_path: Path): + """_drain_chat_message_queue must return immediately (lines 321-322) when already draining.""" + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + # Mark chat as draining + router._chat_message_queue_draining.add(123) + + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router._drain_chat_message_queue(123, context)) + # Should return immediately without doing anything + assert bot.messages == [] + + # Clean up + router._chat_message_queue_draining.discard(123) + + +# =========================================================================== +# project_commands.py — handle_project confirmation with branch (line 307) +# =========================================================================== + + +def test_project_command_shows_html_confirmation_for_same_git_project_with_branch(tmp_path: Path): + """handle_project sends HTML confirmation (line 307) when selecting the same + git repo project where a branch is already detected.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + # Same project already selected, with trust so no trust prompt + store.set_current_project_folder("bot-a", 123, "backend") + store.trust_project("backend") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True, current_branch="main", default_branch="main", local_branches=["main"]) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=["backend"], bot=bot) + + asyncio.run(router.handle_project(update, context)) + + # Should show HTML project confirmation message (not branch selection) + assert bot.messages or bot.sent_messages + + +# =========================================================================== +# queue_processing.py — dispatch and drain uncovered edge cases +# =========================================================================== + + +def test_dispatch_queued_questions_returns_false_when_continue_fails(tmp_path: Path): + """Lines 307-311: _dispatch_queued_questions returns False when _continue_pending_action fails.""" + from coding_agent_telegram.router.queue_processing import QueuedQuestion + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + qf = tmp_path / "q.txt" + qf.write_text("", encoding="utf-8") + + async def always_false(*a, **kw): + return False + + router._continue_pending_action = always_false + + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + result = asyncio.run(router._dispatch_queued_questions( + 123, + context, + queue_file=qf, + queued_messages=[QueuedQuestion(text="hi")], + grouped=False, + )) + assert result is False + # queue_file should be put back at front of queue + assert 123 in router._chat_message_queue_files + + +def test_drain_queue_prompts_continue_with_processing_file_cleanup(tmp_path: Path): + """Lines 339-341: when aborted + processing_file exists, it is cleaned up.""" + from collections import deque + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + qf_pending = tmp_path / "pending.txt" + router._append_question_to_queue_file(qf_pending, "queued question") + router._chat_message_queue_files[123] = deque([qf_pending]) + + processing_f = tmp_path / "processing.txt" + processing_f.write_text("", encoding="utf-8") + router._chat_processing_queue_files[123] = processing_f + router._last_run_results[123] = SimpleNamespace(error_code="agent_aborted") + + sent = [] + + async def fake_send_message(**kwargs): + sent.append(kwargs) + + bot = SimpleNamespace(send_message=fake_send_message) + context = SimpleNamespace(args=[], bot=bot) + asyncio.run(router._drain_chat_message_queue(123, context)) + + assert sent # prompt was sent + assert 123 not in router._chat_processing_queue_files # cleaned up + + +def test_drain_queue_stops_dispatch_returns_false_single_message(tmp_path: Path): + """Line 366: drain stops when single-message dispatch returns False.""" + from collections import deque + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + qf = tmp_path / "q.txt" + router._append_question_to_queue_file(qf, "queued question") + router._chat_message_queue_files[123] = deque([qf]) + + async def always_false(*a, **kw): + return False + + router._dispatch_queued_questions = always_false + + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + asyncio.run(router._drain_chat_message_queue(123, context)) # should return without error + + +def test_drain_queue_stops_dispatch_returns_false_batch_single_mode(tmp_path: Path): + """Line 377: drain stops when batch_mode='single' dispatch returns False.""" + from collections import deque + from coding_agent_telegram.router.queue_processing import QueuedQuestion + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + qf = tmp_path / "q.txt" + router._append_question_to_queue_file(qf, "question 1") + router._append_question_to_queue_file(qf, "question 2") + router._chat_message_queue_files[123] = deque([qf]) + router._chat_queue_batch_modes[123] = "single" # forces single dispatch path + + async def always_false(*a, **kw): + return False + + router._dispatch_queued_questions = always_false + + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + asyncio.run(router._drain_chat_message_queue(123, context)) # should return without error diff --git a/tests/test_config.py b/tests/test_config.py index 7d31aa5..e513618 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -422,3 +422,18 @@ def test_create_initial_env_file_initializes_app_locale_from_system_language(tmp assert app_locale == "ja" assert "APP_LOCALE=ja" in env_path.read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# _parse_allowed_chat_ids: malformed value raises clear ValueError +# --------------------------------------------------------------------------- + + +def test_load_config_invalid_chat_id_raises_clear_error(monkeypatch, tmp_path): + _isolate_env(monkeypatch, tmp_path) + monkeypatch.setenv("WORKSPACE_ROOT", "~/git") + monkeypatch.setenv("TELEGRAM_BOT_TOKENS", "token-a") + monkeypatch.setenv("ALLOWED_CHAT_IDS", "123,abc,456") + + with pytest.raises(ValueError, match="Invalid chat ID in ALLOWED_CHAT_IDS"): + load_config() diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 0000000..b497907 --- /dev/null +++ b/tests/test_i18n.py @@ -0,0 +1,137 @@ +"""Tests for coding_agent_telegram.i18n covering all locale-normalisation paths.""" +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from coding_agent_telegram.i18n import ( + DEFAULT_LOCALE, + _load_locale_catalog, + locale_from_update, + normalize_locale, + translate, +) + + +# --------------------------------------------------------------------------- +# normalize_locale +# --------------------------------------------------------------------------- + + +def test_normalize_locale_returns_default_for_none(): + assert normalize_locale(None) == DEFAULT_LOCALE # line 17 + + +def test_normalize_locale_returns_default_for_empty_string(): + assert normalize_locale("") == DEFAULT_LOCALE # line 17 + + +def test_normalize_locale_zh_hk(): + assert normalize_locale("zh-HK") == "zh-HK" # line 21 + assert normalize_locale("zh_HK") == "zh-HK" + + +def test_normalize_locale_zh_mo(): + assert normalize_locale("zh-MO") == "zh-HK" # line 21 + + +def test_normalize_locale_zh_tw(): + assert normalize_locale("zh-TW") == "zh-TW" # line 23 + assert normalize_locale("zh_TW") == "zh-TW" + + +def test_normalize_locale_zh_hant(): + assert normalize_locale("zh-Hant") == "zh-TW" # line 23 + + +def test_normalize_locale_zh_cn(): + assert normalize_locale("zh-CN") == "zh-CN" # line 25 + assert normalize_locale("zh") == "zh-CN" # line 25 + + +def test_normalize_locale_supported_base_code(): + assert normalize_locale("ja") == "ja" + assert normalize_locale("de") == "de" + assert normalize_locale("ko") == "ko" + + +def test_normalize_locale_unsupported_falls_back_to_default(): + assert normalize_locale("es") == DEFAULT_LOCALE + assert normalize_locale("pt-BR") == DEFAULT_LOCALE + + +# --------------------------------------------------------------------------- +# locale_from_update +# --------------------------------------------------------------------------- + + +def test_locale_from_update_extracts_language_code(): # lines 31-33 + update = SimpleNamespace(effective_user=SimpleNamespace(language_code="ja")) + assert locale_from_update(update) == "ja" + + +def test_locale_from_update_returns_default_when_no_effective_user(): # line 34 + update = SimpleNamespace(effective_user=None) + assert locale_from_update(update) == DEFAULT_LOCALE + + +def test_locale_from_update_returns_default_when_language_code_missing(): + update = SimpleNamespace(effective_user=SimpleNamespace(language_code=None)) + assert locale_from_update(update) == DEFAULT_LOCALE + + +def test_locale_from_update_handles_object_with_no_effective_user_attr(): + update = SimpleNamespace() # no effective_user attribute + assert locale_from_update(update) == DEFAULT_LOCALE + + +# --------------------------------------------------------------------------- +# _load_locale_catalog error paths +# --------------------------------------------------------------------------- + + +def test_load_locale_catalog_returns_empty_dict_for_file_not_found(): # lines 42-43 + # Use a locale code that has no JSON file — should hit the FileNotFoundError branch + _load_locale_catalog.cache_clear() + try: + result = _load_locale_catalog("zz-nonexistent-locale") + finally: + _load_locale_catalog.cache_clear() + assert result == {} + + +def test_load_locale_catalog_returns_empty_dict_for_json_decode_error(): # lines 44-45 + import json + _load_locale_catalog.cache_clear() + try: + # Patch json.loads to simulate corrupt JSON + with patch("coding_agent_telegram.i18n.json.loads", side_effect=json.JSONDecodeError("bad", "", 0)): + result = _load_locale_catalog.__wrapped__("en") + finally: + _load_locale_catalog.cache_clear() + assert result == {} + + +# --------------------------------------------------------------------------- +# translate: fallback to DEFAULT_LOCALE when key missing in non-en locale +# --------------------------------------------------------------------------- + + +def test_translate_falls_back_to_english_when_key_missing_in_locale(): # line 53 + # "common.no_project_selected" should exist in en but not in a fake locale + result = translate("ja", "common.no_project_selected") + # Should return the English string, not the key itself + assert result != "common.no_project_selected" + assert "project" in result.lower() or "Project" in result + + +def test_translate_returns_key_when_missing_in_both_locales(): + result = translate("ja", "this.key.does.not.exist.anywhere.xyz") + assert result == "this.key.does.not.exist.anywhere.xyz" + + +def test_translate_formats_kwargs(): + result = translate("en", "common.no_project_selected") + assert result # just check it renders without error diff --git a/tests/test_native_session_utils.py b/tests/test_native_session_utils.py new file mode 100644 index 0000000..510cbf0 --- /dev/null +++ b/tests/test_native_session_utils.py @@ -0,0 +1,217 @@ +"""Tests for native_session_utils.py — pure utility functions.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from coding_agent_telegram.native_session_utils import ( + first_copilot_user_message, + iso_from_unix, + normalize_init_text, + path_matches_project, + read_simple_yaml_map, +) + + +# --------------------------------------------------------------------------- +# iso_from_unix +# --------------------------------------------------------------------------- + + +def test_iso_from_unix_returns_empty_for_none(): + assert iso_from_unix(None) == "" + + +def test_iso_from_unix_returns_empty_for_zero(): + assert iso_from_unix(0) == "" + + +def test_iso_from_unix_returns_iso_string(): + result = iso_from_unix(0.001) # small positive value + assert result.endswith("Z") + assert "T" in result + + +def test_iso_from_unix_normal_timestamp(): + result = iso_from_unix(1700000000) + assert result.endswith("Z") + + +# --------------------------------------------------------------------------- +# normalize_init_text +# --------------------------------------------------------------------------- + + +def test_normalize_init_text_returns_fallback_for_empty(): + assert normalize_init_text("", fallback="default") == "default" + + +def test_normalize_init_text_returns_fallback_for_whitespace_only(): + assert normalize_init_text(" ", fallback="fb") == "fb" + + +def test_normalize_init_text_truncates_long_text(): + long_text = "word " * 30 # ~150 chars + result = normalize_init_text(long_text, fallback="fb") + assert len(result) <= 120 + assert result.endswith("...") + + +def test_normalize_init_text_preserves_normal_text(): + assert normalize_init_text("hello world", fallback="fb") == "hello world" + + +def test_normalize_init_text_collapses_whitespace(): + assert normalize_init_text(" hello world ", fallback="fb") == "hello world" + + +# --------------------------------------------------------------------------- +# path_matches_project +# --------------------------------------------------------------------------- + + +def test_path_matches_project_returns_false_for_empty_candidate(tmp_path: Path): + assert path_matches_project("", tmp_path) is False + + +def test_path_matches_project_returns_true_for_exact_match(tmp_path: Path): + assert path_matches_project(str(tmp_path), tmp_path) is True + + +def test_path_matches_project_returns_true_for_child_path(tmp_path: Path): + child = tmp_path / "subdir" / "file.py" + assert path_matches_project(str(child), tmp_path) is True + + +def test_path_matches_project_returns_false_for_unrelated_path(tmp_path: Path): + other = tmp_path.parent / "other" + assert path_matches_project(str(other), tmp_path) is False + + +# --------------------------------------------------------------------------- +# first_copilot_user_message +# --------------------------------------------------------------------------- + + +def test_first_copilot_user_message_returns_empty_when_file_missing(tmp_path: Path): + assert first_copilot_user_message(tmp_path / "nonexistent.jsonl") == "" + + +def test_first_copilot_user_message_returns_first_content(tmp_path: Path): + events = tmp_path / "events.jsonl" + events.write_text( + json.dumps({"type": "user.message", "data": {"content": "hello"}}) + "\n", + encoding="utf-8", + ) + assert first_copilot_user_message(events) == "hello" + + +def test_first_copilot_user_message_skips_non_user_message_lines(tmp_path: Path): + events = tmp_path / "events.jsonl" + events.write_text( + json.dumps({"type": "system.init", "data": {}}) + "\n" + + json.dumps({"type": "user.message", "data": {"content": "second"}}) + "\n", + encoding="utf-8", + ) + assert first_copilot_user_message(events) == "second" + + +def test_first_copilot_user_message_skips_malformed_json(tmp_path: Path): + events = tmp_path / "events.jsonl" + events.write_text( + "not valid json\n" + + json.dumps({"type": "user.message", "data": {"content": "valid"}}) + "\n", + encoding="utf-8", + ) + assert first_copilot_user_message(events) == "valid" + + +def test_first_copilot_user_message_returns_empty_when_no_user_message(tmp_path: Path): + events = tmp_path / "events.jsonl" + events.write_text( + json.dumps({"type": "system.init", "data": {}}) + "\n", + encoding="utf-8", + ) + assert first_copilot_user_message(events) == "" + + +def test_first_copilot_user_message_returns_empty_for_empty_content(tmp_path: Path): + events = tmp_path / "events.jsonl" + events.write_text( + json.dumps({"type": "user.message", "data": {"content": ""}}) + "\n" + + json.dumps({"type": "user.message", "data": {"content": "second"}}) + "\n", + encoding="utf-8", + ) + assert first_copilot_user_message(events) == "second" + + +# --------------------------------------------------------------------------- +# read_simple_yaml_map +# --------------------------------------------------------------------------- + + +def test_read_simple_yaml_map_returns_empty_for_missing_file(tmp_path: Path): + assert read_simple_yaml_map(tmp_path / "nope.yaml") == {} + + +def test_read_simple_yaml_map_parses_key_value_pairs(tmp_path: Path): + f = tmp_path / "config.yaml" + f.write_text("key1: value1\nkey2: value2\n", encoding="utf-8") + assert read_simple_yaml_map(f) == {"key1": "value1", "key2": "value2"} + + +def test_read_simple_yaml_map_skips_blank_and_comment_lines(tmp_path: Path): + f = tmp_path / "config.yaml" + f.write_text( + "\n# this is a comment\nkey: val\n # indented comment\n", + encoding="utf-8", + ) + assert read_simple_yaml_map(f) == {"key": "val"} + + +def test_read_simple_yaml_map_skips_lines_without_colon(tmp_path: Path): + f = tmp_path / "config.yaml" + f.write_text("no_colon_here\nkey: value\n", encoding="utf-8") + assert read_simple_yaml_map(f) == {"key": "value"} + + +# --------------------------------------------------------------------------- +# OSError paths +# --------------------------------------------------------------------------- + + +def test_path_matches_project_returns_false_on_oserror(tmp_path: Path): + from unittest.mock import patch, MagicMock + + def raising_resolve(self): + raise OSError("mock oserror") + + with patch.object(Path, "resolve", raising_resolve): + result = path_matches_project("/some/path", tmp_path) + assert result is False + + +def test_first_copilot_user_message_returns_empty_on_oserror(tmp_path: Path): + events = tmp_path / "events.jsonl" + events.write_text("some content\n", encoding="utf-8") + + from unittest.mock import patch, mock_open + + with patch("builtins.open", side_effect=OSError("permission denied")): + result = first_copilot_user_message(events) + assert result == "" + + +def test_first_copilot_user_message_returns_empty_on_file_read_oserror(tmp_path: Path): + """Lines 50-51: except OSError path inside first_copilot_user_message.""" + from unittest.mock import patch + + events = tmp_path / "events.jsonl" + events.write_text("some content\n", encoding="utf-8") + + # Patch Path.open to raise OSError (the function uses events_path.open(...)) + with patch.object(Path, "open", side_effect=OSError("permission denied")): + result = first_copilot_user_message(events) + assert result == "" diff --git a/tests/test_native_sessions.py b/tests/test_native_sessions.py new file mode 100644 index 0000000..60d0108 --- /dev/null +++ b/tests/test_native_sessions.py @@ -0,0 +1,213 @@ +"""Tests for native_codex_sessions.py and native_copilot_sessions.py.""" +from __future__ import annotations + +import json +import os +import sqlite3 +from pathlib import Path +from unittest.mock import patch + +import pytest + +from coding_agent_telegram.native_codex_sessions import discover_codex_sessions +from coding_agent_telegram.native_copilot_sessions import ( + copilot_session_label, + copilot_session_roots, + discover_copilot_sessions, +) + + +# =========================================================================== +# native_codex_sessions.py +# =========================================================================== + + +def test_discover_codex_sessions_returns_empty_when_db_missing(tmp_path: Path): + with patch("coding_agent_telegram.native_codex_sessions.Path.home", return_value=tmp_path): + result = discover_codex_sessions(tmp_path / "proj", "proj") + assert result == [] + + +def test_discover_codex_sessions_returns_empty_on_connect_error(tmp_path: Path): + fake_home = tmp_path / "home" + fake_home.mkdir() + db_dir = fake_home / ".codex" + db_dir.mkdir() + db_path = db_dir / "state_5.sqlite" + db_path.write_bytes(b"not a sqlite db") # corrupted → connect error + + with patch("coding_agent_telegram.native_codex_sessions.Path.home", return_value=fake_home): + result = discover_codex_sessions(tmp_path / "proj", "proj") + assert result == [] + + +def test_discover_codex_sessions_returns_empty_on_query_error(tmp_path: Path): + fake_home = tmp_path / "home" + fake_home.mkdir() + db_dir = fake_home / ".codex" + db_dir.mkdir() + db_path = db_dir / "state_5.sqlite" + + # Create a valid db but with no 'threads' table → query will raise sqlite3.Error + conn = sqlite3.connect(str(db_path)) + conn.close() + + with patch("coding_agent_telegram.native_codex_sessions.Path.home", return_value=fake_home): + result = discover_codex_sessions(tmp_path / "proj", "proj") + assert result == [] + + +def test_discover_codex_sessions_filters_non_matching_projects(tmp_path: Path): + fake_home = tmp_path / "home" + fake_home.mkdir() + db_dir = fake_home / ".codex" + db_dir.mkdir() + db_path = db_dir / "state_5.sqlite" + + proj = tmp_path / "myproj" + proj.mkdir() + other = tmp_path / "other" + other.mkdir() + + conn = sqlite3.connect(str(db_path)) + conn.execute(""" + CREATE TABLE threads ( + id TEXT, cwd TEXT, title TEXT, first_user_message TEXT, + git_branch TEXT, created_at REAL, updated_at REAL, archived INTEGER + ) + """) + # Row for a different project — should be filtered out + conn.execute("INSERT INTO threads VALUES (?,?,?,?,?,?,?,?)", + ("sid1", str(other), "title1", "msg1", "main", 1700000000.0, 1700000001.0, 0)) + # Row for the right project + conn.execute("INSERT INTO threads VALUES (?,?,?,?,?,?,?,?)", + ("sid2", str(proj), "title2", "msg2", "feature", 1700000002.0, 1700000003.0, 0)) + conn.commit() + conn.close() + + with patch("coding_agent_telegram.native_codex_sessions.Path.home", return_value=fake_home): + result = discover_codex_sessions(proj, "myproj") + + assert len(result) == 1 + assert result[0].session_id == "sid2" + assert result[0].branch_name == "feature" + + +# =========================================================================== +# native_copilot_sessions.py +# =========================================================================== + + +def test_copilot_session_roots_uses_env_home(tmp_path: Path): + env_home = str(tmp_path / "custom_home") + with patch.dict(os.environ, {"COPILOT_HOME": env_home}): + roots = copilot_session_roots(tmp_path) + assert len(roots) == 1 + assert roots[0] == Path(env_home) + + +def test_copilot_session_roots_uses_default_when_no_env(tmp_path: Path): + env = {k: v for k, v in os.environ.items() if k != "COPILOT_HOME"} + with patch.dict(os.environ, env, clear=True): + roots = copilot_session_roots(tmp_path) + assert len(roots) == 1 + assert roots[0] == Path.home() / ".copilot" + + +def test_copilot_session_label_with_branch(): + result = copilot_session_label({"branch": "main"}, "sid1", "myproj") + assert "main" in result + + +def test_copilot_session_label_without_branch(): + result = copilot_session_label({}, "sid1", "myproj") + assert "myproj" in result + + +def test_discover_copilot_sessions_returns_empty_when_no_session_root(tmp_path: Path): + fake_home = tmp_path / "home" + fake_home.mkdir() + # No session-state directory + + with patch.dict(os.environ, {"COPILOT_HOME": str(fake_home)}): + result = discover_copilot_sessions(tmp_path / "proj", "proj") + assert result == [] + + +def test_discover_copilot_sessions_skips_non_matching_cwd(tmp_path: Path): + fake_home = tmp_path / "home" + session_state = fake_home / "session-state" / "sess1" + session_state.mkdir(parents=True) + + proj = tmp_path / "myproj" + proj.mkdir() + other = tmp_path / "other" + other.mkdir() + + workspace = session_state / "workspace.yaml" + workspace.write_text(f"id: sess1\ncwd: {other}\n", encoding="utf-8") + + with patch.dict(os.environ, {"COPILOT_HOME": str(fake_home)}): + result = discover_copilot_sessions(proj, "myproj") + assert result == [] + + +def test_discover_copilot_sessions_deduplicates_sessions(tmp_path: Path): + fake_home = tmp_path / "home" + proj = tmp_path / "myproj" + proj.mkdir() + + # Two session dirs with the same session id (duplicate) + for i in [1, 2]: + sess_dir = fake_home / "session-state" / f"sess{i}" + sess_dir.mkdir(parents=True) + workspace = sess_dir / "workspace.yaml" + workspace.write_text( + f"id: same-session-id\ncwd: {proj}\nbranch: main\n", + encoding="utf-8", + ) + + with patch.dict(os.environ, {"COPILOT_HOME": str(fake_home)}): + result = discover_copilot_sessions(proj, "myproj") + + assert len(result) == 1 + assert result[0].session_id == "same-session-id" + + +def test_discover_copilot_sessions_returns_matching_session(tmp_path: Path): + fake_home = tmp_path / "home" + proj = tmp_path / "myproj" + proj.mkdir() + + sess_dir = fake_home / "session-state" / "abc123" + sess_dir.mkdir(parents=True) + workspace = sess_dir / "workspace.yaml" + workspace.write_text( + f"id: abc123\ncwd: {proj}\nbranch: feature-x\nsummary: My summary\n", + encoding="utf-8", + ) + + with patch.dict(os.environ, {"COPILOT_HOME": str(fake_home)}): + result = discover_copilot_sessions(proj, "myproj") + + assert len(result) == 1 + assert result[0].session_id == "abc123" + assert result[0].branch_name == "feature-x" + assert "My summary" in result[0].name + + +def test_discover_codex_sessions_returns_empty_on_sqlite_connect_error(tmp_path: Path): + """Lines 16-17: sqlite3.Error on connect → return [].""" + import sqlite3 + from unittest.mock import patch + + fake_home = tmp_path / "home" + db_dir = fake_home / ".codex" + db_dir.mkdir(parents=True) + (db_dir / "state_5.sqlite").write_bytes(b"") # file exists + + with patch("coding_agent_telegram.native_codex_sessions.Path.home", return_value=fake_home): + with patch("coding_agent_telegram.native_codex_sessions.sqlite3.connect", + side_effect=sqlite3.Error("cannot open")): + result = discover_codex_sessions(tmp_path / "proj", "proj") + assert result == [] diff --git a/tests/test_session_store.py b/tests/test_session_store.py index eb9401e..bb33d92 100644 --- a/tests/test_session_store.py +++ b/tests/test_session_store.py @@ -451,3 +451,79 @@ def test_switch_session_sets_current_branch_from_session(tmp_path: Path): store.switch_session("bot1", 1, "ses1") state = store.get_chat_state("bot1", 1) assert state.get("current_branch") == "my-branch" + + +# --------------------------------------------------------------------------- +# set_active_session_branch: no-op when no active session or session missing +# (lines 315, 327) +# --------------------------------------------------------------------------- + + +def test_set_active_session_branch_is_noop_when_no_active_session(tmp_path: Path): + store = SessionStore(tmp_path / "state.json", tmp_path / "state.json.bak") + store.set_current_project_folder("bot1", 1, "proj") + # No active session_id in chat state + store.set_active_session_branch("bot1", 1, "some-branch") + state = store.get_chat_state("bot1", 1) + # current_branch should not have been set + assert state.get("current_branch") is None + + +def test_set_active_session_branch_is_noop_when_session_not_found(tmp_path: Path): + store = SessionStore(tmp_path / "state.json", tmp_path / "state.json.bak") + store.create_session("bot1", 1, "sess1", "Session 1", "proj", "codex") + store.switch_session("bot1", 1, "sess1") + # Manually remove the session entry while keeping active_session_id set + import json, portalocker + lock_path = tmp_path / "state.json.lock" + with portalocker.Lock(str(lock_path), timeout=5): + raw = json.loads((tmp_path / "state.json").read_text()) + raw["chats"]["bot1:1"]["sessions"].pop("sess1", None) + (tmp_path / "state.json").write_text(json.dumps(raw), encoding="utf-8") + + store.set_active_session_branch("bot1", 1, "new-branch") + state = store.get_chat_state("bot1", 1) + # Should not crash and branch should not be updated + assert state.get("current_branch") != "new-branch" + + +# --------------------------------------------------------------------------- +# list_sessions: triggers migration when state uses legacy single-bot key +# (line 315 — _save_unlocked after migrated=True) +# --------------------------------------------------------------------------- + + +def test_list_sessions_migrates_legacy_chat_key_format(tmp_path: Path): + """When the state file uses the old bare-chat-id key format, list_sessions + must migrate it to the new scoped key and persist the migration.""" + import json + + state_path = tmp_path / "state.json" + backup_path = tmp_path / "state.json.bak" + + # Write a state file in the legacy format (bare chat_id key) + legacy_state = { + "chats": { + "123": { + "sessions": { + "s1": { + "name": "old-session", + "project_folder": "proj", + "provider": "codex", + "branch_name": "", + } + } + } + }, + "trusted_projects": [], + } + state_path.write_text(json.dumps(legacy_state), encoding="utf-8") + + store = SessionStore(state_path, backup_path) + sessions = store.list_sessions("bot-a", 123) + + assert "s1" in sessions + # After migration the file should have been rewritten with the scoped key + reloaded = json.loads(state_path.read_text()) + assert "bot-a:123" in reloaded["chats"] + assert "123" not in reloaded["chats"] From 6ca9bab4902651cbb49780ed632374641451b07b Mon Sep 17 00:00:00 2001 From: DCHA <426225+daocha@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:38:16 +0800 Subject: [PATCH 4/5] Correct translation /provider for README th and de (#39) --- README.de.md | 2 +- README.th.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.de.md b/README.de.md index 6c4b8c7..faf1f62 100644 --- a/README.de.md +++ b/README.de.md @@ -209,7 +209,7 @@ Der Bot akzeptiert derzeit: - + diff --git a/README.th.md b/README.th.md index ee96a25..2a2ab53 100644 --- a/README.th.md +++ b/README.th.md @@ -209,7 +209,7 @@ https://api.telegram.org/bot/getUpdates
/Anbieter/provider Provider für neue Sessions wählen. Die Auswahl wird pro Bot und Chat gespeichert, bis du sie änderst.
- + From 47685906939573df3c461b8ed25e3d5b4ebc4307 Mon Sep 17 00:00:00 2001 From: DCHA <426225+daocha@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:38:44 +0800 Subject: [PATCH 5/5] Telegram message enhancements (#40) * Treat plain-text 'Create session:' messages as /new commands * Style Telegram inline buttons by intent * Add testcase for inline buttons changes and creation sessions text changes --------- Co-authored-by: DCHA Agent <259406208+dcha-agent@users.noreply.github.com> --- src/coding_agent_telegram/router/base.py | 6 ++ .../router/git_commands.py | 12 +++- .../router/message_commands.py | 8 +++ .../router/project_commands.py | 12 +++- .../router/queue_processing.py | 18 ++++- .../router/session_lifecycle_commands.py | 14 ++++ .../router/session_provider_commands.py | 12 +++- tests/test_command_router.py | 66 +++++++++++++++++++ 8 files changed, 139 insertions(+), 9 deletions(-) diff --git a/src/coding_agent_telegram/router/base.py b/src/coding_agent_telegram/router/base.py index 68e263d..6bf0760 100644 --- a/src/coding_agent_telegram/router/base.py +++ b/src/coding_agent_telegram/router/base.py @@ -226,6 +226,12 @@ def _chat_locale(self, chat_id: int) -> str: def _t(self, update: Update | None, key: str, **kwargs) -> str: return translate(self._locale(update), key, **kwargs) + def _affirmative_inline_button_kwargs(self) -> dict[str, dict[str, str]]: + return {"api_kwargs": {"style": "primary"}} + + def _negative_inline_button_kwargs(self) -> dict[str, dict[str, str]]: + return {"api_kwargs": {"style": "danger"}} + async def _notify_if_current_project_busy(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool: chat = update.effective_chat if chat is None: diff --git a/src/coding_agent_telegram/router/git_commands.py b/src/coding_agent_telegram/router/git_commands.py index 1fc0bdc..be4a41f 100644 --- a/src/coding_agent_telegram/router/git_commands.py +++ b/src/coding_agent_telegram/router/git_commands.py @@ -94,8 +94,16 @@ async def handle_push(self, update: Update, context: ContextTypes.DEFAULT_TYPE) confirm_markup = InlineKeyboardMarkup( [ [ - InlineKeyboardButton(self._t(update, "git.push_confirm_button"), callback_data="push:confirm"), - InlineKeyboardButton(self._t(update, "git.cancel_button"), callback_data="push:cancel"), + InlineKeyboardButton( + self._t(update, "git.push_confirm_button"), + callback_data="push:confirm", + **self._affirmative_inline_button_kwargs(), + ), + InlineKeyboardButton( + self._t(update, "git.cancel_button"), + callback_data="push:cancel", + **self._negative_inline_button_kwargs(), + ), ] ] ) diff --git a/src/coding_agent_telegram/router/message_commands.py b/src/coding_agent_telegram/router/message_commands.py index 10e95a8..aafad8c 100644 --- a/src/coding_agent_telegram/router/message_commands.py +++ b/src/coding_agent_telegram/router/message_commands.py @@ -3,6 +3,7 @@ import logging import tempfile from pathlib import Path +from types import SimpleNamespace from telegram import Update from telegram.ext import ContextTypes @@ -66,6 +67,13 @@ async def _process_user_message( async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.message is None or not update.message.text: return + is_create_session, session_name = self._parse_create_session_text(update.message.text) + if is_create_session: + await self.handle_new( + update, + SimpleNamespace(args=[session_name] if session_name else [], bot=context.bot), + ) + return await self._process_user_message(update, context, update.message.text) @require_allowed_chat() diff --git a/src/coding_agent_telegram/router/project_commands.py b/src/coding_agent_telegram/router/project_commands.py index fd9a39b..78f37d7 100644 --- a/src/coding_agent_telegram/router/project_commands.py +++ b/src/coding_agent_telegram/router/project_commands.py @@ -331,8 +331,16 @@ async def handle_project(self, update: Update, context: ContextTypes.DEFAULT_TYP keyboard = InlineKeyboardMarkup( [ [ - InlineKeyboardButton(self._t(update, "queue.button_yes"), callback_data=f"trustproject:yes:{folder}"), - InlineKeyboardButton(self._t(update, "queue.button_no"), callback_data=f"trustproject:no:{folder}"), + InlineKeyboardButton( + self._t(update, "queue.button_yes"), + callback_data=f"trustproject:yes:{folder}", + **self._affirmative_inline_button_kwargs(), + ), + InlineKeyboardButton( + self._t(update, "queue.button_no"), + callback_data=f"trustproject:no:{folder}", + **self._negative_inline_button_kwargs(), + ), ] ] ) diff --git a/src/coding_agent_telegram/router/queue_processing.py b/src/coding_agent_telegram/router/queue_processing.py index cd4cc87..2386e7a 100644 --- a/src/coding_agent_telegram/router/queue_processing.py +++ b/src/coding_agent_telegram/router/queue_processing.py @@ -195,8 +195,16 @@ async def _prompt_continue_queued_questions(self, chat_id: int, context: Context text=translate(locale, "queue.continue_prompt"), reply_markup=InlineKeyboardMarkup( [[ - InlineKeyboardButton(translate(locale, "queue.button_yes"), callback_data="queuecontinue:yes"), - InlineKeyboardButton(translate(locale, "queue.button_no"), callback_data="queuecontinue:no"), + InlineKeyboardButton( + translate(locale, "queue.button_yes"), + callback_data="queuecontinue:yes", + **self._affirmative_inline_button_kwargs(), + ), + InlineKeyboardButton( + translate(locale, "queue.button_no"), + callback_data="queuecontinue:no", + **self._negative_inline_button_kwargs(), + ), ]] ), ) @@ -234,7 +242,11 @@ async def _prompt_queue_batch_decision( [[ InlineKeyboardButton(translate(locale, "queue.button_group"), callback_data="queuebatch:group"), InlineKeyboardButton(translate(locale, "queue.button_single"), callback_data="queuebatch:single"), - InlineKeyboardButton(translate(locale, "queue.button_cancel"), callback_data="queuebatch:cancel"), + InlineKeyboardButton( + translate(locale, "queue.button_cancel"), + callback_data="queuebatch:cancel", + **self._negative_inline_button_kwargs(), + ), ]] ), ) diff --git a/src/coding_agent_telegram/router/session_lifecycle_commands.py b/src/coding_agent_telegram/router/session_lifecycle_commands.py index a9fc5be..73275be 100644 --- a/src/coding_agent_telegram/router/session_lifecycle_commands.py +++ b/src/coding_agent_telegram/router/session_lifecycle_commands.py @@ -1,5 +1,7 @@ from __future__ import annotations +import re + from telegram import Update from telegram.ext import ContextTypes @@ -10,6 +12,18 @@ class SessionLifecycleCommandMixin: + _CREATE_SESSION_TEXT_RE = re.compile(r"^\s*create\s+session\s*:\s*(.*?)\s*$", re.IGNORECASE) + + def _parse_create_session_text(self, text: str) -> tuple[bool, str | None]: + match = self._CREATE_SESSION_TEXT_RE.match(text) + if not match: + return False, None + + session_name = match.group(1).strip() or None + if session_name and session_name.lower() == "new session": + session_name = None + return True, session_name + async def _prompt_for_provider_selection( self, update: Update, diff --git a/src/coding_agent_telegram/router/session_provider_commands.py b/src/coding_agent_telegram/router/session_provider_commands.py index 8031030..e7a22e4 100644 --- a/src/coding_agent_telegram/router/session_provider_commands.py +++ b/src/coding_agent_telegram/router/session_provider_commands.py @@ -64,8 +64,16 @@ def button_label(provider: str) -> str: return InlineKeyboardMarkup( [ [ - InlineKeyboardButton(button_label("codex"), callback_data="provider:set:codex"), - InlineKeyboardButton(button_label("copilot"), callback_data="provider:set:copilot"), + InlineKeyboardButton( + button_label("codex"), + callback_data="provider:set:codex", + api_kwargs={"style": "success"}, + ), + InlineKeyboardButton( + button_label("copilot"), + callback_data="provider:set:copilot", + api_kwargs={"style": "success"}, + ), ] ] ) diff --git a/tests/test_command_router.py b/tests/test_command_router.py index 87c8b35..d0f3de9 100644 --- a/tests/test_command_router.py +++ b/tests/test_command_router.py @@ -682,6 +682,11 @@ def test_project_command_warns_when_existing_project_is_untrusted(tmp_path: Path assert bot.messages[-1][1] == "Do you trust this project?\nProject: backend" assert bot.messages[-1][3] is not None + buttons = bot.messages[-1][3].inline_keyboard[0] + assert buttons[0].text == "Yes" + assert buttons[1].text == "No" + assert buttons[0].api_kwargs == {"style": "primary"} + assert buttons[1].api_kwargs == {"style": "danger"} assert store.is_project_trusted("backend") is False @@ -1537,6 +1542,50 @@ def test_new_without_name_ignores_existing_new_session_labels(tmp_path: Path): assert "Session created successfully: sess_abc123" in bot.messages[-1][1] +def test_plain_text_create_session_new_session_uses_unnamed_flow(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + store.set_current_provider("bot-a", 123, "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router._provider_available = lambda provider: True + + update = make_update(text="Create session: new session") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + state = store.get_chat_state("bot-a", 123) + assert state["sessions"]["sess_abc123"]["name"] == "sess_abc123" + assert runner.create_calls[-1]["user_message"] == "Create session: new session" + + +def test_plain_text_create_session_with_name_matches_new_command(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + store.set_current_provider("bot-a", 123, "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router._provider_available = lambda provider: True + + update = make_update(text="Create session: release prep") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + state = store.get_chat_state("bot-a", 123) + assert state["sessions"]["sess_abc123"]["name"] == "release prep" + assert runner.create_calls[-1]["user_message"] == "Create session: release prep" + + def test_provider_command_sends_inline_buttons(tmp_path: Path): runner = DummyRunner() cfg = make_config(tmp_path) @@ -1561,6 +1610,8 @@ def test_provider_command_sends_inline_buttons(tmp_path: Path): assert buttons[1].callback_data == "provider:set:copilot" assert "missing" in buttons[0].text assert "current" in buttons[1].text + assert buttons[0].api_kwargs == {"style": "success"} + assert buttons[1].api_kwargs == {"style": "success"} def test_provider_callback_updates_current_provider(tmp_path: Path): @@ -3680,6 +3731,12 @@ async def exercise(): assert buttons[0].callback_data == "queuebatch:group" assert buttons[1].callback_data == "queuebatch:single" assert buttons[2].callback_data == "queuebatch:cancel" + assert buttons[0].text == "Group the questions" + assert buttons[1].text == "Process one by one" + assert buttons[2].text == "Cancel" + assert buttons[0].api_kwargs == {} + assert buttons[1].api_kwargs == {} + assert buttons[2].api_kwargs == {"style": "danger"} answers = [] edited = [] @@ -3882,6 +3939,10 @@ async def exercise(): buttons = keyboard.inline_keyboard[0] assert buttons[0].callback_data == "queuecontinue:yes" assert buttons[1].callback_data == "queuecontinue:no" + assert buttons[0].text == "Yes" + assert buttons[1].text == "No" + assert buttons[0].api_kwargs == {"style": "primary"} + assert buttons[1].api_kwargs == {"style": "danger"} asyncio.run(exercise()) @@ -4469,6 +4530,11 @@ def test_push_uses_current_session_branch(tmp_path: Path): assert bot.messages[-1][1] == "Push branch `feature-1` to `origin`?" assert bot.messages[-1][2] == "Markdown" assert bot.messages[-1][3] is not None + buttons = bot.messages[-1][3].inline_keyboard[0] + assert buttons[0].text == "Confirm push" + assert buttons[1].text == "Cancel" + assert buttons[0].api_kwargs == {"style": "primary"} + assert buttons[1].api_kwargs == {"style": "danger"} def test_push_confirmation_executes_push(tmp_path: Path):
/ผู้ให้บริการ/provider เลือกผู้ให้บริการสำหรับเซสชันใหม่ โดยค่าที่เลือกจะถูกเก็บแยกตาม bot และ chat จนกว่าคุณจะเปลี่ยน