diff --git a/.env.example b/.env.example index 875adf9..19feef4 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,8 @@ ORDER_CUTOFF=15:00 ORDER_ASK_UNTIL=15:30 ORDER_REMIND_MINUTES=10 ORDER_EXPIRE_MINUTES=30 +# Seconds without a new message before the bot replies to an order being collected (max 50) +ORDER_UPDATE_QUIET_SECONDS=3 # Result PDF fetching (IST): check from START until a draw is stored, give up at END, retry every N minutes RESULTS_WINDOW_START=16:00 diff --git a/hermes-plugin/srs-gate/.env.example b/hermes-plugin/srs-gate/.env.example new file mode 100644 index 0000000..b9c012d --- /dev/null +++ b/hermes-plugin/srs-gate/.env.example @@ -0,0 +1,3 @@ + +SRS_URL=http://127.0.0.1:8080 +SRS_SECRET=change-me diff --git a/hermes-plugin/srs-gate/.gitignore b/hermes-plugin/srs-gate/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/hermes-plugin/srs-gate/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/hermes-plugin/srs-gate/__init__.py b/hermes-plugin/srs-gate/__init__.py new file mode 100644 index 0000000..7b2e818 --- /dev/null +++ b/hermes-plugin/srs-gate/__init__.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import os +import time +import urllib.error +import urllib.request +from pathlib import Path + +log = logging.getLogger("srs_gate") + +SKIP = {"action": "skip", "reason": "srs-gate"} +CONFIG_NAME = "srs-gate.env" +TRIES = 3 +TRY_TIMEOUT_S = 3.0 +BACKOFF_S = 0.3 +OVERALL_TIMEOUT_S = 9.0 + +_config_warned = False + + +class ConfigError(Exception): + """srs-gate.env is missing or incomplete. Messages carry fixed wording only, never values.""" + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Never follow a redirect: it could carry the x-srs-secret header (and the text) to another host.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + +_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect) + + +# ---------------------------------------------------------------- config + + +def _config_path() -> Path: + home = os.environ.get("HERMES_HOME") + base = Path(home) if home else Path.home() / ".hermes" + return base / CONFIG_NAME + + +def _unquote(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + return value[1:-1] + return value + + +def _load_config() -> dict: + try: + raw = _config_path().read_text(encoding="utf-8") + except FileNotFoundError: + raise ConfigError("config file not found") from None + except (OSError, UnicodeError): + raise ConfigError("config file unreadable") from None + cfg = {} + for line in raw.splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + cfg[key.strip()] = _unquote(value.strip()) + url = cfg.get("SRS_URL", "") + secret = cfg.get("SRS_SECRET", "") + if not secret: + raise ConfigError("config has no SRS_SECRET") + if not url.lower().startswith(("http://", "https://")): + raise ConfigError("config SRS_URL missing or not http(s)") + return {"SRS_URL": url, "SRS_SECRET": secret} + + +# ---------------------------------------------------------------- event filter + + +def _payload(event): + """The /ingest body for a forwardable direct text message, else None.""" + if event is None: + return None + src = getattr(event, "source", None) + if src is None or getattr(event, "internal", False) or getattr(src, "is_bot", False): + return None + if getattr(src, "chat_type", None) != "dm": + return None + if getattr(event, "edit_revision", None) not in (None, ""): + return None + mtype = getattr(event, "message_type", None) + if mtype is None or str(getattr(mtype, "value", mtype)) != "text": + return None + message_id = getattr(event, "message_id", None) + sender = getattr(src, "user_id", None) + chat_id = getattr(src, "chat_id", None) + text = getattr(event, "text", None) + if not (message_id and sender and chat_id and isinstance(text, str) and text.strip()): + return None + if str(chat_id).endswith("@g.us"): + return None + return { + "kind": "text", + "message_id": str(message_id), + "sender_jid": str(sender), + "chat_id": str(chat_id), + "text": text, + } + + +# ---------------------------------------------------------------- HTTP (runs in a worker thread) + + +def _error_name(exc: BaseException) -> str: + reason = getattr(exc, "reason", None) if isinstance(exc, urllib.error.URLError) else None + return type(reason).__name__ if isinstance(reason, BaseException) else type(exc).__name__ + + +def _post(cfg: dict, payload: dict): + """Blocking POST with retries. Returns (http_status or None, error type name or None). Never raises. + + Nothing may cross the thread boundary: on Python 3.9-3.11 asyncio.wait_for runs this in its own task, and a + BaseException such as SystemExit would escape through the event loop instead of the callback's except. + """ + status = None + error = None + try: + req = urllib.request.Request( + cfg["SRS_URL"].rstrip("/") + "/ingest", + data=json.dumps(payload).encode("utf-8"), + method="POST", + headers={"content-type": "application/json", "x-srs-secret": cfg["SRS_SECRET"]}, + ) + for attempt in range(TRIES): + try: + with _OPENER.open(req, timeout=TRY_TIMEOUT_S) as resp: + return resp.status, None + except urllib.error.HTTPError as err: + status, error = err.code, None + err.close() + if err.code < 500: + return status, None + except Exception as exc: + status, error = None, _error_name(exc) + if attempt + 1 < TRIES: + time.sleep(BACKOFF_S) + return status, error + except BaseException as exc: + return status, type(exc).__name__ + + +# ---------------------------------------------------------------- the hook + + +async def on_inbound(event=None, gateway=None, session_store=None, **kwargs): + global _config_warned + message_id = "-" + try: + payload = _payload(event) + if payload is not None: + message_id = payload["message_id"] + try: + cfg = _load_config() + except ConfigError as exc: + cfg = None + if not _config_warned: + _config_warned = True + log.warning("srs-gate: %s; nothing is forwarded", exc) + if cfg is not None: + _config_warned = False + status, error = await asyncio.wait_for( + asyncio.to_thread(_post, cfg, payload), OVERALL_TIMEOUT_S + ) + if status is not None and 200 <= status < 300: + log.info("srs-gate: id=%s status=%s", message_id, status) + else: + log.warning("srs-gate: id=%s status=%s error=%s", message_id, status, error) + except asyncio.CancelledError: + raise + except (Exception, SystemExit) as exc: + log.warning("srs-gate: id=%s not forwarded (%s)", message_id, type(exc).__name__) + return dict(SKIP) + + +def register(ctx): + ctx.register_hook("pre_gateway_dispatch", on_inbound) diff --git a/hermes-plugin/srs-gate/plugin.yaml b/hermes-plugin/srs-gate/plugin.yaml new file mode 100644 index 0000000..a4db96b --- /dev/null +++ b/hermes-plugin/srs-gate/plugin.yaml @@ -0,0 +1,5 @@ +name: srs-gate +version: "0.1" +description: Forwards direct WhatsApp text messages to srs-api and drops them from Hermes +provides_hooks: + - pre_gateway_dispatch diff --git a/hermes-plugin/srs-gate/test_srs_gate.py b/hermes-plugin/srs-gate/test_srs_gate.py new file mode 100644 index 0000000..7c097ad --- /dev/null +++ b/hermes-plugin/srs-gate/test_srs_gate.py @@ -0,0 +1,572 @@ +"""Tests for the srs-gate Hermes plugin. Standard library only. + +Run from the repo root: + python -m unittest discover -s hermes-plugin/srs-gate -v + +All phone digits, ids and the secret below are placeholders, not real values. +""" +from __future__ import annotations + +import asyncio +import enum +import importlib.util +import inspect +import json +import logging +import os +import socket +import tempfile +import threading +import time +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +HERE = Path(__file__).resolve().parent + + +def _load_plugin(): + spec = importlib.util.spec_from_file_location("srs_gate", HERE / "__init__.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +gate = _load_plugin() + +SKIP = {"action": "skip", "reason": "srs-gate"} +SECRET = "test-only-secret-not-real-0123456789" +PHONE = "910000000001" +SENDER = PHONE + "@s.whatsapp.net" +CHAT = "100000000000077@lid" +MID = "3EB0TESTMSGID01" +TEXT = "Shop Zq7\n0012-5\n4567 x 10\ncall " + PHONE +SENSITIVE = [SECRET, PHONE, SENDER, CHAT, "100000000000077", "Zq7", "4567 x 10"] + + +class MessageType(enum.Enum): + TEXT = "text" + LOCATION = "location" + PHOTO = "photo" + VIDEO = "video" + AUDIO = "audio" + VOICE = "voice" + DOCUMENT = "document" + STICKER = "sticker" + COMMAND = "command" + + +def make_event(text=TEXT): + source = SimpleNamespace( + platform="whatsapp", + chat_id=CHAT, + chat_type="dm", + user_id=SENDER, + user_id_alt=None, + chat_id_alt=None, + is_bot=False, + message_id=MID, + ) + return SimpleNamespace( + text=text, + message_type=MessageType.TEXT, + message_id=MID, + source=source, + media_urls=[], + media_types=[], + internal=False, + edit_revision=None, + metadata={}, + user_id=None, + ) + + +class ExplodingEvent: + @property + def source(self): + raise RuntimeError("boom") + + @property + def internal(self): + raise RuntimeError("boom") + + +# ---------------------------------------------------------------- stub srs-api + + +class _Server(ThreadingHTTPServer): + daemon_threads = True + block_on_close = False + + def handle_error(self, request, client_address): + pass + + +class Stub: + """Stands in for srs-api. `script` is a list of actions, one per request; after it runs out, 200. + + Actions: ("status", code, delay_s) or ("garbage",). + """ + + def __init__(self, script=None): + self.requests = [] + self.script = list(script or []) + self._lock = threading.Lock() + stub = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_POST(self): + length = int(self.headers.get("content-length") or 0) + body = self.rfile.read(length) + with stub._lock: + stub.requests.append( + {"method": "POST", "path": self.path, "headers": dict(self.headers), "body": body} + ) + action = stub.script.pop(0) if stub.script else ("status", 200, 0) + if action[0] == "garbage": + self.wfile.write(b"this is not http\r\n\r\n") + self.close_connection = True + return + _, code, delay = action + if delay: + time.sleep(delay) + payload = b'{"handled":true,"duplicate":false}' + self.send_response(code) + if 300 <= code < 400: + self.send_header("location", "/elsewhere") + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + do_GET = do_POST + + self.server = _Server(("127.0.0.1", 0), Handler) + self.url = "http://127.0.0.1:%d" % self.server.server_address[1] + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def stop(self): + self.server.shutdown() + self.server.server_close() + + +def closed_port_url(): + s = socket.socket() + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + return "http://127.0.0.1:%d" % port + + +class ListHandler(logging.Handler): + def __init__(self): + super().__init__(level=logging.DEBUG) + self.records = [] + + def emit(self, record): + self.records.append(record) + + +# ---------------------------------------------------------------- base + + +class GateCase(unittest.IsolatedAsyncioTestCase): + """Fresh stub, config file, fast timings and a log capture for every test. + + Every test also checks, on teardown, that no log record anywhere holds the text, the secret or a number. + """ + + def setUp(self): + self.stub = Stub() + self.addCleanup(self.stub.stop) + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + env = mock.patch.dict(os.environ, {"HERMES_HOME": self.tmp.name}) + env.start() + self.addCleanup(env.stop) + self.write_config(url=self.stub.url) + for name, value in (("TRIES", 3), ("TRY_TIMEOUT_S", 1.0), ("BACKOFF_S", 0.01), ("OVERALL_TIMEOUT_S", 5.0)): + p = mock.patch.object(gate, name, value) + p.start() + self.addCleanup(p.stop) + gate._config_warned = False + self.logs = ListHandler() + root = logging.getLogger() + self._old_level = root.level + root.addHandler(self.logs) + root.setLevel(logging.DEBUG) + self.addCleanup(self._restore_logging) + + def _restore_logging(self): + root = logging.getLogger() + root.removeHandler(self.logs) + root.setLevel(self._old_level) + + def tearDown(self): + self.assert_no_sensitive_logs() + + # helpers + + def write_config(self, url=None, secret=SECRET, extra=""): + lines = ["# srs-gate test config"] + if url is not None: + lines.append("SRS_URL=" + url) + if secret is not None: + lines.append("SRS_SECRET=" + secret) + Path(self.tmp.name, "srs-gate.env").write_text("\n".join(lines) + "\n" + extra, encoding="utf-8") + + def config_path(self): + return Path(self.tmp.name, "srs-gate.env") + + async def call(self, event): + return await gate.on_inbound(event=event, gateway=object(), session_store=object()) + + def log_text(self): + out = [] + for r in self.logs.records: + out.append(r.getMessage()) + out.append(repr(r.args)) + if r.exc_info or r.exc_text: + out.append(logging.Formatter().formatException(r.exc_info) if r.exc_info else r.exc_text) + return "\n".join(out) + + def assert_no_sensitive_logs(self): + text = self.log_text() + for needle in SENSITIVE: + self.assertNotIn(needle, text, "sensitive value in logs") + + def gate_messages(self): + return [r.getMessage() for r in self.logs.records if r.name == "srs_gate"] + + +# ---------------------------------------------------------------- payload + + +class PayloadTests(GateCase): + def test_exact_payload_for_direct_text(self): + self.assertEqual( + gate._payload(make_event()), + {"kind": "text", "message_id": MID, "sender_jid": SENDER, "chat_id": CHAT, "text": TEXT}, + ) + + def test_plain_string_message_type_is_accepted(self): + ev = make_event() + ev.message_type = "text" + self.assertIsNotNone(gate._payload(ev)) + + async def test_text_arrives_byte_for_byte(self): + texts = { + "zeros_and_breaks": "0012\n0099\r\n 0001 \n\n0000 - 5\t", + "malayalam": "ഷോപ്പ് 0012 - 5", + "hundred_lines": "\n".join("%04d - %d" % (i, i % 7 + 1) for i in range(100)), + } + for name, text in texts.items(): + with self.subTest(name): + before = len(self.stub.requests) + self.assertEqual(await self.call(make_event(text)), SKIP) + self.assertEqual(len(self.stub.requests), before + 1) + body = json.loads(self.stub.requests[-1]["body"].decode("utf-8")) + self.assertEqual(body["text"], text) + self.assertEqual(body["kind"], "text") + + +# ---------------------------------------------------------------- filters + + +def _set(path, value): + def apply(ev): + target = ev + parts = path.split(".") + for p in parts[:-1]: + target = getattr(target, p) + setattr(target, parts[-1], value) + + return apply + + +def _delete(path): + def apply(ev): + target = ev + parts = path.split(".") + for p in parts[:-1]: + target = getattr(target, p) + delattr(target, parts[-1]) + + return apply + + +NOT_FORWARDED = [ + ("group chat_type", _set("source.chat_type", "group")), + ("channel chat_type", _set("source.chat_type", "channel")), + ("thread chat_type", _set("source.chat_type", "thread")), + ("unknown chat_type", _set("source.chat_type", "something-new")), + ("chat_type wrong case", _set("source.chat_type", "DM")), + ("chat_type None", _set("source.chat_type", None)), + ("chat_type missing", _delete("source.chat_type")), + ("group chat id", _set("source.chat_id", "120363000000000000@g.us")), + ("internal", _set("internal", True)), + ("is_bot", _set("source.is_bot", True)), + ("edit_revision set", _set("edit_revision", "2")), + ("edit_revision int", _set("edit_revision", 1)), + ("blank text", _set("text", "")), + ("whitespace text", _set("text", " \n\t ")), + ("text None", _set("text", None)), + ("text not a string", _set("text", 1234)), + ("missing message id", _set("message_id", None)), + ("empty message id", _set("message_id", "")), + ("missing sender", _set("source.user_id", None)), + ("missing chat id", _set("source.chat_id", "")), + ("no source", _set("source", None)), + ("message_type missing", _set("message_type", None)), +] + [ + ("message_type " + t.value, _set("message_type", t)) for t in MessageType if t is not MessageType.TEXT +] + + +class FilterTests(GateCase): + async def test_not_forwarded_but_still_skipped(self): + for name, mutate in NOT_FORWARDED: + with self.subTest(name): + ev = make_event() + mutate(ev) + self.assertIsNone(gate._payload(ev)) + self.assertEqual(await self.call(ev), SKIP) + self.assertEqual(self.stub.requests, []) + + async def test_photo_placeholder_text_is_not_forwarded(self): + ev = make_event("[image received]") + ev.message_type = MessageType.PHOTO + self.assertEqual(await self.call(ev), SKIP) + self.assertEqual(self.stub.requests, []) + + +# ---------------------------------------------------------------- request + + +class RequestTests(GateCase): + async def test_headers_method_and_path(self): + self.assertEqual(await self.call(make_event()), SKIP) + self.assertEqual(len(self.stub.requests), 1) + req = self.stub.requests[0] + headers = {k.lower(): v for k, v in req["headers"].items()} + self.assertEqual(req["method"], "POST") + self.assertEqual(req["path"], "/ingest") + self.assertEqual(headers["x-srs-secret"], SECRET) + self.assertEqual(headers["content-type"], "application/json") + + async def test_trailing_slash_in_url(self): + self.write_config(url=self.stub.url + "/") + await self.call(make_event()) + self.assertEqual(self.stub.requests[0]["path"], "/ingest") + + async def test_quoted_config_values(self): + self.write_config(url='"%s"' % self.stub.url, secret="'%s'" % SECRET) + await self.call(make_event()) + headers = {k.lower(): v for k, v in self.stub.requests[0]["headers"].items()} + self.assertEqual(headers["x-srs-secret"], SECRET) + + def test_proxy_environment_is_ignored(self): + dead = closed_port_url() + with mock.patch.dict(os.environ, {"http_proxy": dead, "https_proxy": dead, "no_proxy": ""}): + fresh = _load_plugin() + status, error = fresh._post( + {"SRS_URL": self.stub.url, "SRS_SECRET": SECRET}, fresh._payload(make_event()) + ) + self.assertEqual((status, error), (200, None)) + self.assertEqual(len(self.stub.requests), 1) + + async def test_success_log_has_id_and_status(self): + await self.call(make_event()) + msgs = self.gate_messages() + self.assertEqual(len(msgs), 1) + self.assertIn("id=" + MID, msgs[0]) + self.assertIn("status=200", msgs[0]) + + +# ---------------------------------------------------------------- retries + + +class RetryTests(GateCase): + async def test_500_then_200_succeeds_on_second_try(self): + self.stub.script = [("status", 500, 0), ("status", 200, 0)] + self.assertEqual(await self.call(make_event()), SKIP) + self.assertEqual(len(self.stub.requests), 2) + self.assertIn("status=200", self.gate_messages()[-1]) + + async def test_500_three_times_gives_up(self): + self.stub.script = [("status", 500, 0)] * 5 + self.assertEqual(await self.call(make_event()), SKIP) + self.assertEqual(len(self.stub.requests), 3) + self.assertIn("status=500", self.gate_messages()[-1]) + + async def test_4xx_and_3xx_are_not_retried(self): + for code in (400, 401, 404, 302): + with self.subTest(code): + self.stub.requests.clear() + self.stub.script = [("status", code, 0), ("status", 200, 0)] + self.assertEqual(await self.call(make_event()), SKIP) + self.assertEqual(len(self.stub.requests), 1, "retried or followed a redirect") + self.assertIn("status=%d" % code, self.gate_messages()[-1]) + + def test_connection_refused_ends_after_three_tries(self): + cfg = {"SRS_URL": closed_port_url(), "SRS_SECRET": SECRET} + with mock.patch.object(gate, "TRY_TIMEOUT_S", 0.5), mock.patch.object( + gate._OPENER, "open", wraps=gate._OPENER.open + ) as opened: + status, error = gate._post(cfg, gate._payload(make_event())) + self.assertEqual(opened.call_count, 3) + self.assertIsNone(status) + self.assertIsNotNone(error) + + async def test_slow_server_hits_per_try_timeout_then_retries(self): + self.stub.script = [("status", 200, 0.6), ("status", 200, 0)] + with mock.patch.object(gate, "TRY_TIMEOUT_S", 0.2): + self.assertEqual(await self.call(make_event()), SKIP) + self.assertEqual(len(self.stub.requests), 2) + self.assertIn("status=200", self.gate_messages()[-1]) + + +# ---------------------------------------------------------------- fail closed + + +class FailClosedTests(GateCase): + async def test_opener_raises(self): + with mock.patch.object(gate._OPENER, "open", side_effect=RuntimeError("x")): + self.assertEqual(await self.call(make_event()), SKIP) + + async def test_bad_events(self): + for name, ev in (("None", None), ("object", object()), ("exploding", ExplodingEvent()), ("string", "hi")): + with self.subTest(name): + self.assertEqual(await self.call(ev), SKIP) + self.assertEqual(await gate.on_inbound(), SKIP) + self.assertEqual(self.stub.requests, []) + + async def test_bad_config(self): + cases = { + "missing file": lambda: self.config_path().unlink(), + "no secret": lambda: self.write_config(url=self.stub.url, secret=None), + "empty secret": lambda: self.write_config(url=self.stub.url, secret=""), + "no url": lambda: self.write_config(url=None), + "file scheme": lambda: self.write_config(url="file:///etc/passwd"), + "no scheme": lambda: self.write_config(url="127.0.0.1:8080"), + } + for name, breakit in cases.items(): + with self.subTest(name): + breakit() + self.assertEqual(await self.call(make_event()), SKIP) + self.assertEqual(self.stub.requests, []) + + async def test_config_error_logged_once_until_fixed(self): + self.write_config(url=self.stub.url, secret=None) + await self.call(make_event()) + await self.call(make_event()) + self.assertEqual(len(self.gate_messages()), 1) + self.write_config(url=self.stub.url) + await self.call(make_event()) + self.write_config(url=self.stub.url, secret=None) + await self.call(make_event()) + config_warnings = [m for m in self.gate_messages() if "config" in m] + self.assertEqual(len(config_warnings), 2) + + async def test_garbage_reply(self): + self.stub.script = [("garbage",)] * 3 + self.assertEqual(await self.call(make_event()), SKIP) + self.assertGreaterEqual(len(self.stub.requests), 1) + + async def test_overall_timeout(self): + self.stub.script = [("status", 200, 0.5)] + with mock.patch.object(gate, "OVERALL_TIMEOUT_S", 0.15), mock.patch.object( + gate, "TRIES", 1 + ), mock.patch.object(gate, "TRY_TIMEOUT_S", 1.0): + started = time.monotonic() + self.assertEqual(await self.call(make_event()), SKIP) + self.assertLess(time.monotonic() - started, 0.45) + self.assertIn("TimeoutError", self.gate_messages()[-1]) + + def test_post_never_raises_even_systemexit(self): + cfg = {"SRS_URL": self.stub.url, "SRS_SECRET": SECRET} + with mock.patch.object(gate._OPENER, "open", side_effect=SystemExit(3)): + status, error = gate._post(cfg, gate._payload(make_event())) + self.assertIsNone(status) + self.assertEqual(error, "SystemExit") + + async def test_systemexit_in_http_call(self): + with mock.patch.object(gate._OPENER, "open", side_effect=SystemExit(3)): + self.assertEqual(await self.call(make_event()), SKIP) + + async def test_backstop_catches_systemexit_outside_the_thread(self): + with mock.patch.object(gate, "_load_config", side_effect=SystemExit(3)): + self.assertEqual(await self.call(make_event()), SKIP) + + async def test_backstop_catches_unexpected_error(self): + with mock.patch.object(gate, "_payload", side_effect=ValueError("x")): + self.assertEqual(await self.call(make_event()), SKIP) + + +# ---------------------------------------------------------------- loop behaviour + + +class LoopTests(GateCase): + async def test_slow_srs_api_does_not_block_the_loop(self): + self.stub.script = [("status", 200, 0.8)] + ticks = 0 + done = asyncio.Event() + + async def ticker(): + nonlocal ticks + while not done.is_set(): + await asyncio.sleep(0.02) + ticks += 1 + + async def run(): + try: + return await self.call(make_event()) + finally: + done.set() + + result, _ = await asyncio.gather(run(), ticker()) + self.assertEqual(result, SKIP) + self.assertGreaterEqual(ticks, 15, "event loop was blocked while waiting for srs-api") + + async def test_cancellation_is_re_raised(self): + self.stub.script = [("status", 200, 0.6)] + with mock.patch.object(gate, "TRIES", 1): + task = asyncio.ensure_future(self.call(make_event())) + await asyncio.sleep(0.15) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + + +# ---------------------------------------------------------------- registration + + +class RegistrationTests(unittest.TestCase): + def test_register_hooks_once(self): + calls = [] + ctx = SimpleNamespace(register_hook=lambda name, cb: calls.append((name, cb))) + gate.register(ctx) + self.assertEqual(calls, [("pre_gateway_dispatch", gate.on_inbound)]) + + def test_callback_is_async(self): + self.assertTrue(inspect.iscoroutinefunction(gate.on_inbound)) + + def test_manifest(self): + text = (HERE / "plugin.yaml").read_text(encoding="utf-8") + self.assertIn("name: srs-gate", text) + self.assertIn("- pre_gateway_dispatch", text) + + def test_returns_a_fresh_skip_each_time(self): + a = asyncio.run(gate.on_inbound(event=None)) + a["action"] = "allow" + self.assertEqual(asyncio.run(gate.on_inbound(event=None)), SKIP) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/config.test.ts b/src/config.test.ts index 80cf628..23aa96e 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -49,7 +49,7 @@ describe('loadConfig', () => { const base = { SRS_SECRET: 'a'.repeat(20) }; it('defaults to a 15:00 cutoff, asking until 15:30, remind 10, expire 30', () => { - expect(loadConfig(base).orders).toEqual({ cutoffMin: 900, askUntilMin: 930, remindMin: 10, expireMin: 30 }); + expect(loadConfig(base).orders).toEqual({ cutoffMin: 900, askUntilMin: 930, remindMin: 10, expireMin: 30, updateQuietSec: 3 }); }); it('reads overrides', () => { @@ -60,7 +60,7 @@ describe('loadConfig', () => { ORDER_REMIND_MINUTES: '5', ORDER_EXPIRE_MINUTES: '20', }); - expect(c.orders).toEqual({ cutoffMin: 870, askUntilMin: 900, remindMin: 5, expireMin: 20 }); + expect(c.orders).toEqual({ cutoffMin: 870, askUntilMin: 900, remindMin: 5, expireMin: 20, updateQuietSec: 3 }); }); it('rejects a bad time format', () => { @@ -75,6 +75,9 @@ describe('loadConfig', () => { it('rejects a reminder that is not before the expiry', () => { expect(() => loadConfig({ ...base, ORDER_REMIND_MINUTES: '30' })).toThrow(/smaller than ORDER_EXPIRE/); + expect(loadConfig({ ...base, ORDER_UPDATE_QUIET_SECONDS: '8' }).orders.updateQuietSec).toBe(8); + expect(() => loadConfig({ ...base, ORDER_UPDATE_QUIET_SECONDS: '0' })).toThrow(); + expect(() => loadConfig({ ...base, ORDER_UPDATE_QUIET_SECONDS: '51' })).toThrow(); }); }); }); diff --git a/src/config.ts b/src/config.ts index f4e836c..b62584c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,7 @@ const schema = z.object({ ORDER_ASK_UNTIL: hhmm('15:30'), ORDER_REMIND_MINUTES: z.coerce.number().int().min(1).max(1440).default(10), ORDER_EXPIRE_MINUTES: z.coerce.number().int().min(1).max(1440).default(30), + ORDER_UPDATE_QUIET_SECONDS: z.coerce.number().int().min(1).max(50).default(3), RESULTS_WINDOW_START: hhmm('16:00'), RESULTS_WINDOW_END: hhmm('19:00'), RESULTS_RETRY_MINUTES: z.coerce.number().int().min(1).max(120).default(10), @@ -43,6 +44,7 @@ export interface OrderSettings { askUntilMin: number; remindMin: number; expireMin: number; + updateQuietSec: number; } export interface ResultsSettings { @@ -88,6 +90,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { askUntilMin: toMinutes(e.ORDER_ASK_UNTIL), remindMin: e.ORDER_REMIND_MINUTES, expireMin: e.ORDER_EXPIRE_MINUTES, + updateQuietSec: e.ORDER_UPDATE_QUIET_SECONDS, }, results: { windowStartMin: toMinutes(e.RESULTS_WINDOW_START), diff --git a/src/db/migrate.test.ts b/src/db/migrate.test.ts index 08319ef..97b75fe 100644 --- a/src/db/migrate.test.ts +++ b/src/db/migrate.test.ts @@ -8,7 +8,7 @@ const throwsConstraint = (fn: () => unknown) => expect(fn).toThrow(/CHECK|UNIQUE describe('migrations', () => { it('apply once and are then a no-op', () => { const db = openDb(':memory:'); - expect(runMigrations(db)).toEqual(['001_init.sql', '002_conversation_flow.sql']); + expect(runMigrations(db)).toEqual(['001_init.sql', '002_conversation_flow.sql', '003_progress_update.sql']); expect(runMigrations(db)).toEqual([]); }); }); diff --git a/src/db/migrations/003_progress_update.sql b/src/db/migrations/003_progress_update.sql new file mode 100644 index 0000000..26d7567 --- /dev/null +++ b/src/db/migrations/003_progress_update.sql @@ -0,0 +1,3 @@ +-- update_pending 1 while a later message added order content and the "Added. N lines so far" reply has not gone out. +-- It lives here, not in a timer, so a restart does not lose it. Sending it never saves anything. +ALTER TABLE conversations ADD COLUMN update_pending INTEGER NOT NULL DEFAULT 0 CHECK (update_pending IN (0, 1)); diff --git a/src/parser/edgeCases.test.ts b/src/parser/edgeCases.test.ts index a72288f..4cffce9 100644 --- a/src/parser/edgeCases.test.ts +++ b/src/parser/edgeCases.test.ts @@ -161,6 +161,66 @@ describe('6.4 quantities', () => { }); }); +describe('6.4b a dot as a quantity (decided 4 Nov 2026)', () => { + it('reads a glued dot and 1-3 digits as the quantity', () => { + expect(read('9001.10').lines).toEqual(['9001x10']); + expect(read('9001.5').lines).toEqual(['9001x5']); + expect(read('9001.100').lines).toEqual(['9001x100']); + expect(read('9001.10.').lines).toEqual(['9001x10']); + }); + it('a dot and 4 digits is still two endings', () => { + expect(read('0888.0057').lines).toEqual(['0888x1', '0057x1']); + }); + it('a dot with a space around it is not a quantity', () => { + expect(read('9001. 10')).toMatchObject({ lines: ['9001x1'], unreadable: ['10:stray_number'] }); + expect(read('9001 .10').lines).toEqual(['9001x1']); + }); + it('keeps refusing what it cannot be sure of', () => { + expect(read('0057x2.5').lines).toEqual([]); + expect(read('9001.10.5').lines).toEqual([]); + expect(read('9001.0').unreadable).toEqual(['9001.0:zero_quantity']); + expect(read('9001.5000').lines).toEqual(['9001x1', '5000x1']); + expect(read('2026.11.04').lines).toEqual([]); + }); +}); + +describe('6.4c bold text (decided 4 Nov 2026)', () => { + it('reads WhatsApp bold around a whole line', () => { + expect(read('*6379*100*').lines).toEqual(['6379x100']); + expect(read('*0888*').lines).toEqual(['0888x1']); + expect(read('*6379 100*').lines).toEqual(['6379x100']); + expect(read('*6379x100*').lines).toEqual(['6379x100']); + expect(read('*9001.10*').lines).toEqual(['9001x10']); + expect(read('*5104-5106*').lines).toEqual(['5104x1', '5105x1', '5106x1']); + }); + it('reads bold around part of a line and several on one line', () => { + expect(read('*6379* 100').lines).toEqual(['6379x100']); + expect(read('6379 *100*').lines).toEqual(['6379x100']); + expect(read('*6379* *100*').lines).toEqual(['6379x100']); + expect(read('*6379*100* *9001*10*').lines).toEqual(['6379x100', '9001x10']); + expect(read('*6379*\n*9001*').lines).toEqual(['6379x1', '9001x1']); + }); + it('reads bold after a WhatsApp prefix, and ignores bold words', () => { + expect(read('[12/10, 14:35] Shop 2: *0888*').lines).toEqual(['0888x1']); + expect(read('*Total 50*')).toMatchObject({ lines: [], ignored: ['Total', '50'] }); + }); + it('leaves a star that is a quantity alone', () => { + expect(read('6379*100').lines).toEqual(['6379x100']); + expect(read('6379 *100').lines).toEqual(['6379x100']); + expect(read('0057 * 2').lines).toEqual(['0057x2']); + expect(read('0057 *2 0003 *5').lines).toEqual(['0057x2', '0003x5']); + }); + it('still refuses a bold marker with no partner', () => { + expect(read('*6379*100').lines).toEqual([]); + expect(read('6379*100*').lines).toEqual([]); + }); + it('reads digits styled by "fancy text" tools instead of silently ignoring them', () => { + expect(read('\u{1D7D4}\u{1D7D1}\u{1D7D5}\u{1D7D7}*\u{1D7CF}\u{1D7CE}\u{1D7CE}').lines).toEqual(['6379x100']); // bold + expect(read('\u{1D7F2}\u{1D7EF}\u{1D7F3}\u{1D7F5}').lines).toEqual(['6379x1']); // sans-bold + expect(read('\u{1D7FC}\u{1D7F9}\u{1D7FD}\u{1D7FF}').lines).toEqual(['6379x1']); // monospace + }); +}); + describe('6.5 ranges', () => { it('EC-41 single-ending range', () => { expect(read('5104-5104').lines).toEqual(['5104x1']); @@ -261,7 +321,7 @@ describe('6.8 mixed, merged and noisy input', () => { }); it('EC-87 decimals and thousands separators', () => { - expect(read('0888.5 0057').unreadable).toEqual(['0888.5:mixed_characters']); + expect(read('0888.5 0057').lines).toEqual(['0888x5', '0057x1']); // a glued dot is a quantity, decided 4 Nov // 1 right after an ending is its quantity (space rule); 200 is reported, never joined into 1200. expect(read('0057 1,200')).toMatchObject({ lines: ['0057x1'], unreadable: ['200:stray_number'] }); expect(read('1,200')).toMatchObject({ lines: [], ignored: ['1', '200'] }); diff --git a/src/parser/normalize.ts b/src/parser/normalize.ts index 4750d2e..ef62369 100644 --- a/src/parser/normalize.ts +++ b/src/parser/normalize.ts @@ -4,6 +4,8 @@ const SPACES = /[\t   -    ]/g; const TIMES = /[×✕✖]/g; // × ✕ ✖ const DASHES = /[‐-―−﹣-]/g; // hyphens, en/em dash, minus, full-width hyphen const FULLWIDTH_DIGIT = /[0-9]/g; +// Mathematical bold, double-struck, sans, sans-bold and monospace digits, from "fancy text" tools: five runs of 0-9. +const STYLED_DIGIT = /[\u{1D7CE}-\u{1D7FF}]/gu; export function normalize(text: string): string[] { return text @@ -13,5 +15,32 @@ export function normalize(text: string): string[] { .replace(TIMES, 'x') .replace(DASHES, '-') .replace(FULLWIDTH_DIGIT, (d) => String.fromCharCode(d.charCodeAt(0) - 0xff10 + 48)) + .replace(STYLED_DIGIT, (d) => String(((d.codePointAt(0) as number) - 0x1d7ce) % 10)) .split('\n'); } + +/** + * Removes WhatsApp bold markers from one line: *6379*100* is 6379*100, *0888* is 0888. A span opens at a star that + * follows a space or the line start and is followed by a non-space; it closes at the next star that follows a non-space + * and is followed by a space or the line end. A star with no partner (6379 *100, 0057 * 2) is a quantity and stays. + * One pass over the line, so the work stays linear. + */ +export function stripBold(line: string): string { + const isSpace = (k: number): boolean => k < 0 || k >= line.length || line[k] === ' '; + let out = ''; + let from = 0; + let i = line.indexOf('*'); + while (i !== -1) { + if (isSpace(i - 1) && !isSpace(i + 1)) { + let close = line.indexOf('*', i + 2); + while (close !== -1 && !(isSpace(close + 1) && !isSpace(close - 1))) close = line.indexOf('*', close + 1); + if (close === -1) break; // no closing star here means none for any later opening star either + out += line.slice(from, i) + line.slice(i + 1, close); + from = close + 1; + i = line.indexOf('*', from); + } else { + i = line.indexOf('*', i + 1); + } + } + return out + line.slice(from); +} diff --git a/src/parser/parseOrder.ts b/src/parser/parseOrder.ts index 4cb1e57..a2db63a 100644 --- a/src/parser/parseOrder.ts +++ b/src/parser/parseOrder.ts @@ -1,6 +1,6 @@ import { lex } from './lexer'; import { MAX_INPUT_CHARS } from './limits'; -import { normalize } from './normalize'; +import { normalize, stripBold } from './normalize'; import { stripPrefix } from './prefix'; import { scanLine, type ScanItem } from './scan'; import type { Ignored, OrderLine, ParseResult, RepeatedEnding, Unreadable } from './types'; @@ -14,7 +14,7 @@ export function scanOrder(text: string): ScanResult { const bodies: string[] = []; const items: ScanItem[] = []; normalize(text).forEach((raw, index) => { - const { body } = stripPrefix(raw); + const body = stripBold(stripPrefix(raw).body); bodies.push(body); items.push(...scanLine(body, lex(body), index + 1)); }); diff --git a/src/parser/scan.ts b/src/parser/scan.ts index c0a5ef3..d1dec23 100644 --- a/src/parser/scan.ts +++ b/src/parser/scan.ts @@ -107,6 +107,8 @@ export function scanLine(body: string, atoms: Atom[], line: number): ScanItem[] return single(i); } + if (isOp(a, '.') && !isNoiseDot(k) && b !== undefined && b.kind === 'num') return withQty(i, b.text, k + 2); + if (a !== undefined && a.kind === 'op' && QTY_OPS.has(a.text)) { if (b !== undefined && b.kind === 'num') return withQty(i, b.text, k + 2); if (a.glued) return { ok: false, reason: 'mixed_characters', next: k + 1 }; diff --git a/src/routes/ingest.test.ts b/src/routes/ingest.test.ts index c64d7db..50cb666 100644 --- a/src/routes/ingest.test.ts +++ b/src/routes/ingest.test.ts @@ -183,33 +183,34 @@ describe('POST /ingest: replies', () => { return res; }; - it('sends the collecting menu to the chat the message came from', async () => { + it('does not reply to order text at once, and answers ? in the chat the message came from', async () => { await say('m1', f.shopUser.phone, '8910=3'); - expect(sent).toEqual([{ chatId: chat, message: 'Got it. Forward more, or reply 1 Done 2 Cancel' }]); + expect(sent).toEqual([]); + await say('m2', f.shopUser.phone, '?'); + expect(sent).toEqual([{ chatId: chat, message: '✅ *Got it*\n1 line, 3 tickets so far.\n\n*When the order is finished, reply:*\n1. Done\n2. Cancel' }]); }); it('plays the whole dialog through /ingest and saves the order', async () => { await say('m1', f.shopUser.phone, '8910=3'); await say('m2', f.shopUser.phone, '7810=1'); await say('m3', f.shopUser.phone, 'done'); - expect(sent.at(-1)!.message).toContain('Shop A - draw of TODAY 3 Nov'); + expect(sent.at(-1)!.message).toContain('*Shop:* Shop A\n*Draw:* TODAY 3 Nov'); await say('m4', f.shopUser.phone, '1'); - expect(sent.at(-1)!.message).toBe('Saved #1 - Shop A, 4 tickets, draw of 3 Nov'); + expect(sent.at(-1)!.message).toBe('✅ *Saved #1*\n\n*Shop:* Shop A\n*Tickets:* 4\n*Draw:* 3 Nov'); expect(sent.map((s) => s.chatId).every((c) => c === chat)).toBe(true); expect(db.prepare('SELECT COUNT(*) AS n FROM order_lines').get()).toEqual({ n: 2 }); }); it('sends several replies of one message in order', async () => { - // cancelling while a late message is held answers: cancelled, starting the next order, the menu + // cancelling while a late message is held answers: cancelled, then starting the next order await say('m1', f.shopUser.phone, '8910=3'); db.prepare("UPDATE conversations SET reminded_at = '2026-11-03T05:05:00Z'").run(); await say('m2', f.shopUser.phone, '0838=2'); sent.length = 0; await say('m3', f.shopUser.phone, 'cancel'); expect(sent.map((s) => s.message)).toEqual([ - 'Cancelled. Nothing was saved.', + '❌ *Cancelled*\nNothing was saved.', 'Starting your next order with the message you sent.', - 'Got it. Forward more, or reply 1 Done 2 Cancel', ]); }); @@ -234,8 +235,9 @@ describe('POST /ingest: replies', () => { }); it('a failing bridge does not fail the request or lose the state', async () => { + await say('m1', f.shopUser.phone, '8910=3'); sendFails = true; - const res = await say('m1', f.shopUser.phone, '8910=3'); + const res = await say('m2', f.shopUser.phone, '?'); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ handled: true, duplicate: false }); expect(sent).toEqual([]); @@ -245,11 +247,12 @@ describe('POST /ingest: replies', () => { }); it('after a failed send, ? gives the user the last prompt again', async () => { - sendFails = true; await say('m1', f.shopUser.phone, '8910=3'); - sendFails = false; + sendFails = true; await say('m2', f.shopUser.phone, '?'); - expect(sent.map((s) => s.message)).toEqual(['Got it. Forward more, or reply 1 Done 2 Cancel']); + sendFails = false; + await say('m3', f.shopUser.phone, '?'); + expect(sent.map((s) => s.message)).toEqual(['✅ *Got it*\n1 line, 3 tickets so far.\n\n*When the order is finished, reply:*\n1. Done\n2. Cancel']); }); it('does not wait for the bridge before answering', async () => { @@ -262,7 +265,7 @@ describe('POST /ingest: replies', () => { method: 'POST', url: '/ingest', headers: { 'content-type': 'application/json', 'x-srs-secret': SECRET }, - payload: JSON.stringify(text('m1', f.shopUser.phone, '8910=3')), + payload: JSON.stringify(text('m1', f.shopUser.phone, 'done')), }); expect(res.statusCode).toBe(200); // answered while the send is still blocked release(); @@ -276,7 +279,10 @@ describe('POST /ingest: replies', () => { expect(db.prepare('SELECT COUNT(*) AS n FROM ingest_log').get()).toEqual({ n: 0 }); db.exec('DROP TRIGGER boom'); await say('m1', f.shopUser.phone, '8910=3'); - expect(sent).toHaveLength(1); + expect(db.prepare('SELECT COUNT(*) AS n FROM ingest_log').get()).toEqual({ n: 1 }); + expect(db.prepare('SELECT state, buffer_text, update_pending FROM conversations').all()).toEqual([ + { state: 'COLLECTING', buffer_text: '8910=3', update_pending: 1 }, + ]); }); }); diff --git a/src/testing/config.ts b/src/testing/config.ts index 4f40a3a..473deb2 100644 --- a/src/testing/config.ts +++ b/src/testing/config.ts @@ -9,7 +9,7 @@ export function testConfig(overrides: Partial = {}): Config { jwtSecret: undefined, bridgeUrl: 'http://127.0.0.1:3000', defaultCountryCode: '91', - orders: { cutoffMin: 15 * 60, askUntilMin: 15 * 60 + 30, remindMin: 10, expireMin: 30 }, + orders: { cutoffMin: 15 * 60, askUntilMin: 15 * 60 + 30, remindMin: 10, expireMin: 30, updateQuietSec: 3 }, results: { windowStartMin: 16 * 60, windowEndMin: 19 * 60, retryMin: 10 }, ...overrides, }; diff --git a/src/whatsapp/conversation.test.ts b/src/whatsapp/conversation.test.ts index 5e92f49..b3d318a 100644 --- a/src/whatsapp/conversation.test.ts +++ b/src/whatsapp/conversation.test.ts @@ -46,40 +46,54 @@ describe('the walk-through from the plan (section 3.3)', () => { it('plays out word for word for a user with two shops', () => { const say = chatAs(f.multiUser.id); - expect(say('8910=3')).toEqual(['Got it. Forward more, or reply 1 Done 2 Cancel']); + expect(say('8910=3')).toEqual([]); expect(say('7810=1')).toEqual([]); expect(say('08?8=2')).toEqual([]); expect(say('Total 50')).toEqual([]); - expect(say('1')).toEqual(['Which shop?\n1) Shop A\n2) Shop B']); + expect(say('1')).toEqual(['*Which shop?*\n\n1. Shop A\n2. Shop B']); expect(say('2')).toEqual([ [ - 'Shop B - draw of TODAY 3 Nov', - '2 lines, 4 tickets', + '*Order summary*', + '', + '*Shop:* Shop B', + '*Draw:* TODAY 3 Nov', + '*Total:* 2 lines, 4 tickets', + '', + '*Tickets*', '8910 - 3', '7810 - 1', - 'Not read, not in the order:', + '', + '⚠️ *Not read, not in the order:*', ' 08?8=2', - '1 Save 2 Add more 3 Cancel', + '', + '*Reply:*\n1. Save\n2. Add more\n3. Cancel', ].join('\n'), ]); - expect(say('2')).toEqual(['Send the lines to add. Send only the new or corrected lines, not the whole order.']); + expect(say('2')).toEqual(['*Add to the order*\nSend only the new or corrected lines, not the whole order.']); expect(say('0838=2')).toEqual([ [ - 'Shop B - draw of TODAY 3 Nov', - '3 lines, 6 tickets', + '*Order summary*', + '', + '*Shop:* Shop B', + '*Draw:* TODAY 3 Nov', + '*Total:* 3 lines, 6 tickets', + '', + '*Tickets*', '8910 - 3', '7810 - 1', '0838 - 2', - 'Left out earlier, not in the order:', + '', + '⚠️ *Left out earlier, not in the order:*', ' 08?8=2', - '1 Save 2 Add more 3 Cancel', + '', + '*Reply:*\n1. Save\n2. Add more\n3. Cancel', ].join('\n'), ]); - expect(say('1')).toEqual(['Saved #1 - Shop B, 6 tickets, draw of 3 Nov']); + expect(say('1')).toEqual(['✅ *Saved #1*\n\n*Shop:* Shop B\n*Tickets:* 6\n*Draw:* 3 Nov']); const [order] = orders(); expect(order).toMatchObject({ @@ -113,22 +127,22 @@ describe('who sees which shops', () => { const say = chatAs(f.shopUser.id); say('8910=3'); const [summary] = say('done'); - expect(summary).toContain('Shop A - draw of TODAY 3 Nov'); + expect(summary).toContain('*Shop:* Shop A\n*Draw:* TODAY 3 Nov'); expect(state(f.shopUser.id)).toBe('CONFIRM'); }); it('an admin sees every active shop', () => { const say = chatAs(f.admin.id); say('8910=3'); - expect(say('1')).toEqual(['Which shop?\n1) Shop A\n2) Shop B\n3) Shop C']); + expect(say('1')).toEqual(['*Which shop?*\n\n1. Shop A\n2. Shop B\n3. Shop C']); }); it('an admin can pick any shop and save', () => { const say = chatAs(f.admin.id); say('8910=3'); say('1'); - expect(say('3')[0]).toContain('Shop C - draw of TODAY 3 Nov'); - expect(say('1')).toEqual(['Saved #1 - Shop C, 3 tickets, draw of 3 Nov']); + expect(say('3')[0]).toContain('*Shop:* Shop C\n*Draw:* TODAY 3 Nov'); + expect(say('1')).toEqual(['✅ *Saved #1*\n\n*Shop:* Shop C\n*Tickets:* 3\n*Draw:* 3 Nov']); expect(orders()[0]).toMatchObject({ shop_id: f.shops.c, created_by_user_id: f.admin.id }); }); @@ -145,7 +159,7 @@ describe('who sees which shops', () => { const say = chatAs(f.multiUser.id); say('8910=3'); say('1'); - expect(say('9')).toEqual(['Which shop?\n1) Shop A\n2) Shop B']); + expect(say('9')).toEqual(['*Which shop?*\n\n1. Shop A\n2. Shop B']); expect(state(f.multiUser.id)).toBe('AWAITING_SHOP'); }); @@ -154,10 +168,10 @@ describe('who sees which shops', () => { say('8910=3'); say('1'); db.prepare('DELETE FROM user_shops WHERE user_id = ? AND shop_id = ?').run(f.multiUser.id, f.shops.b); - expect(say('2')).toEqual(['Which shop?\n1) Shop A']); + expect(say('2')).toEqual(['*Which shop?*\n\n1. Shop A']); expect(state(f.multiUser.id)).toBe('AWAITING_SHOP'); const [summary] = say('1'); - expect(summary).toContain('Shop A -'); + expect(summary).toContain('*Shop:* Shop A'); }); it('re-checks shop access at the moment of saving', () => { @@ -173,9 +187,10 @@ describe('who sees which shops', () => { }); describe('collecting', () => { - it('sends the collecting menu once, on the first message only', () => { + it('never replies at once while collecting: the sweeper answers after a pause', () => { const say = chatAs(f.shopUser.id); - expect(say('8910=3')).toHaveLength(1); + expect(say('8910=3')).toEqual([]); + expect(conv(f.shopUser.id)!.update_pending).toBe(1); expect(say('7810=1')).toEqual([]); expect(say('0838=2')).toEqual([]); }); @@ -208,13 +223,13 @@ describe('collecting', () => { say('8910=3'); const [summary] = say('0888=2\n7810=1\n1'); expect(summary).toContain('3 lines, 6 tickets'); - expect(summary).toContain('Everything was read.'); + expect(summary).toContain('✅ Everything was read'); expect(state(f.shopUser.id)).toBe('CONFIRM'); }); it('a trailing 1 in the first message is order text, not Done', () => { const say = chatAs(f.shopUser.id); - expect(say('0888=2\n1')).toEqual([msg.collectingMenu()]); + expect(say('0888=2\n1')).toEqual([]); expect(state(f.shopUser.id)).toBe('COLLECTING'); expect(conv(f.shopUser.id)!.buffer_text).toBe('0888=2\n1'); }); @@ -256,7 +271,7 @@ describe('IDLE chatter does not start an order', () => { const say = chatAs(f.shopUser.id); say('8910=3'); say('done'); - expect(say('1')[0]).toContain('Saved #1'); + expect(say('1')[0]).toContain('*Saved #1*'); expect(say('1')).toEqual([]); expect(say('thanks')).toEqual([]); expect(orders()).toHaveLength(1); @@ -264,7 +279,8 @@ describe('IDLE chatter does not start an order', () => { }); it('a message with one unreadable item does start an order', () => { - expect(chatAs(f.shopUser.id)('08?8=2')).toEqual([msg.collectingMenu()]); + expect(chatAs(f.shopUser.id)('08?8=2')).toEqual([]); + expect(conv(f.shopUser.id)).toMatchObject({ state: 'COLLECTING', update_pending: 1 }); }); }); @@ -273,7 +289,7 @@ describe('unreadable text', () => { const say = chatAs(f.shopUser.id); say('8910=3\n08?8=2'); const [summary] = say('done'); - expect(summary).toContain('Not read, not in the order:\n 08?8=2'); + expect(summary).toContain('⚠️ *Not read, not in the order:*\n 08?8=2'); say('1'); expect(lines(1)).toEqual([{ ending: '8910', qty: 3 }]); }); @@ -284,8 +300,8 @@ describe('unreadable text', () => { say('done'); say('2'); const [again] = say('0x38=1'); - expect(again).toContain('Not read, not in the order:\n 0x38=1'); - expect(again).toContain('Left out earlier, not in the order:\n 08?8=2'); + expect(again).toContain('⚠️ *Not read, not in the order:*\n 0x38=1'); + expect(again).toContain('⚠️ *Left out earlier, not in the order:*\n 08?8=2'); // the new line is unreadable too, so nothing was added to the order expect(again).toContain('1 line, 3 tickets'); expect(again).not.toContain('Everything was read'); @@ -298,7 +314,7 @@ describe('unreadable text', () => { say('2'); const [again] = say('1234=1'); // a forgotten line, not a correction expect(again).toContain('2 lines, 4 tickets'); - expect(again).toContain('Left out earlier, not in the order:\n 08?8=2'); + expect(again).toContain('⚠️ *Left out earlier, not in the order:*\n 08?8=2'); expect(again).not.toContain('Everything was read'); say('1'); expect(lines(1)).toEqual([ @@ -313,17 +329,23 @@ describe('unreadable text', () => { say('done'); say('2'); const [again] = say('1234=1'); - expect(again).toContain('2 lines, 4 tickets. Everything was read.'); + expect(again).toContain('*Total:* 2 lines, 4 tickets\n✅ Everything was read'); expect(again).not.toContain('Left out earlier'); }); - it('an order that is only unreadable cannot be saved', () => { - const say = chatAs(f.shopUser.id); + it('an order that is only unreadable stops at Done and lists what was not read', () => { + const say = chatAs(f.multiUser.id); // two shops: it must not reach the shop question say('08?8=2'); - say('done'); - expect(say('1')).toEqual([msg.noTickets()]); + expect(say('done')).toEqual([msg.noTickets(['08?8=2'])]); expect(orders()).toHaveLength(0); - expect(state(f.shopUser.id)).toBe('IDLE'); + expect(state(f.multiUser.id)).toBe('IDLE'); + }); + + it('readable and unreadable together still go on to the shop question', () => { + const say = chatAs(f.multiUser.id); + say('08?8=2'); + say('7810=1'); + expect(say('done')[0]).toContain('*Which shop?*'); }); it('an order with nothing readable at all is refused at Done', () => { @@ -385,7 +407,7 @@ describe('the menus', () => { say('8910=3'); say('done'); say('2'); - expect(say('1')[0]).toContain('Saved #1'); + expect(say('1')[0]).toContain('*Saved #1*'); }); it('Cancel at the summary clears everything', () => { @@ -402,7 +424,7 @@ describe('the menus', () => { say('8910=3'); const [summary] = say('done'); const [reply] = say('7810=1'); - expect(reply).toBe(`${msg.numberFirst()}\n${summary}`); + expect(reply).toBe(`${msg.numberFirst()}\n\n${summary}`); expect(JSON.parse(conv(f.shopUser.id)!.parsed_json!).segments).toEqual(['8910=3']); }); @@ -410,14 +432,14 @@ describe('the menus', () => { const say = chatAs(f.multiUser.id); say('8910=3'); const [list] = say('1'); - expect(say('7810=1')).toEqual([`${msg.numberFirst()}\n${list}`]); + expect(say('7810=1')).toEqual([`${msg.numberFirst()}\n\n${list}`]); expect(conv(f.multiUser.id)!.buffer_text).toBe('8910=3'); }); it('? repeats the last prompt in every waiting state', () => { const say = chatAs(f.multiUser.id); - const [menu] = say('8910=3'); - expect(say('?')).toEqual([menu]); + say('8910=3'); + expect(say('?')).toEqual([msg.progressUpdate(1, 3)]); const [list] = say('1'); expect(say('help')).toEqual([list]); const [summary] = say('2'); @@ -493,14 +515,14 @@ describe('the draw date', () => { const say = chatAs(f.shopUser.id); say('8910=3', new Date('2026-11-03T09:30:00Z')); // 15:00:00 exactly const [summary] = say('done', new Date('2026-11-03T09:30:00Z')); - expect(summary).toContain('draw of TODAY 3 Nov'); + expect(summary).toContain('*Draw:* TODAY 3 Nov'); }); it('after 15:30 is tomorrow, no question', () => { const say = chatAs(f.shopUser.id); say('8910=3', TOO_LATE); const [summary] = say('done', TOO_LATE); - expect(summary).toContain('draw of TOMORROW 4 Nov'); + expect(summary).toContain('*Draw:* TOMORROW 4 Nov'); }); it('between 15:00 and 15:30 asks, and Today is accepted inside the window', () => { @@ -508,7 +530,7 @@ describe('the draw date', () => { say('8910=3', ASK_TIME); expect(say('done', ASK_TIME)).toEqual([msg.drawQuestion('2026-11-03', '2026-11-04')]); const [summary] = say('1', STILL_ASKING); - expect(summary).toContain('draw of TODAY 3 Nov'); + expect(summary).toContain('*Draw:* TODAY 3 Nov'); say('1', STILL_ASKING); expect(orders()[0]!.draw_date).toBe('2026-11-03'); }); @@ -518,7 +540,7 @@ describe('the draw date', () => { say('8910=3', ASK_TIME); say('done', ASK_TIME); const [summary] = say('2', STILL_ASKING); - expect(summary).toContain('draw of TOMORROW 4 Nov'); + expect(summary).toContain('*Draw:* TOMORROW 4 Nov'); say('1', STILL_ASKING); expect(orders()[0]!.draw_date).toBe('2026-11-04'); }); @@ -529,7 +551,7 @@ describe('the draw date', () => { say('done', ASK_TIME); expect(say('1', TOO_LATE)).toEqual([msg.tooLateForToday('2026-11-04')]); const [summary] = say('1', TOO_LATE); - expect(summary).toContain('draw of TOMORROW 4 Nov'); + expect(summary).toContain('*Draw:* TOMORROW 4 Nov'); }); it('after a refusal, 2 cancels', () => { @@ -555,7 +577,7 @@ describe('the draw date', () => { say('done', ASK_TIME); say('1', STILL_ASKING); const [saved] = say('1', new Date('2026-11-03T11:00:00Z')); // 16:30 IST - expect(saved).toContain('draw of 3 Nov'); + expect(saved).toContain('*Draw:* 3 Nov'); expect(orders()[0]!.draw_date).toBe('2026-11-03'); }); @@ -568,7 +590,7 @@ describe('the draw date', () => { const c = conv(f.multiUser.id)!; expect(c.state).toBe('ASKING'); const [summary] = say('1', TOO_LATE); - expect(summary).toContain('draw of TOMORROW 4 Nov'); + expect(summary).toContain('*Draw:* TOMORROW 4 Nov'); say('1', TOO_LATE); expect(orders()[0]!.draw_date).toBe('2026-11-04'); }); @@ -592,7 +614,7 @@ describe('the draw date', () => { it('the shop pick comes before the draw question', () => { const say = chatAs(f.multiUser.id); say('8910=3', ASK_TIME); - expect(say('done', ASK_TIME)[0]).toContain('Which shop?'); + expect(say('done', ASK_TIME)[0]).toContain('*Which shop?*'); expect(say('1', ASK_TIME)[0]).toContain("today's draw or tomorrow's"); }); }); @@ -675,9 +697,10 @@ describe('late messages after the reminder', () => { expect(summary).toContain('8910 - 3'); expect(summary).not.toContain('0838'); const replies = say('1'); - expect(replies[0]).toContain('Saved #1'); + expect(replies[0]).toContain('*Saved #1*'); expect(replies[1]).toBe(msg.startingHeld()); - expect(replies[2]).toBe(msg.collectingMenu()); + expect(replies).toHaveLength(2); + expect(conv(f.shopUser.id)!.update_pending).toBe(1); expect(lines(1)).toEqual([{ ending: '8910', qty: 3 }]); expect(conv(f.shopUser.id)!.state).toBe('COLLECTING'); expect(conv(f.shopUser.id)!.buffer_text).toBe('0838=2'); @@ -690,7 +713,7 @@ describe('late messages after the reminder', () => { say('0838=2'); say('2'); const replies = say('3'); - expect(replies).toEqual([msg.cancelled(), msg.startingHeld(), msg.collectingMenu()]); + expect(replies).toEqual([msg.cancelled(), msg.startingHeld()]); expect(conv(f.shopUser.id)!.buffer_text).toBe('0838=2'); expect(orders()).toHaveLength(0); }); @@ -700,7 +723,7 @@ describe('late messages after the reminder', () => { say('8910=3'); remind(f.shopUser.id); say('0838=2'); - expect(say('3')).toEqual([msg.collectingMenu()]); + expect(say('3')).toEqual([]); const c = conv(f.shopUser.id)!; expect(c.state).toBe('COLLECTING'); expect(c.buffer_text).toBe('0838=2'); @@ -712,7 +735,8 @@ describe('late messages after the reminder', () => { say('8910=3'); remind(f.shopUser.id); say('0838=2'); - expect(say('cancel')).toEqual([msg.cancelled(), msg.startingHeld(), msg.collectingMenu()]); + expect(say('cancel')).toEqual([msg.cancelled(), msg.startingHeld()]); + expect(conv(f.shopUser.id)!.update_pending).toBe(1); expect(conv(f.shopUser.id)!.buffer_text).toBe('0838=2'); }); @@ -742,6 +766,6 @@ describe('late messages after the reminder', () => { remind(f.shopUser.id); const [question] = say('0838=2'); expect(say('7')).toEqual([question]); - expect(say('more text 0838')).toEqual([`${msg.numberFirst()}\n${question}`]); + expect(say('more text 0838')).toEqual([`${msg.numberFirst()}\n\n${question}`]); }); }); diff --git a/src/whatsapp/conversation.ts b/src/whatsapp/conversation.ts index 795e33a..9387c28 100644 --- a/src/whatsapp/conversation.ts +++ b/src/whatsapp/conversation.ts @@ -41,6 +41,7 @@ export interface ConversationRow { reminded_at: string | null; ask_kind: 'draw' | 'split' | null; held_text: string | null; + update_pending: number; } interface FlowData { @@ -92,6 +93,7 @@ export function resetConversation(db: Db, id: number, now: Date): void { held_text: null, reminded_at: null, last_prompt: null, + update_pending: 0, last_activity_at: iso(now), }); } @@ -121,9 +123,9 @@ function parseSegments(segments: string[]): Parsed { return out; } -export function bufferProgress(text: string): { lines: number; tickets: number } { +export function bufferProgress(text: string): { lines: number; tickets: number; unreadable: string[] } { const r = parseOrder(text); - return { lines: r.lines.length, tickets: r.totalTickets }; + return { lines: r.lines.length, tickets: r.totalTickets, unreadable: r.unreadable.map((u) => u.text) }; } function afterReset(c: Ctx, held: string | null, replies: string[]): string[] { @@ -143,7 +145,7 @@ function repeatPrompt(conv: ConversationRow): string[] { function unexpected(conv: ConversationRow, text: string): string[] { if (!hasOrderContent(text)) return repeatPrompt(conv); - return [conv.last_prompt ? `${msg.numberFirst()}\n${conv.last_prompt}` : msg.numberFirst()]; + return [conv.last_prompt ? `${msg.numberFirst()}\n\n${conv.last_prompt}` : msg.numberFirst()]; } // --------------------------------------------------------------------------------------------------------------- @@ -187,12 +189,12 @@ function step(c: Ctx, text: string): string[] { function begin(c: Ctx, text: string): ConversationRow { c.db .prepare( - `INSERT INTO conversations (user_id, chat_id, state, buffer_text, source, last_activity_at) - VALUES (@user_id, @chat_id, 'COLLECTING', @text, 'whatsapp_text', @at) + `INSERT INTO conversations (user_id, chat_id, state, buffer_text, source, update_pending, last_activity_at) + VALUES (@user_id, @chat_id, 'COLLECTING', @text, 'whatsapp_text', 1, @at) ON CONFLICT (user_id) DO UPDATE SET chat_id = @chat_id, state = 'COLLECTING', buffer_text = @text, source = 'whatsapp_text', parsed_json = NULL, shop_options_json = NULL, selected_shop_id = NULL, ask_kind = NULL, - held_text = NULL, reminded_at = NULL, last_prompt = NULL, last_activity_at = @at`, + held_text = NULL, reminded_at = NULL, last_prompt = NULL, update_pending = 1, last_activity_at = @at`, ) .run({ user_id: c.user.id, chat_id: c.chatId, text, at: iso(c.now) }); return getConversation(c.db, c.user.id)!; @@ -205,12 +207,17 @@ function fromIdle(c: Ctx, text: string): string[] { return done(c, begin(c, cmd.body)); } if (cmd.kind !== 'text' || !hasOrderContent(text)) return []; - const conv = begin(c, text); - return [prompt(c.db, conv.id, msg.collectingMenu())]; + begin(c, text); + return []; } // COLLECTING ---------------------------------------------------------------------------------------------------- +function collectingReply(conv: ConversationRow): string { + const p = bufferProgress(conv.buffer_text); + return msg.progressUpdate(p.lines, p.tickets); +} + function collecting(c: Ctx, conv: ConversationRow, cmd: Command, text: string): string[] { const orderText = cmd.kind === 'text' ? text : cmd.kind === 'submit' ? cmd.body : ''; @@ -229,11 +236,15 @@ function collecting(c: Ctx, conv: ConversationRow, cmd: Command, text: string): case 'number': if (cmd.n === 1) return done(c, conv); if (cmd.n === 2) return endConversation(c, conv, [msg.cancelled()]); - return [conv.last_prompt ?? msg.collectingMenu()]; + return [collectingReply(conv)]; case 'help': - return [conv.last_prompt ?? msg.collectingMenu()]; + return [collectingReply(conv)]; default: - update(c.db, conv.id, { buffer_text: `${conv.buffer_text}\n${text}` }); + // No reply now: the sweeper replies once after a pause, so a long forward gets one answer. + update(c.db, conv.id, { + buffer_text: `${conv.buffer_text}\n${text}`, + update_pending: hasOrderContent(text) ? 1 : conv.update_pending, + }); return []; } } @@ -241,15 +252,13 @@ function collecting(c: Ctx, conv: ConversationRow, cmd: Command, text: string): /** The user said Done: parse, then shop pick (or skip it), then the draw question or the summary. */ function done(c: Ctx, conv: ConversationRow): string[] { const p = parseSegments([conv.buffer_text]); - if (p.lines.length === 0 && p.unreadable[0]!.length === 0) { - return endConversation(c, conv, [msg.noTickets()]); - } + if (p.lines.length === 0) return endConversation(c, conv, [msg.noTickets(p.unreadable.flat())]); const shops = shopsForUser(c.db, c.user); if (shops.length === 0) return endConversation(c, conv, [msg.noShop()]); const data: FlowData = { segments: [conv.buffer_text], acked: 0, addPending: false, doneAt: c.now.toISOString() }; - update(c.db, conv.id, { ask_kind: null, parsed_json: JSON.stringify(data) }); + update(c.db, conv.id, { ask_kind: null, parsed_json: JSON.stringify(data), update_pending: 0 }); if (shops.length === 1) { update(c.db, conv.id, { selected_shop_id: shops[0]!.id }); diff --git a/src/whatsapp/messages.test.ts b/src/whatsapp/messages.test.ts index 7c35abf..f7c4674 100644 --- a/src/whatsapp/messages.test.ts +++ b/src/whatsapp/messages.test.ts @@ -52,13 +52,20 @@ describe('summary', () => { it('matches the walk-through when something was not read', () => { expect(m.summary(base)).toBe( [ - 'Shop B - draw of TODAY 3 Nov', - '2 lines, 4 tickets', + '*Order summary*', + '', + '*Shop:* Shop B', + '*Draw:* TODAY 3 Nov', + '*Total:* 2 lines, 4 tickets', + '', + '*Tickets*', '8910 - 3', '7810 - 1', - 'Not read, not in the order:', + '', + '⚠️ *Not read, not in the order:*', ' 08?8=2', - '1 Save 2 Add more 3 Cancel', + '', + '*Reply:*\n1. Save\n2. Add more\n3. Cancel', ].join('\n'), ); }); @@ -73,12 +80,19 @@ describe('summary', () => { }); expect(t).toBe( [ - 'Shop B - draw of TODAY 3 Nov', - '3 lines, 6 tickets. Everything was read.', + '*Order summary*', + '', + '*Shop:* Shop B', + '*Draw:* TODAY 3 Nov', + '*Total:* 3 lines, 6 tickets', + '✅ Everything was read', + '', + '*Tickets*', '8910 - 3', '7810 - 1', '0838 - 2', - '1 Save 2 Add more 3 Cancel', + '', + '*Reply:*\n1. Save\n2. Add more\n3. Cancel', ].join('\n'), ); }); @@ -87,13 +101,20 @@ describe('summary', () => { const t = m.summary({ ...base, unreadable: [], leftOut: ['08?8=2'], lineCount: 3, ticketCount: 6 }); expect(t).toBe( [ - 'Shop B - draw of TODAY 3 Nov', - '3 lines, 6 tickets', + '*Order summary*', + '', + '*Shop:* Shop B', + '*Draw:* TODAY 3 Nov', + '*Total:* 3 lines, 6 tickets', + '', + '*Tickets*', '8910 - 3', '7810 - 1', - 'Left out earlier, not in the order:', + '', + '⚠️ *Left out earlier, not in the order:*', ' 08?8=2', - '1 Save 2 Add more 3 Cancel', + '', + '*Reply:*\n1. Save\n2. Add more\n3. Cancel', ].join('\n'), ); expect(t).not.toContain('Everything was read'); @@ -101,8 +122,8 @@ describe('summary', () => { it('lists new and earlier unreadable text separately, new first', () => { const t = m.summary({ ...base, unreadable: ['0x38=1'], leftOut: ['08?8=2'] }); - const fresh = t.indexOf('Not read, not in the order:\n 0x38=1'); - const older = t.indexOf('Left out earlier, not in the order:\n 08?8=2'); + const fresh = t.indexOf('⚠️ *Not read, not in the order:*\n 0x38=1'); + const older = t.indexOf('⚠️ *Left out earlier, not in the order:*\n 08?8=2'); expect(fresh).toBeGreaterThan(-1); expect(older).toBeGreaterThan(fresh); }); @@ -114,7 +135,7 @@ describe('summary', () => { }); it('labels tomorrow', () => { - expect(m.summary({ ...base, drawDate: '2026-11-04' })).toContain('draw of TOMORROW 4 Nov'); + expect(m.summary({ ...base, drawDate: '2026-11-04' })).toContain('*Draw:* TOMORROW 4 Nov'); }); it('uses singular forms', () => { @@ -125,7 +146,7 @@ describe('summary', () => { entries: [{ ending: '0001', qty: 1, times: 1 }], unreadable: [], }); - expect(t).toContain('1 line, 1 ticket. Everything was read.'); + expect(t).toContain('*Total:* 1 line, 1 ticket\n✅ Everything was read'); }); it('caps a long unreadable list and counts the rest', () => { @@ -143,42 +164,50 @@ describe('summary', () => { }); describe('prompts', () => { - it('collecting menu offers Done and Cancel', () => { - expect(m.collectingMenu()).toBe('Got it. Forward more, or reply 1 Done 2 Cancel'); + it('the collecting reply gives the totals and offers Done and Cancel', () => { + expect(m.progressUpdate(1, 3)).toBe('✅ *Got it*\n1 line, 3 tickets so far.\n\n*When the order is finished, reply:*\n1. Done\n2. Cancel'); }); it('shop list is numbered in the order given', () => { - expect(m.shopList(['Shop A', 'Shop B'])).toBe('Which shop?\n1) Shop A\n2) Shop B'); + expect(m.shopList(['Shop A', 'Shop B'])).toBe('*Which shop?*\n\n1. Shop A\n2. Shop B'); }); it('draw question names both days', () => { expect(m.drawQuestion('2026-11-03', '2026-11-04')).toBe( - "Is this order for today's draw or tomorrow's?\n1 Today 3 Nov\n2 Tomorrow 4 Nov", + "*Is this order for today's draw or tomorrow's?*\n\n1. Today, 3 Nov\n2. Tomorrow, 4 Nov", ); }); it('too late offers tomorrow or cancel', () => { - expect(m.tooLateForToday('2026-11-04')).toBe("It is too late for today's draw.\n1 Tomorrow 4 Nov\n2 Cancel"); + expect(m.tooLateForToday('2026-11-04')).toBe("*It is too late for today's draw*\n\n1. Tomorrow, 4 Nov\n2. Cancel"); }); it('saved names the order, shop, tickets and draw', () => { expect(m.saved({ orderId: 123, shopName: 'Shop B', ticketCount: 6, drawDate: '2026-11-03' })).toBe( - 'Saved #123 - Shop B, 6 tickets, draw of 3 Nov', + '✅ *Saved #123*\n\n*Shop:* Shop B\n*Tickets:* 6\n*Draw:* 3 Nov', ); }); it('reminder shows progress when something was read', () => { - expect(m.reminder(5, 12)).toBe('You have an unfinished order (5 lines, 12 tickets). Reply 1 Done 2 Cancel.'); + expect(m.reminder(5, 12)).toBe('⏳ *Unfinished order*\n5 lines, 12 tickets so far.\n\n*Reply:*\n1. Done\n2. Cancel'); }); it('reminder omits progress when nothing was read', () => { - expect(m.reminder(0, 0)).toBe('You have an unfinished order. Reply 1 Done 2 Cancel.'); + expect(m.reminder(0, 0)).toBe('⏳ *Unfinished order*\n\n*Reply:*\n1. Done\n2. Cancel'); }); it('split question lists the three choices', () => { expect(m.splitQuestion(5, 12)).toBe( [ - 'You have an unfinished order (5 lines, 12 tickets).', - 'Is this message:', - '1 Part of that order', - '2 A new order (finish the old one first)', - '3 A new order (throw the old one away)', + '⏳ *Unfinished order*', + '5 lines, 12 tickets so far.', + '', + '*Is this message:*', + '1. Part of that order', + '2. A new order (finish the old one first)', + '3. A new order (throw the old one away)', ].join('\n'), ); }); + it('no tickets lists what was not read and asks to send again', () => { + expect(m.noTickets(['08?8=2', 'abc123'])).toBe( + '⚠️ *No tickets found*\n\n*Not read:*\n 08?8=2\n abc123\n\nNothing was saved. Please send the order again.', + ); + expect(m.noTickets()).toBe('⚠️ *No tickets found*\n\nNothing was saved. Please send the order again.'); + }); it('fixed messages say nothing was saved where it matters', () => { for (const t of [m.cancelled(), m.noTickets(), m.expired()]) expect(t).toContain('Nothing was saved'); }); diff --git a/src/whatsapp/messages.ts b/src/whatsapp/messages.ts index 5224fbd..85a7530 100644 --- a/src/whatsapp/messages.ts +++ b/src/whatsapp/messages.ts @@ -37,25 +37,34 @@ export function entryTotals(lines: { ending: string; qty: number }[]): Entry[] { return [...by.values()]; } -export const COLLECTING_MENU = '1 Done 2 Cancel'; -export const SUMMARY_MENU = '1 Save 2 Add more 3 Cancel'; +export const COLLECTING_MENU = '1. Done\n2. Cancel'; +export const SUMMARY_MENU = '*Reply:*\n1. Save\n2. Add more\n3. Cancel'; -export const collectingMenu = (): string => `Got it. Forward more, or reply ${COLLECTING_MENU}`; +const FINISH_PROMPT = `*When the order is finished, reply:*\n${COLLECTING_MENU}`; + +function soFar(lineCount: number, ticketCount: number): string[] { + return lineCount > 0 ? [`${plural(lineCount, 'line')}, ${plural(ticketCount, 'ticket')} so far.`] : []; +} + +export function progressUpdate(lineCount: number, ticketCount: number): string { + return ['✅ *Got it*', ...soFar(lineCount, ticketCount), '', FINISH_PROMPT].join('\n'); +} export function shopList(shopNames: string[]): string { - return ['Which shop?', ...shopNames.map((n, i) => `${i + 1}) ${n}`)].join('\n'); + return ['*Which shop?*', '', ...shopNames.map((n, i) => `${i + 1}. ${n}`)].join('\n'); } export function drawQuestion(today: string, tomorrow: string): string { return [ - "Is this order for today's draw or tomorrow's?", - `1 Today ${formatDay(today)}`, - `2 Tomorrow ${formatDay(tomorrow)}`, + "*Is this order for today's draw or tomorrow's?*", + '', + `1. Today, ${formatDay(today)}`, + `2. Tomorrow, ${formatDay(tomorrow)}`, ].join('\n'); } export function tooLateForToday(tomorrow: string): string { - return ["It is too late for today's draw.", `1 Tomorrow ${formatDay(tomorrow)}`, '2 Cancel'].join('\n'); + return ["*It is too late for today's draw*", '', `1. Tomorrow, ${formatDay(tomorrow)}`, '2. Cancel'].join('\n'); } export interface SummaryInput { @@ -71,54 +80,69 @@ export interface SummaryInput { function pushList(out: string[], heading: string, items: string[]): void { if (items.length === 0) return; - out.push(heading); + out.push('', heading); for (const u of items.slice(0, MAX_UNREADABLE_SHOWN)) out.push(` ${u}`); const more = items.length - MAX_UNREADABLE_SHOWN; if (more > 0) out.push(` ...and ${more} more`); } export function summary(s: SummaryInput): string { - const head = `${plural(s.lineCount, 'line')}, ${plural(s.ticketCount, 'ticket')}`; - const out = [`${s.shopName} - draw of ${drawLabel(s.drawDate, s.today)}`]; - out.push(s.unreadable.length === 0 && s.leftOut.length === 0 ? `${head}. Everything was read.` : head); + const out = [ + '*Order summary*', + '', + `*Shop:* ${s.shopName}`, + `*Draw:* ${drawLabel(s.drawDate, s.today)}`, + `*Total:* ${plural(s.lineCount, 'line')}, ${plural(s.ticketCount, 'ticket')}`, + ]; + if (s.unreadable.length === 0 && s.leftOut.length === 0) out.push('✅ Everything was read'); + out.push('', '*Tickets*'); for (const e of s.entries) { out.push(e.times > 1 ? `${e.ending} - ${e.qty} (${e.times} times)` : `${e.ending} - ${e.qty}`); } - pushList(out, 'Not read, not in the order:', s.unreadable); - pushList(out, 'Left out earlier, not in the order:', s.leftOut); - out.push(SUMMARY_MENU); + pushList(out, '⚠️ *Not read, not in the order:*', s.unreadable); + pushList(out, '⚠️ *Left out earlier, not in the order:*', s.leftOut); + out.push('', SUMMARY_MENU); return out.join('\n'); } export const addPrompt = (): string => - 'Send the lines to add. Send only the new or corrected lines, not the whole order.'; + '*Add to the order*\nSend only the new or corrected lines, not the whole order.'; export function saved(o: { orderId: number; shopName: string; ticketCount: number; drawDate: string }): string { - return `Saved #${o.orderId} - ${o.shopName}, ${plural(o.ticketCount, 'ticket')}, draw of ${formatDay(o.drawDate)}`; + return [ + `✅ *Saved #${o.orderId}*`, + '', + `*Shop:* ${o.shopName}`, + `*Tickets:* ${o.ticketCount}`, + `*Draw:* ${formatDay(o.drawDate)}`, + ].join('\n'); } -export const cancelled = (): string => 'Cancelled. Nothing was saved.'; +export const cancelled = (): string => '❌ *Cancelled*\nNothing was saved.'; export const nothingToSubmit = (): string => 'Nothing to submit yet.'; -export const noTickets = (): string => 'I did not find any tickets. Nothing was saved.'; -export const noShop = (): string => 'No shop is assigned to your account, please contact the owner.'; +export function noTickets(unreadable: string[] = []): string { + const out = ['⚠️ *No tickets found*']; + pushList(out, '*Not read:*', unreadable); + out.push('', 'Nothing was saved. Please send the order again.'); + return out.join('\n'); +} +export const noShop = (): string => '⚠️ *No shop assigned*\nPlease contact the owner.'; export const numberFirst = (): string => 'Reply with a number from the list first. Your last message was not added.'; -export const expired = (): string => 'Order cancelled because there was no reply. Nothing was saved.'; +export const expired = (): string => '❌ *Order cancelled*\nThere was no reply. Nothing was saved.'; export const startingHeld = (): string => 'Starting your next order with the message you sent.'; -function progress(lineCount: number, ticketCount: number): string { - return lineCount > 0 ? ` (${plural(lineCount, 'line')}, ${plural(ticketCount, 'ticket')})` : ''; -} - export function reminder(lineCount: number, ticketCount: number): string { - return `You have an unfinished order${progress(lineCount, ticketCount)}. Reply ${COLLECTING_MENU}.`; + return ['⏳ *Unfinished order*', ...soFar(lineCount, ticketCount), '', '*Reply:*', COLLECTING_MENU].join('\n'); } export function splitQuestion(lineCount: number, ticketCount: number): string { return [ - `You have an unfinished order${progress(lineCount, ticketCount)}.`, - 'Is this message:', - '1 Part of that order', - '2 A new order (finish the old one first)', - '3 A new order (throw the old one away)', + '⏳ *Unfinished order*', + ...soFar(lineCount, ticketCount), + '', + '*Is this message:*', + '1. Part of that order', + '2. A new order (finish the old one first)', + '3. A new order (throw the old one away)', ].join('\n'); } diff --git a/src/whatsapp/sweep.test.ts b/src/whatsapp/sweep.test.ts index a6b6932..3fc0f38 100644 --- a/src/whatsapp/sweep.test.ts +++ b/src/whatsapp/sweep.test.ts @@ -66,7 +66,7 @@ describe('reminder', () => { expect(await sweepAt(after(10))).toEqual({ reminded: 1, expired: 0 }); expect(sent).toEqual([{ chatId: 'chat-1@lid', message: msg.reminder(2, 4) }]); - expect(sent[0]!.message).toBe('You have an unfinished order (2 lines, 4 tickets). Reply 1 Done 2 Cancel.'); + expect(sent[0]!.message).toBe('⏳ *Unfinished order*\n2 lines, 4 tickets so far.\n\n*Reply:*\n1. Done\n2. Cancel'); expect(await sweepAt(after(11))).toEqual({ reminded: 0, expired: 0 }); expect(await sweepAt(after(20))).toEqual({ reminded: 0, expired: 0 }); @@ -178,7 +178,7 @@ describe('expiry', () => { const say = chatAs(f.shopUser.id); say('8910=3', T0); await sweepAt(after(30)); - expect(say('7810=1', after(31))).toEqual([msg.collectingMenu()]); + expect(say('7810=1', after(31))).toEqual([]); expect(getConversation(db, f.shopUser.id)!.buffer_text).toBe('7810=1'); }); @@ -270,3 +270,177 @@ describe('start / stop', () => { expect(logs.filter((l) => l.msg === 'conversation sweep failed').length).toBeGreaterThanOrEqual(2); }); }); + +describe('the reply after a pause', () => { + const REPLY = (lines: number, tickets: number) => msg.progressUpdate(lines, tickets); + const pending = (userId: number) => getConversation(db, userId)?.update_pending; + + async function updatesAt(at: Date, settings = S) { + clock = at; + const n = sweeper(settings).tickUpdates(); + await queue.idle(); + return n; + } + + it('words it with the running totals and the menu', () => { + const menu = '\n\n*When the order is finished, reply:*\n1. Done\n2. Cancel'; + expect(msg.progressUpdate(8, 20)).toBe('✅ *Got it*\n8 lines, 20 tickets so far.' + menu); + expect(msg.progressUpdate(1, 1)).toBe('✅ *Got it*\n1 line, 1 ticket so far.' + menu); + expect(msg.progressUpdate(0, 0)).toBe('✅ *Got it*' + menu); + }); + + it('a single message gets one reply, 3 seconds after it, and only one', async () => { + chatAs(f.shopUser.id)('8910=3', T0); + expect(await updatesAt(after(0, 2))).toBe(0); + expect(await updatesAt(after(0, 3))).toBe(1); + expect(await updatesAt(after(0, 4))).toBe(0); + expect(sent).toEqual([{ chatId: 'chat-1@lid', message: REPLY(1, 3) }]); + }); + + it('every new message restarts the wait, so a burst gets one reply with the final totals', async () => { + const say = chatAs(f.shopUser.id); + say('8910=3', T0); + say('7810=1', after(0, 2)); + expect(await updatesAt(after(0, 4))).toBe(0); + say('0838=2', after(0, 4)); + say('0555=1', after(0, 6)); + expect(await updatesAt(after(0, 8))).toBe(0); + expect(await updatesAt(after(0, 9))).toBe(1); + expect(sent.map((s) => s.message)).toEqual([REPLY(4, 7)]); + }); + + it('the wait is configurable', async () => { + const custom = { ...S, updateQuietSec: 8 }; + chatAs(f.shopUser.id)('8910=3', T0); + expect(await updatesAt(after(0, 7), custom)).toBe(0); + expect(await updatesAt(after(0, 8), custom)).toBe(1); + }); + + it('text with no order content gets no reply', async () => { + const say = chatAs(f.shopUser.id); + say('hello', T0); + expect(await updatesAt(after(1))).toBe(0); + say('8910=3', after(2)); + await updatesAt(after(2, 3)); + sent.length = 0; + say('thanks', after(3)); + expect(await updatesAt(after(4))).toBe(0); + expect(sent).toEqual([]); + }); + + it('a later message after the reply gets its own reply', async () => { + const say = chatAs(f.shopUser.id); + say('8910=3', T0); + await updatesAt(after(0, 3)); + say('7810=1', after(1)); + await updatesAt(after(1, 3)); + expect(sent.map((s) => s.message)).toEqual([REPLY(1, 3), REPLY(2, 4)]); + }); + + it('nothing is sent if the user says Done or Cancel before the pause ends', async () => { + for (const word of ['1', '2', 'cancel', 'done']) { + const say = chatAs(f.shopUser.id); + say('8910=3', T0); + say(word, after(0, 1)); + expect(pending(f.shopUser.id)).toBe(0); + expect(await updatesAt(after(1))).toBe(0); + expect(sent).toEqual([]); + say('cancel', after(1)); + } + }); + + it('never saves anything and leaves the order open', async () => { + const say = chatAs(f.shopUser.id); + say('8910=3', T0); + say('7810=1', after(0, 1)); + await updatesAt(after(0, 4)); + expect(db.prepare('SELECT COUNT(*) AS n FROM orders').get()).toEqual({ n: 0 }); + expect(state(f.shopUser.id)).toBe('COLLECTING'); + expect(getConversation(db, f.shopUser.id)?.buffer_text).toBe('8910=3\n7810=1'); + }); + + it('does not change when the reminder or the expiry happen', async () => { + chatAs(f.shopUser.id)('8910=3', T0); + await updatesAt(after(0, 3)); + sent.length = 0; + await sweepAt(after(10, 1)); + expect(sent.map((s) => s.message)).toEqual([msg.reminder(1, 3)]); + await sweepAt(after(30, 1)); + expect(sent.map((s) => s.message)).toEqual([msg.reminder(1, 3), msg.expired()]); + }); + + it('survives a restart: a new sweeper sends what was pending', async () => { + chatAs(f.shopUser.id)('8910=3', T0); + expect(pending(f.shopUser.id)).toBe(1); + expect(await updatesAt(after(1))).toBe(1); // sweeper() builds a fresh one each call + }); + + it('after a long outage the reminder speaks instead, so the reply is dropped', async () => { + chatAs(f.shopUser.id)('8910=3', T0); + expect(await updatesAt(after(10, 1))).toBe(0); + expect(pending(f.shopUser.id)).toBe(0); + expect(sent).toEqual([]); + }); + + it('a late message confirmed as part of the order gets the reply', async () => { + const say = chatAs(f.shopUser.id); + say('8910=3', T0); + await updatesAt(after(0, 3)); + await sweepAt(after(10)); + sent.length = 0; + say('7810=1', after(11)); + expect(state(f.shopUser.id)).toBe('ASKING'); + expect(await updatesAt(after(11, 30))).toBe(0); + say('1', after(12)); + expect(state(f.shopUser.id)).toBe('COLLECTING'); + expect(await updatesAt(after(12, 3))).toBe(1); + expect(sent.map((s) => s.message)).toEqual([REPLY(2, 4)]); + }); + + it('a held message that starts a new order gets the reply too', async () => { + const say = chatAs(f.shopUser.id); + say('8910=3', T0); + await updatesAt(after(0, 3)); + await sweepAt(after(10)); + sent.length = 0; + say('7810=1', after(11)); + expect(say('3', after(12))).toEqual([]); + expect(await updatesAt(after(12, 3))).toBe(1); + expect(sent.map((s) => s.message)).toEqual([REPLY(1, 1)]); + }); + + it('when nothing could be read it says so and closes, instead of Got it', async () => { + chatAs(f.shopUser.id)('08?8=2', T0); + expect(await updatesAt(after(0, 3))).toBe(1); + expect(sent.map((s) => s.message)).toEqual([msg.noTickets(['08?8=2'])]); + expect(state(f.shopUser.id)).toBe('IDLE'); + expect(getConversation(db, f.shopUser.id)?.buffer_text).toBe(''); + expect(db.prepare('SELECT COUNT(*) AS n FROM orders').get()).toEqual({ n: 0 }); + }); + + it('a good line sent before the pause ends keeps the order open', async () => { + const say = chatAs(f.shopUser.id); + say('08?8=2', T0); + say('7810=1', after(0, 1)); + await updatesAt(after(0, 4)); + expect(sent.map((s) => s.message)).toEqual([REPLY(1, 1)]); + expect(state(f.shopUser.id)).toBe('COLLECTING'); + }); + + it('after an unreadable message is closed, the next message starts a clean order', async () => { + const say = chatAs(f.shopUser.id); + say('08?8=2', T0); + await updatesAt(after(0, 3)); + say('7810=1', after(1)); + expect(getConversation(db, f.shopUser.id)?.buffer_text).toBe('7810=1'); + }); + + it('keeps each user separate', async () => { + chatAs(f.shopUser.id, 'chat-a@lid')('8910=3', T0); + chatAs(f.multiUser.id, 'chat-b@lid')('0838=2', after(0, 2)); + expect(await updatesAt(after(0, 3))).toBe(1); + expect(sent).toEqual([{ chatId: 'chat-a@lid', message: REPLY(1, 3) }]); + expect(await updatesAt(after(0, 5))).toBe(1); + expect(sent[1]).toEqual({ chatId: 'chat-b@lid', message: REPLY(1, 2) }); + }); +}); diff --git a/src/whatsapp/sweep.ts b/src/whatsapp/sweep.ts index f39b9fb..24f1de0 100644 --- a/src/whatsapp/sweep.ts +++ b/src/whatsapp/sweep.ts @@ -18,6 +18,7 @@ export interface SweepResult { } const EVERY_MS = 60_000; +const UPDATE_EVERY_MS = 1_000; const iso = (d: Date): string => d.toISOString().replace(/\.\d{3}Z$/, 'Z'); export function createConversationSweeper(opts: SweeperOptions) { @@ -56,19 +57,54 @@ export function createConversationSweeper(opts: SweeperOptions) { return result; } + function tickUpdates(): number { + const t = now(); + const quietMs = opts.settings.updateQuietSec * 1000; + const remindMs = opts.settings.remindMin * 60_000; + const outbox: Array<{ chatId: string; message: string }> = []; + + opts.db.transaction(() => { + const rows = opts.db + .prepare("SELECT * FROM conversations WHERE state = 'COLLECTING' AND update_pending = 1") + .all() as ConversationRow[]; + + for (const c of rows) { + const idleMs = t.getTime() - Date.parse(c.last_activity_at); + if (idleMs < quietMs) continue; + opts.db.prepare('UPDATE conversations SET update_pending = 0 WHERE id = ?').run(c.id); + const p = bufferProgress(c.buffer_text); + if (p.lines === 0) { + resetConversation(opts.db, c.id, t); + outbox.push({ chatId: c.chat_id, message: msg.noTickets(p.unreadable) }); + } else if (idleMs < remindMs) { + outbox.push({ chatId: c.chat_id, message: msg.progressUpdate(p.lines, p.tickets) }); + } + } + })(); + + for (const o of outbox) opts.queue.enqueue(o.chatId, [o.message]); + return outbox.length; + } + function start(): () => void { - const safeTick = (): void => { + const safely = (fn: () => unknown) => (): void => { try { - tick(); + fn(); } catch (err) { opts.log({ level: 'error', msg: 'conversation sweep failed', error: (err as Error).message }); } }; + const safeTick = safely(tick); const timer = setInterval(safeTick, EVERY_MS); + const updateTimer = setInterval(safely(tickUpdates), UPDATE_EVERY_MS); timer.unref(); + updateTimer.unref(); safeTick(); - return () => clearInterval(timer); + return () => { + clearInterval(timer); + clearInterval(updateTimer); + }; } - return { tick, start }; + return { tick, tickUpdates, start }; } diff --git a/test/fixtures/orders/dot-and-bold-2026-11-04.expected.json b/test/fixtures/orders/dot-and-bold-2026-11-04.expected.json new file mode 100644 index 0000000..c2f6ef2 --- /dev/null +++ b/test/fixtures/orders/dot-and-bold-2026-11-04.expected.json @@ -0,0 +1,13 @@ +{ + "totalTickets": 286, + "lines": [ + "6379 x100 @1", + "9001 x10 @2", + "8203 x100 @3", + "0888 x1 @4", + "7000 x75 @5" + ], + "repeated": [], + "unreadable": [], + "ignored": [] +} diff --git a/test/fixtures/orders/dot-and-bold-2026-11-04.txt b/test/fixtures/orders/dot-and-bold-2026-11-04.txt new file mode 100644 index 0000000..ab0ec42 --- /dev/null +++ b/test/fixtures/orders/dot-and-bold-2026-11-04.txt @@ -0,0 +1,5 @@ +6379*100 +9001.10 +*8203*100* +*0888* +*7000 75*