Discord bot fáze B: tlačítka na EV zprávě → patch session + okamžitý replan
services/discord_bot.py: gateway klient jako lifespan task (spojení ven, žádný veřejný endpoint; bez DISCORD_BOT_TOKEN tiše spí). Tlačítka [za 2h][za 4h][ráno][do plna][nenabíjet] s custom_id ev:<site>:<charger>:<akce> (přežijí restart); whitelist DISCORD_ALLOWED_USER_IDS; akce = fn_ev_session_ apply_patch → run_rolling_replan → export_setpoints → edit zprávy novým plánem. services/ev_notify.py: sdílený builder souhrnu (vyčleněno z collectoru), send bot-first s webhook fallbackem. requirements: discord.py>=2.4. 7 testů helperů (parse, deadline akce vč. morning přes Prague TZ). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
234
backend/services/discord_bot.py
Normal file
234
backend/services/discord_bot.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""Discord bot (gateway) — interaktivní EV zprávy s tlačítky.
|
||||
|
||||
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).
|
||||
|
||||
Tlačítka (custom_id "ev:<site_id>:<charger_code>:<akce>" — DynamicItem
|
||||
template, takže fungují i po restartu backendu):
|
||||
h2 / h4 — odjezd za 2 / 4 hodiny (deadline = teď + N h)
|
||||
morning — ráno (další výskyt default_deadline_hour vozidla, Prague)
|
||||
full — do plna hned (target 100 %, deadline za hodinu → max tempo)
|
||||
stop — nenabíjet (target = SoC při připojení)
|
||||
|
||||
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\-]+):(?P<action>h2|h4|morning|full|stop)$")
|
||||
|
||||
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 action_to_patch(
|
||||
action: str,
|
||||
*,
|
||||
now: datetime,
|
||||
soc_at_connect: float | None,
|
||||
default_deadline_hour: int | None,
|
||||
) -> dict:
|
||||
"""Patch pro fn_ev_session_apply_patch dle tlačítka (čisté, testovatelné)."""
|
||||
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
|
||||
|
||||
|
||||
async def post_ev_arrival(
|
||||
site_id: int, charger_code: str, session_id: int, text: str
|
||||
) -> bool:
|
||||
"""Pošle zprávu s tlačítky přes bota. False = bot neběží/není kanál (fallback webhook)."""
|
||||
if _CLIENT is None or not _CLIENT.is_ready():
|
||||
return False
|
||||
import discord
|
||||
|
||||
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
|
||||
view = discord.ui.View(timeout=None)
|
||||
for action, label in ACTION_LABELS:
|
||||
view.add_item(
|
||||
discord.ui.Button(
|
||||
label=label,
|
||||
style=discord.ButtonStyle.secondary,
|
||||
custom_id=f"ev:{site_id}:{charger_code}:{action}",
|
||||
)
|
||||
)
|
||||
await channel.send(content=text, view=view)
|
||||
return True
|
||||
|
||||
|
||||
async def _handle_action(interaction: Any, site_id: int, charger_code: str, action: str) -> 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
|
||||
patch = action_to_patch(
|
||||
action,
|
||||
now=datetime.now(timezone.utc),
|
||||
soc_at_connect=sess["soc_at_connect_pct"],
|
||||
default_deadline_hour=sess["default_deadline_hour"],
|
||||
)
|
||||
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)
|
||||
|
||||
label = dict(ACTION_LABELS).get(action, action)
|
||||
if new_text:
|
||||
await interaction.message.edit(
|
||||
content=new_text + f"\n_(upraveno: {label})_", view=interaction.message.components and _rebuild_view(site_id, charger_code) or None
|
||||
)
|
||||
await interaction.followup.send(f"Přeplánováno ✓ ({label})", ephemeral=True)
|
||||
|
||||
|
||||
def _rebuild_view(site_id: int, charger_code: str):
|
||||
import discord
|
||||
|
||||
view = discord.ui.View(timeout=None)
|
||||
for action, label in ACTION_LABELS:
|
||||
view.add_item(
|
||||
discord.ui.Button(
|
||||
label=label,
|
||||
style=discord.ButtonStyle.secondary,
|
||||
custom_id=f"ev:{site_id}:{charger_code}:{action}",
|
||||
)
|
||||
)
|
||||
return view
|
||||
|
||||
|
||||
async def run_discord_bot() -> None:
|
||||
"""Lifespan task: připojí gateway a obsluhuje 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)
|
||||
|
||||
@client.event
|
||||
async def on_interaction(interaction: discord.Interaction) -> None:
|
||||
if interaction.type != discord.InteractionType.component:
|
||||
return
|
||||
cid = (interaction.data or {}).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(
|
||||
"Tohle tlačítko není pro tebe. 🙂", ephemeral=True
|
||||
)
|
||||
return
|
||||
await interaction.response.defer(ephemeral=True, thinking=True)
|
||||
site_id, charger_code, action = parsed
|
||||
try:
|
||||
await _handle_action(interaction, site_id, charger_code, action)
|
||||
except Exception:
|
||||
logger.exception("Discord akce selhala (%s)", cid)
|
||||
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
|
||||
Reference in New Issue
Block a user