tai.py (8643 lines) is now a thin facade over tai_pkg/ (33 feature modules, one class or feature per file). tai-orig.py preserves the pre-split single-file version byte for byte (sha256 verified against snapshot); import tai and ./tai.py behavior unchanged. Equivalence proof, all run on this tree immediately pre-commit: differential battery tai-orig.py vs facade 3718 checks 0 failures (surface, pure fuzz, tool dispatch, store/audit, release ops, fake-HOME install roundtrip, CLI runs); test_tai.py 261 OK; test_seal.py 16 OK; --version/--help byte-identical; dir(tai) zero removals. Fix included: opencode wire-protocol schema echo leaked into replies; opencode_parse now scans with JSONDecoder.raw_decode capped at 200 scans (13 OpencodeParseTests). Test count 261 = 237 HEAD + 8 pre-existing uncommitted ToolArgumentsTests (now committed with this change, disclosed) + 13 OpencodeParseTests + 3 EmbeddedStringsTests. Patch-target-only test migration (24 hunks), zero assertion changes. tai_pkg clean-regenerated after mid-verification tamper was found and reverted.
124 lines
4.7 KiB
Python
124 lines
4.7 KiB
Python
"""Telegram bot transport and update handling."""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
|
|
from tai_pkg.consts import Consts
|
|
from tai_pkg.errors import BackendError
|
|
from tai_pkg.sandbox import Sandbox
|
|
from tai_pkg.scheduler import Scheduler
|
|
from tai_pkg.text import Text
|
|
|
|
__all__ = ["Telegram", "TelegramBot"]
|
|
|
|
|
|
class Telegram:
|
|
def __init__(self, token):
|
|
self.token = token
|
|
|
|
def call(self, method, params, timeout=70):
|
|
body = json.dumps(params).encode("utf-8")
|
|
request = urllib.request.Request(Consts.TELEGRAM_API + self.token + "/" + method, data=body, headers={"Content-Type": "application/json"}, method="POST")
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
return json.loads(response.read().decode("utf-8", "replace"))
|
|
|
|
|
|
class TelegramBot:
|
|
"""TelegramBot."""
|
|
|
|
@staticmethod
|
|
def telegram_env_path(home):
|
|
return os.path.join(home, "telegram.env")
|
|
|
|
@staticmethod
|
|
def load_telegram_token(home):
|
|
token = os.environ.get("TELEGRAM_BOT_TOKEN") or ""
|
|
if token:
|
|
return token
|
|
path = TelegramBot.telegram_env_path(home)
|
|
if not os.path.exists(path):
|
|
return ""
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
for line in handle.read().splitlines():
|
|
if line.startswith("TELEGRAM_BOT_TOKEN="):
|
|
return line.split("=", 1)[1].strip().strip("'\"")
|
|
return ""
|
|
|
|
@staticmethod
|
|
def telegram_send(bot, chat_id, text):
|
|
for pos in range(0, len(text), 4000):
|
|
bot.call("sendMessage", {"chat_id": chat_id, "text": text[pos:pos + 4000] or "(empty reply)"})
|
|
|
|
@staticmethod
|
|
def telegram_transcribe(bot, file_id):
|
|
try:
|
|
info = bot.call("getFile", {"file_id": file_id})
|
|
remote = (info.get("result") or {}).get("file_path", "")
|
|
if not remote:
|
|
return "error: telegram returned no file"
|
|
with urllib.request.urlopen(Consts.TELEGRAM_FILE_API + bot.token + "/" + remote, timeout=60) as response:
|
|
audio = response.read(20000000)
|
|
engine = Sandbox.ensure_box()
|
|
return Sandbox.box_transcribe(engine, audio)
|
|
except (OSError, ValueError, BackendError) as exc:
|
|
return "error: transcription failed: " + Text.short_error(exc)
|
|
|
|
@staticmethod
|
|
def handle_telegram_update(agent, bot, update):
|
|
message = update.get("message") or {}
|
|
chat_id = (message.get("chat") or {}).get("id")
|
|
if not chat_id:
|
|
return
|
|
text = message.get("text") or ""
|
|
if text == "/start":
|
|
TelegramBot.telegram_send(bot, chat_id, "tai online. Send any message, /new for a fresh start.")
|
|
return
|
|
if text == "/new":
|
|
agent.reset_history()
|
|
TelegramBot.telegram_send(bot, chat_id, "fresh start.")
|
|
return
|
|
voice = message.get("voice") or message.get("audio")
|
|
if voice:
|
|
bot.call("sendChatAction", {"chat_id": chat_id, "action": "typing"})
|
|
text = TelegramBot.telegram_transcribe(bot, voice.get("file_id", ""))
|
|
if text.startswith("error"):
|
|
TelegramBot.telegram_send(bot, chat_id, text)
|
|
return
|
|
if not text.strip():
|
|
return
|
|
bot.call("sendChatAction", {"chat_id": chat_id, "action": "typing"})
|
|
reply = agent.run_turn(text, capture=True)
|
|
TelegramBot.telegram_send(bot, chat_id, reply)
|
|
|
|
@staticmethod
|
|
def run_telegram_bot(agent):
|
|
token = TelegramBot.load_telegram_token(agent.config.home)
|
|
if not token:
|
|
print("missing TELEGRAM_BOT_TOKEN", file=sys.stderr)
|
|
return 2
|
|
bot = Telegram(token)
|
|
try:
|
|
me = bot.call("getMe", {})
|
|
except (OSError, ValueError) as exc:
|
|
print("telegram auth failed: %s" % Text.short_error(exc), file=sys.stderr)
|
|
return 2
|
|
print("telegram bot online as @%s" % (me.get("result") or {}).get("username", "?"))
|
|
Scheduler.start_scheduler(agent.config, agent.store.seal)
|
|
offset = 0
|
|
while True:
|
|
try:
|
|
data = bot.call("getUpdates", {"offset": offset, "timeout": 50})
|
|
except (OSError, ValueError) as exc:
|
|
print("poll error: %s, retrying" % Text.short_error(exc))
|
|
time.sleep(5)
|
|
continue
|
|
for update in data.get("result") or []:
|
|
offset = update.get("update_id", offset) + 1
|
|
try:
|
|
TelegramBot.handle_telegram_update(agent, bot, update)
|
|
except Exception as exc:
|
|
print("update error: %s" % Text.short_error(exc))
|