"""Discord bot (gateway) — interaktivní EV zprávy se dvěma výběry. Architektura: websocket spojení jde Z BACKENDU VEN (žádný veřejný endpoint, EMS zůstává na VPN). Bot reaguje výhradně na whitelisted user ID a jediné, co umí, je patch otevřené EV session + okamžitý replan — žádné režimy, žádné registry. Bez DISCORD_BOT_TOKEN modul tiše spí (fáze A webhook). UI: dva persistent Selecty (custom_id template, takže fungují i po restartu backendu — obsluha jde přes on_interaction + regex, ne přes zaregistrovanou View instanci): ev:::dep — „Kdy odjíždíš?" za 2 h | za 4 h | dnes večer 18:00 | zítra ráno 7:00 | zítra poledne 12:00 | pondělí ráno 7:00 ev:::tgt — „Kolik potřebuješ?" 30 % | 50 % | 70 % | 100 % | Nenabíjet (target = SoC při připojení) Každý výběr okamžitě PATCHne session (fn_ev_session_apply_patch) jen v dané dimenzi — druhý rozměr zůstává (default z ems.fn_ev_session_defaults nebo předchozí výběr). Legacy tlačítka h2/h4/morning/full/stop ze starších zpráv zůstávají obsloužená (action_to_patch). Postup zřízení bota: docs/discord-ev-interaction.md. """ from __future__ import annotations import asyncio import logging import re from datetime import datetime, timedelta, timezone from typing import Any from zoneinfo import ZoneInfo import asyncpg from app.config import get_settings logger = logging.getLogger(__name__) _PRAGUE = ZoneInfo("Europe/Prague") _POOL: asyncpg.Pool | None = None _CLIENT: Any = None # discord.Client za lazy importem CUSTOM_ID_RE = re.compile( r"^ev:(?P\d+):(?P[a-z0-9\-]+)" r":(?Pdep|tgt|h2|h4|morning|full|stop)$" ) #: Výběr 1 — „Kdy odjíždíš?" (value, label); absolutní čas viz #: departure_choice_to_deadline (Europe/Prague). DEP_CHOICES: list[tuple[str, str]] = [ ("h2", "za 2 h"), ("h4", "za 4 h"), ("today18", "dnes večer 18:00"), ("tomorrow7", "zítra ráno 7:00"), ("tomorrow12", "zítra poledne 12:00"), ("monday7", "pondělí ráno 7:00"), ] #: Výběr 2 — „Kolik potřebuješ?" (value, label); "stop" = nenabíjet. TGT_CHOICES: list[tuple[str, str]] = [ ("30", "30 %"), ("50", "50 %"), ("70", "70 %"), ("100", "100 %"), ("stop", "Nenabíjet"), ] #: Legacy tlačítka (starší zprávy poslané před přechodem na selecty). LEGACY_ACTION_LABELS = { "h2": "za 2 h", "h4": "za 4 h", "morning": "ráno", "full": "do plna", "stop": "nenabíjet", } def parse_custom_id(cid: str) -> tuple[int, str, str] | None: m = CUSTOM_ID_RE.match(cid or "") if not m: return None return int(m.group("site")), m.group("charger"), m.group("action") def departure_choice_to_deadline(choice: str, *, now: datetime) -> datetime: """Absolutní deadline (Europe/Prague) pro volbu z výběru „Kdy odjíždíš?". Čisté a testovatelné. Volby s pevným časem znamenají NEJBLIŽŠÍ budoucí výskyt (dnes 18:00 po 18. hodině → zítra 18:00; pondělí 7:00 z pátku je explicitní volba — smí být i za >48 h). """ local = now.astimezone(_PRAGUE) if choice == "h2": return local + timedelta(hours=2) if choice == "h4": return local + timedelta(hours=4) if choice == "today18": candidate = local.replace(hour=18, minute=0, second=0, microsecond=0) if candidate <= local: candidate += timedelta(days=1) return candidate if choice == "tomorrow7": return (local + timedelta(days=1)).replace( hour=7, minute=0, second=0, microsecond=0 ) if choice == "tomorrow12": return (local + timedelta(days=1)).replace( hour=12, minute=0, second=0, microsecond=0 ) if choice == "monday7": candidate = local.replace(hour=7, minute=0, second=0, microsecond=0) candidate += timedelta(days=(0 - local.weekday()) % 7) # 0 = pondělí if candidate <= local: candidate += timedelta(days=7) return candidate raise ValueError(f"unknown departure choice {choice}") def select_to_patch( kind: str, value: str, *, now: datetime, soc_at_connect: float | None, ) -> dict: """Patch pro fn_ev_session_apply_patch z hodnoty selectu (čisté, testovatelné). Patchuje VŽDY jen jednu dimenzi — druhá zůstává beze změny (default z fn_ev_session_defaults, případně dřívější výběr). """ if kind == "dep": deadline = departure_choice_to_deadline(value, now=now) return {"target_deadline": deadline.isoformat()} if kind == "tgt": if value == "stop": return {"target_soc_pct": float(soc_at_connect or 0)} return {"target_soc_pct": float(value)} raise ValueError(f"unknown select kind {kind}") def choice_label(kind: str, value: str) -> str: """Lidský popisek volby pro potvrzení ve zprávě.""" if kind == "dep": return "odjezd " + dict(DEP_CHOICES).get(value, value) if kind == "tgt": if value == "stop": return "nenabíjet" return "cíl " + dict(TGT_CHOICES).get(value, value) return LEGACY_ACTION_LABELS.get(value, value) def action_to_patch( action: str, *, now: datetime, soc_at_connect: float | None, default_deadline_hour: int | None, ) -> dict: """Patch pro legacy tlačítka h2/h4/morning/full/stop (starší zprávy).""" if action == "h2": return {"target_deadline": (now + timedelta(hours=2)).isoformat()} if action == "h4": return {"target_deadline": (now + timedelta(hours=4)).isoformat()} if action == "morning": hour = int(default_deadline_hour or 7) local = now.astimezone(_PRAGUE) candidate = local.replace(hour=hour, minute=0, second=0, microsecond=0) if candidate <= local: candidate += timedelta(days=1) return {"target_deadline": candidate.isoformat()} if action == "full": return { "target_soc_pct": 100, "target_deadline": (now + timedelta(hours=1)).isoformat(), } if action == "stop": return {"target_soc_pct": float(soc_at_connect or 0)} raise ValueError(f"unknown action {action}") def set_pool(pool: asyncpg.Pool) -> None: global _POOL _POOL = pool def _allowed_user_ids() -> set[int]: raw = (getattr(get_settings(), "discord_allowed_user_ids", "") or "").strip() out: set[int] = set() for part in raw.split(","): part = part.strip() if part.isdigit(): out.add(int(part)) return out def _build_view(site_id: int, charger_code: str): """View se dvěma selecty (persistent custom_id, timeout=None).""" import discord view = discord.ui.View(timeout=None) view.add_item( discord.ui.Select( custom_id=f"ev:{site_id}:{charger_code}:dep", placeholder="🕑 Kdy odjíždíš?", min_values=1, max_values=1, options=[ discord.SelectOption(label=label, value=value) for value, label in DEP_CHOICES ], ) ) view.add_item( discord.ui.Select( custom_id=f"ev:{site_id}:{charger_code}:tgt", placeholder="🔋 Kolik potřebuješ?", min_values=1, max_values=1, options=[ discord.SelectOption(label=label, value=value) for value, label in TGT_CHOICES ], ) ) return view async def post_ev_arrival( site_id: int, charger_code: str, session_id: int, text: str ) -> bool: """Pošle zprávu s výběry přes bota. False = bot neběží/není kanál (fallback webhook).""" if _CLIENT is None or not _CLIENT.is_ready(): return False channel_id = int(getattr(get_settings(), "discord_ev_channel_id", 0) or 0) if not channel_id: return False channel = _CLIENT.get_channel(channel_id) if channel is None: return False await channel.send(content=text, view=_build_view(site_id, charger_code)) return True async def _handle_action( interaction: Any, site_id: int, charger_code: str, action: str, value: str | None, ) -> None: import json from services.control_exporter import export_setpoints from services.ev_notify import build_ev_plan_summary, get_open_session from services.planning_engine import run_rolling_replan assert _POOL is not None async with _POOL.acquire() as conn: sess = await get_open_session(site_id, charger_code, conn) if sess is None: await interaction.followup.send( "Session už není otevřená (auto odpojeno?).", ephemeral=True ) return now = datetime.now(timezone.utc) if action in ("dep", "tgt"): if not value: return patch = select_to_patch( action, value, now=now, soc_at_connect=sess["soc_at_connect_pct"], ) label = choice_label(action, value) else: # legacy tlačítka starších zpráv patch = action_to_patch( action, now=now, soc_at_connect=sess["soc_at_connect_pct"], default_deadline_hour=sess["default_deadline_hour"], ) label = LEGACY_ACTION_LABELS.get(action, action) await conn.fetchval( "select ems.fn_ev_session_apply_patch($1::int, $2::int, $3::jsonb)", site_id, int(sess["session_id"]), json.dumps(patch), ) await run_rolling_replan( site_id, conn, triggered_by=f"discord:{action}:{charger_code}" ) await export_setpoints(site_id, conn) new_text = await build_ev_plan_summary(site_id, charger_code, conn) if new_text: await interaction.message.edit( content=new_text + f"\n_(nastaveno: {label})_", view=_build_view(site_id, charger_code), ) await interaction.followup.send(f"Přeplánováno ✓ ({label})", ephemeral=True) async def run_discord_bot() -> None: """Lifespan task: připojí gateway a obsluhuje selecty/tlačítka. Bez tokenu hned končí.""" token = (getattr(get_settings(), "discord_bot_token", "") or "").strip() if not token: logger.info("Discord bot: token není nastaven — fáze B vypnuta") return import discord intents = discord.Intents.default() client = discord.Client(intents=intents) @client.event async def on_ready() -> None: logger.info("Discord bot připojen jako %s", client.user) try: channel_id = int(getattr(get_settings(), "discord_ev_channel_id", 0) or 0) ch = client.get_channel(channel_id) if channel_id else None if ch is not None: await ch.send("✅ EMS bot online — notifikace aktivní") except Exception: logger.exception("Discord on_ready ping failed") @client.event async def on_interaction(interaction: discord.Interaction) -> None: if interaction.type != discord.InteractionType.component: return data = interaction.data or {} cid = data.get("custom_id", "") parsed = parse_custom_id(str(cid)) if parsed is None: return allowed = _allowed_user_ids() if allowed and interaction.user.id not in allowed: await interaction.response.send_message( "Tenhle výběr není pro tebe. 🙂", ephemeral=True ) return await interaction.response.defer(ephemeral=True, thinking=True) site_id, charger_code, action = parsed values = data.get("values") or [] value = str(values[0]) if values else None try: await _handle_action(interaction, site_id, charger_code, action, value) except Exception: logger.exception("Discord akce selhala (%s, value=%s)", cid, value) try: await interaction.followup.send( "Akce selhala — mrkni do logů.", ephemeral=True ) except Exception: pass global _CLIENT _CLIENT = client try: await client.start(token) except asyncio.CancelledError: await client.close() raise except Exception: logger.exception("Discord bot spadl — fáze B mimo provoz (fallback webhook)") finally: _CLIENT = None