feat(whatsapp): added the Hermes plugin and made replies clearer
This commit is contained in:
parent
606583a1c5
commit
9d77360dc8
@ -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
|
||||
|
||||
3
hermes-plugin/srs-gate/.env.example
Normal file
3
hermes-plugin/srs-gate/.env.example
Normal 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
1
hermes-plugin/srs-gate/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
183
hermes-plugin/srs-gate/__init__.py
Normal file
183
hermes-plugin/srs-gate/__init__.py
Normal 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)
|
||||
5
hermes-plugin/srs-gate/plugin.yaml
Normal file
5
hermes-plugin/srs-gate/plugin.yaml
Normal 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
|
||||
572
hermes-plugin/srs-gate/test_srs_gate.py
Normal file
572
hermes-plugin/srs-gate/test_srs_gate.py
Normal 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()
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
3
src/db/migrations/003_progress_update.sql
Normal file
3
src/db/migrations/003_progress_update.sql
Normal 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));
|
||||
@ -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'] });
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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));
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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}`]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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) });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
13
test/fixtures/orders/dot-and-bold-2026-11-04.expected.json
vendored
Normal file
13
test/fixtures/orders/dot-and-bold-2026-11-04.expected.json
vendored
Normal 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": []
|
||||
}
|
||||
5
test/fixtures/orders/dot-and-bold-2026-11-04.txt
vendored
Normal file
5
test/fixtures/orders/dot-and-bold-2026-11-04.txt
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
6379*100
|
||||
9001.10
|
||||
*8203*100*
|
||||
*0888*
|
||||
*7000 75*
|
||||
Loading…
Reference in New Issue
Block a user