forked from retoor/devplacepy
416 lines
14 KiB
Python
416 lines
14 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import signal
|
|
import sys
|
|
from collections import deque
|
|
from datetime import datetime, timezone
|
|
|
|
from devplacepy.config import BASE_DIR
|
|
from devplacepy.database import get_setting
|
|
from devplacepy.services.base import BaseService, ConfigField
|
|
|
|
from .bridge import TelegramBridge
|
|
from .format import markdown_to_telegram_html, split_for_telegram
|
|
|
|
logger = logging.getLogger("telegram.service")
|
|
|
|
WORKER_MODULE = "devplacepy.services.telegram.worker"
|
|
STREAM_LIMIT = 16 * 1024 * 1024
|
|
RESULT_TIMEOUT_SECONDS = 30.0
|
|
STOP_GRACE_SECONDS = 5.0
|
|
LIVE_LOG_SIZE = 200
|
|
LATENCY_SAMPLES = 200
|
|
|
|
FIELD_TOKEN = "telegram_bot_token"
|
|
FIELD_POLL_TIMEOUT = "telegram_poll_timeout"
|
|
FIELD_CODE_TTL = "telegram_code_ttl_minutes"
|
|
FIELD_MAX_CONCURRENT = "telegram_max_concurrent_turns"
|
|
|
|
|
|
class TelegramService(BaseService):
|
|
default_enabled = False
|
|
min_interval = 15
|
|
title = "Telegram Bot"
|
|
description = (
|
|
"Runs a Telegram bot that bridges Devii into Telegram. A user pairs their Telegram "
|
|
"account with a four digit code from their profile, then chats with their own Devii "
|
|
"with markdown, typing status, live message editing and image understanding. The "
|
|
"long-poller runs as a supervised subprocess and is off by default."
|
|
)
|
|
config_fields = [
|
|
ConfigField(
|
|
FIELD_TOKEN,
|
|
"Bot token",
|
|
type="str",
|
|
default="",
|
|
secret=True,
|
|
help="Telegram bot token from @BotFather. Required for the service to start.",
|
|
group="Telegram",
|
|
),
|
|
ConfigField(
|
|
FIELD_POLL_TIMEOUT,
|
|
"Long-poll timeout (seconds)",
|
|
type="int",
|
|
default=25,
|
|
minimum=1,
|
|
maximum=50,
|
|
help="getUpdates long-poll hold time. 25-30 is recommended.",
|
|
group="Telegram",
|
|
),
|
|
ConfigField(
|
|
FIELD_CODE_TTL,
|
|
"Pairing code lifetime (minutes)",
|
|
type="int",
|
|
default=60,
|
|
minimum=1,
|
|
maximum=1440,
|
|
help="How long a pairing code requested from a profile stays valid.",
|
|
group="Telegram",
|
|
),
|
|
ConfigField(
|
|
FIELD_MAX_CONCURRENT,
|
|
"Max concurrent turns",
|
|
type="int",
|
|
default=8,
|
|
minimum=1,
|
|
maximum=64,
|
|
help="Upper bound on Devii turns processed at once across all chats.",
|
|
group="Telegram",
|
|
),
|
|
]
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__(name="telegram", interval_seconds=30)
|
|
self._proc: asyncio.subprocess.Process | None = None
|
|
self._tasks: list[asyncio.Task] = []
|
|
self._pending: dict[int, asyncio.Future] = {}
|
|
self._req_seq = 0
|
|
self._stdin_lock = asyncio.Lock()
|
|
self._live_logs: deque[str] = deque(maxlen=LIVE_LOG_SIZE)
|
|
self._bridge: TelegramBridge | None = None
|
|
self._stats = self._fresh_stats()
|
|
|
|
def _fresh_stats(self) -> dict:
|
|
return {"in": 0, "out": 0, "edits": 0, "errors": 0, "latencies": deque(maxlen=LATENCY_SAMPLES)}
|
|
|
|
def _token(self) -> str:
|
|
return get_setting(FIELD_TOKEN, "").strip()
|
|
|
|
def _alive(self) -> bool:
|
|
return self._proc is not None and self._proc.returncode is None
|
|
|
|
async def on_enable(self) -> None:
|
|
if not self._token():
|
|
self.log("No bot token configured; waiting for configuration")
|
|
return
|
|
await self._spawn()
|
|
|
|
async def on_disable(self) -> None:
|
|
await self._terminate()
|
|
|
|
async def run_once(self) -> None:
|
|
if not self.is_enabled():
|
|
return
|
|
if not self._token():
|
|
return
|
|
if not self._alive():
|
|
self.log("Worker not running; starting it")
|
|
await self._spawn()
|
|
|
|
async def _spawn(self) -> None:
|
|
if self._alive():
|
|
return
|
|
self._bridge = TelegramBridge(self, max_concurrent=self._max_concurrent())
|
|
self._stats = self._fresh_stats()
|
|
env = {
|
|
**os.environ,
|
|
"TELEGRAM_BOT_TOKEN": self._token(),
|
|
"TELEGRAM_POLL_TIMEOUT": str(self._poll_timeout()),
|
|
}
|
|
self._proc = await asyncio.create_subprocess_exec(
|
|
sys.executable,
|
|
"-m",
|
|
WORKER_MODULE,
|
|
cwd=str(BASE_DIR),
|
|
stdin=asyncio.subprocess.PIPE,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=env,
|
|
start_new_session=True,
|
|
limit=STREAM_LIMIT,
|
|
)
|
|
self._tasks = [
|
|
asyncio.create_task(self._read_stdout()),
|
|
asyncio.create_task(self._read_stderr()),
|
|
]
|
|
self.log(f"Telegram worker started (pid {self._proc.pid})")
|
|
|
|
async def _terminate(self) -> None:
|
|
proc = self._proc
|
|
self._proc = None
|
|
for task in self._tasks:
|
|
task.cancel()
|
|
self._tasks = []
|
|
for future in self._pending.values():
|
|
if not future.done():
|
|
future.cancel()
|
|
self._pending = {}
|
|
if proc is None:
|
|
return
|
|
try:
|
|
if proc.stdin is not None and not proc.stdin.is_closing():
|
|
proc.stdin.close()
|
|
except Exception: # noqa: BLE001 - closing a dead pipe is fine
|
|
pass
|
|
try:
|
|
await asyncio.wait_for(proc.wait(), timeout=STOP_GRACE_SECONDS)
|
|
except asyncio.TimeoutError:
|
|
self._kill_group(proc)
|
|
try:
|
|
await asyncio.wait_for(proc.wait(), timeout=STOP_GRACE_SECONDS)
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
self.log("Telegram worker stopped")
|
|
|
|
def _kill_group(self, proc: asyncio.subprocess.Process) -> None:
|
|
try:
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
except (ProcessLookupError, PermissionError):
|
|
try:
|
|
proc.kill()
|
|
except ProcessLookupError:
|
|
pass
|
|
|
|
async def _read_stdout(self) -> None:
|
|
proc = self._proc
|
|
if proc is None or proc.stdout is None:
|
|
return
|
|
try:
|
|
while True:
|
|
line = await proc.stdout.readline()
|
|
if not line:
|
|
break
|
|
try:
|
|
frame = json.loads(line.decode("utf-8", "replace"))
|
|
except (ValueError, TypeError):
|
|
continue
|
|
await self._handle_frame(frame)
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception: # noqa: BLE001 - reader must not crash the supervisor
|
|
logger.exception("Telegram stdout reader failed")
|
|
|
|
async def _read_stderr(self) -> None:
|
|
proc = self._proc
|
|
if proc is None or proc.stderr is None:
|
|
return
|
|
try:
|
|
while True:
|
|
line = await proc.stderr.readline()
|
|
if not line:
|
|
break
|
|
self._live_log("[stderr] " + line.decode("utf-8", "replace").rstrip())
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
async def _handle_frame(self, frame: dict) -> None:
|
|
kind = frame.get("type")
|
|
if kind == "log":
|
|
self._live_log(str(frame.get("line", "")))
|
|
elif kind == "ready":
|
|
self._live_log("worker ready")
|
|
elif kind == "result":
|
|
self._resolve(frame)
|
|
elif kind in ("message", "callback"):
|
|
self._stats["in"] += 1
|
|
self._live_log(f"in <- chat {frame.get('chat_id')} ({kind})")
|
|
if self._bridge is not None:
|
|
asyncio.create_task(self._dispatch_inbound(frame))
|
|
|
|
async def _dispatch_inbound(self, frame: dict) -> None:
|
|
try:
|
|
await self._bridge.handle_inbound(frame)
|
|
except Exception: # noqa: BLE001 - surfaced as a stat, never crashes the reader
|
|
self._stats["errors"] += 1
|
|
logger.exception("Telegram inbound handling failed")
|
|
|
|
def _resolve(self, frame: dict) -> None:
|
|
future = self._pending.pop(frame.get("req_id"), None)
|
|
if future is not None and not future.done():
|
|
future.set_result(frame)
|
|
|
|
def _live_log(self, line: str) -> None:
|
|
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
|
entry = f"[{stamp}] {line}"
|
|
self._live_logs.append(entry)
|
|
asyncio.create_task(self._publish_log(entry))
|
|
|
|
async def _publish_log(self, entry: str) -> None:
|
|
try:
|
|
from devplacepy.services.pubsub import publish
|
|
|
|
await publish(f"admin.services.{self.name}.logs", {"line": entry})
|
|
except Exception: # noqa: BLE001 - live logs are best-effort
|
|
pass
|
|
|
|
async def _write(self, command: dict) -> None:
|
|
proc = self._proc
|
|
if proc is None or proc.stdin is None or proc.stdin.is_closing():
|
|
raise RuntimeError("Telegram worker is not running")
|
|
payload = (json.dumps(command, ensure_ascii=False) + "\n").encode("utf-8")
|
|
async with self._stdin_lock:
|
|
proc.stdin.write(payload)
|
|
await proc.stdin.drain()
|
|
|
|
def _next_req(self) -> int:
|
|
self._req_seq += 1
|
|
return self._req_seq
|
|
|
|
async def send(
|
|
self,
|
|
chat_id: int,
|
|
text: str,
|
|
parse_mode: str = "HTML",
|
|
reply_markup: dict | None = None,
|
|
) -> int | None:
|
|
req_id = self._next_req()
|
|
loop = asyncio.get_event_loop()
|
|
future: asyncio.Future = loop.create_future()
|
|
self._pending[req_id] = future
|
|
command = {
|
|
"cmd": "send",
|
|
"req_id": req_id,
|
|
"chat_id": chat_id,
|
|
"text": text,
|
|
"parse_mode": parse_mode,
|
|
}
|
|
if reply_markup is not None:
|
|
command["reply_markup"] = reply_markup
|
|
try:
|
|
await self._write(command)
|
|
except RuntimeError:
|
|
self._pending.pop(req_id, None)
|
|
self._stats["errors"] += 1
|
|
return None
|
|
self._stats["out"] += 1
|
|
try:
|
|
result = await asyncio.wait_for(future, timeout=RESULT_TIMEOUT_SECONDS)
|
|
except asyncio.TimeoutError:
|
|
self._pending.pop(req_id, None)
|
|
self._stats["errors"] += 1
|
|
return None
|
|
if not result.get("ok"):
|
|
self._stats["errors"] += 1
|
|
return None
|
|
return result.get("message_id")
|
|
|
|
async def edit(
|
|
self,
|
|
chat_id: int,
|
|
message_id: int,
|
|
text: str,
|
|
parse_mode: str = "HTML",
|
|
reply_markup: dict | None = None,
|
|
) -> bool:
|
|
req_id = self._next_req()
|
|
loop = asyncio.get_event_loop()
|
|
future: asyncio.Future = loop.create_future()
|
|
self._pending[req_id] = future
|
|
command = {
|
|
"cmd": "edit",
|
|
"req_id": req_id,
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
"text": text,
|
|
"parse_mode": parse_mode,
|
|
}
|
|
if reply_markup is not None:
|
|
command["reply_markup"] = reply_markup
|
|
try:
|
|
await self._write(command)
|
|
except RuntimeError:
|
|
self._pending.pop(req_id, None)
|
|
return False
|
|
self._stats["edits"] += 1
|
|
try:
|
|
result = await asyncio.wait_for(future, timeout=RESULT_TIMEOUT_SECONDS)
|
|
except asyncio.TimeoutError:
|
|
self._pending.pop(req_id, None)
|
|
return False
|
|
return bool(result.get("ok"))
|
|
|
|
async def answer_callback(
|
|
self, callback_query_id: str, text: str | None = None
|
|
) -> bool:
|
|
req_id = self._next_req()
|
|
loop = asyncio.get_event_loop()
|
|
future: asyncio.Future = loop.create_future()
|
|
self._pending[req_id] = future
|
|
try:
|
|
await self._write(
|
|
{
|
|
"cmd": "answer_callback",
|
|
"req_id": req_id,
|
|
"callback_query_id": callback_query_id,
|
|
"text": text,
|
|
}
|
|
)
|
|
except RuntimeError:
|
|
self._pending.pop(req_id, None)
|
|
return False
|
|
try:
|
|
result = await asyncio.wait_for(future, timeout=RESULT_TIMEOUT_SECONDS)
|
|
except asyncio.TimeoutError:
|
|
self._pending.pop(req_id, None)
|
|
return False
|
|
return bool(result.get("ok"))
|
|
|
|
async def chat_action(self, chat_id: int, action: str = "typing") -> None:
|
|
try:
|
|
await self._write({"cmd": "chat_action", "chat_id": chat_id, "action": action})
|
|
except RuntimeError:
|
|
pass
|
|
|
|
async def send_markdown(self, chat_id: int, text: str) -> bool:
|
|
chunks = split_for_telegram(markdown_to_telegram_html(text))
|
|
delivered = False
|
|
for chunk in chunks:
|
|
if await self.send(chat_id, chunk) is not None:
|
|
delivered = True
|
|
return delivered
|
|
|
|
def record_latency(self, seconds: float) -> None:
|
|
self._stats["latencies"].append(seconds)
|
|
|
|
def _poll_timeout(self) -> int:
|
|
return max(1, int(self.get_config().get(FIELD_POLL_TIMEOUT, 25)))
|
|
|
|
def _max_concurrent(self) -> int:
|
|
return max(1, int(self.get_config().get(FIELD_MAX_CONCURRENT, 8)))
|
|
|
|
def worker_alive(self) -> bool:
|
|
return self._alive()
|
|
|
|
def collect_metrics(self) -> dict:
|
|
latencies = list(self._stats["latencies"])
|
|
avg_ms = int(sum(latencies) / len(latencies) * 1000) if latencies else 0
|
|
return {
|
|
"stats": [
|
|
{"label": "Worker", "value": "running" if self._alive() else "stopped"},
|
|
{"label": "Messages in", "value": self._stats["in"]},
|
|
{"label": "Messages out", "value": self._stats["out"]},
|
|
{"label": "Edits", "value": self._stats["edits"]},
|
|
{"label": "Errors", "value": self._stats["errors"]},
|
|
{"label": "Avg response", "value": f"{avg_ms} ms"},
|
|
{"label": "Token", "value": "set" if self._token() else "missing"},
|
|
]
|
|
}
|