"""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())