Arrival zpráva má dva persistent Selecty (custom_id ev:<site>:<charger>:dep a :tgt, obsluha on_interaction + regex → přežijí restart): „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; „Kolik potřebuješ?" 30/50/70/100 % | Nenabíjet. Každý výběr okamžitě PATCHne session přes fn_ev_session_apply_patch jen ve své dimenzi (absolutní deadline Europe/Prague, nejbližší budoucí výskyt; pevná volba smí přes 48 h), druhý rozměr zůstává z fn_ev_session_defaults. Pak replan + export a edit zprávy přepočteným plánem (build_ev_plan_summary) + potvrzením. Whitelist DISCORD_ALLOWED_USER_IDS i bot-first/webhook fallback beze změny; legacy tlačítka h2/h4/morning/full/stop starších zpráv dál obsloužená. Testy: mapování výběr→patch, absolutní deadline z voleb (půlnoc, pondělí z pátku >48 h, pondělí ráno v pondělí), parse, legacy akce — bez DB/sítě. Docs: discord-ev-interaction.md (nové UI, no-click = pohotovostní režim 30 % + oportunismus). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
368 lines
12 KiB
Python
368 lines
12 KiB
Python
"""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:<site_id>:<charger_code>: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:<site_id>:<charger_code>: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<site>\d+):(?P<charger>[a-z0-9\-]+)"
|
|
r":(?P<action>dep|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
|