"""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 == "agent.status": data = event.get("data", {}) slug = data.get("slug", "") status = data.get("status", "") if status == "online": _online_members.add(slug) else: _online_members.discard(slug) return if event_type == "message.new": msg = event.get("data", {}) 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("content", "") if not text: return # Format: "Author: message" tg_text = f"{_escape_html(author_name)}: {_escape_html(text)}" # Silent if any human is online in web silent = any(s for s in _online_members if s not in ("coder", "architect", "bridge")) await _send_to_topic(topic_id, tg_text, silent=silent) elif event_type == "task.created": task = event.get("data", {}) 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)}" silent = any(s for s in _online_members if s not in ("coder", "architect", "bridge")) await _send_to_topic(topic_id, tg_text, silent=silent) elif event_type == "project.created": project = event.get("project", {}) project_id = project.get("id") project_name = project.get("name", project.get("slug", "???")) if not project_id: return # Don't create if already mapped if topic_map.get_topic(project_id): return # Auto-create topic in Telegram topic_id = await _create_topic(project_name) if topic_id: topic_map.set(topic_id, project_id) await _send_to_topic(topic_id, f"πŸ”— Π’ΠΎΠΏΠΈΠΊ привязан ΠΊ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Ρƒ {_escape_html(project_name)}") logger.info("Auto-created topic %d for project %s (%s)", topic_id, project_name, project_id[:8]) # Track online members _online_members: set[str] = set() # member slugs that are online 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 _create_topic(name: str) -> int | None: """Create a new forum topic in the Telegram group.""" global _bot if _bot is None: _bot = Bot(config.bot_token) try: topic = await _bot.create_forum_topic( chat_id=config.group_id, name=name, ) logger.info("Created Telegram topic: id=%d name=%s", topic.message_thread_id, name) return topic.message_thread_id except Exception as e: logger.error("Failed to create topic '%s': %s", name, e) return None async def _send_to_topic(topic_id: int, text: str, silent: bool = False): """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", disable_notification=silent, ) 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())