Initial commit
This commit is contained in:
0
server/telegram/__init__.py
Normal file
0
server/telegram/__init__.py
Normal file
BIN
server/telegram/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
server/telegram/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
server/telegram/__pycache__/listener.cpython-314.pyc
Normal file
BIN
server/telegram/__pycache__/listener.cpython-314.pyc
Normal file
Binary file not shown.
BIN
server/telegram/__pycache__/triggers.cpython-314.pyc
Normal file
BIN
server/telegram/__pycache__/triggers.cpython-314.pyc
Normal file
Binary file not shown.
292
server/telegram/listener.py
Normal file
292
server/telegram/listener.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
telegram/listener.py — Telegram bot long-polling listener.
|
||||
|
||||
Supports both the global (admin) bot and per-user bots.
|
||||
TelegramListenerManager maintains a pool of TelegramListener instances.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from ..database import credential_store
|
||||
from .triggers import get_enabled_triggers, is_allowed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_API = "https://api.telegram.org/bot{token}/{method}"
|
||||
_POLL_TIMEOUT = 30
|
||||
_HTTP_TIMEOUT = 35
|
||||
_MAX_BACKOFF = 60
|
||||
|
||||
|
||||
class TelegramListener:
|
||||
"""
|
||||
Single Telegram long-polling listener. user_id=None means global/admin bot.
|
||||
Per-user listeners read bot token from user_settings["telegram_bot_token"].
|
||||
"""
|
||||
|
||||
def __init__(self, user_id: str | None = None) -> None:
|
||||
self._user_id = user_id
|
||||
self._task: asyncio.Task | None = None
|
||||
self._running = False
|
||||
self._configured = False
|
||||
self._error: str | None = None
|
||||
|
||||
# ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
def start(self) -> None:
|
||||
if self._task is None or self._task.done():
|
||||
name = f"telegram-listener-{self._user_id or 'global'}"
|
||||
self._task = asyncio.create_task(self._run_loop(), name=name)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
self._running = False
|
||||
|
||||
def reconnect(self) -> None:
|
||||
self.stop()
|
||||
self.start()
|
||||
|
||||
@property
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"configured": self._configured,
|
||||
"running": self._running,
|
||||
"error": self._error,
|
||||
"user_id": self._user_id,
|
||||
}
|
||||
|
||||
# ── Credential helpers ─────────────────────────────────────────────────────
|
||||
|
||||
async def _get_token(self) -> str | None:
|
||||
if self._user_id is None:
|
||||
return await credential_store.get("telegram:bot_token")
|
||||
from ..database import user_settings_store
|
||||
return await user_settings_store.get(self._user_id, "telegram_bot_token")
|
||||
|
||||
async def _is_configured(self) -> bool:
|
||||
return bool(await self._get_token())
|
||||
|
||||
# ── Session ID ────────────────────────────────────────────────────────────
|
||||
|
||||
def _session_id(self, chat_id: str) -> str:
|
||||
if self._user_id is None:
|
||||
return f"telegram:{chat_id}"
|
||||
return f"telegram:{self._user_id}:{chat_id}"
|
||||
|
||||
# ── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
backoff = 1
|
||||
while True:
|
||||
self._configured = await self._is_configured()
|
||||
if not self._configured:
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
try:
|
||||
await self._poll_loop()
|
||||
backoff = 1
|
||||
except asyncio.CancelledError:
|
||||
self._running = False
|
||||
break
|
||||
except Exception as e:
|
||||
self._running = False
|
||||
self._error = str(e)
|
||||
logger.warning("TelegramListener[%s] error: %s - retrying in %ds",
|
||||
self._user_id or "global", e, backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, _MAX_BACKOFF)
|
||||
|
||||
async def _poll_loop(self) -> None:
|
||||
offset = 0
|
||||
self._running = True
|
||||
self._error = None
|
||||
logger.info("TelegramListener[%s] started polling", self._user_id or "global")
|
||||
|
||||
token = await self._get_token()
|
||||
|
||||
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as http:
|
||||
while True:
|
||||
url = _API.format(token=token, method="getUpdates")
|
||||
resp = await http.get(
|
||||
url,
|
||||
params={
|
||||
"offset": offset,
|
||||
"timeout": _POLL_TIMEOUT,
|
||||
"allowed_updates": ["message"],
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if not data.get("ok"):
|
||||
raise RuntimeError(f"Telegram API error: {data}")
|
||||
|
||||
for update in data.get("result", []):
|
||||
await self._handle_update(update, http, token)
|
||||
offset = update["update_id"] + 1
|
||||
|
||||
async def _handle_update(self, update: dict, http: httpx.AsyncClient, token: str) -> None:
|
||||
msg = update.get("message")
|
||||
if not msg:
|
||||
return
|
||||
|
||||
chat_id = str(msg["chat"]["id"])
|
||||
text = (msg.get("text") or "").strip()
|
||||
|
||||
if not text:
|
||||
return
|
||||
|
||||
from ..security import sanitize_external_content
|
||||
text = await sanitize_external_content(text, source="telegram")
|
||||
|
||||
logger.info("TelegramListener[%s]: message from chat_id=%s",
|
||||
self._user_id or "global", chat_id)
|
||||
|
||||
# Whitelist check (scoped to this user)
|
||||
if not await is_allowed(chat_id, user_id=self._user_id):
|
||||
logger.info("TelegramListener[%s]: chat_id %s not whitelisted",
|
||||
self._user_id or "global", chat_id)
|
||||
await self._send(http, token, chat_id,
|
||||
"Sorry, you are not authorised to interact with this bot.\n"
|
||||
"Please contact the system owner.")
|
||||
return
|
||||
|
||||
# Email agent keyword routing — /keyword <message> before trigger matching
|
||||
if text.startswith("/"):
|
||||
parts = text[1:].split(None, 1)
|
||||
keyword = parts[0].lower()
|
||||
rest = parts[1].strip() if len(parts) > 1 else ""
|
||||
from ..inbox.telegram_handler import handle_keyword_message
|
||||
handled = await handle_keyword_message(
|
||||
chat_id=chat_id,
|
||||
user_id=self._user_id,
|
||||
keyword=keyword,
|
||||
message=rest,
|
||||
)
|
||||
if handled:
|
||||
return
|
||||
|
||||
# Trigger matching (scoped to this user)
|
||||
triggers = await get_enabled_triggers(user_id=self._user_id)
|
||||
text_lower = text.lower()
|
||||
matched = next(
|
||||
(t for t in triggers
|
||||
if all(tok in text_lower for tok in t["trigger_word"].lower().split())),
|
||||
None,
|
||||
)
|
||||
|
||||
if matched is None:
|
||||
# For global listener: fall back to default_agent_id
|
||||
# For per-user: no default (could add user-level default later)
|
||||
if self._user_id is None:
|
||||
default_agent_id = await credential_store.get("telegram:default_agent_id")
|
||||
if not default_agent_id:
|
||||
logger.info(
|
||||
"TelegramListener[global]: no trigger match and no default agent "
|
||||
"for chat_id=%s - dropping", chat_id,
|
||||
)
|
||||
return
|
||||
matched = {"agent_id": default_agent_id, "trigger_word": "(default)"}
|
||||
else:
|
||||
logger.info(
|
||||
"TelegramListener[%s]: no trigger match for chat_id=%s - dropping",
|
||||
self._user_id, chat_id,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"TelegramListener[%s]: trigger '%s' matched - running agent %s",
|
||||
self._user_id or "global", matched["trigger_word"], matched["agent_id"],
|
||||
)
|
||||
agent_input = (
|
||||
f"You received a Telegram message.\n"
|
||||
f"From chat_id: {chat_id}\n\n"
|
||||
f"{text}\n\n"
|
||||
f"Please process this request. "
|
||||
f"Your response will be sent back to chat_id {chat_id} via Telegram."
|
||||
)
|
||||
try:
|
||||
from ..agents.runner import agent_runner
|
||||
result_text = await agent_runner.run_agent_and_wait(
|
||||
matched["agent_id"], override_message=agent_input,
|
||||
session_id=self._session_id(chat_id),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("TelegramListener[%s]: agent run failed: %s",
|
||||
self._user_id or "global", e)
|
||||
result_text = f"Sorry, an error occurred while processing your request: {e}"
|
||||
|
||||
await self._send(http, token, chat_id, result_text)
|
||||
|
||||
async def _send(self, http: httpx.AsyncClient, token: str, chat_id: str, text: str) -> None:
|
||||
try:
|
||||
url = _API.format(token=token, method="sendMessage")
|
||||
resp = await http.post(url, json={"chat_id": chat_id, "text": text[:4096]})
|
||||
resp.raise_for_status()
|
||||
except Exception as e:
|
||||
logger.error("TelegramListener[%s]: failed to send to %s: %s",
|
||||
self._user_id or "global", chat_id, e)
|
||||
|
||||
|
||||
# ── Manager ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class TelegramListenerManager:
|
||||
"""
|
||||
Maintains a pool of TelegramListener instances.
|
||||
Exposes the same .status / .reconnect() / .stop() interface as the old
|
||||
singleton for backward compatibility with existing admin routes.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._listeners: dict[str | None, TelegramListener] = {}
|
||||
|
||||
def _ensure(self, user_id: str | None) -> TelegramListener:
|
||||
if user_id not in self._listeners:
|
||||
self._listeners[user_id] = TelegramListener(user_id=user_id)
|
||||
return self._listeners[user_id]
|
||||
|
||||
def start(self) -> None:
|
||||
self._ensure(None).start()
|
||||
|
||||
def start_all(self) -> None:
|
||||
self.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
g = self._listeners.get(None)
|
||||
if g:
|
||||
g.stop()
|
||||
|
||||
def stop_all(self) -> None:
|
||||
for listener in self._listeners.values():
|
||||
listener.stop()
|
||||
self._listeners.clear()
|
||||
|
||||
def reconnect(self) -> None:
|
||||
self._ensure(None).reconnect()
|
||||
|
||||
def start_for_user(self, user_id: str) -> None:
|
||||
self._ensure(user_id).reconnect()
|
||||
|
||||
def stop_for_user(self, user_id: str) -> None:
|
||||
if user_id in self._listeners:
|
||||
self._listeners[user_id].stop()
|
||||
del self._listeners[user_id]
|
||||
|
||||
def reconnect_for_user(self, user_id: str) -> None:
|
||||
self._ensure(user_id).reconnect()
|
||||
|
||||
@property
|
||||
def status(self) -> dict:
|
||||
g = self._listeners.get(None)
|
||||
return g.status if g else {"configured": False, "running": False, "error": None}
|
||||
|
||||
def all_statuses(self) -> dict:
|
||||
return {(k or "global"): v.status for k, v in self._listeners.items()}
|
||||
|
||||
|
||||
# Module-level singleton (backward-compatible name kept)
|
||||
telegram_listener = TelegramListenerManager()
|
||||
207
server/telegram/triggers.py
Normal file
207
server/telegram/triggers.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
telegram/triggers.py — CRUD for telegram_triggers and telegram_whitelist tables (async).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from ..database import _rowcount, get_pool
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
# ── Trigger rules ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_triggers(user_id: str | None = "GLOBAL") -> list[dict]:
|
||||
"""
|
||||
- user_id="GLOBAL" (default): global triggers (user_id IS NULL)
|
||||
- user_id=None: ALL triggers
|
||||
- user_id="<uuid>": that user's triggers only
|
||||
"""
|
||||
pool = await get_pool()
|
||||
if user_id == "GLOBAL":
|
||||
rows = await pool.fetch(
|
||||
"SELECT t.*, a.name AS agent_name "
|
||||
"FROM telegram_triggers t LEFT JOIN agents a ON a.id = t.agent_id "
|
||||
"WHERE t.user_id IS NULL ORDER BY t.created_at"
|
||||
)
|
||||
elif user_id is None:
|
||||
rows = await pool.fetch(
|
||||
"SELECT t.*, a.name AS agent_name "
|
||||
"FROM telegram_triggers t LEFT JOIN agents a ON a.id = t.agent_id "
|
||||
"ORDER BY t.created_at"
|
||||
)
|
||||
else:
|
||||
rows = await pool.fetch(
|
||||
"SELECT t.*, a.name AS agent_name "
|
||||
"FROM telegram_triggers t LEFT JOIN agents a ON a.id = t.agent_id "
|
||||
"WHERE t.user_id = $1 ORDER BY t.created_at",
|
||||
user_id,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def create_trigger(
|
||||
trigger_word: str,
|
||||
agent_id: str,
|
||||
description: str = "",
|
||||
enabled: bool = True,
|
||||
user_id: str | None = None,
|
||||
) -> dict:
|
||||
now = _now()
|
||||
trigger_id = str(uuid.uuid4())
|
||||
pool = await get_pool()
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO telegram_triggers
|
||||
(id, trigger_word, agent_id, description, enabled, user_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
""",
|
||||
trigger_id, trigger_word, agent_id, description, enabled, user_id, now, now,
|
||||
)
|
||||
return {
|
||||
"id": trigger_id,
|
||||
"trigger_word": trigger_word,
|
||||
"agent_id": agent_id,
|
||||
"description": description,
|
||||
"enabled": enabled,
|
||||
"user_id": user_id,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
|
||||
async def update_trigger(id: str, **fields) -> bool:
|
||||
fields["updated_at"] = _now()
|
||||
|
||||
set_parts = []
|
||||
values: list[Any] = []
|
||||
for i, (k, v) in enumerate(fields.items(), start=1):
|
||||
set_parts.append(f"{k} = ${i}")
|
||||
values.append(v)
|
||||
|
||||
id_param = len(fields) + 1
|
||||
values.append(id)
|
||||
|
||||
pool = await get_pool()
|
||||
status = await pool.execute(
|
||||
f"UPDATE telegram_triggers SET {', '.join(set_parts)} WHERE id = ${id_param}",
|
||||
*values,
|
||||
)
|
||||
return _rowcount(status) > 0
|
||||
|
||||
|
||||
async def delete_trigger(id: str) -> bool:
|
||||
pool = await get_pool()
|
||||
status = await pool.execute("DELETE FROM telegram_triggers WHERE id = $1", id)
|
||||
return _rowcount(status) > 0
|
||||
|
||||
|
||||
async def toggle_trigger(id: str) -> None:
|
||||
pool = await get_pool()
|
||||
await pool.execute(
|
||||
"UPDATE telegram_triggers SET enabled = NOT enabled, updated_at = $1 WHERE id = $2",
|
||||
_now(), id,
|
||||
)
|
||||
|
||||
|
||||
async def get_enabled_triggers(user_id: str | None = "GLOBAL") -> list[dict]:
|
||||
"""Return enabled triggers scoped to user_id."""
|
||||
pool = await get_pool()
|
||||
if user_id == "GLOBAL":
|
||||
rows = await pool.fetch(
|
||||
"SELECT * FROM telegram_triggers WHERE enabled = TRUE AND user_id IS NULL"
|
||||
)
|
||||
elif user_id is None:
|
||||
rows = await pool.fetch("SELECT * FROM telegram_triggers WHERE enabled = TRUE")
|
||||
else:
|
||||
rows = await pool.fetch(
|
||||
"SELECT * FROM telegram_triggers WHERE enabled = TRUE AND user_id = $1",
|
||||
user_id,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── Chat ID whitelist ─────────────────────────────────────────────────────────
|
||||
|
||||
async def list_whitelist(user_id: str | None = "GLOBAL") -> list[dict]:
|
||||
"""
|
||||
- user_id="GLOBAL" (default): global whitelist (user_id IS NULL)
|
||||
- user_id=None: ALL whitelist entries
|
||||
- user_id="<uuid>": that user's entries
|
||||
"""
|
||||
pool = await get_pool()
|
||||
if user_id == "GLOBAL":
|
||||
rows = await pool.fetch(
|
||||
"SELECT * FROM telegram_whitelist WHERE user_id IS NULL ORDER BY created_at"
|
||||
)
|
||||
elif user_id is None:
|
||||
rows = await pool.fetch("SELECT * FROM telegram_whitelist ORDER BY created_at")
|
||||
else:
|
||||
rows = await pool.fetch(
|
||||
"SELECT * FROM telegram_whitelist WHERE user_id = $1 ORDER BY created_at",
|
||||
user_id,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def add_to_whitelist(
|
||||
chat_id: str,
|
||||
label: str = "",
|
||||
user_id: str | None = None,
|
||||
) -> dict:
|
||||
now = _now()
|
||||
chat_id = str(chat_id)
|
||||
pool = await get_pool()
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO telegram_whitelist (chat_id, label, user_id, created_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (chat_id, user_id) NULLS NOT DISTINCT DO UPDATE SET label = EXCLUDED.label
|
||||
""",
|
||||
chat_id, label, user_id, now,
|
||||
)
|
||||
return {"chat_id": chat_id, "label": label, "user_id": user_id, "created_at": now}
|
||||
|
||||
|
||||
async def remove_from_whitelist(chat_id: str, user_id: str | None = "GLOBAL") -> bool:
|
||||
"""Remove whitelist entry. user_id="GLOBAL" deletes only global entry (user_id IS NULL)."""
|
||||
pool = await get_pool()
|
||||
if user_id == "GLOBAL":
|
||||
status = await pool.execute(
|
||||
"DELETE FROM telegram_whitelist WHERE chat_id = $1 AND user_id IS NULL", str(chat_id)
|
||||
)
|
||||
elif user_id is None:
|
||||
status = await pool.execute(
|
||||
"DELETE FROM telegram_whitelist WHERE chat_id = $1", str(chat_id)
|
||||
)
|
||||
else:
|
||||
status = await pool.execute(
|
||||
"DELETE FROM telegram_whitelist WHERE chat_id = $1 AND user_id = $2",
|
||||
str(chat_id), user_id,
|
||||
)
|
||||
return _rowcount(status) > 0
|
||||
|
||||
|
||||
async def is_allowed(chat_id: str | int, user_id: str | None = "GLOBAL") -> bool:
|
||||
"""Check if chat_id is whitelisted. Scoped to user_id (or global if "GLOBAL")."""
|
||||
pool = await get_pool()
|
||||
if user_id == "GLOBAL":
|
||||
row = await pool.fetchrow(
|
||||
"SELECT 1 FROM telegram_whitelist WHERE chat_id = $1 AND user_id IS NULL",
|
||||
str(chat_id),
|
||||
)
|
||||
elif user_id is None:
|
||||
row = await pool.fetchrow(
|
||||
"SELECT 1 FROM telegram_whitelist WHERE chat_id = $1", str(chat_id)
|
||||
)
|
||||
else:
|
||||
row = await pool.fetchrow(
|
||||
"SELECT 1 FROM telegram_whitelist WHERE chat_id = $1 AND user_id = $2",
|
||||
str(chat_id), user_id,
|
||||
)
|
||||
return row is not None
|
||||
Reference in New Issue
Block a user