Merge branch 'worktree-agent-a288972b643cdefcc' into dev
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user