"""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": data = event.get("data", {}) actor = data.get("actor") or {} payload = data.get("payload") or {} evt_type = data.get("type", "") content = payload.get("content", "") logger.info("message.new type=%s from %s: %s", evt_type, actor.get("slug", "?"), content[:50]) # Only forward chat messages (not task_comment, task_status, etc.) if evt_type not in ("chat_message",): return # Skip messages from bridge itself (avoid echo) actor_slug = actor.get("slug", "") if actor_slug == "bridge": return # Skip messages that bridge sent on behalf of Telegram users # (they contain "[Username]" prefix) if content.startswith("[") and "] " in content[:50]: return project_id = data.get("project_id") if not project_id: return topic_id = topic_map.get_topic(project_id) if not topic_id: return if not content: return actor_name = actor.get("name", actor.get("slug", "???")) tg_text = f"{_escape_html(actor_name)}: {_escape_html(content)}" # 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 disabled β€” use /link to bind topics manually logger.info("New project %s (%s) β€” use /link to bind a topic", 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())