bridge/bridge.py

300 lines
9.3 KiB
Python

"""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", {})
logger.info("message.new from %s: %s", author.get("slug", "?"), (msg.get("content", ""))[:50])
# Skip messages from bridge itself (avoid echo)
author_slug = author.get("slug", "")
if author_slug == "bridge":
return
# Skip messages that bridge sent on behalf of Telegram users
# (they contain "[Username]" prefix)
text_content = msg.get("content", "")
if text_content.startswith("[") and "] " in text_content[:50]:
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"<b>{_escape_html(author_name)}</b>: {_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"📋 Новая задача <b>{_escape_html(key)}</b>: {_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 <project_slug> — 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 <project_slug>")
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"✅ Топик привязан к проекту <b>{_escape_html(slug)}</b>", 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 = ["<b>Привязки топик → проект:</b>"]
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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())