From 48f5a6b00b26ed96d4c88370fd17f446a8a90135 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Fri, 12 Jun 2026 19:14:56 +0200 Subject: [PATCH] =?UTF-8?q?Discord=20EV:=20dva=20v=C3=BDb=C4=9Bry=20(odjez?= =?UTF-8?q?d=20=C3=97=20c=C3=ADl)=20m=C3=ADsto=20=C5=99ady=20tla=C4=8D?= =?UTF-8?q?=C3=ADtek?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arrival zpráva má dva persistent Selecty (custom_id ev:::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) --- backend/services/discord_bot.py | 240 +++++++++++++++++++++++------- backend/tests/test_discord_bot.py | 119 ++++++++++++++- docs/discord-ev-interaction.md | 59 +++++--- 3 files changed, 338 insertions(+), 80 deletions(-) diff --git a/backend/services/discord_bot.py b/backend/services/discord_bot.py index 7870c12..5da7dc0 100644 --- a/backend/services/discord_bot.py +++ b/backend/services/discord_bot.py @@ -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:::" — 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:::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. """ @@ -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\d+):(?P[a-z0-9\-]+):(?Ph2|h4|morning|full|stop)$") +CUSTOM_ID_RE = re.compile( + r"^ev:(?P\d+):(?P[a-z0-9\-]+)" + r":(?Pdep|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 diff --git a/backend/tests/test_discord_bot.py b/backend/tests/test_discord_bot.py index 7b4279e..a12fc11 100644 --- a/backend/tests/test_discord_bot.py +++ b/backend/tests/test_discord_bot.py @@ -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, diff --git a/docs/discord-ev-interaction.md b/docs/discord-ev-interaction.md index 75e6b41..d7c81e3 100644 --- a/docs/discord-ev-interaction.md +++ b/docs/discord-ev-interaction.md @@ -15,34 +15,59 @@ Plán nabíjení: 11:30–13:45; 02:15–04: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:30–13:45; 02:15–04: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:::` — 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:::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?" →