Telegram-Kategorie-Checkboxen: Nutzer koennen bei Lage-Erstellung einzelne Telegram-Quellkategorien auswaehlen

Dieser Commit ist enthalten in:
Claude Dev
2026-03-13 19:08:36 +01:00
Ursprung bb3711a471
Commit 2792e916c2
8 geänderte Dateien mit 435 neuen und 261 gelöschten Zeilen

Datei anzeigen

@@ -536,6 +536,14 @@ class AgentOrchestrator:
incident_type = incident["type"] or "adhoc" incident_type = incident["type"] or "adhoc"
international = bool(incident["international_sources"]) if "international_sources" in incident.keys() else True 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 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" visibility = incident["visibility"] if "visibility" in incident.keys() else "public"
created_by = incident["created_by"] if "created_by" in incident.keys() else None 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 tenant_id = incident["tenant_id"] if "tenant_id" in incident.keys() else None
@@ -625,7 +633,7 @@ class AgentOrchestrator:
"""Telegram-Kanal-Suche.""" """Telegram-Kanal-Suche."""
from feeds.telegram_parser import TelegramParser from feeds.telegram_parser import TelegramParser
tg_parser = 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") logger.info(f"Telegram-Pipeline: {len(articles)} Nachrichten")
return articles, None return articles, None

Datei anzeigen

@@ -264,6 +264,11 @@ async def init_db():
await db.commit() await db.commit()
logger.info("Migration: include_telegram zu incidents hinzugefuegt") 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: if "tenant_id" not in columns:
await db.execute("ALTER TABLE incidents ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)") await db.execute("ALTER TABLE incidents ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)")

Datei anzeigen

@@ -1,251 +1,262 @@
"""Telegram-Kanal Parser: Liest Nachrichten aus konfigurierten Telegram-Kanaelen.""" """Telegram-Kanal Parser: Liest Nachrichten aus konfigurierten Telegram-Kanaelen."""
import asyncio import asyncio
import logging import logging
import os import os
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from config import TIMEZONE, TELEGRAM_API_ID, TELEGRAM_API_HASH, TELEGRAM_SESSION_PATH from config import TIMEZONE, TELEGRAM_API_ID, TELEGRAM_API_HASH, TELEGRAM_SESSION_PATH
logger = logging.getLogger("osint.telegram") logger = logging.getLogger("osint.telegram")
# Stoppwoerter (gleich wie RSS-Parser) # Stoppwoerter (gleich wie RSS-Parser)
STOP_WORDS = { STOP_WORDS = {
"und", "oder", "der", "die", "das", "ein", "eine", "in", "im", "am", "an", "und", "oder", "der", "die", "das", "ein", "eine", "in", "im", "am", "an",
"auf", "fuer", "mit", "von", "zu", "zum", "zur", "bei", "nach", "vor", "auf", "fuer", "mit", "von", "zu", "zum", "zur", "bei", "nach", "vor",
"ueber", "unter", "ist", "sind", "hat", "the", "and", "for", "with", "from", "ueber", "unter", "ist", "sind", "hat", "the", "and", "for", "with", "from",
} }
class TelegramParser: class TelegramParser:
"""Durchsucht Telegram-Kanaele nach relevanten Nachrichten.""" """Durchsucht Telegram-Kanaele nach relevanten Nachrichten."""
_client = None _client = None
_lock = asyncio.Lock() _lock = asyncio.Lock()
async def _get_client(self): async def _get_client(self):
"""Telethon-Client erstellen oder wiederverwenden.""" """Telethon-Client erstellen oder wiederverwenden."""
if TelegramParser._client is not None: if TelegramParser._client is not None:
if TelegramParser._client.is_connected(): if TelegramParser._client.is_connected():
return TelegramParser._client return TelegramParser._client
async with TelegramParser._lock: async with TelegramParser._lock:
# Double-check nach Lock # Double-check nach Lock
if TelegramParser._client is not None and TelegramParser._client.is_connected(): if TelegramParser._client is not None and TelegramParser._client.is_connected():
return TelegramParser._client return TelegramParser._client
try: try:
from telethon import TelegramClient from telethon import TelegramClient
session_path = TELEGRAM_SESSION_PATH session_path = TELEGRAM_SESSION_PATH
if not os.path.exists(session_path + ".session") and not os.path.exists(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) logger.error("Telegram-Session nicht gefunden: %s", session_path)
return None return None
client = TelegramClient(session_path, TELEGRAM_API_ID, TELEGRAM_API_HASH) client = TelegramClient(session_path, TELEGRAM_API_ID, TELEGRAM_API_HASH)
await client.connect() await client.connect()
if not await client.is_user_authorized(): if not await client.is_user_authorized():
logger.error("Telegram-Session nicht autorisiert. Bitte neu einloggen.") logger.error("Telegram-Session nicht autorisiert. Bitte neu einloggen.")
await client.disconnect() await client.disconnect()
return None return None
TelegramParser._client = client TelegramParser._client = client
me = await client.get_me() me = await client.get_me()
logger.info("Telegram verbunden als: %s (%s)", me.first_name, me.phone) logger.info("Telegram verbunden als: %s (%s)", me.first_name, me.phone)
return client return client
except ImportError: except ImportError:
logger.error("telethon nicht installiert: pip install telethon") logger.error("telethon nicht installiert: pip install telethon")
return None return None
except Exception as e: except Exception as e:
logger.error("Telegram-Verbindung fehlgeschlagen: %s", e) logger.error("Telegram-Verbindung fehlgeschlagen: %s", e)
return None return None
async def search_channels(self, search_term: str, tenant_id: int = None, async def search_channels(self, search_term: str, tenant_id: int = None,
keywords: list[str] = None) -> list[dict]: keywords: list[str] = None, categories: list[str] = None) -> list[dict]:
"""Liest Nachrichten aus konfigurierten Telegram-Kanaelen. """Liest Nachrichten aus konfigurierten Telegram-Kanaelen.
Gibt Artikel-Dicts zurueck (kompatibel mit RSS-Parser-Format). Gibt Artikel-Dicts zurueck (kompatibel mit RSS-Parser-Format).
""" """
client = await self._get_client() client = await self._get_client()
if not client: if not client:
logger.warning("Telegram-Client nicht verfuegbar, ueberspringe Telegram-Pipeline") logger.warning("Telegram-Client nicht verfuegbar, ueberspringe Telegram-Pipeline")
return [] return []
# Telegram-Kanaele aus DB laden # Telegram-Kanaele aus DB laden
channels = await self._get_telegram_channels(tenant_id) channels = await self._get_telegram_channels(tenant_id, categories=categories)
if not channels: if not channels:
logger.info("Keine Telegram-Kanaele konfiguriert") logger.info("Keine Telegram-Kanaele konfiguriert")
return [] return []
# Suchwoerter vorbereiten # Suchwoerter vorbereiten
if keywords: if keywords:
search_words = [w.lower().strip() for w in keywords if w.strip()] search_words = [w.lower().strip() for w in keywords if w.strip()]
else: else:
search_words = [ search_words = [
w for w in search_term.lower().split() w for w in search_term.lower().split()
if w not in STOP_WORDS and len(w) >= 3 if w not in STOP_WORDS and len(w) >= 3
] ]
if not search_words: if not search_words:
search_words = search_term.lower().split()[:2] search_words = search_term.lower().split()[:2]
# Kanaele parallel abrufen # Kanaele parallel abrufen
tasks = [] tasks = []
for ch in channels: for ch in channels:
channel_id = ch["url"] or ch["name"] channel_id = ch["url"] or ch["name"]
tasks.append(self._fetch_channel(client, channel_id, search_words)) tasks.append(self._fetch_channel(client, channel_id, search_words))
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
all_articles = [] all_articles = []
for i, result in enumerate(results): for i, result in enumerate(results):
if isinstance(result, Exception): if isinstance(result, Exception):
logger.warning("Telegram-Kanal %s: %s", channels[i]["name"], result) logger.warning("Telegram-Kanal %s: %s", channels[i]["name"], result)
continue continue
all_articles.extend(result) all_articles.extend(result)
logger.info("Telegram: %d relevante Nachrichten aus %d Kanaelen", len(all_articles), len(channels)) logger.info("Telegram: %d relevante Nachrichten aus %d Kanaelen", len(all_articles), len(channels))
return all_articles return all_articles
async def _get_telegram_channels(self, tenant_id: int = None) -> list[dict]: async def _get_telegram_channels(self, tenant_id: int = None, categories: list[str] = None) -> list[dict]:
"""Laedt Telegram-Kanaele aus der sources-Tabelle.""" """Laedt Telegram-Kanaele aus der sources-Tabelle."""
try: try:
from database import get_db from database import get_db
db = await get_db() db = await get_db()
try: try:
cursor = await db.execute( if categories and len(categories) > 0:
"""SELECT id, name, url FROM sources placeholders = ",".join("?" for _ in categories)
WHERE source_type = 'telegram_channel' cursor = await db.execute(
AND status = 'active' f"""SELECT id, name, url FROM sources
AND (tenant_id IS NULL OR tenant_id = ?)""", WHERE source_type = 'telegram_channel'
(tenant_id,), AND status = 'active'
) AND (tenant_id IS NULL OR tenant_id = ?)
rows = await cursor.fetchall() AND category IN ({placeholders})""",
return [dict(row) for row in rows] (tenant_id, *categories),
finally: )
await db.close() else:
except Exception as e: cursor = await db.execute(
logger.error("Fehler beim Laden der Telegram-Kanaele: %s", e) """SELECT id, name, url FROM sources
return [] WHERE source_type = 'telegram_channel'
AND status = 'active'
async def _fetch_channel(self, client, channel_id: str, search_words: list[str], AND (tenant_id IS NULL OR tenant_id = ?)""",
limit: int = 50) -> list[dict]: (tenant_id,),
"""Letzte N Nachrichten eines Kanals abrufen und nach Keywords filtern.""" )
articles = [] rows = await cursor.fetchall()
try: return [dict(row) for row in rows]
# Kanal-Identifier normalisieren finally:
identifier = channel_id.strip() await db.close()
if identifier.startswith("https://t.me/"): except Exception as e:
identifier = identifier.replace("https://t.me/", "") logger.error("Fehler beim Laden der Telegram-Kanaele: %s", e)
if identifier.startswith("t.me/"): return []
identifier = identifier.replace("t.me/", "")
async def _fetch_channel(self, client, channel_id: str, search_words: list[str],
# Privater Invite-Link limit: int = 50) -> list[dict]:
if identifier.startswith("+") or identifier.startswith("joinchat/"): """Letzte N Nachrichten eines Kanals abrufen und nach Keywords filtern."""
entity = await client.get_entity(channel_id) articles = []
else: try:
# Oeffentlicher Kanal # Kanal-Identifier normalisieren
if not identifier.startswith("@"): identifier = channel_id.strip()
identifier = "@" + identifier if identifier.startswith("https://t.me/"):
entity = await client.get_entity(identifier) identifier = identifier.replace("https://t.me/", "")
if identifier.startswith("t.me/"):
messages = await client.get_messages(entity, limit=limit) identifier = identifier.replace("t.me/", "")
channel_title = getattr(entity, "title", identifier) # Privater Invite-Link
channel_username = getattr(entity, "username", identifier.replace("@", "")) if identifier.startswith("+") or identifier.startswith("joinchat/"):
entity = await client.get_entity(channel_id)
for msg in messages: else:
if not msg.text: # Oeffentlicher Kanal
continue if not identifier.startswith("@"):
identifier = "@" + identifier
text = msg.text entity = await client.get_entity(identifier)
text_lower = text.lower()
messages = await client.get_messages(entity, limit=limit)
# Keyword-Matching (gleiche Logik wie RSS-Parser)
min_matches = min(2, max(1, (len(search_words) + 1) // 2)) channel_title = getattr(entity, "title", identifier)
match_count = sum(1 for word in search_words if word in text_lower) channel_username = getattr(entity, "username", identifier.replace("@", ""))
if match_count < min_matches: for msg in messages:
continue if not msg.text:
continue
# Erste Zeile als Headline, Rest als Content
lines = text.strip().split("\n") text = msg.text
headline = lines[0][:200] if lines else text[:200] text_lower = text.lower()
content = text
# Keyword-Matching (gleiche Logik wie RSS-Parser)
# Datum min_matches = min(2, max(1, (len(search_words) + 1) // 2))
published = None match_count = sum(1 for word in search_words if word in text_lower)
if msg.date:
try: if match_count < min_matches:
published = msg.date.astimezone(TIMEZONE).isoformat() continue
except Exception:
published = msg.date.isoformat() # Erste Zeile als Headline, Rest als Content
lines = text.strip().split("\n")
# Source-URL: t.me/channel/msg_id headline = lines[0][:200] if lines else text[:200]
if channel_username: content = text
source_url = "https://t.me/%s/%s" % (channel_username, msg.id)
else: # Datum
source_url = "https://t.me/c/%s/%s" % (entity.id, msg.id) published = None
if msg.date:
relevance_score = match_count / len(search_words) if search_words else 0.0 try:
published = msg.date.astimezone(TIMEZONE).isoformat()
articles.append({ except Exception:
"headline": headline, published = msg.date.isoformat()
"headline_de": headline if self._is_german(headline) else None,
"source": "Telegram: %s" % channel_title, # Source-URL: t.me/channel/msg_id
"source_url": source_url, if channel_username:
"content_original": content[:2000], source_url = "https://t.me/%s/%s" % (channel_username, msg.id)
"content_de": content[:2000] if self._is_german(content) else None, else:
"language": "de" if self._is_german(content) else "en", source_url = "https://t.me/c/%s/%s" % (entity.id, msg.id)
"published_at": published,
"relevance_score": relevance_score, relevance_score = match_count / len(search_words) if search_words else 0.0
})
articles.append({
except Exception as e: "headline": headline,
logger.warning("Telegram-Kanal %s: %s", channel_id, e) "headline_de": headline if self._is_german(headline) else None,
"source": "Telegram: %s" % channel_title,
return articles "source_url": source_url,
"content_original": content[:2000],
async def validate_channel(self, channel_id: str) -> Optional[dict]: "content_de": content[:2000] if self._is_german(content) else None,
"""Prueft ob ein Telegram-Kanal erreichbar ist und gibt Info zurueck.""" "language": "de" if self._is_german(content) else "en",
client = await self._get_client() "published_at": published,
if not client: "relevance_score": relevance_score,
return None })
try: except Exception as e:
identifier = channel_id.strip() logger.warning("Telegram-Kanal %s: %s", channel_id, e)
if identifier.startswith("https://t.me/"):
identifier = identifier.replace("https://t.me/", "") return articles
if identifier.startswith("t.me/"):
identifier = identifier.replace("t.me/", "") async def validate_channel(self, channel_id: str) -> Optional[dict]:
"""Prueft ob ein Telegram-Kanal erreichbar ist und gibt Info zurueck."""
if identifier.startswith("+") or identifier.startswith("joinchat/"): client = await self._get_client()
return {"valid": True, "name": "Privater Kanal", "description": "Privater Einladungslink", "subscribers": None} if not client:
return None
if not identifier.startswith("@"):
identifier = "@" + identifier try:
identifier = channel_id.strip()
entity = await client.get_entity(identifier) if identifier.startswith("https://t.me/"):
identifier = identifier.replace("https://t.me/", "")
from telethon.tl.functions.channels import GetFullChannelRequest if identifier.startswith("t.me/"):
full = await client(GetFullChannelRequest(entity)) identifier = identifier.replace("t.me/", "")
return { if identifier.startswith("+") or identifier.startswith("joinchat/"):
"valid": True, return {"valid": True, "name": "Privater Kanal", "description": "Privater Einladungslink", "subscribers": None}
"name": getattr(entity, "title", identifier),
"description": getattr(full.full_chat, "about", "") or "", if not identifier.startswith("@"):
"subscribers": getattr(full.full_chat, "participants_count", None), identifier = "@" + identifier
"username": getattr(entity, "username", ""),
} entity = await client.get_entity(identifier)
except Exception as e:
logger.warning("Telegram-Kanal-Validierung fehlgeschlagen fuer %s: %s", channel_id, e) from telethon.tl.functions.channels import GetFullChannelRequest
return None full = await client(GetFullChannelRequest(entity))
def _is_german(self, text: str) -> bool: return {
"""Einfache Heuristik ob ein Text deutsch ist.""" "valid": True,
german_words = {"der", "die", "das", "und", "ist", "von", "mit", "fuer", "auf", "ein", "name": getattr(entity, "title", identifier),
"eine", "den", "dem", "des", "sich", "wird", "nach", "bei", "auch", "description": getattr(full.full_chat, "about", "") or "",
"ueber", "wie", "aus", "hat", "zum", "zur", "als", "noch", "mehr", "subscribers": getattr(full.full_chat, "participants_count", None),
"nicht", "aber", "oder", "sind", "vor", "einem", "einer", "wurde"} "username": getattr(entity, "username", ""),
words = set(text.lower().split()) }
matches = words & german_words except Exception as e:
return len(matches) >= 2 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

Datei anzeigen

@@ -53,6 +53,7 @@ class IncidentCreate(BaseModel):
retention_days: int = Field(default=0, ge=0, le=999) retention_days: int = Field(default=0, ge=0, le=999)
international_sources: bool = True international_sources: bool = True
include_telegram: bool = False include_telegram: bool = False
telegram_categories: Optional[list[str]] = None
visibility: str = Field(default="public", pattern="^(public|private)$") 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) retention_days: Optional[int] = Field(default=None, ge=0, le=999)
international_sources: Optional[bool] = None international_sources: Optional[bool] = None
include_telegram: Optional[bool] = None include_telegram: Optional[bool] = None
telegram_categories: Optional[list[str]] = None
visibility: Optional[str] = Field(default=None, pattern="^(public|private)$") visibility: Optional[str] = Field(default=None, pattern="^(public|private)$")
@@ -83,6 +85,7 @@ class IncidentResponse(BaseModel):
sources_json: Optional[str] = None sources_json: Optional[str] = None
international_sources: bool = True international_sources: bool = True
include_telegram: bool = False include_telegram: bool = False
telegram_categories: Optional[list[str]] = None
created_by: int created_by: int
created_by_username: str = "" created_by_username: str = ""
created_at: str created_at: str

Datei anzeigen

@@ -20,7 +20,7 @@ router = APIRouter(prefix="/api/incidents", tags=["incidents"])
INCIDENT_UPDATE_COLUMNS = { INCIDENT_UPDATE_COLUMNS = {
"title", "description", "type", "status", "refresh_mode", "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["article_count"] = article_count
incident["source_count"] = source_count incident["source_count"] = source_count
incident["created_by_username"] = user_row["email"] if user_row else "Unbekannt" 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 return incident
@@ -105,9 +113,9 @@ async def create_incident(
now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S') now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
cursor = await db.execute( cursor = await db.execute(
"""INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval, """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) tenant_id, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
data.title, data.title,
data.description, data.description,
@@ -117,6 +125,7 @@ async def create_incident(
data.retention_days, data.retention_days,
1 if data.international_sources else 0, 1 if data.international_sources else 0,
1 if data.include_telegram else 0, 1 if data.include_telegram else 0,
__import__('json').dumps(data.telegram_categories) if data.telegram_categories else None,
data.visibility, data.visibility,
tenant_id, tenant_id,
current_user["id"], current_user["id"],
@@ -180,7 +189,13 @@ async def update_incident(
for field, value in data.model_dump(exclude_none=True).items(): for field, value in data.model_dump(exclude_none=True).items():
if field not in INCIDENT_UPDATE_COLUMNS: if field not in INCIDENT_UPDATE_COLUMNS:
continue 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: if not updates:
return await _enrich_incident(db, row) return await _enrich_incident(db, row)
@@ -723,6 +738,7 @@ def _build_json_export(
"summary": incident.get("summary"), "summary": incident.get("summary"),
"international_sources": bool(incident.get("international_sources")), "international_sources": bool(incident.get("international_sources")),
"include_telegram": bool(incident.get("include_telegram")), "include_telegram": bool(incident.get("include_telegram")),
"telegram_categories": incident.get("telegram_categories"),
}, },
"sources": sources, "sources": sources,
"fact_checks": [ "fact_checks": [

Datei anzeigen

@@ -4545,3 +4545,54 @@ a.map-popup-article:hover {
height: 100% !important; 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);
}

Datei anzeigen

@@ -347,6 +347,24 @@
</label> </label>
<div class="form-hint" id="telegram-hint">Nachrichten aus konfigurierten Telegram-Kanälen berücksichtigen</div> <div class="form-hint" id="telegram-hint">Nachrichten aus konfigurierten Telegram-Kanälen berücksichtigen</div>
</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>
<div class="form-group"> <div class="form-group">
<label>Sichtbarkeit</label> <label>Sichtbarkeit</label>
@@ -466,7 +484,7 @@
<div class="sources-form-row"> <div class="sources-form-row">
<div class="form-group flex-1"> <div class="form-group flex-1">
<label for="src-discover-url">URL oder Domain</label> <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> </div>
<button class="btn btn-secondary btn-small" id="src-discover-btn" onclick="App.discoverSource()">Erkennen</button> <button class="btn btn-secondary btn-small" id="src-discover-btn" onclick="App.discoverSource()">Erkennen</button>
</div> </div>
@@ -491,11 +509,19 @@
<option value="regional">Regional</option> <option value="regional">Regional</option>
<option value="boulevard">Boulevard</option> <option value="boulevard">Boulevard</option>
<option value="sonstige" selected>Sonstige</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> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Typ</label> <label for="src-type-select">Typ</label>
<input type="text" id="src-type-display" class="input-readonly" readonly> <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>
<div class="form-group" id="src-rss-url-group"> <div class="form-group" id="src-rss-url-group">
<label>RSS-Feed URL</label> <label>RSS-Feed URL</label>

Datei anzeigen

@@ -502,6 +502,12 @@ const App = {
document.getElementById('archive-incident-btn').addEventListener('click', () => this.handleArchive()); document.getElementById('archive-incident-btn').addEventListener('click', () => this.handleArchive());
document.getElementById('inc-international').addEventListener('change', () => updateSourcesHint()); document.getElementById('inc-international').addEventListener('change', () => updateSourcesHint());
document.getElementById('inc-visibility').addEventListener('change', () => updateVisibilityHint()); 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 // Feedback
document.getElementById('feedback-form').addEventListener('submit', (e) => this.submitFeedback(e)); 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, retention_days: parseInt(document.getElementById('inc-retention').value) || 0,
international_sources: document.getElementById('inc-international').checked, international_sources: document.getElementById('inc-international').checked,
include_telegram: document.getElementById('inc-telegram').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', 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-retention').value = incident.retention_days;
document.getElementById('inc-international').checked = incident.international_sources !== false && incident.international_sources !== 0; document.getElementById('inc-international').checked = incident.international_sources !== false && incident.international_sources !== 0;
document.getElementById('inc-telegram').checked = !!incident.include_telegram; 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'; document.getElementById('inc-visibility').checked = incident.visibility !== 'private';
updateVisibilityHint(); updateVisibilityHint();
updateSourcesHint(); updateSourcesHint();
@@ -2596,6 +2614,7 @@ const App = {
document.getElementById('src-discovery-result').style.display = 'none'; document.getElementById('src-discovery-result').style.display = 'none';
document.getElementById('src-discover-btn').disabled = false; document.getElementById('src-discover-btn').disabled = false;
document.getElementById('src-discover-btn').textContent = 'Erkennen'; document.getElementById('src-discover-btn').textContent = 'Erkennen';
document.getElementById('src-type-select').value = 'rss_feed';
// Save-Button Text zurücksetzen // Save-Button Text zurücksetzen
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary'); const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
if (saveBtn) saveBtn.textContent = 'Speichern'; if (saveBtn) saveBtn.textContent = 'Speichern';
@@ -2612,6 +2631,27 @@ const App = {
async discoverSource() { async discoverSource() {
const urlInput = document.getElementById('src-discover-url'); 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(); const url = urlInput.value.trim();
if (!url) { if (!url) {
UI.showToast('Bitte URL oder Domain eingeben.', 'warning'); UI.showToast('Bitte URL oder Domain eingeben.', 'warning');
@@ -2652,6 +2692,8 @@ const App = {
document.getElementById('src-notes').value = ''; 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 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; document.getElementById('src-type-display').value = typeLabel;
const rssGroup = document.getElementById('src-rss-url-group'); const rssGroup = document.getElementById('src-rss-url-group');
@@ -2730,6 +2772,8 @@ const App = {
document.getElementById('src-domain').value = source.domain || ''; 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 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; document.getElementById('src-type-display').value = typeLabel;
const rssGroup = document.getElementById('src-rss-url-group'); const rssGroup = document.getElementById('src-rss-url-group');
@@ -2769,9 +2813,9 @@ const App = {
const discovered = this._discoveredData || {}; const discovered = this._discoveredData || {};
const data = { const data = {
name, 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, 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, domain: document.getElementById('src-domain').value.trim() || discovered.domain || null,
notes: document.getElementById('src-notes').value.trim() || null, notes: document.getElementById('src-notes').value.trim() || null,
}; };
@@ -3068,6 +3112,16 @@ function buildDetailedSourceOverview() {
return html; 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() { function toggleRefreshInterval() {
const mode = document.getElementById('inc-refresh-mode').value; const mode = document.getElementById('inc-refresh-mode').value;
const field = document.getElementById('refresh-interval-field'); const field = document.getElementById('refresh-interval-field');