diff --git a/src/aitrader/notify/bot.py b/src/aitrader/notify/bot.py new file mode 100644 index 0000000..dbf777c --- /dev/null +++ b/src/aitrader/notify/bot.py @@ -0,0 +1,126 @@ +"""Discord-Bot: Slash-Commands + Auto-Cleanup.""" +from __future__ import annotations + +import asyncio +import threading +from datetime import datetime, timedelta, timezone + +import discord +from discord import app_commands + +from ..config import Settings +from ..logging_setup import get_logger +from . import state + +log = get_logger(__name__) + +# Embed-Titel-Prefixe die als "Trade/Wichtig" gelten und nicht gelöscht werden +_KEEP_PREFIXES = ("📈", "📉", "✅", "❌", "📊", "🤖") + + +class _AitraderBot(discord.Client): + def __init__(self, settings: Settings) -> None: + intents = discord.Intents.default() + super().__init__(intents=intents) + self.settings = settings + self.tree = app_commands.CommandTree(self) + + async def setup_hook(self) -> None: + self._register_commands() + await self.tree.sync() + self.loop.create_task(self._auto_cleanup_loop()) + log.info("discord_bot.ready") + + def _register_commands(self) -> None: + @self.tree.command(name="notify", description="Benachrichtigungen ein/ausschalten") + @app_commands.describe(typ="errors | trades | decisions", status="on oder off") + async def notify_cmd(interaction: discord.Interaction, typ: str, status: str) -> None: + if typ not in ("errors", "trades", "decisions"): + await interaction.response.send_message( + "❌ Unbekannter Typ. Erlaubt: `errors`, `trades`, `decisions`", + ephemeral=True, + ) + return + if status not in ("on", "off"): + await interaction.response.send_message( + "❌ Status muss `on` oder `off` sein.", ephemeral=True + ) + return + enabled = status == "on" + state.set_key(typ, enabled) + emoji = "✅" if enabled else "🔕" + await interaction.response.send_message( + f"{emoji} `{typ}` Benachrichtigungen: **{'an' if enabled else 'aus'}**", + ephemeral=True, + ) + log.info("discord_bot.notify_toggle", typ=typ, enabled=enabled) + + @self.tree.command(name="cleanup", description="Alte Nachrichten löschen") + @app_commands.describe(hours="Nachrichten älter als X Stunden löschen (Standard: 24)") + async def cleanup_cmd(interaction: discord.Interaction, hours: int = 24) -> None: + await interaction.response.defer(ephemeral=True) + deleted = await self._do_cleanup(hours) + await interaction.followup.send( + f"🗑️ {deleted} alte Nachrichten gelöscht (älter als {hours}h).", + ephemeral=True, + ) + + @self.tree.command(name="status", description="Zeigt aktuellen Notify-Status") + async def status_cmd(interaction: discord.Interaction) -> None: + s = state.get_all() + lines = [ + f"{'✅' if v else '🔕'} **{k}**: {'an' if v else 'aus'}" for k, v in s.items() + ] + await interaction.response.send_message("\n".join(lines), ephemeral=True) + + async def _auto_cleanup_loop(self) -> None: + hours = self.settings.discord.auto_cleanup_hours + while True: + await asyncio.sleep(3600) + try: + await self._do_cleanup(hours) + except Exception as e: + log.warning("discord_bot.auto_cleanup_error", error=str(e)) + + async def _do_cleanup(self, hours: int) -> int: + cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) + deleted = 0 + for raw_id in ( + self.settings.discord_channel_id, + self.settings.discord_decisions_channel_id, + ): + if not raw_id: + continue + ch = self.get_channel(int(raw_id)) + if not isinstance(ch, discord.TextChannel): + continue + to_delete: list[discord.Message] = [] + async for msg in ch.history(limit=500, before=cutoff): + if msg.author.bot and msg.embeds: + title = msg.embeds[0].title or "" + if not any(title.startswith(p) for p in _KEEP_PREFIXES): + to_delete.append(msg) + for msg in to_delete: + try: + await msg.delete() + deleted += 1 + await asyncio.sleep(0.5) + except discord.HTTPException: + pass + log.info("discord_bot.cleanup_done", deleted=deleted, hours=hours) + return deleted + + +def start_bot(settings: Settings) -> None: + if not settings.discord_bot_token: + log.info("discord_bot.disabled", reason="kein Token gesetzt") + return + state.init(settings.db_path) + + def _run() -> None: + bot = _AitraderBot(settings) + asyncio.run(bot.start(settings.discord_bot_token)) + + t = threading.Thread(target=_run, daemon=True, name="discord-bot") + t.start() + log.info("discord_bot.thread_started") diff --git a/src/aitrader/notify/state.py b/src/aitrader/notify/state.py new file mode 100644 index 0000000..92d04be --- /dev/null +++ b/src/aitrader/notify/state.py @@ -0,0 +1,49 @@ +"""Laufzeit-Zustand der Discord-Benachrichtigungen (per Slash-Command steuerbar).""" +from __future__ import annotations + +import json +import threading +from pathlib import Path + +_lock = threading.Lock() +_state: dict[str, bool] = {"errors": True, "trades": True, "decisions": True} +_path: Path | None = None + +# Mapping: notify_on event-typ → state-key +_EVENT_TO_KEY: dict[str, str] = { + "error": "errors", + "trade_open": "trades", + "trade_close": "trades", + "decision": "decisions", +} + + +def init(db_path: str) -> None: + global _path, _state + _path = Path(db_path).parent / "discord_notify_state.json" + if _path.exists(): + try: + _state.update(json.loads(_path.read_text())) + except Exception: + pass + + +def is_enabled(event: str) -> bool: + key = _EVENT_TO_KEY.get(event) + if key is None: + return True + return _state.get(key, True) + + +def set_key(key: str, value: bool) -> None: + with _lock: + _state[key] = value + if _path: + try: + _path.write_text(json.dumps(_state, indent=2)) + except Exception: + pass + + +def get_all() -> dict[str, bool]: + return dict(_state)