Telegram-Kategorie-Checkboxen: Nutzer koennen bei Lage-Erstellung einzelne Telegram-Quellkategorien auswaehlen
Dieser Commit ist enthalten in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -347,6 +347,24 @@
|
||||
</label>
|
||||
<div class="form-hint" id="telegram-hint">Nachrichten aus konfigurierten Telegram-Kanälen berücksichtigen</div>
|
||||
</div>
|
||||
<div class="tg-categories-panel" id="tg-categories-panel" style="display:none;">
|
||||
<div class="form-hint" style="margin-bottom:6px;font-weight:500;">Telegram-Kategorien auswählen:</div>
|
||||
<div class="tg-cat-grid">
|
||||
<label class="tg-cat-item"><input type="checkbox" class="tg-cat-cb" value="ukraine-russland-krieg" checked><span>Ukraine-Russland-Krieg</span><span class="tg-cat-count" data-cat="ukraine-russland-krieg"></span></label>
|
||||
<label class="tg-cat-item"><input type="checkbox" class="tg-cat-cb" value="russische-staatspropaganda" checked><span>Russische Staatspropaganda</span><span class="tg-cat-count" data-cat="russische-staatspropaganda"></span></label>
|
||||
<label class="tg-cat-item"><input type="checkbox" class="tg-cat-cb" value="russische-opposition" checked><span>Russische Opposition</span><span class="tg-cat-count" data-cat="russische-opposition"></span></label>
|
||||
<label class="tg-cat-item"><input type="checkbox" class="tg-cat-cb" value="extremismus-deutschland" checked><span>Extremismus Deutschland</span><span class="tg-cat-count" data-cat="extremismus-deutschland"></span></label>
|
||||
<label class="tg-cat-item"><input type="checkbox" class="tg-cat-cb" value="cybercrime" checked><span>Cybercrime</span><span class="tg-cat-count" data-cat="cybercrime"></span></label>
|
||||
<label class="tg-cat-item"><input type="checkbox" class="tg-cat-cb" value="cybercrime-leaks" checked><span>Cybercrime Leaks</span><span class="tg-cat-count" data-cat="cybercrime-leaks"></span></label>
|
||||
<label class="tg-cat-item"><input type="checkbox" class="tg-cat-cb" value="osint-international" checked><span>OSINT International</span><span class="tg-cat-count" data-cat="osint-international"></span></label>
|
||||
<label class="tg-cat-item"><input type="checkbox" class="tg-cat-cb" value="irankonflikt" checked><span>Irankonflikt</span><span class="tg-cat-count" data-cat="irankonflikt"></span></label>
|
||||
<label class="tg-cat-item"><input type="checkbox" class="tg-cat-cb" value="syrien-nahost" checked><span>Syrien / Nahost</span><span class="tg-cat-count" data-cat="syrien-nahost"></span></label>
|
||||
</div>
|
||||
<div class="tg-cat-actions">
|
||||
<button type="button" class="btn-link" onclick="toggleAllTgCats(true)">Alle</button>
|
||||
<button type="button" class="btn-link" onclick="toggleAllTgCats(false)">Keine</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Sichtbarkeit</label>
|
||||
@@ -466,7 +484,7 @@
|
||||
<div class="sources-form-row">
|
||||
<div class="form-group flex-1">
|
||||
<label for="src-discover-url">URL oder Domain</label>
|
||||
<input type="text" id="src-discover-url" placeholder="z.B. netzpolitik.org">
|
||||
<input type="text" id="src-discover-url" placeholder="z.B. netzpolitik.org oder t.me/kanalname">
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-small" id="src-discover-btn" onclick="App.discoverSource()">Erkennen</button>
|
||||
</div>
|
||||
@@ -491,11 +509,19 @@
|
||||
<option value="regional">Regional</option>
|
||||
<option value="boulevard">Boulevard</option>
|
||||
<option value="sonstige" selected>Sonstige</option>
|
||||
<option value="ukraine-russland-krieg">Ukraine-Russland-Krieg</option>
|
||||
<option value="irankonflikt">Irankonflikt</option>
|
||||
<option value="osint-international">OSINT International</option>
|
||||
<option value="extremismus-deutschland">Extremismus Deutschland</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Typ</label>
|
||||
<input type="text" id="src-type-display" class="input-readonly" readonly>
|
||||
<label for="src-type-select">Typ</label>
|
||||
<select id="src-type-select">
|
||||
<option value="rss_feed">RSS-Feed</option>
|
||||
<option value="web_source">Web-Quelle</option>
|
||||
<option value="telegram_channel">Telegram-Kanal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="src-rss-url-group">
|
||||
<label>RSS-Feed URL</label>
|
||||
|
||||
@@ -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');
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren