feat(whatsapp): added the Hermes plugin and made replies clearer

This commit is contained in:
Aswin B. S 2026-11-04 10:51:16 +05:30
parent 606583a1c5
commit 9d77360dc8
24 changed files with 1340 additions and 154 deletions

View File

@ -16,6 +16,8 @@ ORDER_CUTOFF=15:00
ORDER_ASK_UNTIL=15:30 ORDER_ASK_UNTIL=15:30
ORDER_REMIND_MINUTES=10 ORDER_REMIND_MINUTES=10
ORDER_EXPIRE_MINUTES=30 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 # 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 RESULTS_WINDOW_START=16:00

View File

@ -0,0 +1,3 @@
SRS_URL=http://127.0.0.1:8080
SRS_SECRET=change-me

1
hermes-plugin/srs-gate/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
__pycache__/

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -49,7 +49,7 @@ describe('loadConfig', () => {
const base = { SRS_SECRET: 'a'.repeat(20) }; const base = { SRS_SECRET: 'a'.repeat(20) };
it('defaults to a 15:00 cutoff, asking until 15:30, remind 10, expire 30', () => { 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', () => { it('reads overrides', () => {
@ -60,7 +60,7 @@ describe('loadConfig', () => {
ORDER_REMIND_MINUTES: '5', ORDER_REMIND_MINUTES: '5',
ORDER_EXPIRE_MINUTES: '20', 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', () => { it('rejects a bad time format', () => {
@ -75,6 +75,9 @@ describe('loadConfig', () => {
it('rejects a reminder that is not before the expiry', () => { 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_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();
}); });
}); });
}); });

View File

@ -21,6 +21,7 @@ const schema = z.object({
ORDER_ASK_UNTIL: hhmm('15:30'), ORDER_ASK_UNTIL: hhmm('15:30'),
ORDER_REMIND_MINUTES: z.coerce.number().int().min(1).max(1440).default(10), 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_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_START: hhmm('16:00'),
RESULTS_WINDOW_END: hhmm('19:00'), RESULTS_WINDOW_END: hhmm('19:00'),
RESULTS_RETRY_MINUTES: z.coerce.number().int().min(1).max(120).default(10), RESULTS_RETRY_MINUTES: z.coerce.number().int().min(1).max(120).default(10),
@ -43,6 +44,7 @@ export interface OrderSettings {
askUntilMin: number; askUntilMin: number;
remindMin: number; remindMin: number;
expireMin: number; expireMin: number;
updateQuietSec: number;
} }
export interface ResultsSettings { export interface ResultsSettings {
@ -88,6 +90,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
askUntilMin: toMinutes(e.ORDER_ASK_UNTIL), askUntilMin: toMinutes(e.ORDER_ASK_UNTIL),
remindMin: e.ORDER_REMIND_MINUTES, remindMin: e.ORDER_REMIND_MINUTES,
expireMin: e.ORDER_EXPIRE_MINUTES, expireMin: e.ORDER_EXPIRE_MINUTES,
updateQuietSec: e.ORDER_UPDATE_QUIET_SECONDS,
}, },
results: { results: {
windowStartMin: toMinutes(e.RESULTS_WINDOW_START), windowStartMin: toMinutes(e.RESULTS_WINDOW_START),

View File

@ -8,7 +8,7 @@ const throwsConstraint = (fn: () => unknown) => expect(fn).toThrow(/CHECK|UNIQUE
describe('migrations', () => { describe('migrations', () => {
it('apply once and are then a no-op', () => { it('apply once and are then a no-op', () => {
const db = openDb(':memory:'); 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([]); expect(runMigrations(db)).toEqual([]);
}); });
}); });

View File

@ -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));

View File

@ -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', () => { describe('6.5 ranges', () => {
it('EC-41 single-ending range', () => { it('EC-41 single-ending range', () => {
expect(read('5104-5104').lines).toEqual(['5104x1']); 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', () => { 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. // 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('0057 1,200')).toMatchObject({ lines: ['0057x1'], unreadable: ['200:stray_number'] });
expect(read('1,200')).toMatchObject({ lines: [], ignored: ['1', '200'] }); expect(read('1,200')).toMatchObject({ lines: [], ignored: ['1', '200'] });

View File

@ -4,6 +4,8 @@ const SPACES = /[\t   -    ]/g;
const TIMES = /[×✕✖]/g; // × ✕ ✖ const TIMES = /[×✕✖]/g; // × ✕ ✖
const DASHES = /[‐-―−﹣-]/g; // hyphens, en/em dash, minus, full-width hyphen const DASHES = /[‐-―−﹣-]/g; // hyphens, en/em dash, minus, full-width hyphen
const FULLWIDTH_DIGIT = /[0-9]/g; 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[] { export function normalize(text: string): string[] {
return text return text
@ -13,5 +15,32 @@ export function normalize(text: string): string[] {
.replace(TIMES, 'x') .replace(TIMES, 'x')
.replace(DASHES, '-') .replace(DASHES, '-')
.replace(FULLWIDTH_DIGIT, (d) => String.fromCharCode(d.charCodeAt(0) - 0xff10 + 48)) .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'); .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);
}

View File

@ -1,6 +1,6 @@
import { lex } from './lexer'; import { lex } from './lexer';
import { MAX_INPUT_CHARS } from './limits'; import { MAX_INPUT_CHARS } from './limits';
import { normalize } from './normalize'; import { normalize, stripBold } from './normalize';
import { stripPrefix } from './prefix'; import { stripPrefix } from './prefix';
import { scanLine, type ScanItem } from './scan'; import { scanLine, type ScanItem } from './scan';
import type { Ignored, OrderLine, ParseResult, RepeatedEnding, Unreadable } from './types'; import type { Ignored, OrderLine, ParseResult, RepeatedEnding, Unreadable } from './types';
@ -14,7 +14,7 @@ export function scanOrder(text: string): ScanResult {
const bodies: string[] = []; const bodies: string[] = [];
const items: ScanItem[] = []; const items: ScanItem[] = [];
normalize(text).forEach((raw, index) => { normalize(text).forEach((raw, index) => {
const { body } = stripPrefix(raw); const body = stripBold(stripPrefix(raw).body);
bodies.push(body); bodies.push(body);
items.push(...scanLine(body, lex(body), index + 1)); items.push(...scanLine(body, lex(body), index + 1));
}); });

View File

@ -107,6 +107,8 @@ export function scanLine(body: string, atoms: Atom[], line: number): ScanItem[]
return single(i); 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 (a !== undefined && a.kind === 'op' && QTY_OPS.has(a.text)) {
if (b !== undefined && b.kind === 'num') return withQty(i, b.text, k + 2); 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 }; if (a.glued) return { ok: false, reason: 'mixed_characters', next: k + 1 };

View File

@ -183,33 +183,34 @@ describe('POST /ingest: replies', () => {
return res; 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'); 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 () => { it('plays the whole dialog through /ingest and saves the order', async () => {
await say('m1', f.shopUser.phone, '8910=3'); await say('m1', f.shopUser.phone, '8910=3');
await say('m2', f.shopUser.phone, '7810=1'); await say('m2', f.shopUser.phone, '7810=1');
await say('m3', f.shopUser.phone, 'done'); 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'); 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(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 }); expect(db.prepare('SELECT COUNT(*) AS n FROM order_lines').get()).toEqual({ n: 2 });
}); });
it('sends several replies of one message in order', async () => { 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'); await say('m1', f.shopUser.phone, '8910=3');
db.prepare("UPDATE conversations SET reminded_at = '2026-11-03T05:05:00Z'").run(); db.prepare("UPDATE conversations SET reminded_at = '2026-11-03T05:05:00Z'").run();
await say('m2', f.shopUser.phone, '0838=2'); await say('m2', f.shopUser.phone, '0838=2');
sent.length = 0; sent.length = 0;
await say('m3', f.shopUser.phone, 'cancel'); await say('m3', f.shopUser.phone, 'cancel');
expect(sent.map((s) => s.message)).toEqual([ expect(sent.map((s) => s.message)).toEqual([
'Cancelled. Nothing was saved.', '❌ *Cancelled*\nNothing was saved.',
'Starting your next order with the message you sent.', '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 () => { it('a failing bridge does not fail the request or lose the state', async () => {
await say('m1', f.shopUser.phone, '8910=3');
sendFails = true; 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.statusCode).toBe(200);
expect(res.json()).toEqual({ handled: true, duplicate: false }); expect(res.json()).toEqual({ handled: true, duplicate: false });
expect(sent).toEqual([]); expect(sent).toEqual([]);
@ -245,11 +247,12 @@ describe('POST /ingest: replies', () => {
}); });
it('after a failed send, ? gives the user the last prompt again', async () => { it('after a failed send, ? gives the user the last prompt again', async () => {
sendFails = true;
await say('m1', f.shopUser.phone, '8910=3'); await say('m1', f.shopUser.phone, '8910=3');
sendFails = false; sendFails = true;
await say('m2', f.shopUser.phone, '?'); 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 () => { it('does not wait for the bridge before answering', async () => {
@ -262,7 +265,7 @@ describe('POST /ingest: replies', () => {
method: 'POST', method: 'POST',
url: '/ingest', url: '/ingest',
headers: { 'content-type': 'application/json', 'x-srs-secret': SECRET }, 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 expect(res.statusCode).toBe(200); // answered while the send is still blocked
release(); release();
@ -276,7 +279,10 @@ describe('POST /ingest: replies', () => {
expect(db.prepare('SELECT COUNT(*) AS n FROM ingest_log').get()).toEqual({ n: 0 }); expect(db.prepare('SELECT COUNT(*) AS n FROM ingest_log').get()).toEqual({ n: 0 });
db.exec('DROP TRIGGER boom'); db.exec('DROP TRIGGER boom');
await say('m1', f.shopUser.phone, '8910=3'); 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 },
]);
}); });
}); });

View File

@ -9,7 +9,7 @@ export function testConfig(overrides: Partial<Config> = {}): Config {
jwtSecret: undefined, jwtSecret: undefined,
bridgeUrl: 'http://127.0.0.1:3000', bridgeUrl: 'http://127.0.0.1:3000',
defaultCountryCode: '91', 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 }, results: { windowStartMin: 16 * 60, windowEndMin: 19 * 60, retryMin: 10 },
...overrides, ...overrides,
}; };

View File

@ -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', () => { it('plays out word for word for a user with two shops', () => {
const say = chatAs(f.multiUser.id); 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('7810=1')).toEqual([]);
expect(say('08?8=2')).toEqual([]); expect(say('08?8=2')).toEqual([]);
expect(say('Total 50')).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([ expect(say('2')).toEqual([
[ [
'Shop B - draw of TODAY 3 Nov', '*Order summary*',
'2 lines, 4 tickets', '',
'*Shop:* Shop B',
'*Draw:* TODAY 3 Nov',
'*Total:* 2 lines, 4 tickets',
'',
'*Tickets*',
'8910 - 3', '8910 - 3',
'7810 - 1', '7810 - 1',
'Not read, not in the order:', '',
'⚠️ *Not read, not in the order:*',
' 08?8=2', ' 08?8=2',
'1 Save 2 Add more 3 Cancel', '',
'*Reply:*\n1. Save\n2. Add more\n3. Cancel',
].join('\n'), ].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([ expect(say('0838=2')).toEqual([
[ [
'Shop B - draw of TODAY 3 Nov', '*Order summary*',
'3 lines, 6 tickets', '',
'*Shop:* Shop B',
'*Draw:* TODAY 3 Nov',
'*Total:* 3 lines, 6 tickets',
'',
'*Tickets*',
'8910 - 3', '8910 - 3',
'7810 - 1', '7810 - 1',
'0838 - 2', '0838 - 2',
'Left out earlier, not in the order:', '',
'⚠️ *Left out earlier, not in the order:*',
' 08?8=2', ' 08?8=2',
'1 Save 2 Add more 3 Cancel', '',
'*Reply:*\n1. Save\n2. Add more\n3. Cancel',
].join('\n'), ].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(); const [order] = orders();
expect(order).toMatchObject({ expect(order).toMatchObject({
@ -113,22 +127,22 @@ describe('who sees which shops', () => {
const say = chatAs(f.shopUser.id); const say = chatAs(f.shopUser.id);
say('8910=3'); say('8910=3');
const [summary] = say('done'); 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'); expect(state(f.shopUser.id)).toBe('CONFIRM');
}); });
it('an admin sees every active shop', () => { it('an admin sees every active shop', () => {
const say = chatAs(f.admin.id); const say = chatAs(f.admin.id);
say('8910=3'); 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', () => { it('an admin can pick any shop and save', () => {
const say = chatAs(f.admin.id); const say = chatAs(f.admin.id);
say('8910=3'); say('8910=3');
say('1'); say('1');
expect(say('3')[0]).toContain('Shop C - draw of TODAY 3 Nov'); expect(say('3')[0]).toContain('*Shop:* Shop C\n*Draw:* TODAY 3 Nov');
expect(say('1')).toEqual(['Saved #1 - Shop C, 3 tickets, draw of 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 }); 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); const say = chatAs(f.multiUser.id);
say('8910=3'); say('8910=3');
say('1'); 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'); expect(state(f.multiUser.id)).toBe('AWAITING_SHOP');
}); });
@ -154,10 +168,10 @@ describe('who sees which shops', () => {
say('8910=3'); say('8910=3');
say('1'); say('1');
db.prepare('DELETE FROM user_shops WHERE user_id = ? AND shop_id = ?').run(f.multiUser.id, f.shops.b); 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'); expect(state(f.multiUser.id)).toBe('AWAITING_SHOP');
const [summary] = say('1'); 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', () => { it('re-checks shop access at the moment of saving', () => {
@ -173,9 +187,10 @@ describe('who sees which shops', () => {
}); });
describe('collecting', () => { 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); 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('7810=1')).toEqual([]);
expect(say('0838=2')).toEqual([]); expect(say('0838=2')).toEqual([]);
}); });
@ -208,13 +223,13 @@ describe('collecting', () => {
say('8910=3'); say('8910=3');
const [summary] = say('0888=2\n7810=1\n1'); const [summary] = say('0888=2\n7810=1\n1');
expect(summary).toContain('3 lines, 6 tickets'); 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'); expect(state(f.shopUser.id)).toBe('CONFIRM');
}); });
it('a trailing 1 in the first message is order text, not Done', () => { it('a trailing 1 in the first message is order text, not Done', () => {
const say = chatAs(f.shopUser.id); 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(state(f.shopUser.id)).toBe('COLLECTING');
expect(conv(f.shopUser.id)!.buffer_text).toBe('0888=2\n1'); 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); const say = chatAs(f.shopUser.id);
say('8910=3'); say('8910=3');
say('done'); say('done');
expect(say('1')[0]).toContain('Saved #1'); expect(say('1')[0]).toContain('*Saved #1*');
expect(say('1')).toEqual([]); expect(say('1')).toEqual([]);
expect(say('thanks')).toEqual([]); expect(say('thanks')).toEqual([]);
expect(orders()).toHaveLength(1); 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', () => { 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); const say = chatAs(f.shopUser.id);
say('8910=3\n08?8=2'); say('8910=3\n08?8=2');
const [summary] = say('done'); 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'); say('1');
expect(lines(1)).toEqual([{ ending: '8910', qty: 3 }]); expect(lines(1)).toEqual([{ ending: '8910', qty: 3 }]);
}); });
@ -284,8 +300,8 @@ describe('unreadable text', () => {
say('done'); say('done');
say('2'); say('2');
const [again] = say('0x38=1'); const [again] = say('0x38=1');
expect(again).toContain('Not read, not in the order:\n 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('⚠️ *Left out earlier, not in the order:*\n 08?8=2');
// the new line is unreadable too, so nothing was added to the order // the new line is unreadable too, so nothing was added to the order
expect(again).toContain('1 line, 3 tickets'); expect(again).toContain('1 line, 3 tickets');
expect(again).not.toContain('Everything was read'); expect(again).not.toContain('Everything was read');
@ -298,7 +314,7 @@ describe('unreadable text', () => {
say('2'); say('2');
const [again] = say('1234=1'); // a forgotten line, not a correction const [again] = say('1234=1'); // a forgotten line, not a correction
expect(again).toContain('2 lines, 4 tickets'); 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'); expect(again).not.toContain('Everything was read');
say('1'); say('1');
expect(lines(1)).toEqual([ expect(lines(1)).toEqual([
@ -313,17 +329,23 @@ describe('unreadable text', () => {
say('done'); say('done');
say('2'); say('2');
const [again] = say('1234=1'); 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'); expect(again).not.toContain('Left out earlier');
}); });
it('an order that is only unreadable cannot be saved', () => { it('an order that is only unreadable stops at Done and lists what was not read', () => {
const say = chatAs(f.shopUser.id); const say = chatAs(f.multiUser.id); // two shops: it must not reach the shop question
say('08?8=2'); say('08?8=2');
say('done'); expect(say('done')).toEqual([msg.noTickets(['08?8=2'])]);
expect(say('1')).toEqual([msg.noTickets()]);
expect(orders()).toHaveLength(0); 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', () => { it('an order with nothing readable at all is refused at Done', () => {
@ -385,7 +407,7 @@ describe('the menus', () => {
say('8910=3'); say('8910=3');
say('done'); say('done');
say('2'); say('2');
expect(say('1')[0]).toContain('Saved #1'); expect(say('1')[0]).toContain('*Saved #1*');
}); });
it('Cancel at the summary clears everything', () => { it('Cancel at the summary clears everything', () => {
@ -402,7 +424,7 @@ describe('the menus', () => {
say('8910=3'); say('8910=3');
const [summary] = say('done'); const [summary] = say('done');
const [reply] = say('7810=1'); 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']); 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); const say = chatAs(f.multiUser.id);
say('8910=3'); say('8910=3');
const [list] = say('1'); 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'); expect(conv(f.multiUser.id)!.buffer_text).toBe('8910=3');
}); });
it('? repeats the last prompt in every waiting state', () => { it('? repeats the last prompt in every waiting state', () => {
const say = chatAs(f.multiUser.id); const say = chatAs(f.multiUser.id);
const [menu] = say('8910=3'); say('8910=3');
expect(say('?')).toEqual([menu]); expect(say('?')).toEqual([msg.progressUpdate(1, 3)]);
const [list] = say('1'); const [list] = say('1');
expect(say('help')).toEqual([list]); expect(say('help')).toEqual([list]);
const [summary] = say('2'); const [summary] = say('2');
@ -493,14 +515,14 @@ describe('the draw date', () => {
const say = chatAs(f.shopUser.id); const say = chatAs(f.shopUser.id);
say('8910=3', new Date('2026-11-03T09:30:00Z')); // 15:00:00 exactly 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')); 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', () => { it('after 15:30 is tomorrow, no question', () => {
const say = chatAs(f.shopUser.id); const say = chatAs(f.shopUser.id);
say('8910=3', TOO_LATE); say('8910=3', TOO_LATE);
const [summary] = say('done', 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', () => { 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); say('8910=3', ASK_TIME);
expect(say('done', ASK_TIME)).toEqual([msg.drawQuestion('2026-11-03', '2026-11-04')]); expect(say('done', ASK_TIME)).toEqual([msg.drawQuestion('2026-11-03', '2026-11-04')]);
const [summary] = say('1', STILL_ASKING); 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); say('1', STILL_ASKING);
expect(orders()[0]!.draw_date).toBe('2026-11-03'); expect(orders()[0]!.draw_date).toBe('2026-11-03');
}); });
@ -518,7 +540,7 @@ describe('the draw date', () => {
say('8910=3', ASK_TIME); say('8910=3', ASK_TIME);
say('done', ASK_TIME); say('done', ASK_TIME);
const [summary] = say('2', STILL_ASKING); 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); say('1', STILL_ASKING);
expect(orders()[0]!.draw_date).toBe('2026-11-04'); expect(orders()[0]!.draw_date).toBe('2026-11-04');
}); });
@ -529,7 +551,7 @@ describe('the draw date', () => {
say('done', ASK_TIME); say('done', ASK_TIME);
expect(say('1', TOO_LATE)).toEqual([msg.tooLateForToday('2026-11-04')]); expect(say('1', TOO_LATE)).toEqual([msg.tooLateForToday('2026-11-04')]);
const [summary] = say('1', TOO_LATE); 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', () => { it('after a refusal, 2 cancels', () => {
@ -555,7 +577,7 @@ describe('the draw date', () => {
say('done', ASK_TIME); say('done', ASK_TIME);
say('1', STILL_ASKING); say('1', STILL_ASKING);
const [saved] = say('1', new Date('2026-11-03T11:00:00Z')); // 16:30 IST 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'); expect(orders()[0]!.draw_date).toBe('2026-11-03');
}); });
@ -568,7 +590,7 @@ describe('the draw date', () => {
const c = conv(f.multiUser.id)!; const c = conv(f.multiUser.id)!;
expect(c.state).toBe('ASKING'); expect(c.state).toBe('ASKING');
const [summary] = say('1', TOO_LATE); 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); say('1', TOO_LATE);
expect(orders()[0]!.draw_date).toBe('2026-11-04'); 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', () => { it('the shop pick comes before the draw question', () => {
const say = chatAs(f.multiUser.id); const say = chatAs(f.multiUser.id);
say('8910=3', ASK_TIME); 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"); 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).toContain('8910 - 3');
expect(summary).not.toContain('0838'); expect(summary).not.toContain('0838');
const replies = say('1'); const replies = say('1');
expect(replies[0]).toContain('Saved #1'); expect(replies[0]).toContain('*Saved #1*');
expect(replies[1]).toBe(msg.startingHeld()); 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(lines(1)).toEqual([{ ending: '8910', qty: 3 }]);
expect(conv(f.shopUser.id)!.state).toBe('COLLECTING'); expect(conv(f.shopUser.id)!.state).toBe('COLLECTING');
expect(conv(f.shopUser.id)!.buffer_text).toBe('0838=2'); expect(conv(f.shopUser.id)!.buffer_text).toBe('0838=2');
@ -690,7 +713,7 @@ describe('late messages after the reminder', () => {
say('0838=2'); say('0838=2');
say('2'); say('2');
const replies = say('3'); 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(conv(f.shopUser.id)!.buffer_text).toBe('0838=2');
expect(orders()).toHaveLength(0); expect(orders()).toHaveLength(0);
}); });
@ -700,7 +723,7 @@ describe('late messages after the reminder', () => {
say('8910=3'); say('8910=3');
remind(f.shopUser.id); remind(f.shopUser.id);
say('0838=2'); say('0838=2');
expect(say('3')).toEqual([msg.collectingMenu()]); expect(say('3')).toEqual([]);
const c = conv(f.shopUser.id)!; const c = conv(f.shopUser.id)!;
expect(c.state).toBe('COLLECTING'); expect(c.state).toBe('COLLECTING');
expect(c.buffer_text).toBe('0838=2'); expect(c.buffer_text).toBe('0838=2');
@ -712,7 +735,8 @@ describe('late messages after the reminder', () => {
say('8910=3'); say('8910=3');
remind(f.shopUser.id); remind(f.shopUser.id);
say('0838=2'); 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'); expect(conv(f.shopUser.id)!.buffer_text).toBe('0838=2');
}); });
@ -742,6 +766,6 @@ describe('late messages after the reminder', () => {
remind(f.shopUser.id); remind(f.shopUser.id);
const [question] = say('0838=2'); const [question] = say('0838=2');
expect(say('7')).toEqual([question]); 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}`]);
}); });
}); });

View File

@ -41,6 +41,7 @@ export interface ConversationRow {
reminded_at: string | null; reminded_at: string | null;
ask_kind: 'draw' | 'split' | null; ask_kind: 'draw' | 'split' | null;
held_text: string | null; held_text: string | null;
update_pending: number;
} }
interface FlowData { interface FlowData {
@ -92,6 +93,7 @@ export function resetConversation(db: Db, id: number, now: Date): void {
held_text: null, held_text: null,
reminded_at: null, reminded_at: null,
last_prompt: null, last_prompt: null,
update_pending: 0,
last_activity_at: iso(now), last_activity_at: iso(now),
}); });
} }
@ -121,9 +123,9 @@ function parseSegments(segments: string[]): Parsed {
return out; 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); 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[] { 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[] { function unexpected(conv: ConversationRow, text: string): string[] {
if (!hasOrderContent(text)) return repeatPrompt(conv); 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 { function begin(c: Ctx, text: string): ConversationRow {
c.db c.db
.prepare( .prepare(
`INSERT INTO conversations (user_id, chat_id, state, buffer_text, source, last_activity_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', @at) VALUES (@user_id, @chat_id, 'COLLECTING', @text, 'whatsapp_text', 1, @at)
ON CONFLICT (user_id) DO UPDATE SET ON CONFLICT (user_id) DO UPDATE SET
chat_id = @chat_id, state = 'COLLECTING', buffer_text = @text, source = 'whatsapp_text', 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, 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) }); .run({ user_id: c.user.id, chat_id: c.chatId, text, at: iso(c.now) });
return getConversation(c.db, c.user.id)!; 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)); return done(c, begin(c, cmd.body));
} }
if (cmd.kind !== 'text' || !hasOrderContent(text)) return []; if (cmd.kind !== 'text' || !hasOrderContent(text)) return [];
const conv = begin(c, text); begin(c, text);
return [prompt(c.db, conv.id, msg.collectingMenu())]; return [];
} }
// COLLECTING ---------------------------------------------------------------------------------------------------- // 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[] { function collecting(c: Ctx, conv: ConversationRow, cmd: Command, text: string): string[] {
const orderText = cmd.kind === 'text' ? text : cmd.kind === 'submit' ? cmd.body : ''; 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': case 'number':
if (cmd.n === 1) return done(c, conv); if (cmd.n === 1) return done(c, conv);
if (cmd.n === 2) return endConversation(c, conv, [msg.cancelled()]); if (cmd.n === 2) return endConversation(c, conv, [msg.cancelled()]);
return [conv.last_prompt ?? msg.collectingMenu()]; return [collectingReply(conv)];
case 'help': case 'help':
return [conv.last_prompt ?? msg.collectingMenu()]; return [collectingReply(conv)];
default: 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 []; 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. */ /** 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[] { function done(c: Ctx, conv: ConversationRow): string[] {
const p = parseSegments([conv.buffer_text]); const p = parseSegments([conv.buffer_text]);
if (p.lines.length === 0 && p.unreadable[0]!.length === 0) { if (p.lines.length === 0) return endConversation(c, conv, [msg.noTickets(p.unreadable.flat())]);
return endConversation(c, conv, [msg.noTickets()]);
}
const shops = shopsForUser(c.db, c.user); const shops = shopsForUser(c.db, c.user);
if (shops.length === 0) return endConversation(c, conv, [msg.noShop()]); 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() }; 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) { if (shops.length === 1) {
update(c.db, conv.id, { selected_shop_id: shops[0]!.id }); update(c.db, conv.id, { selected_shop_id: shops[0]!.id });

View File

@ -52,13 +52,20 @@ describe('summary', () => {
it('matches the walk-through when something was not read', () => { it('matches the walk-through when something was not read', () => {
expect(m.summary(base)).toBe( expect(m.summary(base)).toBe(
[ [
'Shop B - draw of TODAY 3 Nov', '*Order summary*',
'2 lines, 4 tickets', '',
'*Shop:* Shop B',
'*Draw:* TODAY 3 Nov',
'*Total:* 2 lines, 4 tickets',
'',
'*Tickets*',
'8910 - 3', '8910 - 3',
'7810 - 1', '7810 - 1',
'Not read, not in the order:', '',
'⚠️ *Not read, not in the order:*',
' 08?8=2', ' 08?8=2',
'1 Save 2 Add more 3 Cancel', '',
'*Reply:*\n1. Save\n2. Add more\n3. Cancel',
].join('\n'), ].join('\n'),
); );
}); });
@ -73,12 +80,19 @@ describe('summary', () => {
}); });
expect(t).toBe( expect(t).toBe(
[ [
'Shop B - draw of TODAY 3 Nov', '*Order summary*',
'3 lines, 6 tickets. Everything was read.', '',
'*Shop:* Shop B',
'*Draw:* TODAY 3 Nov',
'*Total:* 3 lines, 6 tickets',
'✅ Everything was read',
'',
'*Tickets*',
'8910 - 3', '8910 - 3',
'7810 - 1', '7810 - 1',
'0838 - 2', '0838 - 2',
'1 Save 2 Add more 3 Cancel', '',
'*Reply:*\n1. Save\n2. Add more\n3. Cancel',
].join('\n'), ].join('\n'),
); );
}); });
@ -87,13 +101,20 @@ describe('summary', () => {
const t = m.summary({ ...base, unreadable: [], leftOut: ['08?8=2'], lineCount: 3, ticketCount: 6 }); const t = m.summary({ ...base, unreadable: [], leftOut: ['08?8=2'], lineCount: 3, ticketCount: 6 });
expect(t).toBe( expect(t).toBe(
[ [
'Shop B - draw of TODAY 3 Nov', '*Order summary*',
'3 lines, 6 tickets', '',
'*Shop:* Shop B',
'*Draw:* TODAY 3 Nov',
'*Total:* 3 lines, 6 tickets',
'',
'*Tickets*',
'8910 - 3', '8910 - 3',
'7810 - 1', '7810 - 1',
'Left out earlier, not in the order:', '',
'⚠️ *Left out earlier, not in the order:*',
' 08?8=2', ' 08?8=2',
'1 Save 2 Add more 3 Cancel', '',
'*Reply:*\n1. Save\n2. Add more\n3. Cancel',
].join('\n'), ].join('\n'),
); );
expect(t).not.toContain('Everything was read'); expect(t).not.toContain('Everything was read');
@ -101,8 +122,8 @@ describe('summary', () => {
it('lists new and earlier unreadable text separately, new first', () => { it('lists new and earlier unreadable text separately, new first', () => {
const t = m.summary({ ...base, unreadable: ['0x38=1'], leftOut: ['08?8=2'] }); 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 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 older = t.indexOf('⚠️ *Left out earlier, not in the order:*\n 08?8=2');
expect(fresh).toBeGreaterThan(-1); expect(fresh).toBeGreaterThan(-1);
expect(older).toBeGreaterThan(fresh); expect(older).toBeGreaterThan(fresh);
}); });
@ -114,7 +135,7 @@ describe('summary', () => {
}); });
it('labels tomorrow', () => { 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', () => { it('uses singular forms', () => {
@ -125,7 +146,7 @@ describe('summary', () => {
entries: [{ ending: '0001', qty: 1, times: 1 }], entries: [{ ending: '0001', qty: 1, times: 1 }],
unreadable: [], 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', () => { it('caps a long unreadable list and counts the rest', () => {
@ -143,42 +164,50 @@ describe('summary', () => {
}); });
describe('prompts', () => { describe('prompts', () => {
it('collecting menu offers Done and Cancel', () => { it('the collecting reply gives the totals and offers Done and Cancel', () => {
expect(m.collectingMenu()).toBe('Got it. Forward more, or reply 1 Done 2 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', () => { 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', () => { it('draw question names both days', () => {
expect(m.drawQuestion('2026-11-03', '2026-11-04')).toBe( 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', () => { 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', () => { it('saved names the order, shop, tickets and draw', () => {
expect(m.saved({ orderId: 123, shopName: 'Shop B', ticketCount: 6, drawDate: '2026-11-03' })).toBe( 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', () => { 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', () => { 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', () => { it('split question lists the three choices', () => {
expect(m.splitQuestion(5, 12)).toBe( expect(m.splitQuestion(5, 12)).toBe(
[ [
'You have an unfinished order (5 lines, 12 tickets).', '⏳ *Unfinished order*',
'Is this message:', '5 lines, 12 tickets so far.',
'1 Part of that order', '',
'2 A new order (finish the old one first)', '*Is this message:*',
'3 A new order (throw the old one away)', '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'), ].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', () => { 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'); for (const t of [m.cancelled(), m.noTickets(), m.expired()]) expect(t).toContain('Nothing was saved');
}); });

View File

@ -37,25 +37,34 @@ export function entryTotals(lines: { ending: string; qty: number }[]): Entry[] {
return [...by.values()]; return [...by.values()];
} }
export const COLLECTING_MENU = '1 Done 2 Cancel'; export const COLLECTING_MENU = '1. Done\n2. Cancel';
export const SUMMARY_MENU = '1 Save 2 Add more 3 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 { 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 { export function drawQuestion(today: string, tomorrow: string): string {
return [ return [
"Is this order for today's draw or tomorrow's?", "*Is this order for today's draw or tomorrow's?*",
`1 Today ${formatDay(today)}`, '',
`2 Tomorrow ${formatDay(tomorrow)}`, `1. Today, ${formatDay(today)}`,
`2. Tomorrow, ${formatDay(tomorrow)}`,
].join('\n'); ].join('\n');
} }
export function tooLateForToday(tomorrow: string): string { 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 { export interface SummaryInput {
@ -71,54 +80,69 @@ export interface SummaryInput {
function pushList(out: string[], heading: string, items: string[]): void { function pushList(out: string[], heading: string, items: string[]): void {
if (items.length === 0) return; if (items.length === 0) return;
out.push(heading); out.push('', heading);
for (const u of items.slice(0, MAX_UNREADABLE_SHOWN)) out.push(` ${u}`); for (const u of items.slice(0, MAX_UNREADABLE_SHOWN)) out.push(` ${u}`);
const more = items.length - MAX_UNREADABLE_SHOWN; const more = items.length - MAX_UNREADABLE_SHOWN;
if (more > 0) out.push(` ...and ${more} more`); if (more > 0) out.push(` ...and ${more} more`);
} }
export function summary(s: SummaryInput): string { export function summary(s: SummaryInput): string {
const head = `${plural(s.lineCount, 'line')}, ${plural(s.ticketCount, 'ticket')}`; const out = [
const out = [`${s.shopName} - draw of ${drawLabel(s.drawDate, s.today)}`]; '*Order summary*',
out.push(s.unreadable.length === 0 && s.leftOut.length === 0 ? `${head}. Everything was read.` : head); '',
`*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) { for (const e of s.entries) {
out.push(e.times > 1 ? `${e.ending} - ${e.qty} (${e.times} times)` : `${e.ending} - ${e.qty}`); 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, '⚠️ *Not read, not in the order:*', s.unreadable);
pushList(out, 'Left out earlier, not in the order:', s.leftOut); pushList(out, '⚠️ *Left out earlier, not in the order:*', s.leftOut);
out.push(SUMMARY_MENU); out.push('', SUMMARY_MENU);
return out.join('\n'); return out.join('\n');
} }
export const addPrompt = (): string => 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 { 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 nothingToSubmit = (): string => 'Nothing to submit yet.';
export const noTickets = (): string => 'I did not find any tickets. Nothing was saved.'; export function noTickets(unreadable: string[] = []): string {
export const noShop = (): string => 'No shop is assigned to your account, please contact the owner.'; 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 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.'; 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 { 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 { export function splitQuestion(lineCount: number, ticketCount: number): string {
return [ return [
`You have an unfinished order${progress(lineCount, ticketCount)}.`, '⏳ *Unfinished order*',
'Is this message:', ...soFar(lineCount, ticketCount),
'1 Part of that order', '',
'2 A new order (finish the old one first)', '*Is this message:*',
'3 A new order (throw the old one away)', '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'); ].join('\n');
} }

View File

@ -66,7 +66,7 @@ describe('reminder', () => {
expect(await sweepAt(after(10))).toEqual({ reminded: 1, expired: 0 }); expect(await sweepAt(after(10))).toEqual({ reminded: 1, expired: 0 });
expect(sent).toEqual([{ chatId: 'chat-1@lid', message: msg.reminder(2, 4) }]); 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(11))).toEqual({ reminded: 0, expired: 0 });
expect(await sweepAt(after(20))).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); const say = chatAs(f.shopUser.id);
say('8910=3', T0); say('8910=3', T0);
await sweepAt(after(30)); 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'); 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); 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) });
});
});

View File

@ -18,6 +18,7 @@ export interface SweepResult {
} }
const EVERY_MS = 60_000; const EVERY_MS = 60_000;
const UPDATE_EVERY_MS = 1_000;
const iso = (d: Date): string => d.toISOString().replace(/\.\d{3}Z$/, 'Z'); const iso = (d: Date): string => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
export function createConversationSweeper(opts: SweeperOptions) { export function createConversationSweeper(opts: SweeperOptions) {
@ -56,19 +57,54 @@ export function createConversationSweeper(opts: SweeperOptions) {
return result; 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 { function start(): () => void {
const safeTick = (): void => { const safely = (fn: () => unknown) => (): void => {
try { try {
tick(); fn();
} catch (err) { } catch (err) {
opts.log({ level: 'error', msg: 'conversation sweep failed', error: (err as Error).message }); opts.log({ level: 'error', msg: 'conversation sweep failed', error: (err as Error).message });
} }
}; };
const safeTick = safely(tick);
const timer = setInterval(safeTick, EVERY_MS); const timer = setInterval(safeTick, EVERY_MS);
const updateTimer = setInterval(safely(tickUpdates), UPDATE_EVERY_MS);
timer.unref(); timer.unref();
updateTimer.unref();
safeTick(); safeTick();
return () => clearInterval(timer); return () => {
clearInterval(timer);
clearInterval(updateTimer);
};
} }
return { tick, start }; return { tick, tickUpdates, start };
} }

View File

@ -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": []
}

View File

@ -0,0 +1,5 @@
6379*100
9001.10
*8203*100*
*0888*
*7000 75*