From 2792e916c2f82533206e7fef9cc1ba2a4a59a10a Mon Sep 17 00:00:00 2001 From: Claude Dev Date: Fri, 13 Mar 2026 19:08:36 +0100 Subject: [PATCH] Telegram-Kategorie-Checkboxen: Nutzer koennen bei Lage-Erstellung einzelne Telegram-Quellkategorien auswaehlen --- src/agents/orchestrator.py | 10 +- src/database.py | 5 + src/feeds/telegram_parser.py | 513 ++++++++++++++++++----------------- src/models.py | 3 + src/routers/incidents.py | 24 +- src/static/css/style.css | 51 ++++ src/static/dashboard.html | 32 ++- src/static/js/app.js | 58 +++- 8 files changed, 435 insertions(+), 261 deletions(-) diff --git a/src/agents/orchestrator.py b/src/agents/orchestrator.py index 34b48e9..5fa7f77 100644 --- a/src/agents/orchestrator.py +++ b/src/agents/orchestrator.py @@ -536,6 +536,14 @@ class AgentOrchestrator: incident_type = incident["type"] or "adhoc" international = bool(incident["international_sources"]) if "international_sources" in incident.keys() else True include_telegram = bool(incident["include_telegram"]) if "include_telegram" in incident.keys() else False + telegram_categories_raw = incident["telegram_categories"] if "telegram_categories" in incident.keys() else None + telegram_categories = None + if telegram_categories_raw: + import json + try: + telegram_categories = json.loads(telegram_categories_raw) if isinstance(telegram_categories_raw, str) else telegram_categories_raw + except (json.JSONDecodeError, TypeError): + telegram_categories = None visibility = incident["visibility"] if "visibility" in incident.keys() else "public" created_by = incident["created_by"] if "created_by" in incident.keys() else None tenant_id = incident["tenant_id"] if "tenant_id" in incident.keys() else None @@ -625,7 +633,7 @@ class AgentOrchestrator: """Telegram-Kanal-Suche.""" from feeds.telegram_parser import TelegramParser tg_parser = TelegramParser() - articles = await tg_parser.search_channels(title, tenant_id=tenant_id, keywords=None) + articles = await tg_parser.search_channels(title, tenant_id=tenant_id, keywords=None, categories=telegram_categories) logger.info(f"Telegram-Pipeline: {len(articles)} Nachrichten") return articles, None diff --git a/src/database.py b/src/database.py index 3d99fcf..fcb9fc0 100644 --- a/src/database.py +++ b/src/database.py @@ -264,6 +264,11 @@ async def init_db(): await db.commit() logger.info("Migration: include_telegram zu incidents hinzugefuegt") + if "telegram_categories" not in columns: + await db.execute("ALTER TABLE incidents ADD COLUMN telegram_categories TEXT DEFAULT NULL") + await db.commit() + logger.info("Migration: telegram_categories zu incidents hinzugefuegt") + if "tenant_id" not in columns: await db.execute("ALTER TABLE incidents ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)") diff --git a/src/feeds/telegram_parser.py b/src/feeds/telegram_parser.py index be3b49f..248655a 100644 --- a/src/feeds/telegram_parser.py +++ b/src/feeds/telegram_parser.py @@ -1,251 +1,262 @@ -"""Telegram-Kanal Parser: Liest Nachrichten aus konfigurierten Telegram-Kanaelen.""" -import asyncio -import logging -import os -from datetime import datetime, timezone -from typing import Optional - -from config import TIMEZONE, TELEGRAM_API_ID, TELEGRAM_API_HASH, TELEGRAM_SESSION_PATH - -logger = logging.getLogger("osint.telegram") - -# Stoppwoerter (gleich wie RSS-Parser) -STOP_WORDS = { - "und", "oder", "der", "die", "das", "ein", "eine", "in", "im", "am", "an", - "auf", "fuer", "mit", "von", "zu", "zum", "zur", "bei", "nach", "vor", - "ueber", "unter", "ist", "sind", "hat", "the", "and", "for", "with", "from", -} - - -class TelegramParser: - """Durchsucht Telegram-Kanaele nach relevanten Nachrichten.""" - - _client = None - _lock = asyncio.Lock() - - async def _get_client(self): - """Telethon-Client erstellen oder wiederverwenden.""" - if TelegramParser._client is not None: - if TelegramParser._client.is_connected(): - return TelegramParser._client - - async with TelegramParser._lock: - # Double-check nach Lock - if TelegramParser._client is not None and TelegramParser._client.is_connected(): - return TelegramParser._client - - try: - from telethon import TelegramClient - session_path = TELEGRAM_SESSION_PATH - if not os.path.exists(session_path + ".session") and not os.path.exists(session_path): - logger.error("Telegram-Session nicht gefunden: %s", session_path) - return None - - client = TelegramClient(session_path, TELEGRAM_API_ID, TELEGRAM_API_HASH) - await client.connect() - - if not await client.is_user_authorized(): - logger.error("Telegram-Session nicht autorisiert. Bitte neu einloggen.") - await client.disconnect() - return None - - TelegramParser._client = client - me = await client.get_me() - logger.info("Telegram verbunden als: %s (%s)", me.first_name, me.phone) - return client - except ImportError: - logger.error("telethon nicht installiert: pip install telethon") - return None - except Exception as e: - logger.error("Telegram-Verbindung fehlgeschlagen: %s", e) - return None - - async def search_channels(self, search_term: str, tenant_id: int = None, - keywords: list[str] = None) -> list[dict]: - """Liest Nachrichten aus konfigurierten Telegram-Kanaelen. - - Gibt Artikel-Dicts zurueck (kompatibel mit RSS-Parser-Format). - """ - client = await self._get_client() - if not client: - logger.warning("Telegram-Client nicht verfuegbar, ueberspringe Telegram-Pipeline") - return [] - - # Telegram-Kanaele aus DB laden - channels = await self._get_telegram_channels(tenant_id) - if not channels: - logger.info("Keine Telegram-Kanaele konfiguriert") - return [] - - # Suchwoerter vorbereiten - if keywords: - search_words = [w.lower().strip() for w in keywords if w.strip()] - else: - search_words = [ - w for w in search_term.lower().split() - if w not in STOP_WORDS and len(w) >= 3 - ] - if not search_words: - search_words = search_term.lower().split()[:2] - - # Kanaele parallel abrufen - tasks = [] - for ch in channels: - channel_id = ch["url"] or ch["name"] - tasks.append(self._fetch_channel(client, channel_id, search_words)) - - results = await asyncio.gather(*tasks, return_exceptions=True) - - all_articles = [] - for i, result in enumerate(results): - if isinstance(result, Exception): - logger.warning("Telegram-Kanal %s: %s", channels[i]["name"], result) - continue - all_articles.extend(result) - - logger.info("Telegram: %d relevante Nachrichten aus %d Kanaelen", len(all_articles), len(channels)) - return all_articles - - async def _get_telegram_channels(self, tenant_id: int = None) -> list[dict]: - """Laedt Telegram-Kanaele aus der sources-Tabelle.""" - try: - from database import get_db - db = await get_db() - try: - cursor = await db.execute( - """SELECT id, name, url FROM sources - WHERE source_type = 'telegram_channel' - AND status = 'active' - AND (tenant_id IS NULL OR tenant_id = ?)""", - (tenant_id,), - ) - rows = await cursor.fetchall() - return [dict(row) for row in rows] - finally: - await db.close() - except Exception as e: - logger.error("Fehler beim Laden der Telegram-Kanaele: %s", e) - return [] - - async def _fetch_channel(self, client, channel_id: str, search_words: list[str], - limit: int = 50) -> list[dict]: - """Letzte N Nachrichten eines Kanals abrufen und nach Keywords filtern.""" - articles = [] - try: - # Kanal-Identifier normalisieren - identifier = channel_id.strip() - if identifier.startswith("https://t.me/"): - identifier = identifier.replace("https://t.me/", "") - if identifier.startswith("t.me/"): - identifier = identifier.replace("t.me/", "") - - # Privater Invite-Link - if identifier.startswith("+") or identifier.startswith("joinchat/"): - entity = await client.get_entity(channel_id) - else: - # Oeffentlicher Kanal - if not identifier.startswith("@"): - identifier = "@" + identifier - entity = await client.get_entity(identifier) - - messages = await client.get_messages(entity, limit=limit) - - channel_title = getattr(entity, "title", identifier) - channel_username = getattr(entity, "username", identifier.replace("@", "")) - - for msg in messages: - if not msg.text: - continue - - text = msg.text - text_lower = text.lower() - - # Keyword-Matching (gleiche Logik wie RSS-Parser) - min_matches = min(2, max(1, (len(search_words) + 1) // 2)) - match_count = sum(1 for word in search_words if word in text_lower) - - if match_count < min_matches: - continue - - # Erste Zeile als Headline, Rest als Content - lines = text.strip().split("\n") - headline = lines[0][:200] if lines else text[:200] - content = text - - # Datum - published = None - if msg.date: - try: - published = msg.date.astimezone(TIMEZONE).isoformat() - except Exception: - published = msg.date.isoformat() - - # Source-URL: t.me/channel/msg_id - if channel_username: - source_url = "https://t.me/%s/%s" % (channel_username, msg.id) - else: - source_url = "https://t.me/c/%s/%s" % (entity.id, msg.id) - - relevance_score = match_count / len(search_words) if search_words else 0.0 - - articles.append({ - "headline": headline, - "headline_de": headline if self._is_german(headline) else None, - "source": "Telegram: %s" % channel_title, - "source_url": source_url, - "content_original": content[:2000], - "content_de": content[:2000] if self._is_german(content) else None, - "language": "de" if self._is_german(content) else "en", - "published_at": published, - "relevance_score": relevance_score, - }) - - except Exception as e: - logger.warning("Telegram-Kanal %s: %s", channel_id, e) - - return articles - - async def validate_channel(self, channel_id: str) -> Optional[dict]: - """Prueft ob ein Telegram-Kanal erreichbar ist und gibt Info zurueck.""" - client = await self._get_client() - if not client: - return None - - try: - identifier = channel_id.strip() - if identifier.startswith("https://t.me/"): - identifier = identifier.replace("https://t.me/", "") - if identifier.startswith("t.me/"): - identifier = identifier.replace("t.me/", "") - - if identifier.startswith("+") or identifier.startswith("joinchat/"): - return {"valid": True, "name": "Privater Kanal", "description": "Privater Einladungslink", "subscribers": None} - - if not identifier.startswith("@"): - identifier = "@" + identifier - - entity = await client.get_entity(identifier) - - from telethon.tl.functions.channels import GetFullChannelRequest - full = await client(GetFullChannelRequest(entity)) - - return { - "valid": True, - "name": getattr(entity, "title", identifier), - "description": getattr(full.full_chat, "about", "") or "", - "subscribers": getattr(full.full_chat, "participants_count", None), - "username": getattr(entity, "username", ""), - } - except Exception as e: - logger.warning("Telegram-Kanal-Validierung fehlgeschlagen fuer %s: %s", channel_id, e) - return None - - def _is_german(self, text: str) -> bool: - """Einfache Heuristik ob ein Text deutsch ist.""" - german_words = {"der", "die", "das", "und", "ist", "von", "mit", "fuer", "auf", "ein", - "eine", "den", "dem", "des", "sich", "wird", "nach", "bei", "auch", - "ueber", "wie", "aus", "hat", "zum", "zur", "als", "noch", "mehr", - "nicht", "aber", "oder", "sind", "vor", "einem", "einer", "wurde"} - words = set(text.lower().split()) - matches = words & german_words - return len(matches) >= 2 +"""Telegram-Kanal Parser: Liest Nachrichten aus konfigurierten Telegram-Kanaelen.""" +import asyncio +import logging +import os +from datetime import datetime, timezone +from typing import Optional + +from config import TIMEZONE, TELEGRAM_API_ID, TELEGRAM_API_HASH, TELEGRAM_SESSION_PATH + +logger = logging.getLogger("osint.telegram") + +# Stoppwoerter (gleich wie RSS-Parser) +STOP_WORDS = { + "und", "oder", "der", "die", "das", "ein", "eine", "in", "im", "am", "an", + "auf", "fuer", "mit", "von", "zu", "zum", "zur", "bei", "nach", "vor", + "ueber", "unter", "ist", "sind", "hat", "the", "and", "for", "with", "from", +} + + +class TelegramParser: + """Durchsucht Telegram-Kanaele nach relevanten Nachrichten.""" + + _client = None + _lock = asyncio.Lock() + + async def _get_client(self): + """Telethon-Client erstellen oder wiederverwenden.""" + if TelegramParser._client is not None: + if TelegramParser._client.is_connected(): + return TelegramParser._client + + async with TelegramParser._lock: + # Double-check nach Lock + if TelegramParser._client is not None and TelegramParser._client.is_connected(): + return TelegramParser._client + + try: + from telethon import TelegramClient + session_path = TELEGRAM_SESSION_PATH + if not os.path.exists(session_path + ".session") and not os.path.exists(session_path): + logger.error("Telegram-Session nicht gefunden: %s", session_path) + return None + + client = TelegramClient(session_path, TELEGRAM_API_ID, TELEGRAM_API_HASH) + await client.connect() + + if not await client.is_user_authorized(): + logger.error("Telegram-Session nicht autorisiert. Bitte neu einloggen.") + await client.disconnect() + return None + + TelegramParser._client = client + me = await client.get_me() + logger.info("Telegram verbunden als: %s (%s)", me.first_name, me.phone) + return client + except ImportError: + logger.error("telethon nicht installiert: pip install telethon") + return None + except Exception as e: + logger.error("Telegram-Verbindung fehlgeschlagen: %s", e) + return None + + async def search_channels(self, search_term: str, tenant_id: int = None, + keywords: list[str] = None, categories: list[str] = None) -> list[dict]: + """Liest Nachrichten aus konfigurierten Telegram-Kanaelen. + + Gibt Artikel-Dicts zurueck (kompatibel mit RSS-Parser-Format). + """ + client = await self._get_client() + if not client: + logger.warning("Telegram-Client nicht verfuegbar, ueberspringe Telegram-Pipeline") + return [] + + # Telegram-Kanaele aus DB laden + channels = await self._get_telegram_channels(tenant_id, categories=categories) + if not channels: + logger.info("Keine Telegram-Kanaele konfiguriert") + return [] + + # Suchwoerter vorbereiten + if keywords: + search_words = [w.lower().strip() for w in keywords if w.strip()] + else: + search_words = [ + w for w in search_term.lower().split() + if w not in STOP_WORDS and len(w) >= 3 + ] + if not search_words: + search_words = search_term.lower().split()[:2] + + # Kanaele parallel abrufen + tasks = [] + for ch in channels: + channel_id = ch["url"] or ch["name"] + tasks.append(self._fetch_channel(client, channel_id, search_words)) + + results = await asyncio.gather(*tasks, return_exceptions=True) + + all_articles = [] + for i, result in enumerate(results): + if isinstance(result, Exception): + logger.warning("Telegram-Kanal %s: %s", channels[i]["name"], result) + continue + all_articles.extend(result) + + logger.info("Telegram: %d relevante Nachrichten aus %d Kanaelen", len(all_articles), len(channels)) + return all_articles + + async def _get_telegram_channels(self, tenant_id: int = None, categories: list[str] = None) -> list[dict]: + """Laedt Telegram-Kanaele aus der sources-Tabelle.""" + try: + from database import get_db + db = await get_db() + try: + if categories and len(categories) > 0: + placeholders = ",".join("?" for _ in categories) + cursor = await db.execute( + f"""SELECT id, name, url FROM sources + WHERE source_type = 'telegram_channel' + AND status = 'active' + AND (tenant_id IS NULL OR tenant_id = ?) + AND category IN ({placeholders})""", + (tenant_id, *categories), + ) + else: + cursor = await db.execute( + """SELECT id, name, url FROM sources + WHERE source_type = 'telegram_channel' + AND status = 'active' + AND (tenant_id IS NULL OR tenant_id = ?)""", + (tenant_id,), + ) + rows = await cursor.fetchall() + return [dict(row) for row in rows] + finally: + await db.close() + except Exception as e: + logger.error("Fehler beim Laden der Telegram-Kanaele: %s", e) + return [] + + async def _fetch_channel(self, client, channel_id: str, search_words: list[str], + limit: int = 50) -> list[dict]: + """Letzte N Nachrichten eines Kanals abrufen und nach Keywords filtern.""" + articles = [] + try: + # Kanal-Identifier normalisieren + identifier = channel_id.strip() + if identifier.startswith("https://t.me/"): + identifier = identifier.replace("https://t.me/", "") + if identifier.startswith("t.me/"): + identifier = identifier.replace("t.me/", "") + + # Privater Invite-Link + if identifier.startswith("+") or identifier.startswith("joinchat/"): + entity = await client.get_entity(channel_id) + else: + # Oeffentlicher Kanal + if not identifier.startswith("@"): + identifier = "@" + identifier + entity = await client.get_entity(identifier) + + messages = await client.get_messages(entity, limit=limit) + + channel_title = getattr(entity, "title", identifier) + channel_username = getattr(entity, "username", identifier.replace("@", "")) + + for msg in messages: + if not msg.text: + continue + + text = msg.text + text_lower = text.lower() + + # Keyword-Matching (gleiche Logik wie RSS-Parser) + min_matches = min(2, max(1, (len(search_words) + 1) // 2)) + match_count = sum(1 for word in search_words if word in text_lower) + + if match_count < min_matches: + continue + + # Erste Zeile als Headline, Rest als Content + lines = text.strip().split("\n") + headline = lines[0][:200] if lines else text[:200] + content = text + + # Datum + published = None + if msg.date: + try: + published = msg.date.astimezone(TIMEZONE).isoformat() + except Exception: + published = msg.date.isoformat() + + # Source-URL: t.me/channel/msg_id + if channel_username: + source_url = "https://t.me/%s/%s" % (channel_username, msg.id) + else: + source_url = "https://t.me/c/%s/%s" % (entity.id, msg.id) + + relevance_score = match_count / len(search_words) if search_words else 0.0 + + articles.append({ + "headline": headline, + "headline_de": headline if self._is_german(headline) else None, + "source": "Telegram: %s" % channel_title, + "source_url": source_url, + "content_original": content[:2000], + "content_de": content[:2000] if self._is_german(content) else None, + "language": "de" if self._is_german(content) else "en", + "published_at": published, + "relevance_score": relevance_score, + }) + + except Exception as e: + logger.warning("Telegram-Kanal %s: %s", channel_id, e) + + return articles + + async def validate_channel(self, channel_id: str) -> Optional[dict]: + """Prueft ob ein Telegram-Kanal erreichbar ist und gibt Info zurueck.""" + client = await self._get_client() + if not client: + return None + + try: + identifier = channel_id.strip() + if identifier.startswith("https://t.me/"): + identifier = identifier.replace("https://t.me/", "") + if identifier.startswith("t.me/"): + identifier = identifier.replace("t.me/", "") + + if identifier.startswith("+") or identifier.startswith("joinchat/"): + return {"valid": True, "name": "Privater Kanal", "description": "Privater Einladungslink", "subscribers": None} + + if not identifier.startswith("@"): + identifier = "@" + identifier + + entity = await client.get_entity(identifier) + + from telethon.tl.functions.channels import GetFullChannelRequest + full = await client(GetFullChannelRequest(entity)) + + return { + "valid": True, + "name": getattr(entity, "title", identifier), + "description": getattr(full.full_chat, "about", "") or "", + "subscribers": getattr(full.full_chat, "participants_count", None), + "username": getattr(entity, "username", ""), + } + except Exception as e: + logger.warning("Telegram-Kanal-Validierung fehlgeschlagen fuer %s: %s", channel_id, e) + return None + + def _is_german(self, text: str) -> bool: + """Einfache Heuristik ob ein Text deutsch ist.""" + german_words = {"der", "die", "das", "und", "ist", "von", "mit", "fuer", "auf", "ein", + "eine", "den", "dem", "des", "sich", "wird", "nach", "bei", "auch", + "ueber", "wie", "aus", "hat", "zum", "zur", "als", "noch", "mehr", + "nicht", "aber", "oder", "sind", "vor", "einem", "einer", "wurde"} + words = set(text.lower().split()) + matches = words & german_words + return len(matches) >= 2 diff --git a/src/models.py b/src/models.py index c31ccb4..5358998 100644 --- a/src/models.py +++ b/src/models.py @@ -53,6 +53,7 @@ class IncidentCreate(BaseModel): retention_days: int = Field(default=0, ge=0, le=999) international_sources: bool = True include_telegram: bool = False + telegram_categories: Optional[list[str]] = None visibility: str = Field(default="public", pattern="^(public|private)$") @@ -66,6 +67,7 @@ class IncidentUpdate(BaseModel): retention_days: Optional[int] = Field(default=None, ge=0, le=999) international_sources: Optional[bool] = None include_telegram: Optional[bool] = None + telegram_categories: Optional[list[str]] = None visibility: Optional[str] = Field(default=None, pattern="^(public|private)$") @@ -83,6 +85,7 @@ class IncidentResponse(BaseModel): sources_json: Optional[str] = None international_sources: bool = True include_telegram: bool = False + telegram_categories: Optional[list[str]] = None created_by: int created_by_username: str = "" created_at: str diff --git a/src/routers/incidents.py b/src/routers/incidents.py index f7b0694..aed8687 100644 --- a/src/routers/incidents.py +++ b/src/routers/incidents.py @@ -20,7 +20,7 @@ router = APIRouter(prefix="/api/incidents", tags=["incidents"]) INCIDENT_UPDATE_COLUMNS = { "title", "description", "type", "status", "refresh_mode", - "refresh_interval", "retention_days", "international_sources", "include_telegram", "visibility", + "refresh_interval", "retention_days", "international_sources", "include_telegram", "telegram_categories", "visibility", } @@ -64,6 +64,14 @@ async def _enrich_incident(db: aiosqlite.Connection, row: aiosqlite.Row) -> dict incident["article_count"] = article_count incident["source_count"] = source_count incident["created_by_username"] = user_row["email"] if user_row else "Unbekannt" + # telegram_categories: JSON-String -> Liste + tc = incident.get("telegram_categories") + if tc and isinstance(tc, str): + import json + try: + incident["telegram_categories"] = json.loads(tc) + except (json.JSONDecodeError, TypeError): + incident["telegram_categories"] = None return incident @@ -105,9 +113,9 @@ async def create_incident( now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S') cursor = await db.execute( """INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval, - retention_days, international_sources, include_telegram, visibility, + retention_days, international_sources, include_telegram, telegram_categories, visibility, tenant_id, created_by, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( data.title, data.description, @@ -117,6 +125,7 @@ async def create_incident( data.retention_days, 1 if data.international_sources else 0, 1 if data.include_telegram else 0, + __import__('json').dumps(data.telegram_categories) if data.telegram_categories else None, data.visibility, tenant_id, current_user["id"], @@ -180,7 +189,13 @@ async def update_incident( for field, value in data.model_dump(exclude_none=True).items(): if field not in INCIDENT_UPDATE_COLUMNS: continue - updates[field] = value + if field == "telegram_categories": + import json + updates[field] = json.dumps(value) if value else None + elif field in ("international_sources", "include_telegram"): + updates[field] = 1 if value else 0 + else: + updates[field] = value if not updates: return await _enrich_incident(db, row) @@ -723,6 +738,7 @@ def _build_json_export( "summary": incident.get("summary"), "international_sources": bool(incident.get("international_sources")), "include_telegram": bool(incident.get("include_telegram")), + "telegram_categories": incident.get("telegram_categories"), }, "sources": sources, "fact_checks": [ diff --git a/src/static/css/style.css b/src/static/css/style.css index 0b6dd9e..cdce503 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -4545,3 +4545,54 @@ a.map-popup-article:hover { height: 100% !important; } + +/* Telegram Category Selection Panel */ +.tg-categories-panel { + margin-top: 8px; + padding: 12px 14px; + background: var(--bg-tertiary); + border-radius: var(--radius); + border: 1px solid var(--border); +} +.tg-cat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px 16px; +} +.tg-cat-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-primary); + cursor: pointer; + padding: 3px 0; +} +.tg-cat-item input[type="checkbox"] { + accent-color: var(--accent); + width: 15px; + height: 15px; + cursor: pointer; +} +.tg-cat-count { + font-size: 11px; + color: var(--text-disabled); + margin-left: auto; +} +.tg-cat-actions { + margin-top: 8px; + display: flex; + gap: 12px; +} +.btn-link { + background: none; + border: none; + color: var(--accent); + font-size: 12px; + cursor: pointer; + padding: 0; + text-decoration: underline; +} +.btn-link:hover { + color: var(--accent-hover); +} diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 2829177..eafb915 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -347,6 +347,24 @@
Nachrichten aus konfigurierten Telegram-Kanälen berücksichtigen
+
@@ -466,7 +484,7 @@
- +
@@ -491,11 +509,19 @@ + + + +
- - + +
diff --git a/src/static/js/app.js b/src/static/js/app.js index 2f26730..50d329c 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -502,6 +502,12 @@ const App = { document.getElementById('archive-incident-btn').addEventListener('click', () => this.handleArchive()); document.getElementById('inc-international').addEventListener('change', () => updateSourcesHint()); document.getElementById('inc-visibility').addEventListener('change', () => updateVisibilityHint()); + // Telegram-Kategorien Toggle + const tgCheckbox = document.getElementById('inc-telegram'); + if (tgCheckbox) { + tgCheckbox.addEventListener('change', function() { toggleTgCategories(this.checked); }); + } + // Feedback document.getElementById('feedback-form').addEventListener('submit', (e) => this.submitFeedback(e)); @@ -1453,6 +1459,9 @@ const App = { retention_days: parseInt(document.getElementById('inc-retention').value) || 0, international_sources: document.getElementById('inc-international').checked, include_telegram: document.getElementById('inc-telegram').checked, + telegram_categories: document.getElementById('inc-telegram').checked + ? Array.from(document.querySelectorAll('.tg-cat-cb:checked')).map(cb => cb.value) + : null, visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private', }; }, @@ -1807,6 +1816,15 @@ const App = { document.getElementById('inc-retention').value = incident.retention_days; document.getElementById('inc-international').checked = incident.international_sources !== false && incident.international_sources !== 0; document.getElementById('inc-telegram').checked = !!incident.include_telegram; + // Telegram-Kategorien wiederherstellen + toggleTgCategories(!!incident.include_telegram); + if (incident.telegram_categories) { + let cats = incident.telegram_categories; + if (typeof cats === 'string') { try { cats = JSON.parse(cats); } catch(e) { cats = []; } } + document.querySelectorAll('.tg-cat-cb').forEach(cb => { + cb.checked = cats.includes(cb.value); + }); + } document.getElementById('inc-visibility').checked = incident.visibility !== 'private'; updateVisibilityHint(); updateSourcesHint(); @@ -2596,6 +2614,7 @@ const App = { document.getElementById('src-discovery-result').style.display = 'none'; document.getElementById('src-discover-btn').disabled = false; document.getElementById('src-discover-btn').textContent = 'Erkennen'; + document.getElementById('src-type-select').value = 'rss_feed'; // Save-Button Text zurücksetzen const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary'); if (saveBtn) saveBtn.textContent = 'Speichern'; @@ -2612,6 +2631,27 @@ const App = { async discoverSource() { const urlInput = document.getElementById('src-discover-url'); + const urlVal = urlInput.value.trim(); + + // Telegram-URLs direkt behandeln (kein Discovery noetig) + if (urlVal.match(/^(https?:\/\/)?(t\.me|telegram\.me)\//i)) { + const channelName = urlVal.replace(/^(https?:\/\/)?(t\.me|telegram\.me)\//, '').replace(/\/$/, ''); + const tgUrl = 't.me/' + channelName; + this._discoveredData = { + name: '@' + channelName, + domain: 't.me', + source_type: 'telegram_channel', + rss_url: null, + }; + document.getElementById('src-name').value = '@' + channelName; + document.getElementById('src-type-select').value = 'telegram_channel'; + document.getElementById('src-domain').value = tgUrl; + document.getElementById('src-rss-url-group').style.display = 'none'; + document.getElementById('src-discovery-result').style.display = 'block'; + const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary'); + if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; } + return; + } const url = urlInput.value.trim(); if (!url) { UI.showToast('Bitte URL oder Domain eingeben.', 'warning'); @@ -2652,6 +2692,8 @@ const App = { document.getElementById('src-notes').value = ''; const typeLabel = this._discoveredData.source_type === 'rss_feed' ? 'RSS-Feed' : this._discoveredData.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle'; + const typeSelect = document.getElementById('src-type-select'); + if (typeSelect) typeSelect.value = this._discoveredData.source_type || 'web_source'; document.getElementById('src-type-display').value = typeLabel; const rssGroup = document.getElementById('src-rss-url-group'); @@ -2730,6 +2772,8 @@ const App = { document.getElementById('src-domain').value = source.domain || ''; const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : source.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle'; + const typeSelect = document.getElementById('src-type-select'); + if (typeSelect) typeSelect.value = source.source_type || 'web_source'; document.getElementById('src-type-display').value = typeLabel; const rssGroup = document.getElementById('src-rss-url-group'); @@ -2769,9 +2813,9 @@ const App = { const discovered = this._discoveredData || {}; const data = { name, - source_type: discovered.source_type || 'web_source', + source_type: document.getElementById('src-type-select') ? document.getElementById('src-type-select').value : (discovered.source_type || 'web_source'), category: document.getElementById('src-category').value, - url: discovered.rss_url || null, + url: discovered.rss_url || (discovered.source_type === 'telegram_channel' ? (document.getElementById('src-domain').value || null) : null), domain: document.getElementById('src-domain').value.trim() || discovered.domain || null, notes: document.getElementById('src-notes').value.trim() || null, }; @@ -3068,6 +3112,16 @@ function buildDetailedSourceOverview() { return html; } + + +function toggleTgCategories(show) { + const panel = document.getElementById('tg-categories-panel'); + if (panel) panel.style.display = show ? 'block' : 'none'; +} + +function toggleAllTgCats(checked) { + document.querySelectorAll('.tg-cat-cb').forEach(cb => { cb.checked = checked; }); +} function toggleRefreshInterval() { const mode = document.getElementById('inc-refresh-mode').value; const field = document.getElementById('refresh-interval-field');