From 93f1d6b22ede1192eaaec915fe9c63676dcab99c Mon Sep 17 00:00:00 2001 From: Markov Date: Sat, 28 Feb 2026 11:45:55 +0100 Subject: [PATCH] Initial bridge: Telegram <-> Tracker --- .env.example | 8 ++ .gitignore | 5 + =0.27 | 0 =1.0 | 0 =12.0 | 0 =2.0 | 0 bridge.py | 241 ++++++++++++++++++++++++++++++++++++++++++++++ config.py | 25 +++++ requirements.txt | 5 + topic_map.py | 52 ++++++++++ tracker_client.py | 80 +++++++++++++++ 11 files changed, 416 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 =0.27 create mode 100644 =1.0 create mode 100644 =12.0 create mode 100644 =2.0 create mode 100644 bridge.py create mode 100644 config.py create mode 100644 requirements.txt create mode 100644 topic_map.py create mode 100644 tracker_client.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2f2a648 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Telegram +TELEGRAM_BOT_TOKEN=your-bot-token +TELEGRAM_GROUP_ID=your-group-id + +# Tracker +TRACKER_URL=https://dev.team.uix.su +TRACKER_WS_URL=wss://dev.team.uix.su/ws +BRIDGE_TOKEN=tb-bridge-dev-token diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0be7ea0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +venv/ +.env +data/ +__pycache__/ +*.pyc diff --git a/=0.27 b/=0.27 new file mode 100644 index 0000000..e69de29 diff --git a/=1.0 b/=1.0 new file mode 100644 index 0000000..e69de29 diff --git a/=12.0 b/=12.0 new file mode 100644 index 0000000..e69de29 diff --git a/=2.0 b/=2.0 new file mode 100644 index 0000000..e69de29 diff --git a/bridge.py b/bridge.py new file mode 100644 index 0000000..080aa65 --- /dev/null +++ b/bridge.py @@ -0,0 +1,241 @@ +"""Main bridge: Telegram <-> Tracker.""" + +import asyncio +import logging +import httpx +from telegram import Update, Bot +from telegram.ext import ( + Application, + MessageHandler, + CommandHandler, + ContextTypes, + filters, +) + +from config import Config +from topic_map import TopicMap +from tracker_client import TrackerClient + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", +) +logger = logging.getLogger(__name__) + +config = Config() +config.validate() +topic_map = TopicMap() + + +# --- Tracker -> Telegram --- + +async def on_tracker_event(event: dict): + """Forward Tracker events to Telegram topics.""" + event_type = event.get("type", "") + + if event_type == "message.new": + msg = event.get("message", {}) + author = msg.get("author", {}) + + # Skip messages from bridge itself (avoid echo) + if author.get("slug") == "bridge": + return + + project_id = msg.get("project_id") + if not project_id: + return + + topic_id = topic_map.get_topic(project_id) + if not topic_id: + return + + author_name = author.get("name", author.get("slug", "???")) + text = msg.get("text", "") + if not text: + return + + # Format: "Author: message" + tg_text = f"{_escape_html(author_name)}: {_escape_html(text)}" + await _send_to_topic(topic_id, tg_text) + + elif event_type == "task.created": + task = event.get("task", {}) + project_id = task.get("project_id") + topic_id = topic_map.get_topic(project_id) if project_id else None + if not topic_id: + return + + key = task.get("key", "?") + title = task.get("title", "") + tg_text = f"πŸ“‹ Новая Π·Π°Π΄Π°Ρ‡Π° {_escape_html(key)}: {_escape_html(title)}" + await _send_to_topic(topic_id, tg_text) + + +tracker = TrackerClient(config, on_message=on_tracker_event) + + +# --- Telegram -> Tracker --- + +async def handle_telegram_message(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Forward Telegram messages to Tracker project chat.""" + message = update.effective_message + if not message or not message.text: + return + + # Only process messages from the configured group + if message.chat_id != config.group_id: + return + + thread_id = message.message_thread_id + if not thread_id: + return # General topic β€” skip or handle separately + + project_uuid = topic_map.get_project(thread_id) + if not project_uuid: + logger.debug("No mapping for topic %d, ignoring", thread_id) + return + + user = message.from_user + sender = user.full_name if user else "Unknown" + text = f"[{sender}] {message.text}" + + await tracker.send_message(project_uuid, text) + logger.info("TG->Tracker: topic=%d project=%s", thread_id, project_uuid[:8]) + + +async def cmd_link(update: Update, context: ContextTypes.DEFAULT_TYPE): + """/link β€” link current topic to a project.""" + message = update.effective_message + if not message or message.chat_id != config.group_id: + return + + thread_id = message.message_thread_id + if not thread_id: + await message.reply_text("⚠️ Π­Ρ‚Ρƒ ΠΊΠΎΠΌΠ°Π½Π΄Ρƒ ΠΌΠΎΠΆΠ½ΠΎ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π² Ρ‚ΠΎΠΏΠΈΠΊΠ΅.") + return + + args = context.args + if not args: + await message.reply_text("ИспользованиС: /link ") + return + + slug = args[0] + + # Look up project UUID by slug + project_uuid = await _resolve_project(slug) + if not project_uuid: + await message.reply_text(f"❌ ΠŸΡ€ΠΎΠ΅ΠΊΡ‚ '{slug}' Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½.") + return + + topic_map.set(thread_id, project_uuid) + await message.reply_text(f"βœ… Π’ΠΎΠΏΠΈΠΊ привязан ΠΊ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Ρƒ {_escape_html(slug)}", parse_mode="HTML") + logger.info("Linked topic %d -> project %s (%s)", thread_id, slug, project_uuid[:8]) + + +async def cmd_unlink(update: Update, context: ContextTypes.DEFAULT_TYPE): + """/unlink β€” unlink current topic from project.""" + message = update.effective_message + if not message or message.chat_id != config.group_id: + return + + thread_id = message.message_thread_id + if not thread_id: + await message.reply_text("⚠️ Π­Ρ‚Ρƒ ΠΊΠΎΠΌΠ°Π½Π΄Ρƒ ΠΌΠΎΠΆΠ½ΠΎ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π² Ρ‚ΠΎΠΏΠΈΠΊΠ΅.") + return + + topic_map.remove_by_topic(thread_id) + await message.reply_text("βœ… ΠŸΡ€ΠΈΠ²ΡΠ·ΠΊΠ° снята.") + + +async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE): + """/bridge_status β€” show bridge status and mappings.""" + message = update.effective_message + if not message: + return + + mappings = topic_map.all() + if not mappings: + await message.reply_text("НСт привязанных Ρ‚ΠΎΠΏΠΈΠΊΠΎΠ².") + return + + lines = ["ΠŸΡ€ΠΈΠ²ΡΠ·ΠΊΠΈ Ρ‚ΠΎΠΏΠΈΠΊ β†’ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚:"] + for tid, puuid in mappings.items(): + lines.append(f"β€’ topic {tid} β†’ {puuid[:8]}...") + await message.reply_text("\n".join(lines), parse_mode="HTML") + + +# --- Helpers --- + +_bot: Bot | None = None + + +async def _send_to_topic(topic_id: int, text: str): + """Send a message to a specific Telegram topic.""" + global _bot + if _bot is None: + _bot = Bot(config.bot_token) + + try: + await _bot.send_message( + chat_id=config.group_id, + message_thread_id=topic_id, + text=text, + parse_mode="HTML", + ) + except Exception as e: + logger.error("Failed to send to topic %d: %s", topic_id, e) + + +def _escape_html(text: str) -> str: + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +async def _resolve_project(slug: str) -> str | None: + """Look up project UUID by slug via Tracker API.""" + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{config.tracker_url}/api/v1/projects", + headers={"Authorization": f"Bearer {config.bridge_token}"}, + ) + if resp.status_code != 200: + return None + projects = resp.json() + for p in projects: + if p.get("slug") == slug: + return p.get("id") + except Exception as e: + logger.error("Failed to resolve project '%s': %s", slug, e) + return None + + +# --- Main --- + +async def main(): + """Start both Telegram bot and Tracker WS client.""" + app = Application.builder().token(config.bot_token).build() + + app.add_handler(CommandHandler("link", cmd_link)) + app.add_handler(CommandHandler("unlink", cmd_unlink)) + app.add_handler(CommandHandler("bridge_status", cmd_status)) + app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_telegram_message)) + + # Start Telegram polling in background + await app.initialize() + await app.start() + await app.updater.start_polling(drop_pending_updates=True) + + logger.info("Bridge started! Telegram polling + Tracker WS") + + # Run Tracker WS client (blocks until stopped) + try: + await tracker.connect() + finally: + tracker.stop() + await app.updater.stop() + await app.stop() + await app.shutdown() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/config.py b/config.py new file mode 100644 index 0000000..7ea03e4 --- /dev/null +++ b/config.py @@ -0,0 +1,25 @@ +"""Bridge configuration.""" + +import os +from dataclasses import dataclass +from dotenv import load_dotenv + +load_dotenv() + + +@dataclass +class Config: + # Telegram + bot_token: str = os.getenv("TELEGRAM_BOT_TOKEN", "") + group_id: int = int(os.getenv("TELEGRAM_GROUP_ID", "0")) + + # Tracker + tracker_url: str = os.getenv("TRACKER_URL", "https://dev.team.uix.su") + tracker_ws_url: str = os.getenv("TRACKER_WS_URL", "wss://dev.team.uix.su/ws") + bridge_token: str = os.getenv("BRIDGE_TOKEN", "tb-bridge-dev-token") + + def validate(self): + if not self.bot_token: + raise ValueError("TELEGRAM_BOT_TOKEN is required") + if not self.group_id: + raise ValueError("TELEGRAM_GROUP_ID is required") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d7265d7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +python-telegram-bot[webhooks]==21.* +websockets>=12.0 +httpx>=0.27 +pydantic>=2.0 +python-dotenv>=1.0 diff --git a/topic_map.py b/topic_map.py new file mode 100644 index 0000000..4fbc16a --- /dev/null +++ b/topic_map.py @@ -0,0 +1,52 @@ +"""Persistent mapping between Telegram topics and Tracker projects.""" + +import json +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +MAP_FILE = Path(__file__).parent / "data" / "topic_map.json" + + +class TopicMap: + """Bidirectional mapping: topic_id <-> project_uuid.""" + + def __init__(self): + self._by_topic: dict[int, str] = {} # topic_id -> project_uuid + self._by_project: dict[str, int] = {} # project_uuid -> topic_id + self._load() + + def _load(self): + if MAP_FILE.exists(): + data = json.loads(MAP_FILE.read_text()) + for tid_str, puuid in data.items(): + tid = int(tid_str) + self._by_topic[tid] = puuid + self._by_project[puuid] = tid + logger.info("Loaded %d topic mappings", len(self._by_topic)) + + def _save(self): + MAP_FILE.parent.mkdir(parents=True, exist_ok=True) + data = {str(tid): puuid for tid, puuid in self._by_topic.items()} + MAP_FILE.write_text(json.dumps(data, indent=2)) + + def set(self, topic_id: int, project_uuid: str): + self._by_topic[topic_id] = project_uuid + self._by_project[project_uuid] = topic_id + self._save() + + def get_project(self, topic_id: int) -> str | None: + return self._by_topic.get(topic_id) + + def get_topic(self, project_uuid: str) -> int | None: + return self._by_project.get(project_uuid) + + def remove_by_topic(self, topic_id: int): + puuid = self._by_topic.pop(topic_id, None) + if puuid: + self._by_project.pop(puuid, None) + self._save() + + def all(self) -> dict[int, str]: + return dict(self._by_topic) diff --git a/tracker_client.py b/tracker_client.py new file mode 100644 index 0000000..ac388d2 --- /dev/null +++ b/tracker_client.py @@ -0,0 +1,80 @@ +"""WebSocket client for Tracker.""" + +import asyncio +import json +import logging +from typing import Callable, Awaitable + +import websockets +from websockets.asyncio.client import connect + +from config import Config + +logger = logging.getLogger(__name__) + + +class TrackerClient: + """Connects to Tracker WS as a bridge member.""" + + def __init__(self, config: Config, on_message: Callable[[dict], Awaitable[None]]): + self.config = config + self.on_message = on_message + self._ws = None + self._topic_map: dict[str, int] = {} # project_uuid -> topic_id + self._running = False + + async def connect(self): + """Connect and authenticate.""" + url = f"{self.config.tracker_ws_url}?token={self.config.bridge_token}" + self._running = True + + while self._running: + try: + async with connect(url) as ws: + self._ws = ws + logger.info("Connected to Tracker WS") + + async for raw in ws: + try: + event = json.loads(raw) + await self._handle_event(event) + except json.JSONDecodeError: + logger.warning("Invalid JSON from Tracker: %s", raw[:200]) + except websockets.ConnectionClosed: + logger.warning("Tracker WS closed, reconnecting in 5s...") + await asyncio.sleep(5) + except Exception as e: + logger.error("Tracker WS error: %s, reconnecting in 5s...", e) + await asyncio.sleep(5) + + async def _handle_event(self, event: dict): + """Route Tracker events.""" + event_type = event.get("type", "") + + if event_type == "auth.ok": + logger.info("Authenticated as bridge") + return + + if event_type == "error": + logger.error("Tracker error: %s", event.get("message")) + return + + # Forward message events to Telegram + if event_type in ("message.new", "task.updated", "task.created"): + await self.on_message(event) + + async def send_message(self, project_uuid: str, text: str, author_name: str = None): + """Send a chat message to Tracker.""" + if not self._ws: + logger.warning("Not connected to Tracker") + return + + payload = { + "type": "chat.send", + "project_id": project_uuid, + "text": text, + } + await self._ws.send(json.dumps(payload)) + + def stop(self): + self._running = False