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_REMIND_MINUTES=10
ORDER_EXPIRE_MINUTES=30
# Seconds without a new message before the bot replies to an order being collected (max 50)
ORDER_UPDATE_QUIET_SECONDS=3
# Result PDF fetching (IST): check from START until a draw is stored, give up at END, retry every N minutes
RESULTS_WINDOW_START=16:00

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) };
it('defaults to a 15:00 cutoff, asking until 15:30, remind 10, expire 30', () => {
expect(loadConfig(base).orders).toEqual({ cutoffMin: 900, askUntilMin: 930, remindMin: 10, expireMin: 30 });
expect(loadConfig(base).orders).toEqual({ cutoffMin: 900, askUntilMin: 930, remindMin: 10, expireMin: 30, updateQuietSec: 3 });
});
it('reads overrides', () => {
@ -60,7 +60,7 @@ describe('loadConfig', () => {
ORDER_REMIND_MINUTES: '5',
ORDER_EXPIRE_MINUTES: '20',
});
expect(c.orders).toEqual({ cutoffMin: 870, askUntilMin: 900, remindMin: 5, expireMin: 20 });
expect(c.orders).toEqual({ cutoffMin: 870, askUntilMin: 900, remindMin: 5, expireMin: 20, updateQuietSec: 3 });
});
it('rejects a bad time format', () => {
@ -75,6 +75,9 @@ describe('loadConfig', () => {
it('rejects a reminder that is not before the expiry', () => {
expect(() => loadConfig({ ...base, ORDER_REMIND_MINUTES: '30' })).toThrow(/smaller than ORDER_EXPIRE/);
expect(loadConfig({ ...base, ORDER_UPDATE_QUIET_SECONDS: '8' }).orders.updateQuietSec).toBe(8);
expect(() => loadConfig({ ...base, ORDER_UPDATE_QUIET_SECONDS: '0' })).toThrow();
expect(() => loadConfig({ ...base, ORDER_UPDATE_QUIET_SECONDS: '51' })).toThrow();
});
});
});

View File

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

View File

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

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', () => {
it('EC-41 single-ending range', () => {
expect(read('5104-5104').lines).toEqual(['5104x1']);
@ -261,7 +321,7 @@ describe('6.8 mixed, merged and noisy input', () => {
});
it('EC-87 decimals and thousands separators', () => {
expect(read('0888.5 0057').unreadable).toEqual(['0888.5:mixed_characters']);
expect(read('0888.5 0057').lines).toEqual(['0888x5', '0057x1']); // a glued dot is a quantity, decided 4 Nov
// 1 right after an ending is its quantity (space rule); 200 is reported, never joined into 1200.
expect(read('0057 1,200')).toMatchObject({ lines: ['0057x1'], unreadable: ['200:stray_number'] });
expect(read('1,200')).toMatchObject({ lines: [], ignored: ['1', '200'] });

View File

@ -4,6 +4,8 @@ const SPACES = /[\t   -    ]/g;
const TIMES = /[×✕✖]/g; // × ✕ ✖
const DASHES = /[‐-―−﹣-]/g; // hyphens, en/em dash, minus, full-width hyphen
const FULLWIDTH_DIGIT = /[0-9]/g;
// Mathematical bold, double-struck, sans, sans-bold and monospace digits, from "fancy text" tools: five runs of 0-9.
const STYLED_DIGIT = /[\u{1D7CE}-\u{1D7FF}]/gu;
export function normalize(text: string): string[] {
return text
@ -13,5 +15,32 @@ export function normalize(text: string): string[] {
.replace(TIMES, 'x')
.replace(DASHES, '-')
.replace(FULLWIDTH_DIGIT, (d) => String.fromCharCode(d.charCodeAt(0) - 0xff10 + 48))
.replace(STYLED_DIGIT, (d) => String(((d.codePointAt(0) as number) - 0x1d7ce) % 10))
.split('\n');
}
/**
* Removes WhatsApp bold markers from one line: *6379*100* is 6379*100, *0888* is 0888. A span opens at a star that
* follows a space or the line start and is followed by a non-space; it closes at the next star that follows a non-space
* and is followed by a space or the line end. A star with no partner (6379 *100, 0057 * 2) is a quantity and stays.
* One pass over the line, so the work stays linear.
*/
export function stripBold(line: string): string {
const isSpace = (k: number): boolean => k < 0 || k >= line.length || line[k] === ' ';
let out = '';
let from = 0;
let i = line.indexOf('*');
while (i !== -1) {
if (isSpace(i - 1) && !isSpace(i + 1)) {
let close = line.indexOf('*', i + 2);
while (close !== -1 && !(isSpace(close + 1) && !isSpace(close - 1))) close = line.indexOf('*', close + 1);
if (close === -1) break; // no closing star here means none for any later opening star either
out += line.slice(from, i) + line.slice(i + 1, close);
from = close + 1;
i = line.indexOf('*', from);
} else {
i = line.indexOf('*', i + 1);
}
}
return out + line.slice(from);
}

View File

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

View File

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

View File

@ -183,33 +183,34 @@ describe('POST /ingest: replies', () => {
return res;
};
it('sends the collecting menu to the chat the message came from', async () => {
it('does not reply to order text at once, and answers ? in the chat the message came from', async () => {
await say('m1', f.shopUser.phone, '8910=3');
expect(sent).toEqual([{ chatId: chat, message: 'Got it. Forward more, or reply 1 Done 2 Cancel' }]);
expect(sent).toEqual([]);
await say('m2', f.shopUser.phone, '?');
expect(sent).toEqual([{ chatId: chat, message: '✅ *Got it*\n1 line, 3 tickets so far.\n\n*When the order is finished, reply:*\n1. Done\n2. Cancel' }]);
});
it('plays the whole dialog through /ingest and saves the order', async () => {
await say('m1', f.shopUser.phone, '8910=3');
await say('m2', f.shopUser.phone, '7810=1');
await say('m3', f.shopUser.phone, 'done');
expect(sent.at(-1)!.message).toContain('Shop A - draw of TODAY 3 Nov');
expect(sent.at(-1)!.message).toContain('*Shop:* Shop A\n*Draw:* TODAY 3 Nov');
await say('m4', f.shopUser.phone, '1');
expect(sent.at(-1)!.message).toBe('Saved #1 - Shop A, 4 tickets, draw of 3 Nov');
expect(sent.at(-1)!.message).toBe('✅ *Saved #1*\n\n*Shop:* Shop A\n*Tickets:* 4\n*Draw:* 3 Nov');
expect(sent.map((s) => s.chatId).every((c) => c === chat)).toBe(true);
expect(db.prepare('SELECT COUNT(*) AS n FROM order_lines').get()).toEqual({ n: 2 });
});
it('sends several replies of one message in order', async () => {
// cancelling while a late message is held answers: cancelled, starting the next order, the menu
// cancelling while a late message is held answers: cancelled, then starting the next order
await say('m1', f.shopUser.phone, '8910=3');
db.prepare("UPDATE conversations SET reminded_at = '2026-11-03T05:05:00Z'").run();
await say('m2', f.shopUser.phone, '0838=2');
sent.length = 0;
await say('m3', f.shopUser.phone, 'cancel');
expect(sent.map((s) => s.message)).toEqual([
'Cancelled. Nothing was saved.',
'❌ *Cancelled*\nNothing was saved.',
'Starting your next order with the message you sent.',
'Got it. Forward more, or reply 1 Done 2 Cancel',
]);
});
@ -234,8 +235,9 @@ describe('POST /ingest: replies', () => {
});
it('a failing bridge does not fail the request or lose the state', async () => {
await say('m1', f.shopUser.phone, '8910=3');
sendFails = true;
const res = await say('m1', f.shopUser.phone, '8910=3');
const res = await say('m2', f.shopUser.phone, '?');
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ handled: true, duplicate: false });
expect(sent).toEqual([]);
@ -245,11 +247,12 @@ describe('POST /ingest: replies', () => {
});
it('after a failed send, ? gives the user the last prompt again', async () => {
sendFails = true;
await say('m1', f.shopUser.phone, '8910=3');
sendFails = false;
sendFails = true;
await say('m2', f.shopUser.phone, '?');
expect(sent.map((s) => s.message)).toEqual(['Got it. Forward more, or reply 1 Done 2 Cancel']);
sendFails = false;
await say('m3', f.shopUser.phone, '?');
expect(sent.map((s) => s.message)).toEqual(['✅ *Got it*\n1 line, 3 tickets so far.\n\n*When the order is finished, reply:*\n1. Done\n2. Cancel']);
});
it('does not wait for the bridge before answering', async () => {
@ -262,7 +265,7 @@ describe('POST /ingest: replies', () => {
method: 'POST',
url: '/ingest',
headers: { 'content-type': 'application/json', 'x-srs-secret': SECRET },
payload: JSON.stringify(text('m1', f.shopUser.phone, '8910=3')),
payload: JSON.stringify(text('m1', f.shopUser.phone, 'done')),
});
expect(res.statusCode).toBe(200); // answered while the send is still blocked
release();
@ -276,7 +279,10 @@ describe('POST /ingest: replies', () => {
expect(db.prepare('SELECT COUNT(*) AS n FROM ingest_log').get()).toEqual({ n: 0 });
db.exec('DROP TRIGGER boom');
await say('m1', f.shopUser.phone, '8910=3');
expect(sent).toHaveLength(1);
expect(db.prepare('SELECT COUNT(*) AS n FROM ingest_log').get()).toEqual({ n: 1 });
expect(db.prepare('SELECT state, buffer_text, update_pending FROM conversations').all()).toEqual([
{ state: 'COLLECTING', buffer_text: '8910=3', update_pending: 1 },
]);
});
});

View File

@ -9,7 +9,7 @@ export function testConfig(overrides: Partial<Config> = {}): Config {
jwtSecret: undefined,
bridgeUrl: 'http://127.0.0.1:3000',
defaultCountryCode: '91',
orders: { cutoffMin: 15 * 60, askUntilMin: 15 * 60 + 30, remindMin: 10, expireMin: 30 },
orders: { cutoffMin: 15 * 60, askUntilMin: 15 * 60 + 30, remindMin: 10, expireMin: 30, updateQuietSec: 3 },
results: { windowStartMin: 16 * 60, windowEndMin: 19 * 60, retryMin: 10 },
...overrides,
};

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

View File

@ -41,6 +41,7 @@ export interface ConversationRow {
reminded_at: string | null;
ask_kind: 'draw' | 'split' | null;
held_text: string | null;
update_pending: number;
}
interface FlowData {
@ -92,6 +93,7 @@ export function resetConversation(db: Db, id: number, now: Date): void {
held_text: null,
reminded_at: null,
last_prompt: null,
update_pending: 0,
last_activity_at: iso(now),
});
}
@ -121,9 +123,9 @@ function parseSegments(segments: string[]): Parsed {
return out;
}
export function bufferProgress(text: string): { lines: number; tickets: number } {
export function bufferProgress(text: string): { lines: number; tickets: number; unreadable: string[] } {
const r = parseOrder(text);
return { lines: r.lines.length, tickets: r.totalTickets };
return { lines: r.lines.length, tickets: r.totalTickets, unreadable: r.unreadable.map((u) => u.text) };
}
function afterReset(c: Ctx, held: string | null, replies: string[]): string[] {
@ -143,7 +145,7 @@ function repeatPrompt(conv: ConversationRow): string[] {
function unexpected(conv: ConversationRow, text: string): string[] {
if (!hasOrderContent(text)) return repeatPrompt(conv);
return [conv.last_prompt ? `${msg.numberFirst()}\n${conv.last_prompt}` : msg.numberFirst()];
return [conv.last_prompt ? `${msg.numberFirst()}\n\n${conv.last_prompt}` : msg.numberFirst()];
}
// ---------------------------------------------------------------------------------------------------------------
@ -187,12 +189,12 @@ function step(c: Ctx, text: string): string[] {
function begin(c: Ctx, text: string): ConversationRow {
c.db
.prepare(
`INSERT INTO conversations (user_id, chat_id, state, buffer_text, source, last_activity_at)
VALUES (@user_id, @chat_id, 'COLLECTING', @text, 'whatsapp_text', @at)
`INSERT INTO conversations (user_id, chat_id, state, buffer_text, source, update_pending, last_activity_at)
VALUES (@user_id, @chat_id, 'COLLECTING', @text, 'whatsapp_text', 1, @at)
ON CONFLICT (user_id) DO UPDATE SET
chat_id = @chat_id, state = 'COLLECTING', buffer_text = @text, source = 'whatsapp_text',
parsed_json = NULL, shop_options_json = NULL, selected_shop_id = NULL, ask_kind = NULL,
held_text = NULL, reminded_at = NULL, last_prompt = NULL, last_activity_at = @at`,
held_text = NULL, reminded_at = NULL, last_prompt = NULL, update_pending = 1, last_activity_at = @at`,
)
.run({ user_id: c.user.id, chat_id: c.chatId, text, at: iso(c.now) });
return getConversation(c.db, c.user.id)!;
@ -205,12 +207,17 @@ function fromIdle(c: Ctx, text: string): string[] {
return done(c, begin(c, cmd.body));
}
if (cmd.kind !== 'text' || !hasOrderContent(text)) return [];
const conv = begin(c, text);
return [prompt(c.db, conv.id, msg.collectingMenu())];
begin(c, text);
return [];
}
// COLLECTING ----------------------------------------------------------------------------------------------------
function collectingReply(conv: ConversationRow): string {
const p = bufferProgress(conv.buffer_text);
return msg.progressUpdate(p.lines, p.tickets);
}
function collecting(c: Ctx, conv: ConversationRow, cmd: Command, text: string): string[] {
const orderText = cmd.kind === 'text' ? text : cmd.kind === 'submit' ? cmd.body : '';
@ -229,11 +236,15 @@ function collecting(c: Ctx, conv: ConversationRow, cmd: Command, text: string):
case 'number':
if (cmd.n === 1) return done(c, conv);
if (cmd.n === 2) return endConversation(c, conv, [msg.cancelled()]);
return [conv.last_prompt ?? msg.collectingMenu()];
return [collectingReply(conv)];
case 'help':
return [conv.last_prompt ?? msg.collectingMenu()];
return [collectingReply(conv)];
default:
update(c.db, conv.id, { buffer_text: `${conv.buffer_text}\n${text}` });
// No reply now: the sweeper replies once after a pause, so a long forward gets one answer.
update(c.db, conv.id, {
buffer_text: `${conv.buffer_text}\n${text}`,
update_pending: hasOrderContent(text) ? 1 : conv.update_pending,
});
return [];
}
}
@ -241,15 +252,13 @@ function collecting(c: Ctx, conv: ConversationRow, cmd: Command, text: string):
/** The user said Done: parse, then shop pick (or skip it), then the draw question or the summary. */
function done(c: Ctx, conv: ConversationRow): string[] {
const p = parseSegments([conv.buffer_text]);
if (p.lines.length === 0 && p.unreadable[0]!.length === 0) {
return endConversation(c, conv, [msg.noTickets()]);
}
if (p.lines.length === 0) return endConversation(c, conv, [msg.noTickets(p.unreadable.flat())]);
const shops = shopsForUser(c.db, c.user);
if (shops.length === 0) return endConversation(c, conv, [msg.noShop()]);
const data: FlowData = { segments: [conv.buffer_text], acked: 0, addPending: false, doneAt: c.now.toISOString() };
update(c.db, conv.id, { ask_kind: null, parsed_json: JSON.stringify(data) });
update(c.db, conv.id, { ask_kind: null, parsed_json: JSON.stringify(data), update_pending: 0 });
if (shops.length === 1) {
update(c.db, conv.id, { selected_shop_id: shops[0]!.id });

View File

@ -52,13 +52,20 @@ describe('summary', () => {
it('matches the walk-through when something was not read', () => {
expect(m.summary(base)).toBe(
[
'Shop B - draw of TODAY 3 Nov',
'2 lines, 4 tickets',
'*Order summary*',
'',
'*Shop:* Shop B',
'*Draw:* TODAY 3 Nov',
'*Total:* 2 lines, 4 tickets',
'',
'*Tickets*',
'8910 - 3',
'7810 - 1',
'Not read, not in the order:',
'',
'⚠️ *Not read, not in the order:*',
' 08?8=2',
'1 Save 2 Add more 3 Cancel',
'',
'*Reply:*\n1. Save\n2. Add more\n3. Cancel',
].join('\n'),
);
});
@ -73,12 +80,19 @@ describe('summary', () => {
});
expect(t).toBe(
[
'Shop B - draw of TODAY 3 Nov',
'3 lines, 6 tickets. Everything was read.',
'*Order summary*',
'',
'*Shop:* Shop B',
'*Draw:* TODAY 3 Nov',
'*Total:* 3 lines, 6 tickets',
'✅ Everything was read',
'',
'*Tickets*',
'8910 - 3',
'7810 - 1',
'0838 - 2',
'1 Save 2 Add more 3 Cancel',
'',
'*Reply:*\n1. Save\n2. Add more\n3. Cancel',
].join('\n'),
);
});
@ -87,13 +101,20 @@ describe('summary', () => {
const t = m.summary({ ...base, unreadable: [], leftOut: ['08?8=2'], lineCount: 3, ticketCount: 6 });
expect(t).toBe(
[
'Shop B - draw of TODAY 3 Nov',
'3 lines, 6 tickets',
'*Order summary*',
'',
'*Shop:* Shop B',
'*Draw:* TODAY 3 Nov',
'*Total:* 3 lines, 6 tickets',
'',
'*Tickets*',
'8910 - 3',
'7810 - 1',
'Left out earlier, not in the order:',
'',
'⚠️ *Left out earlier, not in the order:*',
' 08?8=2',
'1 Save 2 Add more 3 Cancel',
'',
'*Reply:*\n1. Save\n2. Add more\n3. Cancel',
].join('\n'),
);
expect(t).not.toContain('Everything was read');
@ -101,8 +122,8 @@ describe('summary', () => {
it('lists new and earlier unreadable text separately, new first', () => {
const t = m.summary({ ...base, unreadable: ['0x38=1'], leftOut: ['08?8=2'] });
const fresh = t.indexOf('Not read, not in the order:\n 0x38=1');
const older = t.indexOf('Left out earlier, not in the order:\n 08?8=2');
const fresh = t.indexOf('⚠️ *Not read, not in the order:*\n 0x38=1');
const older = t.indexOf('⚠️ *Left out earlier, not in the order:*\n 08?8=2');
expect(fresh).toBeGreaterThan(-1);
expect(older).toBeGreaterThan(fresh);
});
@ -114,7 +135,7 @@ describe('summary', () => {
});
it('labels tomorrow', () => {
expect(m.summary({ ...base, drawDate: '2026-11-04' })).toContain('draw of TOMORROW 4 Nov');
expect(m.summary({ ...base, drawDate: '2026-11-04' })).toContain('*Draw:* TOMORROW 4 Nov');
});
it('uses singular forms', () => {
@ -125,7 +146,7 @@ describe('summary', () => {
entries: [{ ending: '0001', qty: 1, times: 1 }],
unreadable: [],
});
expect(t).toContain('1 line, 1 ticket. Everything was read.');
expect(t).toContain('*Total:* 1 line, 1 ticket\n✅ Everything was read');
});
it('caps a long unreadable list and counts the rest', () => {
@ -143,42 +164,50 @@ describe('summary', () => {
});
describe('prompts', () => {
it('collecting menu offers Done and Cancel', () => {
expect(m.collectingMenu()).toBe('Got it. Forward more, or reply 1 Done 2 Cancel');
it('the collecting reply gives the totals and offers Done and Cancel', () => {
expect(m.progressUpdate(1, 3)).toBe('✅ *Got it*\n1 line, 3 tickets so far.\n\n*When the order is finished, reply:*\n1. Done\n2. Cancel');
});
it('shop list is numbered in the order given', () => {
expect(m.shopList(['Shop A', 'Shop B'])).toBe('Which shop?\n1) Shop A\n2) Shop B');
expect(m.shopList(['Shop A', 'Shop B'])).toBe('*Which shop?*\n\n1. Shop A\n2. Shop B');
});
it('draw question names both days', () => {
expect(m.drawQuestion('2026-11-03', '2026-11-04')).toBe(
"Is this order for today's draw or tomorrow's?\n1 Today 3 Nov\n2 Tomorrow 4 Nov",
"*Is this order for today's draw or tomorrow's?*\n\n1. Today, 3 Nov\n2. Tomorrow, 4 Nov",
);
});
it('too late offers tomorrow or cancel', () => {
expect(m.tooLateForToday('2026-11-04')).toBe("It is too late for today's draw.\n1 Tomorrow 4 Nov\n2 Cancel");
expect(m.tooLateForToday('2026-11-04')).toBe("*It is too late for today's draw*\n\n1. Tomorrow, 4 Nov\n2. Cancel");
});
it('saved names the order, shop, tickets and draw', () => {
expect(m.saved({ orderId: 123, shopName: 'Shop B', ticketCount: 6, drawDate: '2026-11-03' })).toBe(
'Saved #123 - Shop B, 6 tickets, draw of 3 Nov',
'✅ *Saved #123*\n\n*Shop:* Shop B\n*Tickets:* 6\n*Draw:* 3 Nov',
);
});
it('reminder shows progress when something was read', () => {
expect(m.reminder(5, 12)).toBe('You have an unfinished order (5 lines, 12 tickets). Reply 1 Done 2 Cancel.');
expect(m.reminder(5, 12)).toBe('⏳ *Unfinished order*\n5 lines, 12 tickets so far.\n\n*Reply:*\n1. Done\n2. Cancel');
});
it('reminder omits progress when nothing was read', () => {
expect(m.reminder(0, 0)).toBe('You have an unfinished order. Reply 1 Done 2 Cancel.');
expect(m.reminder(0, 0)).toBe('⏳ *Unfinished order*\n\n*Reply:*\n1. Done\n2. Cancel');
});
it('split question lists the three choices', () => {
expect(m.splitQuestion(5, 12)).toBe(
[
'You have an unfinished order (5 lines, 12 tickets).',
'Is this message:',
'1 Part of that order',
'2 A new order (finish the old one first)',
'3 A new order (throw the old one away)',
'⏳ *Unfinished order*',
'5 lines, 12 tickets so far.',
'',
'*Is this message:*',
'1. Part of that order',
'2. A new order (finish the old one first)',
'3. A new order (throw the old one away)',
].join('\n'),
);
});
it('no tickets lists what was not read and asks to send again', () => {
expect(m.noTickets(['08?8=2', 'abc123'])).toBe(
'⚠️ *No tickets found*\n\n*Not read:*\n 08?8=2\n abc123\n\nNothing was saved. Please send the order again.',
);
expect(m.noTickets()).toBe('⚠️ *No tickets found*\n\nNothing was saved. Please send the order again.');
});
it('fixed messages say nothing was saved where it matters', () => {
for (const t of [m.cancelled(), m.noTickets(), m.expired()]) expect(t).toContain('Nothing was saved');
});

View File

@ -37,25 +37,34 @@ export function entryTotals(lines: { ending: string; qty: number }[]): Entry[] {
return [...by.values()];
}
export const COLLECTING_MENU = '1 Done 2 Cancel';
export const SUMMARY_MENU = '1 Save 2 Add more 3 Cancel';
export const COLLECTING_MENU = '1. Done\n2. Cancel';
export const SUMMARY_MENU = '*Reply:*\n1. Save\n2. Add more\n3. Cancel';
export const collectingMenu = (): string => `Got it. Forward more, or reply ${COLLECTING_MENU}`;
const FINISH_PROMPT = `*When the order is finished, reply:*\n${COLLECTING_MENU}`;
function soFar(lineCount: number, ticketCount: number): string[] {
return lineCount > 0 ? [`${plural(lineCount, 'line')}, ${plural(ticketCount, 'ticket')} so far.`] : [];
}
export function progressUpdate(lineCount: number, ticketCount: number): string {
return ['✅ *Got it*', ...soFar(lineCount, ticketCount), '', FINISH_PROMPT].join('\n');
}
export function shopList(shopNames: string[]): string {
return ['Which shop?', ...shopNames.map((n, i) => `${i + 1}) ${n}`)].join('\n');
return ['*Which shop?*', '', ...shopNames.map((n, i) => `${i + 1}. ${n}`)].join('\n');
}
export function drawQuestion(today: string, tomorrow: string): string {
return [
"Is this order for today's draw or tomorrow's?",
`1 Today ${formatDay(today)}`,
`2 Tomorrow ${formatDay(tomorrow)}`,
"*Is this order for today's draw or tomorrow's?*",
'',
`1. Today, ${formatDay(today)}`,
`2. Tomorrow, ${formatDay(tomorrow)}`,
].join('\n');
}
export function tooLateForToday(tomorrow: string): string {
return ["It is too late for today's draw.", `1 Tomorrow ${formatDay(tomorrow)}`, '2 Cancel'].join('\n');
return ["*It is too late for today's draw*", '', `1. Tomorrow, ${formatDay(tomorrow)}`, '2. Cancel'].join('\n');
}
export interface SummaryInput {
@ -71,54 +80,69 @@ export interface SummaryInput {
function pushList(out: string[], heading: string, items: string[]): void {
if (items.length === 0) return;
out.push(heading);
out.push('', heading);
for (const u of items.slice(0, MAX_UNREADABLE_SHOWN)) out.push(` ${u}`);
const more = items.length - MAX_UNREADABLE_SHOWN;
if (more > 0) out.push(` ...and ${more} more`);
}
export function summary(s: SummaryInput): string {
const head = `${plural(s.lineCount, 'line')}, ${plural(s.ticketCount, 'ticket')}`;
const out = [`${s.shopName} - draw of ${drawLabel(s.drawDate, s.today)}`];
out.push(s.unreadable.length === 0 && s.leftOut.length === 0 ? `${head}. Everything was read.` : head);
const out = [
'*Order summary*',
'',
`*Shop:* ${s.shopName}`,
`*Draw:* ${drawLabel(s.drawDate, s.today)}`,
`*Total:* ${plural(s.lineCount, 'line')}, ${plural(s.ticketCount, 'ticket')}`,
];
if (s.unreadable.length === 0 && s.leftOut.length === 0) out.push('✅ Everything was read');
out.push('', '*Tickets*');
for (const e of s.entries) {
out.push(e.times > 1 ? `${e.ending} - ${e.qty} (${e.times} times)` : `${e.ending} - ${e.qty}`);
}
pushList(out, 'Not read, not in the order:', s.unreadable);
pushList(out, 'Left out earlier, not in the order:', s.leftOut);
out.push(SUMMARY_MENU);
pushList(out, '⚠️ *Not read, not in the order:*', s.unreadable);
pushList(out, '⚠️ *Left out earlier, not in the order:*', s.leftOut);
out.push('', SUMMARY_MENU);
return out.join('\n');
}
export const addPrompt = (): string =>
'Send the lines to add. Send only the new or corrected lines, not the whole order.';
'*Add to the order*\nSend only the new or corrected lines, not the whole order.';
export function saved(o: { orderId: number; shopName: string; ticketCount: number; drawDate: string }): string {
return `Saved #${o.orderId} - ${o.shopName}, ${plural(o.ticketCount, 'ticket')}, draw of ${formatDay(o.drawDate)}`;
return [
`✅ *Saved #${o.orderId}*`,
'',
`*Shop:* ${o.shopName}`,
`*Tickets:* ${o.ticketCount}`,
`*Draw:* ${formatDay(o.drawDate)}`,
].join('\n');
}
export const cancelled = (): string => 'Cancelled. Nothing was saved.';
export const cancelled = (): string => '❌ *Cancelled*\nNothing was saved.';
export const nothingToSubmit = (): string => 'Nothing to submit yet.';
export const noTickets = (): string => 'I did not find any tickets. Nothing was saved.';
export const noShop = (): string => 'No shop is assigned to your account, please contact the owner.';
export function noTickets(unreadable: string[] = []): string {
const out = ['⚠️ *No tickets found*'];
pushList(out, '*Not read:*', unreadable);
out.push('', 'Nothing was saved. Please send the order again.');
return out.join('\n');
}
export const noShop = (): string => '⚠️ *No shop assigned*\nPlease contact the owner.';
export const numberFirst = (): string => 'Reply with a number from the list first. Your last message was not added.';
export const expired = (): string => 'Order cancelled because there was no reply. Nothing was saved.';
export const expired = (): string => '❌ *Order cancelled*\nThere was no reply. Nothing was saved.';
export const startingHeld = (): string => 'Starting your next order with the message you sent.';
function progress(lineCount: number, ticketCount: number): string {
return lineCount > 0 ? ` (${plural(lineCount, 'line')}, ${plural(ticketCount, 'ticket')})` : '';
}
export function reminder(lineCount: number, ticketCount: number): string {
return `You have an unfinished order${progress(lineCount, ticketCount)}. Reply ${COLLECTING_MENU}.`;
return ['⏳ *Unfinished order*', ...soFar(lineCount, ticketCount), '', '*Reply:*', COLLECTING_MENU].join('\n');
}
export function splitQuestion(lineCount: number, ticketCount: number): string {
return [
`You have an unfinished order${progress(lineCount, ticketCount)}.`,
'Is this message:',
'1 Part of that order',
'2 A new order (finish the old one first)',
'3 A new order (throw the old one away)',
'⏳ *Unfinished order*',
...soFar(lineCount, ticketCount),
'',
'*Is this message:*',
'1. Part of that order',
'2. A new order (finish the old one first)',
'3. A new order (throw the old one away)',
].join('\n');
}

View File

@ -66,7 +66,7 @@ describe('reminder', () => {
expect(await sweepAt(after(10))).toEqual({ reminded: 1, expired: 0 });
expect(sent).toEqual([{ chatId: 'chat-1@lid', message: msg.reminder(2, 4) }]);
expect(sent[0]!.message).toBe('You have an unfinished order (2 lines, 4 tickets). Reply 1 Done 2 Cancel.');
expect(sent[0]!.message).toBe('⏳ *Unfinished order*\n2 lines, 4 tickets so far.\n\n*Reply:*\n1. Done\n2. Cancel');
expect(await sweepAt(after(11))).toEqual({ reminded: 0, expired: 0 });
expect(await sweepAt(after(20))).toEqual({ reminded: 0, expired: 0 });
@ -178,7 +178,7 @@ describe('expiry', () => {
const say = chatAs(f.shopUser.id);
say('8910=3', T0);
await sweepAt(after(30));
expect(say('7810=1', after(31))).toEqual([msg.collectingMenu()]);
expect(say('7810=1', after(31))).toEqual([]);
expect(getConversation(db, f.shopUser.id)!.buffer_text).toBe('7810=1');
});
@ -270,3 +270,177 @@ describe('start / stop', () => {
expect(logs.filter((l) => l.msg === 'conversation sweep failed').length).toBeGreaterThanOrEqual(2);
});
});
describe('the reply after a pause', () => {
const REPLY = (lines: number, tickets: number) => msg.progressUpdate(lines, tickets);
const pending = (userId: number) => getConversation(db, userId)?.update_pending;
async function updatesAt(at: Date, settings = S) {
clock = at;
const n = sweeper(settings).tickUpdates();
await queue.idle();
return n;
}
it('words it with the running totals and the menu', () => {
const menu = '\n\n*When the order is finished, reply:*\n1. Done\n2. Cancel';
expect(msg.progressUpdate(8, 20)).toBe('✅ *Got it*\n8 lines, 20 tickets so far.' + menu);
expect(msg.progressUpdate(1, 1)).toBe('✅ *Got it*\n1 line, 1 ticket so far.' + menu);
expect(msg.progressUpdate(0, 0)).toBe('✅ *Got it*' + menu);
});
it('a single message gets one reply, 3 seconds after it, and only one', async () => {
chatAs(f.shopUser.id)('8910=3', T0);
expect(await updatesAt(after(0, 2))).toBe(0);
expect(await updatesAt(after(0, 3))).toBe(1);
expect(await updatesAt(after(0, 4))).toBe(0);
expect(sent).toEqual([{ chatId: 'chat-1@lid', message: REPLY(1, 3) }]);
});
it('every new message restarts the wait, so a burst gets one reply with the final totals', async () => {
const say = chatAs(f.shopUser.id);
say('8910=3', T0);
say('7810=1', after(0, 2));
expect(await updatesAt(after(0, 4))).toBe(0);
say('0838=2', after(0, 4));
say('0555=1', after(0, 6));
expect(await updatesAt(after(0, 8))).toBe(0);
expect(await updatesAt(after(0, 9))).toBe(1);
expect(sent.map((s) => s.message)).toEqual([REPLY(4, 7)]);
});
it('the wait is configurable', async () => {
const custom = { ...S, updateQuietSec: 8 };
chatAs(f.shopUser.id)('8910=3', T0);
expect(await updatesAt(after(0, 7), custom)).toBe(0);
expect(await updatesAt(after(0, 8), custom)).toBe(1);
});
it('text with no order content gets no reply', async () => {
const say = chatAs(f.shopUser.id);
say('hello', T0);
expect(await updatesAt(after(1))).toBe(0);
say('8910=3', after(2));
await updatesAt(after(2, 3));
sent.length = 0;
say('thanks', after(3));
expect(await updatesAt(after(4))).toBe(0);
expect(sent).toEqual([]);
});
it('a later message after the reply gets its own reply', async () => {
const say = chatAs(f.shopUser.id);
say('8910=3', T0);
await updatesAt(after(0, 3));
say('7810=1', after(1));
await updatesAt(after(1, 3));
expect(sent.map((s) => s.message)).toEqual([REPLY(1, 3), REPLY(2, 4)]);
});
it('nothing is sent if the user says Done or Cancel before the pause ends', async () => {
for (const word of ['1', '2', 'cancel', 'done']) {
const say = chatAs(f.shopUser.id);
say('8910=3', T0);
say(word, after(0, 1));
expect(pending(f.shopUser.id)).toBe(0);
expect(await updatesAt(after(1))).toBe(0);
expect(sent).toEqual([]);
say('cancel', after(1));
}
});
it('never saves anything and leaves the order open', async () => {
const say = chatAs(f.shopUser.id);
say('8910=3', T0);
say('7810=1', after(0, 1));
await updatesAt(after(0, 4));
expect(db.prepare('SELECT COUNT(*) AS n FROM orders').get()).toEqual({ n: 0 });
expect(state(f.shopUser.id)).toBe('COLLECTING');
expect(getConversation(db, f.shopUser.id)?.buffer_text).toBe('8910=3\n7810=1');
});
it('does not change when the reminder or the expiry happen', async () => {
chatAs(f.shopUser.id)('8910=3', T0);
await updatesAt(after(0, 3));
sent.length = 0;
await sweepAt(after(10, 1));
expect(sent.map((s) => s.message)).toEqual([msg.reminder(1, 3)]);
await sweepAt(after(30, 1));
expect(sent.map((s) => s.message)).toEqual([msg.reminder(1, 3), msg.expired()]);
});
it('survives a restart: a new sweeper sends what was pending', async () => {
chatAs(f.shopUser.id)('8910=3', T0);
expect(pending(f.shopUser.id)).toBe(1);
expect(await updatesAt(after(1))).toBe(1); // sweeper() builds a fresh one each call
});
it('after a long outage the reminder speaks instead, so the reply is dropped', async () => {
chatAs(f.shopUser.id)('8910=3', T0);
expect(await updatesAt(after(10, 1))).toBe(0);
expect(pending(f.shopUser.id)).toBe(0);
expect(sent).toEqual([]);
});
it('a late message confirmed as part of the order gets the reply', async () => {
const say = chatAs(f.shopUser.id);
say('8910=3', T0);
await updatesAt(after(0, 3));
await sweepAt(after(10));
sent.length = 0;
say('7810=1', after(11));
expect(state(f.shopUser.id)).toBe('ASKING');
expect(await updatesAt(after(11, 30))).toBe(0);
say('1', after(12));
expect(state(f.shopUser.id)).toBe('COLLECTING');
expect(await updatesAt(after(12, 3))).toBe(1);
expect(sent.map((s) => s.message)).toEqual([REPLY(2, 4)]);
});
it('a held message that starts a new order gets the reply too', async () => {
const say = chatAs(f.shopUser.id);
say('8910=3', T0);
await updatesAt(after(0, 3));
await sweepAt(after(10));
sent.length = 0;
say('7810=1', after(11));
expect(say('3', after(12))).toEqual([]);
expect(await updatesAt(after(12, 3))).toBe(1);
expect(sent.map((s) => s.message)).toEqual([REPLY(1, 1)]);
});
it('when nothing could be read it says so and closes, instead of Got it', async () => {
chatAs(f.shopUser.id)('08?8=2', T0);
expect(await updatesAt(after(0, 3))).toBe(1);
expect(sent.map((s) => s.message)).toEqual([msg.noTickets(['08?8=2'])]);
expect(state(f.shopUser.id)).toBe('IDLE');
expect(getConversation(db, f.shopUser.id)?.buffer_text).toBe('');
expect(db.prepare('SELECT COUNT(*) AS n FROM orders').get()).toEqual({ n: 0 });
});
it('a good line sent before the pause ends keeps the order open', async () => {
const say = chatAs(f.shopUser.id);
say('08?8=2', T0);
say('7810=1', after(0, 1));
await updatesAt(after(0, 4));
expect(sent.map((s) => s.message)).toEqual([REPLY(1, 1)]);
expect(state(f.shopUser.id)).toBe('COLLECTING');
});
it('after an unreadable message is closed, the next message starts a clean order', async () => {
const say = chatAs(f.shopUser.id);
say('08?8=2', T0);
await updatesAt(after(0, 3));
say('7810=1', after(1));
expect(getConversation(db, f.shopUser.id)?.buffer_text).toBe('7810=1');
});
it('keeps each user separate', async () => {
chatAs(f.shopUser.id, 'chat-a@lid')('8910=3', T0);
chatAs(f.multiUser.id, 'chat-b@lid')('0838=2', after(0, 2));
expect(await updatesAt(after(0, 3))).toBe(1);
expect(sent).toEqual([{ chatId: 'chat-a@lid', message: REPLY(1, 3) }]);
expect(await updatesAt(after(0, 5))).toBe(1);
expect(sent[1]).toEqual({ chatId: 'chat-b@lid', message: REPLY(1, 2) });
});
});

View File

@ -18,6 +18,7 @@ export interface SweepResult {
}
const EVERY_MS = 60_000;
const UPDATE_EVERY_MS = 1_000;
const iso = (d: Date): string => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
export function createConversationSweeper(opts: SweeperOptions) {
@ -56,19 +57,54 @@ export function createConversationSweeper(opts: SweeperOptions) {
return result;
}
function tickUpdates(): number {
const t = now();
const quietMs = opts.settings.updateQuietSec * 1000;
const remindMs = opts.settings.remindMin * 60_000;
const outbox: Array<{ chatId: string; message: string }> = [];
opts.db.transaction(() => {
const rows = opts.db
.prepare("SELECT * FROM conversations WHERE state = 'COLLECTING' AND update_pending = 1")
.all() as ConversationRow[];
for (const c of rows) {
const idleMs = t.getTime() - Date.parse(c.last_activity_at);
if (idleMs < quietMs) continue;
opts.db.prepare('UPDATE conversations SET update_pending = 0 WHERE id = ?').run(c.id);
const p = bufferProgress(c.buffer_text);
if (p.lines === 0) {
resetConversation(opts.db, c.id, t);
outbox.push({ chatId: c.chat_id, message: msg.noTickets(p.unreadable) });
} else if (idleMs < remindMs) {
outbox.push({ chatId: c.chat_id, message: msg.progressUpdate(p.lines, p.tickets) });
}
}
})();
for (const o of outbox) opts.queue.enqueue(o.chatId, [o.message]);
return outbox.length;
}
function start(): () => void {
const safeTick = (): void => {
const safely = (fn: () => unknown) => (): void => {
try {
tick();
fn();
} catch (err) {
opts.log({ level: 'error', msg: 'conversation sweep failed', error: (err as Error).message });
}
};
const safeTick = safely(tick);
const timer = setInterval(safeTick, EVERY_MS);
const updateTimer = setInterval(safely(tickUpdates), UPDATE_EVERY_MS);
timer.unref();
updateTimer.unref();
safeTick();
return () => clearInterval(timer);
return () => {
clearInterval(timer);
clearInterval(updateTimer);
};
}
return { tick, start };
return { tick, tickUpdates, start };
}

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*