Initial bridge: Telegram <-> Tracker
This commit is contained in:
commit
93f1d6b22e
8
.env.example
Normal file
8
.env.example
Normal file
@ -0,0 +1,8 @@
|
||||
# Telegram
|
||||
TELEGRAM_BOT_TOKEN=your-bot-token
|
||||
TELEGRAM_GROUP_ID=your-group-id
|
||||
|
||||
# Tracker
|
||||
TRACKER_URL=https://dev.team.uix.su
|
||||
TRACKER_WS_URL=wss://dev.team.uix.su/ws
|
||||
BRIDGE_TOKEN=tb-bridge-dev-token
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
venv/
|
||||
.env
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
241
bridge.py
Normal file
241
bridge.py
Normal file
@ -0,0 +1,241 @@
|
||||
"""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())
|
||||
25
config.py
Normal file
25
config.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""Bridge configuration."""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
# Telegram
|
||||
bot_token: str = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||
group_id: int = int(os.getenv("TELEGRAM_GROUP_ID", "0"))
|
||||
|
||||
# Tracker
|
||||
tracker_url: str = os.getenv("TRACKER_URL", "https://dev.team.uix.su")
|
||||
tracker_ws_url: str = os.getenv("TRACKER_WS_URL", "wss://dev.team.uix.su/ws")
|
||||
bridge_token: str = os.getenv("BRIDGE_TOKEN", "tb-bridge-dev-token")
|
||||
|
||||
def validate(self):
|
||||
if not self.bot_token:
|
||||
raise ValueError("TELEGRAM_BOT_TOKEN is required")
|
||||
if not self.group_id:
|
||||
raise ValueError("TELEGRAM_GROUP_ID is required")
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@ -0,0 +1,5 @@
|
||||
python-telegram-bot[webhooks]==21.*
|
||||
websockets>=12.0
|
||||
httpx>=0.27
|
||||
pydantic>=2.0
|
||||
python-dotenv>=1.0
|
||||
52
topic_map.py
Normal file
52
topic_map.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""Persistent mapping between Telegram topics and Tracker projects."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAP_FILE = Path(__file__).parent / "data" / "topic_map.json"
|
||||
|
||||
|
||||
class TopicMap:
|
||||
"""Bidirectional mapping: topic_id <-> project_uuid."""
|
||||
|
||||
def __init__(self):
|
||||
self._by_topic: dict[int, str] = {} # topic_id -> project_uuid
|
||||
self._by_project: dict[str, int] = {} # project_uuid -> topic_id
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
if MAP_FILE.exists():
|
||||
data = json.loads(MAP_FILE.read_text())
|
||||
for tid_str, puuid in data.items():
|
||||
tid = int(tid_str)
|
||||
self._by_topic[tid] = puuid
|
||||
self._by_project[puuid] = tid
|
||||
logger.info("Loaded %d topic mappings", len(self._by_topic))
|
||||
|
||||
def _save(self):
|
||||
MAP_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {str(tid): puuid for tid, puuid in self._by_topic.items()}
|
||||
MAP_FILE.write_text(json.dumps(data, indent=2))
|
||||
|
||||
def set(self, topic_id: int, project_uuid: str):
|
||||
self._by_topic[topic_id] = project_uuid
|
||||
self._by_project[project_uuid] = topic_id
|
||||
self._save()
|
||||
|
||||
def get_project(self, topic_id: int) -> str | None:
|
||||
return self._by_topic.get(topic_id)
|
||||
|
||||
def get_topic(self, project_uuid: str) -> int | None:
|
||||
return self._by_project.get(project_uuid)
|
||||
|
||||
def remove_by_topic(self, topic_id: int):
|
||||
puuid = self._by_topic.pop(topic_id, None)
|
||||
if puuid:
|
||||
self._by_project.pop(puuid, None)
|
||||
self._save()
|
||||
|
||||
def all(self) -> dict[int, str]:
|
||||
return dict(self._by_topic)
|
||||
80
tracker_client.py
Normal file
80
tracker_client.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""WebSocket client for Tracker."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Callable, Awaitable
|
||||
|
||||
import websockets
|
||||
from websockets.asyncio.client import connect
|
||||
|
||||
from config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TrackerClient:
|
||||
"""Connects to Tracker WS as a bridge member."""
|
||||
|
||||
def __init__(self, config: Config, on_message: Callable[[dict], Awaitable[None]]):
|
||||
self.config = config
|
||||
self.on_message = on_message
|
||||
self._ws = None
|
||||
self._topic_map: dict[str, int] = {} # project_uuid -> topic_id
|
||||
self._running = False
|
||||
|
||||
async def connect(self):
|
||||
"""Connect and authenticate."""
|
||||
url = f"{self.config.tracker_ws_url}?token={self.config.bridge_token}"
|
||||
self._running = True
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
async with connect(url) as ws:
|
||||
self._ws = ws
|
||||
logger.info("Connected to Tracker WS")
|
||||
|
||||
async for raw in ws:
|
||||
try:
|
||||
event = json.loads(raw)
|
||||
await self._handle_event(event)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Invalid JSON from Tracker: %s", raw[:200])
|
||||
except websockets.ConnectionClosed:
|
||||
logger.warning("Tracker WS closed, reconnecting in 5s...")
|
||||
await asyncio.sleep(5)
|
||||
except Exception as e:
|
||||
logger.error("Tracker WS error: %s, reconnecting in 5s...", e)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _handle_event(self, event: dict):
|
||||
"""Route Tracker events."""
|
||||
event_type = event.get("type", "")
|
||||
|
||||
if event_type == "auth.ok":
|
||||
logger.info("Authenticated as bridge")
|
||||
return
|
||||
|
||||
if event_type == "error":
|
||||
logger.error("Tracker error: %s", event.get("message"))
|
||||
return
|
||||
|
||||
# Forward message events to Telegram
|
||||
if event_type in ("message.new", "task.updated", "task.created"):
|
||||
await self.on_message(event)
|
||||
|
||||
async def send_message(self, project_uuid: str, text: str, author_name: str = None):
|
||||
"""Send a chat message to Tracker."""
|
||||
if not self._ws:
|
||||
logger.warning("Not connected to Tracker")
|
||||
return
|
||||
|
||||
payload = {
|
||||
"type": "chat.send",
|
||||
"project_id": project_uuid,
|
||||
"text": text,
|
||||
}
|
||||
await self._ws.send(json.dumps(payload))
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
Loading…
Reference in New Issue
Block a user