Discord EV: dva výběry (odjezd × cíl) místo řady tlačítek

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>
This commit is contained in:
Dusan Vojacek
2026-06-12 19:14:56 +02:00
parent 60eda46dd7
commit 48f5a6b00b
3 changed files with 338 additions and 80 deletions

View File

@@ -1,16 +1,23 @@
"""Discord bot (gateway) — interaktivní EV zprávy s tlačítky.
"""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).
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í)
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.
"""
@@ -34,16 +41,40 @@ _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)$")
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)$"
)
ACTION_LABELS = [
("h2", "🕑 za 2 h"),
("h4", "🕓 za 4 h"),
("morning", "🌅 ráno"),
("full", "⚡ do plna"),
("stop", "✋ nenabíjet"),
#: 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 "")
@@ -52,6 +83,73 @@ def parse_custom_id(cid: str) -> tuple[int, str, str] | 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,
*,
@@ -59,7 +157,7 @@ def action_to_patch(
soc_at_connect: float | None,
default_deadline_hour: int | None,
) -> dict:
"""Patch pro fn_ev_session_apply_patch dle tlačítka (čisté, testovatelné)."""
"""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":
@@ -96,34 +194,61 @@ def _allowed_user_ids() -> set[int]:
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 tlačítky přes bota. False = bot neběží/není kanál (fallback webhook)."""
"""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
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)
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) -> None:
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
@@ -138,12 +263,25 @@ async def _handle_action(interaction: Any, site_id: int, charger_code: str, acti
"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"],
)
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,
@@ -156,31 +294,16 @@ async def _handle_action(interaction: Any, site_id: int, charger_code: str, acti
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
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)
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čí."""
"""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")
@@ -205,22 +328,25 @@ async def run_discord_bot() -> None:
async def on_interaction(interaction: discord.Interaction) -> None:
if interaction.type != discord.InteractionType.component:
return
cid = (interaction.data or {}).get("custom_id", "")
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(
"Tohle tlačítko není pro tebe. 🙂", ephemeral=True
"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)
await _handle_action(interaction, site_id, charger_code, action, value)
except Exception:
logger.exception("Discord akce selhala (%s)", cid)
logger.exception("Discord akce selhala (%s, value=%s)", cid, value)
try:
await interaction.followup.send(
"Akce selhala — mrkni do logů.", ephemeral=True

View File

@@ -1,27 +1,134 @@
"""Discord bot — čisté helpery (custom_id, patch akcí), bez sítě/discord lib."""
"""Discord bot — čisté helpery (custom_id, výběry → patch, deadline z voleb),
bez sítě/discord lib."""
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from services.discord_bot import action_to_patch, parse_custom_id
from services.discord_bot import (
action_to_patch,
choice_label,
departure_choice_to_deadline,
parse_custom_id,
select_to_patch,
)
_NOW = datetime(2026, 6, 12, 10, 0, tzinfo=timezone.utc) # 12:00 Prague
_PRAGUE = ZoneInfo("Europe/Prague")
# 2026-06-12 je pátek; 10:00 UTC = 12:00 Europe/Prague (CEST)
_NOW = datetime(2026, 6, 12, 10, 0, tzinfo=timezone.utc)
def _prague(dt: datetime) -> str:
return dt.astimezone(_PRAGUE).strftime("%Y-%m-%d %H:%M")
class ParseCustomIdTests(unittest.TestCase):
def test_valid(self) -> None:
def test_valid_selects(self) -> None:
self.assertEqual(
parse_custom_id("ev:2:ev-charger-1:dep"), (2, "ev-charger-1", "dep")
)
self.assertEqual(
parse_custom_id("ev:2:ev-charger-1:tgt"), (2, "ev-charger-1", "tgt")
)
def test_valid_legacy_buttons(self) -> None:
self.assertEqual(
parse_custom_id("ev:2:ev-charger-1:h2"), (2, "ev-charger-1", "h2")
)
def test_invalid(self) -> None:
for bad in ("", "ev:2:x:jump", "foo:1:c:h2", "ev:abc:c:h2"):
for bad in ("", "ev:2:x:jump", "foo:1:c:h2", "ev:abc:c:h2", "ev:1:c:dep:x"):
self.assertIsNone(parse_custom_id(bad))
class ActionPatchTests(unittest.TestCase):
class DepartureChoiceTests(unittest.TestCase):
"""Absolutní deadline z výběru „Kdy odjíždíš?" (Europe/Prague)."""
def test_h2(self) -> None:
dl = departure_choice_to_deadline("h2", now=_NOW)
self.assertEqual(_prague(dl), "2026-06-12 14:00")
def test_h4(self) -> None:
dl = departure_choice_to_deadline("h4", now=_NOW)
self.assertEqual(_prague(dl), "2026-06-12 16:00")
def test_today18_before_18(self) -> None:
dl = departure_choice_to_deadline("today18", now=_NOW) # 12:00 Prague
self.assertEqual(_prague(dl), "2026-06-12 18:00")
def test_today18_after_18_rolls_to_next_day(self) -> None:
late = datetime(2026, 6, 12, 17, 30, tzinfo=timezone.utc) # 19:30 Prague
dl = departure_choice_to_deadline("today18", now=late)
self.assertEqual(_prague(dl), "2026-06-13 18:00")
def test_tomorrow7_crosses_midnight(self) -> None:
# 23:30 Prague v pátek → zítra (sobota) 07:00, tj. +7,5 h
late = datetime(2026, 6, 12, 21, 30, tzinfo=timezone.utc)
dl = departure_choice_to_deadline("tomorrow7", now=late)
self.assertEqual(_prague(dl), "2026-06-13 07:00")
def test_tomorrow12(self) -> None:
dl = departure_choice_to_deadline("tomorrow12", now=_NOW)
self.assertEqual(_prague(dl), "2026-06-13 12:00")
def test_monday7_from_friday_allows_over_48h(self) -> None:
# explicitní volba smí přes 48h limit fn_ev_session_defaults
dl = departure_choice_to_deadline("monday7", now=_NOW) # pátek 12:00
self.assertEqual(_prague(dl), "2026-06-15 07:00")
self.assertGreater((dl - _NOW).total_seconds(), 48 * 3600)
def test_monday7_on_monday_before_7_is_today(self) -> None:
mon_early = datetime(2026, 6, 15, 3, 0, tzinfo=timezone.utc) # po 05:00
dl = departure_choice_to_deadline("monday7", now=mon_early)
self.assertEqual(_prague(dl), "2026-06-15 07:00")
def test_monday7_on_monday_after_7_is_next_week(self) -> None:
mon_late = datetime(2026, 6, 15, 8, 0, tzinfo=timezone.utc) # po 10:00
dl = departure_choice_to_deadline("monday7", now=mon_late)
self.assertEqual(_prague(dl), "2026-06-22 07:00")
def test_unknown_choice_raises(self) -> None:
with self.assertRaises(ValueError):
departure_choice_to_deadline("never", now=_NOW)
class SelectPatchTests(unittest.TestCase):
"""Mapování výběrů na patch payload pro fn_ev_session_apply_patch."""
def test_dep_patches_only_deadline(self) -> None:
p = select_to_patch("dep", "today18", now=_NOW, soc_at_connect=55.0)
self.assertEqual(set(p), {"target_deadline"})
self.assertIn("2026-06-12T18:00", p["target_deadline"])
self.assertIn("+02:00", p["target_deadline"]) # Europe/Prague (CEST)
def test_tgt_patches_only_target(self) -> None:
for value, expected in (("30", 30.0), ("50", 50.0), ("70", 70.0), ("100", 100.0)):
p = select_to_patch("tgt", value, now=_NOW, soc_at_connect=55.0)
self.assertEqual(p, {"target_soc_pct": expected})
def test_tgt_stop_targets_connect_soc(self) -> None:
p = select_to_patch("tgt", "stop", now=_NOW, soc_at_connect=42.5)
self.assertEqual(p, {"target_soc_pct": 42.5})
def test_tgt_stop_without_soc(self) -> None:
p = select_to_patch("tgt", "stop", now=_NOW, soc_at_connect=None)
self.assertEqual(p, {"target_soc_pct": 0.0})
def test_unknown_kind_raises(self) -> None:
with self.assertRaises(ValueError):
select_to_patch("foo", "30", now=_NOW, soc_at_connect=None)
def test_labels(self) -> None:
self.assertEqual(choice_label("dep", "monday7"), "odjezd pondělí ráno 7:00")
self.assertEqual(choice_label("tgt", "70"), "cíl 70 %")
self.assertEqual(choice_label("tgt", "stop"), "nenabíjet")
class LegacyActionPatchTests(unittest.TestCase):
"""Legacy tlačítka starších zpráv (h2/h4/morning/full/stop)."""
def _patch(self, action: str, **kw):
return action_to_patch(
action,

View File

@@ -15,34 +15,59 @@ Plán nabíjení: 11:3013:45; 02:1504:30 — 34.2 kWh, ø 1.85 Kč/kWh
Implementace: `_notify_ev_arrival_plan` v `telemetry_collector.py` (sloty
`ev*_setpoint_w > 0` z aktivního plánu shlukované do oken).
## Fáze B — zpětná vazba tlačítkem — ✅ IMPLEMENTOVÁNO (2026-06-12)
## Fáze B — zpětná vazba dvěma výběry — ✅ IMPLEMENTOVÁNO (2026-06-12)
**Architektura: Discord BOT přes gateway** — spojení jde Z backendu VEN
(websocket), žádný veřejný endpoint do EMS (na rozdíl od interactions
webhooku). Knihovna `discord.py`, token v `/opt/ems-deploy/.env`.
Zpráva z fáze A dostane tlačítka:
`[Odjezd za 2 h] [za 4 h] [Ráno (typicky)] [Do plna hned] [Nenabíjet]`
Zpráva z fáze A dostane **dva selecty** (místo dřívější řady tlačítek):
Callback tlačítka:
1. `fn_ev_session_apply_patch(site, session, {"target_deadline": now+2h, …})`
(„Do plna hned" navíc `target_soc_pct=100`; „Nenabíjet" `target_soc_pct=soc`),
2. okamžitý `run_rolling_replan` + `export_setpoints` (vzor ev_arrival),
3. bot **edituje původní zprávu** novým plánem (žádný spam).
```
🔌 Tesla Model Y připojeno
Baterie auta: 55 % → cíl 90 % (~26 kWh)
Deadline: po 15.06. 07:00
Plán nabíjení: 11:3013:45; 02:1504:30 — 26.2 kWh, ø 1.85 Kč/kWh
[ 🕑 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
```
**Sémantika:**
- Každý výběr **okamžitě** PATCHne otevřenou session přes
`fn_ev_session_apply_patch` — ale jen ve SVÉ dimenzi: výběr 1 nastaví
`target_deadline` (absolutní čas Europe/Prague; pevné volby = nejbližší
budoucí výskyt, „pondělí ráno" z pátku je validní i přes 48 h),
výběr 2 nastaví `target_soc_pct`. Druhý rozměr zůstává beze změny
(default z `fn_ev_session_defaults`, případně dřívější výběr).
- „Nenabíjet" = stop akce (target = SoC při připojení → solver nic neplánuje).
- **No-click = pohotovostní režim:** bez kliknutí jede session na defaultech
z `ems.fn_ev_session_defaults` (týdenní požadavek `ev_weekly_requirement`
→ forecast z rytmu → defaulty vozidla; viz `docs/04-modules/ev-charging.md`).
S `min_target_soc_pct` 30 % to znamená: drž aspoň ~30 % pro pohotovost
a zbytek doplňuj oportunisticky v levných/záporných slotech
(`opportunistic_value_czk_kwh` — měkký cíl).
Po každém výběru: patch session → okamžitý `run_rolling_replan` +
`export_setpoints` (vzor ev_arrival) → bot **edituje původní zprávu**
přepočteným plánem (`build_ev_plan_summary`) + potvrzením
`_(nastaveno: odjezd dnes večer 18:00)_` (žádný spam).
Bezpečnost: bot reaguje jen na whitelisted user ID (majitel), akce omezené
na patch session + replan (žádné režimy/registry). Tlačítka expirují
s koncem session.
na patch session + replan (žádné režimy/registry). Selecty expirují
s koncem session (uzavřená session → ephemeral „Session už není otevřená").
**Implementace:** `services/discord_bot.py` (lifespan task; discord.py
gateway), `services/ev_notify.py` (sdílený souhrn plánu; bot-first, webhook
fallback). custom_id `ev:<site>:<charger>:<akce>` — tlačítka přežijí restart.
Env: `DISCORD_BOT_TOKEN`, `DISCORD_EV_CHANNEL_ID`, `DISCORD_ALLOWED_USER_IDS`
(čárkami; prázdné = bot vypnut, jede fáze A webhook). Akce: h2/h4 (deadline
teď+N), morning (další default_deadline_hour vozidla, Prague), full (100 % +
deadline za 1 h → max tempo), stop (target = SoC při připojení). Po akci:
patch session → okamžitý replan + export → bot zedituje zprávu novým plánem.
Testy: tests/test_discord_bot.py (parse, patch akcí).
fallback). custom_id `ev:<site>:<charger>:dep` / `:tgt` — obsluha přes
`on_interaction` + regex (persistent vzor), selecty přežijí restart backendu.
Legacy tlačítka `h2|h4|morning|full|stop` ze starších zpráv zůstávají
obsloužená (`action_to_patch`). Env: `DISCORD_BOT_TOKEN`,
`DISCORD_EV_CHANNEL_ID`, `DISCORD_ALLOWED_USER_IDS` (čárkami; prázdné =
bot vypnut, jede fáze A webhook).
Testy: tests/test_discord_bot.py (parse, výběr→patch, absolutní deadline
z voleb vč. půlnoci a pondělí z pátku, legacy akce).
## Výhled (fáze C)
Stejný bot = kanál pro ranní triáž s dotazy („proč jsi v 19:00 nabíjel?" →