242 lines
7.2 KiB
Python
242 lines
7.2 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 == "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"<b>{_escape_html(author_name)}</b>: {_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"📋 Новая задача <b>{_escape_html(key)}</b>: {_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 <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 _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())
|