# retoor <retoor@molodetz.nl>
from __future__ import annotations
import re
from datetime import datetime
from typing import Any
from .schema import InteractionRequest, WidgetModel, flatten_widgets
YES = frozenset({"y", "yes", "true", "1", "ok", "confirm", "ja", "oui", "j"})
NO = frozenset({"n", "no", "false", "0", "nee", "non"})
CANCEL = frozenset({"cancel", "c", "abort", "quit", "annuleren", "stop"})
DATE_FORMATS = (
"%d/%m/%Y",
"%d-%m-%Y",
"%d.%m.%Y",
"%d/%m/%y",
"%d-%m-%y",
"%Y-%m-%d",
)
def parse_plain_reply(
request: InteractionRequest, text: str
) -> tuple[str, dict[str, Any], str | None]:
raw = (text or "").strip()
if not raw:
return "error", {}, "Empty reply."
lower = raw.lower()
if lower in CANCEL:
return "cancelled", {}, None
widgets = [w for w in flatten_widgets(request.widgets) if w.type != "//"]
if not widgets:
return "submitted", {}, None
if len(widgets) == 1:
return _parse_single(widgets[0], raw, strict=True)
if not _looks_like_multi_answer(raw, widgets):
return "error", {}, "Not a structured answer for the open form."
values: dict[str, Any] = {}
chunks = [part.strip() for part in re.split(r"[;\n]+", raw) if part.strip()]
if len(chunks) == 1 and "," in chunks[0] and len(widgets) > 1:
chunks = [part.strip() for part in chunks[0].split(",") if part.strip()]
if len(chunks) != len(widgets):
return (
"error",
{},
f"Expected {len(widgets)} values (one per field), got {len(chunks)}.",
)
for widget, chunk in zip(widgets, chunks):
status, partial, err = _parse_single(widget, chunk, strict=True)
if status != "submitted":
return status, {}, err
values.update(partial)
return "submitted", values, None
def _looks_like_multi_answer(raw: str, widgets: list[WidgetModel]) -> bool:
if ";" in raw or "\n" in raw:
return True
if "," in raw and len(widgets) > 1:
return True
return False
def _parse_single(
widget: WidgetModel, raw: str, *, strict: bool = False
) -> tuple[str, dict[str, Any], str | None]:
text = raw.strip()
lower = text.lower()
if lower in CANCEL:
return "cancelled", {}, None
name = widget.name
if widget.type == "confirm":
if lower in YES:
return "submitted", {name: True}, None
if lower in NO:
return "submitted", {name: False}, None
return "error", {}, "Reply yes/no (or ja/nee)."
if widget.type in ("choice", "select"):
value = _match_option(widget, text)
if value is None:
return "error", {}, f"Unknown option for {name}."
return "submitted", {name: value}, None
if widget.type == "choice_multi":
parts = [p.strip() for p in re.split(r"[,\s]+", text) if p.strip()]
values: list[str] = []
for part in parts:
matched = _match_option(widget, part)
if matched is None:
return "error", {}, f"Unknown option {part!r} for {name}."
if matched not in values:
values.append(matched)
if widget.required and not values:
return "error", {}, f"{name} requires at least one option."
return "submitted", {name: values}, None
if widget.type == "number":
if strict and not re.fullmatch(r"[+-]?\d+(?:[.,]\d+)?", text):
return "error", {}, f"{name} must be a number."
normalized = text.replace(",", ".")
try:
number = float(normalized) if "." in normalized else int(normalized)
except ValueError:
return "error", {}, f"{name} must be a number."
if widget.min is not None and number < widget.min:
return "error", {}, f"{name} must be ≥ {widget.min}."
if widget.max is not None and number > widget.max:
return "error", {}, f"{name} must be ≤ {widget.max}."
return "submitted", {name: number}, None
if widget.type == "date":
if strict and not _looks_like_date(text):
return "error", {}, f"{name} must be a date as DD/MM/YYYY."
european = _parse_date_european(text)
if european is None:
return "error", {}, f"{name} must be a date as DD/MM/YYYY."
return "submitted", {name: european}, None
if widget.type == "text":
if strict and _looks_like_command(text):
return "error", {}, f"{name} does not look like a field answer."
if widget.max_length and len(text) > widget.max_length:
return "error", {}, f"{name} exceeds max length {widget.max_length}."
if widget.required and not text:
return "error", {}, f"{name} is required."
return "submitted", {name: text}, None
return "error", {}, f"Unsupported widget type {widget.type}."
def _looks_like_date(text: str) -> bool:
return bool(
re.fullmatch(r"\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4}", text.strip())
or re.fullmatch(r"\d{4}-\d{2}-\d{2}", text.strip())
)
def _looks_like_command(text: str) -> bool:
value = text.strip().lower()
if not value:
return False
if value.startswith("/"):
return True
starters = (
"show me ",
"laat ",
"geef ",
"toon ",
"please ",
"can you ",
"could you ",
"i want ",
"ik wil ",
"volgende",
"next ",
)
return any(value.startswith(prefix) for prefix in starters)
def _parse_date_european(text: str) -> str | None:
value = text.strip()
for fmt in DATE_FORMATS:
try:
return datetime.strptime(value, fmt).strftime("%d/%m/%Y")
except ValueError:
continue
return None
def _match_option(widget: WidgetModel, text: str) -> str | None:
raw = text.strip()
if raw.isdigit():
index = int(raw)
if 1 <= index <= len(widget.options):
option = widget.options[index - 1]
if not option.disabled:
return option.value
lower = raw.lower()
for option in widget.options:
if option.disabled:
continue
if option.value == raw or option.value.lower() == lower:
return option.value
if option.label.lower() == lower:
return option.value
return None