feat(whatsapp): take orders end to end, from first forward to Save

Wire the order parser to the WhatsApp bot. A known user forwards an order, ends it with Done, picks a shop, confirms a summary and the order is saved.
This commit is contained in:
Aswin B. S 2026-11-03 17:29:44 +05:30
parent ed4d1280e1
commit f18779e182
29 changed files with 2637 additions and 91 deletions

View File

@ -11,3 +11,8 @@ JWT_SECRET=change-me-to-another-long-random-string
# Hermes WhatsApp bridge (local only) # Hermes WhatsApp bridge (local only)
BRIDGE_URL=http://127.0.0.1:3000 BRIDGE_URL=http://127.0.0.1:3000
DEFAULT_COUNTRY_CODE=91 DEFAULT_COUNTRY_CODE=91
ORDER_CUTOFF=15:00
ORDER_ASK_UNTIL=15:30
ORDER_REMIND_MINUTES=10
ORDER_EXPIRE_MINUTES=30

View File

@ -5,18 +5,21 @@ import type { Db } from './db';
import { healthRoutes } from './routes/health'; import { healthRoutes } from './routes/health';
import { ingestRoutes } from './routes/ingest'; import { ingestRoutes } from './routes/ingest';
import type { Bridge } from './whatsapp/bridge'; import type { Bridge } from './whatsapp/bridge';
import { createSendQueue, type SendQueue } from './whatsapp/sendQueue';
export interface AppDeps { export interface AppDeps {
db: Db; db: Db;
config: Config; config: Config;
bridge: Bridge; bridge: Bridge;
sendQueue?: SendQueue;
now?: () => Date;
} }
export interface AppOptions { export interface AppOptions {
logger?: boolean; logger?: boolean;
} }
export function createApp({ db, config, bridge }: AppDeps, options: AppOptions = {}): FastifyInstance { export function createApp({ db, config, bridge, sendQueue, now }: AppDeps, options: AppOptions = {}): FastifyInstance {
const app = Fastify({ const app = Fastify({
logger: options.logger ?? false, logger: options.logger ?? false,
bodyLimit: 12 * 1024 * 1024, bodyLimit: 12 * 1024 * 1024,
@ -24,7 +27,8 @@ export function createApp({ db, config, bridge }: AppDeps, options: AppOptions =
}); });
app.register(healthRoutes({ db, bridge, secret: config.srsSecret })); app.register(healthRoutes({ db, bridge, secret: config.srsSecret }));
app.register(ingestRoutes({ db, secret: config.srsSecret })); const queue = sendQueue ?? createSendQueue(bridge, (entry) => app.log.error(entry));
app.register(ingestRoutes({ db, secret: config.srsSecret, settings: config.orders, queue, now }));
app.setNotFoundHandler((_req, reply) => { app.setNotFoundHandler((_req, reply) => {

View File

@ -20,4 +20,37 @@ describe('loadConfig', () => {
expect(c.port).toBe(9000); expect(c.port).toBe(9000);
expect(c.dbPath).toBe('/tmp/x.db'); expect(c.dbPath).toBe('/tmp/x.db');
}); });
describe('order settings', () => {
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 });
});
it('reads overrides', () => {
const c = loadConfig({
...base,
ORDER_CUTOFF: '14:30',
ORDER_ASK_UNTIL: '15:00',
ORDER_REMIND_MINUTES: '5',
ORDER_EXPIRE_MINUTES: '20',
});
expect(c.orders).toEqual({ cutoffMin: 870, askUntilMin: 900, remindMin: 5, expireMin: 20 });
});
it('rejects a bad time format', () => {
expect(() => loadConfig({ ...base, ORDER_CUTOFF: '3pm' })).toThrow(/HH:MM/);
expect(() => loadConfig({ ...base, ORDER_CUTOFF: '25:00' })).toThrow(/HH:MM/);
});
it('rejects ask-until that is not after the cutoff', () => {
expect(() => loadConfig({ ...base, ORDER_ASK_UNTIL: '15:00' })).toThrow(/later than ORDER_CUTOFF/);
expect(() => loadConfig({ ...base, ORDER_ASK_UNTIL: '14:00' })).toThrow(/later than ORDER_CUTOFF/);
});
it('rejects a reminder that is not before the expiry', () => {
expect(() => loadConfig({ ...base, ORDER_REMIND_MINUTES: '30' })).toThrow(/smaller than ORDER_EXPIRE/);
});
});
}); });

View File

@ -1,6 +1,14 @@
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { z } from 'zod'; import { z } from 'zod';
const hhmm = (def: string) =>
z
.string()
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'use HH:MM, 24-hour')
.default(def);
const toMinutes = (v: string): number => Number(v.slice(0, 2)) * 60 + Number(v.slice(3, 5));
const schema = z.object({ const schema = z.object({
PORT: z.coerce.number().int().min(1).max(65535).default(8080), PORT: z.coerce.number().int().min(1).max(65535).default(8080),
HOST: z.string().min(1).default('127.0.0.1'), HOST: z.string().min(1).default('127.0.0.1'),
@ -9,7 +17,26 @@ const schema = z.object({
JWT_SECRET: z.string().min(16).optional(), JWT_SECRET: z.string().min(16).optional(),
BRIDGE_URL: z.string().url().default('http://127.0.0.1:3000'), BRIDGE_URL: z.string().url().default('http://127.0.0.1:3000'),
DEFAULT_COUNTRY_CODE: z.string().regex(/^\d{1,3}$/, 'digits only').default('91'), DEFAULT_COUNTRY_CODE: z.string().regex(/^\d{1,3}$/, 'digits only').default('91'),
}); ORDER_CUTOFF: hhmm('15:00'),
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),
})
.refine((e) => toMinutes(e.ORDER_ASK_UNTIL) > toMinutes(e.ORDER_CUTOFF), {
path: ['ORDER_ASK_UNTIL'],
message: 'must be later than ORDER_CUTOFF',
})
.refine((e) => e.ORDER_REMIND_MINUTES < e.ORDER_EXPIRE_MINUTES, {
path: ['ORDER_REMIND_MINUTES'],
message: 'must be smaller than ORDER_EXPIRE_MINUTES',
});
export interface OrderSettings {
cutoffMin: number;
askUntilMin: number;
remindMin: number;
expireMin: number;
}
export interface Config { export interface Config {
port: number; port: number;
@ -19,6 +46,7 @@ export interface Config {
jwtSecret: string | undefined; jwtSecret: string | undefined;
bridgeUrl: string; bridgeUrl: string;
defaultCountryCode: string; defaultCountryCode: string;
orders: OrderSettings;
} }
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
@ -41,5 +69,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
jwtSecret: e.JWT_SECRET, jwtSecret: e.JWT_SECRET,
bridgeUrl: e.BRIDGE_URL, bridgeUrl: e.BRIDGE_URL,
defaultCountryCode: e.DEFAULT_COUNTRY_CODE, defaultCountryCode: e.DEFAULT_COUNTRY_CODE,
orders: {
cutoffMin: toMinutes(e.ORDER_CUTOFF),
askUntilMin: toMinutes(e.ORDER_ASK_UNTIL),
remindMin: e.ORDER_REMIND_MINUTES,
expireMin: e.ORDER_EXPIRE_MINUTES,
},
}; };
} }

View File

@ -8,11 +8,23 @@ const throwsConstraint = (fn: () => unknown) => expect(fn).toThrow(/CHECK|UNIQUE
describe('migrations', () => { describe('migrations', () => {
it('apply once and are then a no-op', () => { it('apply once and are then a no-op', () => {
const db = openDb(':memory:'); const db = openDb(':memory:');
expect(runMigrations(db)).toEqual(['001_init.sql']); expect(runMigrations(db)).toEqual(['001_init.sql', '002_conversation_flow.sql']);
expect(runMigrations(db)).toEqual([]); expect(runMigrations(db)).toEqual([]);
}); });
}); });
describe('conversation flow columns (002)', () => {
it('exist, start empty, and ask_kind only takes known values', () => {
const db = makeDb();
const f = seedFixture(db);
db.prepare("INSERT INTO conversations (user_id, chat_id, state) VALUES (?, 'c', 'IDLE')").run(f.admin.id);
const row = db.prepare('SELECT * FROM conversations').get() as Record<string, unknown>;
for (const col of ['last_prompt', 'reminded_at', 'ask_kind', 'held_text']) expect(row[col]).toBeNull();
throwsConstraint(() => db.prepare("UPDATE conversations SET ask_kind = 'other'").run());
db.prepare("UPDATE conversations SET ask_kind = 'split'").run();
});
});
describe('schema rules', () => { describe('schema rules', () => {
const orderFor = (db: ReturnType<typeof makeDb>) => { const orderFor = (db: ReturnType<typeof makeDb>) => {
const f = seedFixture(db); const f = seedFixture(db);

View File

@ -0,0 +1,8 @@
-- last_prompt the last question the bot asked, repeated on "?" or an unexpected reply
-- reminded_at set once the 10-minute reminder was sent; after it a late order message is held, not appended
-- ask_kind which question an ASKING conversation is waiting on
-- held_text a late message waiting while the user decides whether it belongs to the unfinished order
ALTER TABLE conversations ADD COLUMN last_prompt TEXT;
ALTER TABLE conversations ADD COLUMN reminded_at TEXT;
ALTER TABLE conversations ADD COLUMN ask_kind TEXT CHECK (ask_kind IN ('draw', 'split'));
ALTER TABLE conversations ADD COLUMN held_text TEXT;

View File

@ -44,9 +44,7 @@ export function selectRecent(entries: Entry[], today: string, days: number): Ent
return entries.filter((e) => e.date >= lo && e.date <= today).sort((a, b) => a.date.localeCompare(b.date)); return entries.filter((e) => e.date >= lo && e.date <= today).sort((a, b) => a.date.localeCompare(b.date));
} }
export function todayIst(now = new Date()): string { export { istDate as todayIst } from '../services/ist';
return new Date(now.getTime() + 5.5 * 3_600_000).toISOString().slice(0, 10);
}
export type FetchLike = typeof fetch; export type FetchLike = typeof fetch;

View File

@ -1,5 +1,6 @@
import type { Db } from '../db'; import type { Db } from '../db';
import { runResultsJob, type DrawReport } from './job'; import { runResultsJob, type DrawReport } from './job';
import { istMinutes } from '../services/ist';
import { todayIst } from './portal'; import { todayIst } from './portal';
const WINDOW_START_MIN = 16 * 60; // 4 PM IST const WINDOW_START_MIN = 16 * 60; // 4 PM IST
@ -13,11 +14,6 @@ export interface SchedulerOptions {
run?: (db: Db) => Promise<DrawReport[]>; run?: (db: Db) => Promise<DrawReport[]>;
} }
const minutesIst = (d: Date): number => {
const s = new Date(d.getTime() + 5.5 * 3_600_000);
return s.getUTCHours() * 60 + s.getUTCMinutes();
};
function todaysDrawReady(db: Db, today: string): boolean { function todaysDrawReady(db: Db, today: string): boolean {
return !!db.prepare('SELECT 1 FROM draws WHERE draw_date = ? LIMIT 1').get(today); return !!db.prepare('SELECT 1 FROM draws WHERE draw_date = ? LIMIT 1').get(today);
} }
@ -32,7 +28,7 @@ export function createResultsScheduler(opts: SchedulerOptions) {
async function tick(): Promise<void> { async function tick(): Promise<void> {
const t = now(); const t = now();
const today = todayIst(t); const today = todayIst(t);
const minutes = minutesIst(t); const minutes = istMinutes(t);
if (minutes >= WINDOW_END_MIN) { if (minutes >= WINDOW_END_MIN) {
if (loggedGiveUpFor !== today && !todaysDrawReady(opts.db, today)) { if (loggedGiveUpFor !== today && !todaysDrawReady(opts.db, today)) {

View File

@ -3,28 +3,29 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createApp } from '../app'; import { createApp } from '../app';
import type { Config } from '../config'; import type { Config } from '../config';
import type { Db } from '../db'; import type { Db } from '../db';
import { testConfig } from '../testing/config';
import { makeDb, seedFixture, type Fixture } from '../testing/helpers'; import { makeDb, seedFixture, type Fixture } from '../testing/helpers';
import type { Bridge } from '../whatsapp/bridge'; import type { Bridge } from '../whatsapp/bridge';
import { createSendQueue, type SendQueue } from '../whatsapp/sendQueue';
const SECRET = 'test-secret-0123456789'; const SECRET = 'test-secret-0123456789';
const config: Config = { const config: Config = testConfig({ srsSecret: SECRET });
port: 0,
host: '127.0.0.1',
dbPath: ':memory:',
srsSecret: SECRET,
jwtSecret: undefined,
bridgeUrl: 'http://127.0.0.1:3000',
defaultCountryCode: '91',
};
let db: Db; let db: Db;
let f: Fixture; let f: Fixture;
let app: FastifyInstance; let app: FastifyInstance;
let bridgeStatus: string | null; let bridgeStatus: string | null;
let queue: SendQueue;
let sent: Array<{ chatId: string; message: string }>;
let sendFails: boolean;
let logs: Array<Record<string, unknown>>;
let clock: Date;
const fakeBridge: Bridge = { const fakeBridge: Bridge = {
async sendText() { async sendText(chatId, message) {
if (sendFails) throw new Error('bridge /send failed with HTTP 500');
sent.push({ chatId, message });
return { messageIds: [] }; return { messageIds: [] };
}, },
async health() { async health() {
@ -36,7 +37,12 @@ beforeEach(async () => {
db = makeDb(); db = makeDb();
f = seedFixture(db); f = seedFixture(db);
bridgeStatus = 'connected'; bridgeStatus = 'connected';
app = createApp({ db, config, bridge: fakeBridge }); sent = [];
logs = [];
sendFails = false;
clock = new Date('2026-11-03T05:00:00Z'); // 10:30 IST
queue = createSendQueue(fakeBridge, (e) => logs.push(e));
app = createApp({ db, config, bridge: fakeBridge, sendQueue: queue, now: () => clock });
await app.ready(); await app.ready();
}); });
@ -169,6 +175,111 @@ describe('POST /ingest: collecting text', () => {
}); });
}); });
describe('POST /ingest: replies', () => {
const chat = '145483561476218@lid';
const say = async (id: string, phone: string, t: string) => {
const res = await ingest(text(id, phone, t));
await queue.idle();
return res;
};
it('sends the collecting menu to 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' }]);
});
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');
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.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
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.',
'Starting your next order with the message you sent.',
'Got it. Forward more, or reply 1 Done 2 Cancel',
]);
});
it('sends nothing for a duplicate message id', async () => {
await say('m1', f.shopUser.phone, '8910=3');
sent.length = 0;
const again = await say('m1', f.shopUser.phone, '8910=3');
expect(again.json()).toEqual({ handled: true, duplicate: true });
expect(sent).toEqual([]);
});
it('sends nothing to unknown, @lid and inactive senders', async () => {
await say('m1', '919111111111', '8910=3');
await ingest({ ...text('m2', f.admin.phone, '8910=3'), sender_jid: '145483561476218@lid' });
await say('m3', f.disabledUser.phone, '8910=3');
expect(sent).toEqual([]);
});
it('sends nothing for chatter from a known user with no order in progress', async () => {
await say('m1', f.shopUser.phone, 'thanks');
expect(sent).toEqual([]);
});
it('a failing bridge does not fail the request or lose the state', async () => {
sendFails = true;
const res = await say('m1', f.shopUser.phone, '8910=3');
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ handled: true, duplicate: false });
expect(sent).toEqual([]);
expect(db.prepare('SELECT state FROM conversations').get()).toEqual({ state: 'COLLECTING' });
expect(logs).toHaveLength(1);
expect(JSON.stringify(logs[0])).not.toContain('8910');
});
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;
await say('m2', f.shopUser.phone, '?');
expect(sent.map((s) => s.message)).toEqual(['Got it. Forward more, or reply 1 Done 2 Cancel']);
});
it('does not wait for the bridge before answering', async () => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const slow: Bridge = { ...fakeBridge, sendText: async () => (await gate, { messageIds: [] }) };
const slowApp = createApp({ db, config, bridge: slow, sendQueue: createSendQueue(slow, () => {}), now: () => clock });
await slowApp.ready();
const res = await slowApp.inject({
method: 'POST',
url: '/ingest',
headers: { 'content-type': 'application/json', 'x-srs-secret': SECRET },
payload: JSON.stringify(text('m1', f.shopUser.phone, '8910=3')),
});
expect(res.statusCode).toBe(200); // answered while the send is still blocked
release();
await slowApp.close();
});
it('a retry after a crash rolls back with the transaction and replies once', async () => {
db.exec(`CREATE TRIGGER boom BEFORE INSERT ON conversations BEGIN SELECT RAISE(ABORT, 'boom'); END;`);
const failed = await ingest(text('m1', f.shopUser.phone, '8910=3'));
expect(failed.statusCode).toBe(500);
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);
});
});
describe('GET /api/health and /internal/health', () => { describe('GET /api/health and /internal/health', () => {
it('reports ok on the public route without details', async () => { it('reports ok on the public route without details', async () => {
const res = await app.inject({ method: 'GET', url: '/api/health' }); const res = await app.inject({ method: 'GET', url: '/api/health' });

View File

@ -3,7 +3,9 @@ import { z } from 'zod';
import type { Db } from '../db'; import type { Db } from '../db';
import { phoneFromJid } from '../services/phone'; import { phoneFromJid } from '../services/phone';
import { findUserByPhone } from '../services/users'; import { findUserByPhone } from '../services/users';
import { receiveText } from '../whatsapp/conversation'; import type { OrderSettings } from '../config';
import { handleMessage } from '../whatsapp/conversation';
import type { SendQueue } from '../whatsapp/sendQueue';
import { requireSecret } from './auth'; import { requireSecret } from './auth';
const base = z.object({ const base = z.object({
@ -24,13 +26,16 @@ const payloadSchema = z.discriminatedUnion('kind', [
interface IngestDeps { interface IngestDeps {
db: Db; db: Db;
secret: string; secret: string;
settings: OrderSettings;
queue: SendQueue;
now?: () => Date;
} }
const isUniqueViolation = (e: unknown): boolean => const isUniqueViolation = (e: unknown): boolean =>
typeof e === 'object' && e !== null && 'code' in e && String((e as { code: unknown }).code).startsWith('SQLITE_CONSTRAINT'); typeof e === 'object' && e !== null && 'code' in e && String((e as { code: unknown }).code).startsWith('SQLITE_CONSTRAINT');
export const ingestRoutes = export const ingestRoutes =
({ db, secret }: IngestDeps): FastifyPluginAsync => ({ db, secret, settings, queue, now = () => new Date() }: IngestDeps): FastifyPluginAsync =>
async (app) => { async (app) => {
const insertLog = db.prepare( const insertLog = db.prepare(
`INSERT INTO ingest_log (wa_message_id, sender_phone, user_id, kind, outcome) `INSERT INTO ingest_log (wa_message_id, sender_phone, user_id, kind, outcome)
@ -57,7 +62,7 @@ export const ingestRoutes =
const accepted = outcome === 'accepted' && user !== undefined; const accepted = outcome === 'accepted' && user !== undefined;
const apply = db.transaction((): { duplicate: boolean } => { const apply = db.transaction((): { duplicate: boolean; replies: string[] } => {
try { try {
insertLog.run({ insertLog.run({
wa_message_id: msg.message_id, wa_message_id: msg.message_id,
@ -67,17 +72,17 @@ export const ingestRoutes =
outcome, outcome,
}); });
} catch (e) { } catch (e) {
if (isUniqueViolation(e)) return { duplicate: true }; if (isUniqueViolation(e)) return { duplicate: true, replies: [] };
throw e; throw e;
} }
if (accepted && msg.kind === 'text') { const replies =
receiveText(db, user, msg.chat_id, msg.text); accepted && msg.kind === 'text' ? handleMessage(db, settings, user, msg.chat_id, msg.text, now()) : [];
} return { duplicate: false, replies };
return { duplicate: false };
}); });
const { duplicate } = apply(); const { duplicate, replies } = apply();
queue.enqueue(msg.chat_id, replies);
if (duplicate) return { handled: true, duplicate: true }; if (duplicate) return { handled: true, duplicate: true };
if (!accepted) return { handled: false }; if (!accepted) return { handled: false };

View File

@ -4,6 +4,8 @@ import { openDb } from './db';
import { runMigrations } from './db/migrate'; import { runMigrations } from './db/migrate';
import { createResultsScheduler } from './results/scheduler'; import { createResultsScheduler } from './results/scheduler';
import { createBridge } from './whatsapp/bridge'; import { createBridge } from './whatsapp/bridge';
import { createSendQueue } from './whatsapp/sendQueue';
import { createConversationSweeper } from './whatsapp/sweep';
async function main(): Promise<void> { async function main(): Promise<void> {
const config = loadConfig(); const config = loadConfig();
@ -15,12 +17,18 @@ async function main(): Promise<void> {
} }
const bridge = createBridge(config.bridgeUrl); const bridge = createBridge(config.bridgeUrl);
const app = createApp({ db, config, bridge }, { logger: true }); let log: (entry: Record<string, unknown>) => void = (e) => console.log(JSON.stringify(e));
const sendQueue = createSendQueue(bridge, (e) => log(e));
// TODO(conversation): on startup, re-arm the ~20 s collection timers for conversations that are const app = createApp({ db, config, bridge, sendQueue }, { logger: true });
// still COLLECTING, so a restart does not lose half-finished orders. log = (e) => app.log.info(e);
const stopScheduler = createResultsScheduler({ db, log: (e) => app.log.info(e) }).start(); const stopScheduler = createResultsScheduler({ db, log: (e) => app.log.info(e) }).start();
const stopSweeper = createConversationSweeper({
db,
settings: config.orders,
queue: sendQueue,
log: (e) => app.log.info(e),
}).start();
await app.listen({ port: config.port, host: config.host }); await app.listen({ port: config.port, host: config.host });
@ -31,7 +39,9 @@ async function main(): Promise<void> {
app.log.info({ signal }, 'shutting down'); app.log.info({ signal }, 'shutting down');
setTimeout(() => process.exit(1), 10_000).unref(); setTimeout(() => process.exit(1), 10_000).unref();
stopScheduler(); stopScheduler();
stopSweeper();
await app.close(); await app.close();
await sendQueue.idle();
db.close(); db.close();
process.exit(0); process.exit(0);
}; };

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { drawDateForOrder } from './drawDate'; import { drawChoiceAt, drawDateForOrder } from './drawDate';
const at = (iso: string) => drawDateForOrder(new Date(iso)); const at = (iso: string) => drawDateForOrder(new Date(iso));
@ -24,3 +24,46 @@ describe('drawDateForOrder (3 PM IST cutoff)', () => {
expect(at('2026-12-31T12:00:00Z')).toBe('2027-01-01'); expect(at('2026-12-31T12:00:00Z')).toBe('2027-01-01');
}); });
}); });
describe('drawDateForOrder with a custom cutoff', () => {
it('uses the given cutoff', () => {
expect(drawDateForOrder(new Date('2026-11-02T09:00:00Z'), 14 * 60 + 30)).toBe('2026-11-02');
expect(drawDateForOrder(new Date('2026-11-02T09:00:01Z'), 14 * 60 + 30)).toBe('2026-11-03');
});
});
describe('drawChoiceAt (15:00 cutoff, ask until 15:30)', () => {
const w = { cutoffMin: 15 * 60, askUntilMin: 15 * 60 + 30 };
const at = (iso: string) => drawChoiceAt(new Date(iso), w);
it('before the cutoff is today, no question', () => {
expect(at('2026-11-03T09:29:59Z')).toEqual({ kind: 'today', date: '2026-11-03' });
});
it('exactly 15:00:00 is still today', () => {
expect(at('2026-11-03T09:30:00Z')).toEqual({ kind: 'today', date: '2026-11-03' });
});
it('15:00:01 asks', () => {
expect(at('2026-11-03T09:30:01Z')).toEqual({ kind: 'ask', today: '2026-11-03', tomorrow: '2026-11-04' });
});
it('exactly 15:30:00 still asks', () => {
expect(at('2026-11-03T10:00:00Z')).toEqual({ kind: 'ask', today: '2026-11-03', tomorrow: '2026-11-04' });
});
it('15:30:01 is tomorrow, no question', () => {
expect(at('2026-11-03T10:00:01Z')).toEqual({ kind: 'tomorrow', date: '2026-11-04' });
});
it('late evening is tomorrow', () => {
expect(at('2026-11-03T17:00:00Z')).toEqual({ kind: 'tomorrow', date: '2026-11-04' });
});
it('uses the Indian date, not the UTC date', () => {
expect(at('2026-11-03T20:00:00Z')).toEqual({ kind: 'today', date: '2026-11-04' });
expect(at('2026-11-03T17:00:00Z')).toEqual({ kind: 'tomorrow', date: '2026-11-04' });
expect(at('2026-11-02T19:00:00Z')).toEqual({ kind: 'today', date: '2026-11-03' });
});
it('rolls over a month end when asking', () => {
expect(at('2026-11-30T09:45:00Z')).toEqual({ kind: 'ask', today: '2026-11-30', tomorrow: '2026-12-01' });
});
it('honours other windows', () => {
const early = drawChoiceAt(new Date('2026-11-03T09:00:01Z'), { cutoffMin: 14 * 60 + 30, askUntilMin: 15 * 60 });
expect(early).toEqual({ kind: 'ask', today: '2026-11-03', tomorrow: '2026-11-04' });
});
});

View File

@ -1,18 +1,31 @@
const IST_OFFSET_MS = 5.5 * 3_600_000; import { istParts } from './ist';
const CUTOFF_MINUTES = 15 * 60;
function ist(at: Date): { date: string; minutes: number; seconds: number } { const DEFAULT_CUTOFF_MINUTES = 15 * 60;
const shifted = new Date(at.getTime() + IST_OFFSET_MS);
return { const nextDay = (date: string): string =>
date: shifted.toISOString().slice(0, 10), new Date(Date.parse(`${date}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10);
minutes: shifted.getUTCHours() * 60 + shifted.getUTCMinutes(),
seconds: shifted.getUTCSeconds(), const isAfter = (t: { minutes: number; seconds: number }, limitMinutes: number): boolean =>
}; t.minutes > limitMinutes || (t.minutes === limitMinutes && t.seconds > 0);
export function drawDateForOrder(receivedAt: Date, cutoffMinutes: number = DEFAULT_CUTOFF_MINUTES): string {
const t = istParts(receivedAt);
return isAfter(t, cutoffMinutes) ? nextDay(t.date) : t.date;
} }
export function drawDateForOrder(receivedAt: Date): string { export type DrawChoice =
const t = ist(receivedAt); | { kind: 'today'; date: string }
const afterCutoff = t.minutes > CUTOFF_MINUTES || (t.minutes === CUTOFF_MINUTES && t.seconds > 0); | { kind: 'tomorrow'; date: string }
if (!afterCutoff) return t.date; | { kind: 'ask'; today: string; tomorrow: string };
return new Date(Date.parse(`${t.date}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10);
export interface DrawWindow {
cutoffMin: number;
askUntilMin: number;
}
export function drawChoiceAt(at: Date, w: DrawWindow): DrawChoice {
const t = istParts(at);
if (!isAfter(t, w.cutoffMin)) return { kind: 'today', date: t.date };
if (!isAfter(t, w.askUntilMin)) return { kind: 'ask', today: t.date, tomorrow: nextDay(t.date) };
return { kind: 'tomorrow', date: nextDay(t.date) };
} }

55
src/services/ist.test.ts Normal file
View File

@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { todayIst } from '../results/portal';
import { istDate, istMinutes, istParts } from './ist';
describe('istParts', () => {
it('shifts UTC by 5 hours 30 minutes', () => {
expect(istParts(new Date('2026-11-03T05:00:00Z'))).toEqual({ date: '2026-11-03', minutes: 10 * 60 + 30, seconds: 0 });
});
it('keeps the seconds', () => {
expect(istParts(new Date('2026-11-03T09:30:01Z')).seconds).toBe(1);
expect(istParts(new Date('2026-11-03T09:30:59Z')).seconds).toBe(59);
});
it('rolls the date at 18:30 UTC, which is midnight in India', () => {
expect(istParts(new Date('2026-11-02T18:29:59Z'))).toEqual({ date: '2026-11-02', minutes: 23 * 60 + 59, seconds: 59 });
expect(istParts(new Date('2026-11-02T18:30:00Z'))).toEqual({ date: '2026-11-03', minutes: 0, seconds: 0 });
});
it('rolls month and year ends', () => {
expect(istParts(new Date('2026-12-31T18:30:00Z')).date).toBe('2027-01-01');
expect(istParts(new Date('2026-11-30T20:00:00Z')).date).toBe('2026-12-01');
});
it('does not depend on the machine timezone', () => {
const before = process.env.TZ;
try {
for (const tz of ['UTC', 'Asia/Kolkata', 'America/Los_Angeles', 'Pacific/Auckland']) {
process.env.TZ = tz;
expect(istParts(new Date('2026-11-02T20:00:00Z'))).toEqual({ date: '2026-11-03', minutes: 90, seconds: 0 });
}
} finally {
if (before === undefined) delete process.env.TZ;
else process.env.TZ = before;
}
});
});
describe('istDate / istMinutes', () => {
it('give the Indian date and minutes after midnight', () => {
const at = new Date('2026-11-03T09:40:00Z'); // 15:10 IST
expect(istDate(at)).toBe('2026-11-03');
expect(istMinutes(at)).toBe(15 * 60 + 10);
});
it('istDate defaults to now', () => {
expect(istDate()).toBe(istDate(new Date()));
});
it('the results code uses the same function', () => {
const at = new Date('2026-11-02T20:00:00Z');
expect(todayIst(at)).toBe('2026-11-03');
expect(todayIst(at)).toBe(istDate(at));
});
});

19
src/services/ist.ts Normal file
View File

@ -0,0 +1,19 @@
export const IST_OFFSET_MS = 5.5 * 3_600_000;
export interface IstParts {
date: string;
minutes: number;
seconds: number;
}
export function istParts(at: Date): IstParts {
const shifted = new Date(at.getTime() + IST_OFFSET_MS);
return {
date: shifted.toISOString().slice(0, 10),
minutes: shifted.getUTCHours() * 60 + shifted.getUTCMinutes(),
seconds: shifted.getUTCSeconds(),
};
}
export const istDate = (at: Date = new Date()): string => istParts(at).date;
export const istMinutes = (at: Date): number => istParts(at).minutes;

View File

@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { Db } from '../db';
import { makeDb, seedFixture, type Fixture } from '../testing/helpers';
import { saveOrder, ShopNotAllowedError } from './orders';
import { getUserById } from './users';
let db: Db;
let f: Fixture;
beforeEach(() => {
db = makeDb();
f = seedFixture(db);
});
afterEach(() => db.close());
const count = (table: string) => (db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
describe('saveOrder', () => {
it('stores the order and every line as written, repeats not merged', () => {
const user = getUserById(db, f.shopUser.id)!;
const id = saveOrder(db, {
user,
shopId: f.shops.a,
drawDate: '2026-11-03',
rawText: '8910=3\n7810=1\n8910=3',
lines: [
{ ending: '8910', qty: 3 },
{ ending: '7810', qty: 1 },
{ ending: '8910', qty: 3 },
],
});
expect(db.prepare('SELECT * FROM orders WHERE id = ?').get(id)).toMatchObject({
shop_id: f.shops.a,
draw_date: '2026-11-03',
status: 'confirmed',
source: 'whatsapp_text',
created_by_user_id: f.shopUser.id,
raw_text: '8910=3\n7810=1\n8910=3',
});
expect(db.prepare('SELECT ending, qty FROM order_lines WHERE order_id = ? ORDER BY id').all(id)).toEqual([
{ ending: '8910', qty: 3 },
{ ending: '7810', qty: 1 },
{ ending: '8910', qty: 3 },
]);
});
it('keeps leading zeros in endings', () => {
const user = getUserById(db, f.shopUser.id)!;
const id = saveOrder(db, {
user,
shopId: f.shops.a,
drawDate: '2026-11-03',
rawText: '0057',
lines: [{ ending: '0057', qty: 1 }],
});
expect((db.prepare('SELECT ending FROM order_lines WHERE order_id = ?').get(id) as { ending: string }).ending).toBe('0057');
});
it('refuses a shop the user cannot use and writes nothing', () => {
const user = getUserById(db, f.shopUser.id)!; // only has shop A
expect(() =>
saveOrder(db, { user, shopId: f.shops.b, drawDate: '2026-11-03', rawText: 'x', lines: [{ ending: '8910', qty: 1 }] }),
).toThrow(ShopNotAllowedError);
expect(count('orders')).toBe(0);
expect(count('order_lines')).toBe(0);
});
it('refuses an inactive user', () => {
const user = getUserById(db, f.disabledUser.id)!;
expect(() =>
saveOrder(db, { user, shopId: f.shops.a, drawDate: '2026-11-03', rawText: 'x', lines: [{ ending: '8910', qty: 1 }] }),
).toThrow(ShopNotAllowedError);
});
it('lets an admin use any shop', () => {
const admin = getUserById(db, f.admin.id)!;
saveOrder(db, { user: admin, shopId: f.shops.c, drawDate: '2026-11-03', rawText: 'x', lines: [{ ending: '8910', qty: 1 }] });
expect(count('orders')).toBe(1);
});
it('leaves no partial order when a line fails to insert', () => {
const user = getUserById(db, f.shopUser.id)!;
expect(() =>
saveOrder(db, {
user,
shopId: f.shops.a,
drawDate: '2026-11-03',
rawText: 'x',
lines: [
{ ending: '8910', qty: 1 },
{ ending: 'bad', qty: 1 }, // violates the 4-digit CHECK
],
}),
).toThrow(/CHECK|constraint/i);
expect(count('orders')).toBe(0);
expect(count('order_lines')).toBe(0);
});
});

39
src/services/orders.ts Normal file
View File

@ -0,0 +1,39 @@
import type { Db } from '../db';
import { canUseShop } from './shopScope';
import type { User } from './users';
export class ShopNotAllowedError extends Error {
constructor() {
super('user cannot use this shop');
this.name = 'ShopNotAllowedError';
}
}
export interface SaveOrderInput {
user: User;
shopId: number;
drawDate: string;
rawText: string;
lines: { ending: string; qty: number }[];
source?: 'whatsapp_text' | 'whatsapp_photo';
}
export function saveOrder(db: Db, input: SaveOrderInput): number {
const run = db.transaction((): number => {
if (!canUseShop(db, input.user, input.shopId)) throw new ShopNotAllowedError();
const orderId = Number(
db
.prepare(
`INSERT INTO orders (shop_id, draw_date, source, created_by_user_id, raw_text)
VALUES (?, ?, ?, ?, ?)`,
)
.run(input.shopId, input.drawDate, input.source ?? 'whatsapp_text', input.user.id, input.rawText)
.lastInsertRowid,
);
const insertLine = db.prepare('INSERT INTO order_lines (order_id, ending, qty) VALUES (?, ?, ?)');
for (const l of input.lines) insertLine.run(orderId, l.ending, l.qty);
return orderId;
});
return run();
}

15
src/testing/config.ts Normal file
View File

@ -0,0 +1,15 @@
import type { Config } from '../config';
export function testConfig(overrides: Partial<Config> = {}): Config {
return {
port: 0,
host: '127.0.0.1',
dbPath: ':memory:',
srsSecret: 'test-secret-0123456789',
jwtSecret: undefined,
bridgeUrl: 'http://127.0.0.1:3000',
defaultCountryCode: '91',
orders: { cutoffMin: 15 * 60, askUntilMin: 15 * 60 + 30, remindMin: 10, expireMin: 30 },
...overrides,
};
}

View File

@ -15,6 +15,7 @@ export interface Fixture {
admin: { id: number; phone: string }; admin: { id: number; phone: string };
admin2: { id: number; phone: string }; admin2: { id: number; phone: string };
shopUser: { id: number; phone: string }; shopUser: { id: number; phone: string };
multiUser: { id: number; phone: string };
disabledUser: { id: number; phone: string }; disabledUser: { id: number; phone: string };
} }
@ -36,10 +37,13 @@ export function seedFixture(db: Db): Fixture {
const admin2 = user('admin2', '919000000004', 1, 1); const admin2 = user('admin2', '919000000004', 1, 1);
const shopUser = user('shop-a', '919000000002', 0, 1); const shopUser = user('shop-a', '919000000002', 0, 1);
const disabledUser = user('gone', '919000000003', 0, 0); const disabledUser = user('gone', '919000000003', 0, 0);
const multiUser = user('shop-ab', '919000000005', 0, 1);
db.prepare('INSERT INTO user_shops (user_id, shop_id) VALUES (?, ?)').run(shopUser.id, shops.a); db.prepare('INSERT INTO user_shops (user_id, shop_id) VALUES (?, ?)').run(shopUser.id, shops.a);
db.prepare('INSERT INTO user_shops (user_id, shop_id) VALUES (?, ?)').run(multiUser.id, shops.a);
db.prepare('INSERT INTO user_shops (user_id, shop_id) VALUES (?, ?)').run(multiUser.id, shops.b);
return { shops, admin, admin2, shopUser, disabledUser }; return { shops, admin, admin2, shopUser, multiUser, disabledUser };
} }
export const hasPdftotext: boolean = (() => { export const hasPdftotext: boolean = (() => {

View File

@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { classify, hasOrderContent } from './commands';
const idle = { collecting: false };
const col = { collecting: true };
describe('classify: single-word commands', () => {
it.each(['done', 'DONE', 'Done', ' done ', 'done.', 'Done!', 'submit', 'Submit.'])('%j is submit', (t) => {
expect(classify(t, idle)).toEqual({ kind: 'submit', body: '' });
});
it.each(['cancel', 'Cancel', 'CANCEL!', ' cancel. '])('%j is cancel', (t) => {
expect(classify(t, idle)).toEqual({ kind: 'cancel' });
});
it.each(['?', 'help', 'Help', 'HELP'])('%j is help', (t) => {
expect(classify(t, idle)).toEqual({ kind: 'help' });
});
it.each([
['1', 1],
['2', 2],
['3', 3],
[' 4 ', 4],
['12', 12],
['1.', 1],
])('%j is number %i', (t, n) => {
expect(classify(t, idle)).toEqual({ kind: 'number', n });
});
});
describe('classify: order text and look-alikes', () => {
it.each(['0888=2', '0888', '123', 'hello', 'thanks', '👍', 'done please', 'cancelled', 'yes', ''])(
'%j is text',
(t) => {
expect(classify(t, idle)).toEqual({ kind: 'text' });
},
);
it('whitespace only is text', () => {
expect(classify(' \n \n', idle)).toEqual({ kind: 'text' });
});
it('three or more digits are never a menu number', () => {
expect(classify('100', idle)).toEqual({ kind: 'text' });
});
});
describe('classify: done as the last line', () => {
it('splits order text from Done', () => {
expect(classify('0888\n0057\ndone', idle)).toEqual({ kind: 'submit', body: '0888\n0057' });
});
it('ignores case, punctuation, CRLF and trailing blank lines', () => {
expect(classify('0888=2\r\n7810=1\r\nSubmit.\n\n', idle)).toEqual({ kind: 'submit', body: '0888=2\n7810=1' });
});
it('done only counts on the last line', () => {
expect(classify('done\n0888', idle)).toEqual({ kind: 'text' });
});
it('cancel on a later line of a longer message is not a command', () => {
expect(classify('0888\ncancel', idle)).toEqual({ kind: 'text' });
});
});
describe('classify: trailing 1 (Hermes merges quick messages)', () => {
it('is Done when the conversation was already collecting', () => {
expect(classify('0888=2\n7810=1\n1', col)).toEqual({ kind: 'submit', body: '0888=2\n7810=1' });
});
it('stays order text in the first message of an order', () => {
expect(classify('0888=2\n7810=1\n1', idle)).toEqual({ kind: 'text' });
});
it('a lone 1 is a number either way (the engine decides what it means)', () => {
expect(classify('1', col)).toEqual({ kind: 'number', n: 1 });
expect(classify('1', idle)).toEqual({ kind: 'number', n: 1 });
});
it('only 1 works this way, not other numbers', () => {
expect(classify('0888=2\n2', col)).toEqual({ kind: 'text' });
});
it('a line ending in 1 is not a trailing 1', () => {
expect(classify('0888=2\n7801', col)).toEqual({ kind: 'text' });
});
});
describe('hasOrderContent', () => {
it('is true for an entry', () => {
expect(hasOrderContent('0888=2')).toBe(true);
});
it('is true for something unreadable', () => {
expect(hasOrderContent('08?8=2')).toBe(true);
});
it('is false for chatter, emoji, a lone number and totals', () => {
for (const t of ['ok', 'thanks', '👍', '1', '12', 'Total 50', '']) expect(hasOrderContent(t)).toBe(false);
});
});

50
src/whatsapp/commands.ts Normal file
View File

@ -0,0 +1,50 @@
import { parseOrder } from '../parser/parseOrder';
/**
*
* submit the order is finished; `body` is whatever order text came before the command ('' if none)
* cancel clear the conversation
* help repeat the last prompt
* number 1-2 digits on their own: a menu answer, never order text (endings are 4 digits)
* text anything else: order text, or an unexpected reply
*/
export type Command =
| { kind: 'submit'; body: string }
| { kind: 'cancel' }
| { kind: 'help' }
| { kind: 'number'; n: number }
| { kind: 'text' };
export interface ClassifyOptions {
collecting: boolean;
}
const SUBMIT_WORDS = new Set(['done', 'submit']);
const word = (s: string): string => s.trim().toLowerCase().replace(/[.,!]+$/, '').trim();
export function classify(text: string, opts: ClassifyOptions): Command {
const lines = text.split(/\r?\n/).map((l) => l.trim());
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
const nonEmpty = lines.filter((l) => l !== '');
if (nonEmpty.length === 0) return { kind: 'text' };
if (nonEmpty.length === 1) {
const w = word(nonEmpty[0]!);
if (SUBMIT_WORDS.has(w)) return { kind: 'submit', body: '' };
if (w === 'cancel') return { kind: 'cancel' };
if (w === '?' || w === 'help') return { kind: 'help' };
if (/^\d{1,2}$/.test(w)) return { kind: 'number', n: Number(w) };
return { kind: 'text' };
}
const last = word(lines[lines.length - 1]!);
const body = lines.slice(0, -1).join('\n').trim();
if (SUBMIT_WORDS.has(last)) return { kind: 'submit', body };
if (last === '1' && opts.collecting) return { kind: 'submit', body };
return { kind: 'text' };
}
export function hasOrderContent(text: string): boolean {
const r = parseOrder(text);
return r.lines.length > 0 || r.unreadable.length > 0;
}

View File

@ -0,0 +1,747 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { Db } from '../db';
import { getUserById } from '../services/users';
import { testConfig } from '../testing/config';
import { makeDb, seedFixture, type Fixture } from '../testing/helpers';
import { getConversation, handleMessage } from './conversation';
import * as msg from './messages';
const S = testConfig().orders;
// 2026-11-03 10:30 IST: well before the 15:00 cutoff
const MORNING = new Date('2026-11-03T05:00:00Z');
// 15:10 IST: inside the "ask today or tomorrow" window
const ASK_TIME = new Date('2026-11-03T09:40:00Z');
// 15:20 IST, 15:40 IST
const STILL_ASKING = new Date('2026-11-03T09:50:00Z');
const TOO_LATE = new Date('2026-11-03T10:10:00Z');
let db: Db;
let f: Fixture;
beforeEach(() => {
db = makeDb();
f = seedFixture(db);
});
afterEach(() => db.close());
/** A person chatting with the bot: `say(text, at)` returns the bot's replies. */
function chatAs(userId: number, chatId = 'chat-1@lid') {
const user = getUserById(db, userId)!;
return (text: string, at: Date = MORNING): string[] => handleMessage(db, S, user, chatId, text, at);
}
const conv = (userId: number) => getConversation(db, userId);
const state = (userId: number) => conv(userId)?.state ?? 'IDLE';
const orders = () => db.prepare('SELECT * FROM orders ORDER BY id').all() as Array<Record<string, unknown>>;
const lines = (orderId: number) =>
db.prepare('SELECT ending, qty FROM order_lines WHERE order_id = ? ORDER BY id').all(orderId) as Array<{
ending: string;
qty: number;
}>;
const SUMMARY_MENU = msg.SUMMARY_MENU;
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('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('2')).toEqual([
[
'Shop B - draw of TODAY 3 Nov',
'2 lines, 4 tickets',
'8910 - 3',
'7810 - 1',
'Not read, not in the order:',
' 08?8=2',
'1 Save 2 Add more 3 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('0838=2')).toEqual([
[
'Shop B - draw of TODAY 3 Nov',
'3 lines, 6 tickets',
'8910 - 3',
'7810 - 1',
'0838 - 2',
'Left out earlier, not in the order:',
' 08?8=2',
'1 Save 2 Add more 3 Cancel',
].join('\n'),
]);
expect(say('1')).toEqual(['Saved #1 - Shop B, 6 tickets, draw of 3 Nov']);
const [order] = orders();
expect(order).toMatchObject({
shop_id: f.shops.b,
draw_date: '2026-11-03',
status: 'confirmed',
source: 'whatsapp_text',
created_by_user_id: f.multiUser.id,
});
expect(lines(1)).toEqual([
{ ending: '8910', qty: 3 },
{ ending: '7810', qty: 1 },
{ ending: '0838', qty: 2 },
]);
expect(state(f.multiUser.id)).toBe('IDLE');
});
it('keeps the original text and the added lines together in raw_text', () => {
const say = chatAs(f.shopUser.id);
say('8910=3\n08?8=2');
say('1');
say('2');
say('0838=2');
say('1');
expect(orders()[0]!.raw_text).toBe('8910=3\n08?8=2\n0838=2');
});
});
describe('who sees which shops', () => {
it('a one-shop user skips the shop pick', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
const [summary] = say('done');
expect(summary).toContain('Shop A - draw of 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']);
});
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(orders()[0]).toMatchObject({ shop_id: f.shops.c, created_by_user_id: f.admin.id });
});
it('a user with no shop is told so and the conversation resets', () => {
db.prepare('DELETE FROM user_shops WHERE user_id = ?').run(f.shopUser.id);
const say = chatAs(f.shopUser.id);
say('8910=3');
expect(say('done')).toEqual([msg.noShop()]);
expect(state(f.shopUser.id)).toBe('IDLE');
expect(orders()).toHaveLength(0);
});
it('an out-of-range shop number repeats the list', () => {
const say = chatAs(f.multiUser.id);
say('8910=3');
say('1');
expect(say('9')).toEqual(['Which shop?\n1) Shop A\n2) Shop B']);
expect(state(f.multiUser.id)).toBe('AWAITING_SHOP');
});
it('a shop removed after the list was shown is refused and a fresh list is sent', () => {
const say = chatAs(f.multiUser.id);
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(state(f.multiUser.id)).toBe('AWAITING_SHOP');
const [summary] = say('1');
expect(summary).toContain('Shop A -');
});
it('re-checks shop access at the moment of saving', () => {
const say = chatAs(f.multiUser.id);
say('8910=3');
say('1');
say('2');
db.prepare('DELETE FROM user_shops WHERE user_id = ? AND shop_id = ?').run(f.multiUser.id, f.shops.b);
expect(say('1')).toEqual([msg.noShop()]);
expect(orders()).toHaveLength(0);
expect(state(f.multiUser.id)).toBe('IDLE');
});
});
describe('collecting', () => {
it('sends the collecting menu once, on the first message only', () => {
const say = chatAs(f.shopUser.id);
expect(say('8910=3')).toHaveLength(1);
expect(say('7810=1')).toEqual([]);
expect(say('0838=2')).toEqual([]);
});
it('accepts done and submit typed as words', () => {
for (const word of ['done', 'Submit.']) {
const say = chatAs(f.shopUser.id);
say('8910=3');
expect(say(word)[0]).toContain('Shop A');
say('3'); // cancel
}
});
it('treats "done" as the last line of an order message', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
const [summary] = say('7810=1\ndone');
expect(summary).toContain('2 lines, 4 tickets');
});
it('a whole order with done in the very first message needs no menu', () => {
const say = chatAs(f.shopUser.id);
const [summary] = say('0888\n0057\ndone');
expect(summary).toContain('2 lines, 2 tickets');
expect(state(f.shopUser.id)).toBe('CONFIRM');
});
it('a trailing 1 is Done when already collecting (Hermes merged two quick messages)', () => {
const say = chatAs(f.shopUser.id);
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(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(state(f.shopUser.id)).toBe('COLLECTING');
expect(conv(f.shopUser.id)!.buffer_text).toBe('0888=2\n1');
});
it('1 Done, 2 Cancel from the menu', () => {
const a = chatAs(f.shopUser.id);
a('8910=3');
a('2');
expect(state(f.shopUser.id)).toBe('IDLE');
});
it('chatter while collecting is kept out of the way: no reply', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
expect(say('ok')).toEqual([]);
expect(state(f.shopUser.id)).toBe('COLLECTING');
});
it('records the latest chat id', () => {
chatAs(f.shopUser.id, 'old@lid')('8910=3');
chatAs(f.shopUser.id, 'new@lid')('7810=1');
expect(conv(f.shopUser.id)!.chat_id).toBe('new@lid');
});
});
describe('IDLE chatter does not start an order', () => {
it.each(['ok', 'thanks', '👍', '1', '2', 'yes', 'cancel', '?', 'Total 50'])('%j', (t) => {
const say = chatAs(f.shopUser.id);
expect(say(t)).toEqual([]);
expect(conv(f.shopUser.id)).toBeUndefined();
});
it('done with nothing to submit says so', () => {
expect(chatAs(f.shopUser.id)('done')).toEqual([msg.nothingToSubmit()]);
expect(conv(f.shopUser.id)).toBeUndefined();
});
it('a double-tapped Save after Saved creates nothing', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
expect(say('1')[0]).toContain('Saved #1');
expect(say('1')).toEqual([]);
expect(say('thanks')).toEqual([]);
expect(orders()).toHaveLength(1);
expect(state(f.shopUser.id)).toBe('IDLE');
});
it('a message with one unreadable item does start an order', () => {
expect(chatAs(f.shopUser.id)('08?8=2')).toEqual([msg.collectingMenu()]);
});
});
describe('unreadable text', () => {
it('saving without fixing it leaves it out', () => {
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');
say('1');
expect(lines(1)).toEqual([{ ending: '8910', qty: 3 }]);
});
it('Add more keeps listing earlier unreadable text, and new unreadable text separately', () => {
const say = chatAs(f.shopUser.id);
say('8910=3\n08?8=2');
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');
// 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');
});
it('adding a forgotten line does not hide the earlier unreadable one', () => {
const say = chatAs(f.shopUser.id);
say('8910=3\n08?8=2');
say('done');
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).not.toContain('Everything was read');
say('1');
expect(lines(1)).toEqual([
{ ending: '8910', qty: 3 },
{ ending: '1234', qty: 1 },
]);
});
it('says everything was read only when nothing was ever left out', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
say('2');
const [again] = say('1234=1');
expect(again).toContain('2 lines, 4 tickets. 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);
say('08?8=2');
say('done');
expect(say('1')).toEqual([msg.noTickets()]);
expect(orders()).toHaveLength(0);
expect(state(f.shopUser.id)).toBe('IDLE');
});
it('an order with nothing readable at all is refused at Done', () => {
// started with a leading chatter line so the order opened, then everything is words
const say = chatAs(f.shopUser.id);
say('08?8=2');
db.prepare("UPDATE conversations SET buffer_text = 'just words'").run();
expect(say('done')).toEqual([msg.noTickets()]);
expect(state(f.shopUser.id)).toBe('IDLE');
});
});
describe('repeated endings', () => {
it('are added up and shown once, not asked about', () => {
const say = chatAs(f.shopUser.id);
say('8910=3\n7810=1\n8910=3');
const [summary] = say('done');
expect(summary).toContain('3 lines, 7 tickets');
expect(summary).toContain('8910 - 6 (2 times)');
expect(summary).toContain('7810 - 1');
expect(state(f.shopUser.id)).toBe('CONFIRM');
});
it('are saved as written, not merged', () => {
const say = chatAs(f.shopUser.id);
say('8910=3\n7810=1\n8910=3');
say('done');
say('1');
expect(lines(1)).toEqual([
{ ending: '8910', qty: 3 },
{ ending: '7810', qty: 1 },
{ ending: '8910', qty: 3 },
]);
});
it('a whole order resent through Add more is visible as repeats', () => {
const say = chatAs(f.shopUser.id);
say('8910=3\n7810=1');
say('done');
say('2');
const [summary] = say('8910=3\n7810=1');
expect(summary).toContain('8910 - 6 (2 times)');
expect(summary).toContain('7810 - 2 (2 times)');
});
});
describe('the menus', () => {
it('Add more asks for new lines only and the next message is added', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
expect(say('2')).toEqual([msg.addPrompt()]);
const [summary] = say('7810=1');
expect(summary).toContain('2 lines, 4 tickets');
});
it('1 and 3 keep their meaning while an Add more is pending', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
say('2');
expect(say('1')[0]).toContain('Saved #1');
});
it('Cancel at the summary clears everything', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
expect(say('3')).toEqual([msg.cancelled()]);
expect(state(f.shopUser.id)).toBe('IDLE');
expect(orders()).toHaveLength(0);
});
it('order text at the summary is refused and not added', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
const [summary] = say('done');
const [reply] = say('7810=1');
expect(reply).toBe(`${msg.numberFirst()}\n${summary}`);
expect(JSON.parse(conv(f.shopUser.id)!.parsed_json!).segments).toEqual(['8910=3']);
});
it('order text while the shop list is open is refused and not added', () => {
const say = chatAs(f.multiUser.id);
say('8910=3');
const [list] = say('1');
expect(say('7810=1')).toEqual([`${msg.numberFirst()}\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]);
const [list] = say('1');
expect(say('help')).toEqual([list]);
const [summary] = say('2');
expect(say('?')).toEqual([summary]);
});
it('never sends a blank message when there is no prompt on record', () => {
const say = chatAs(f.multiUser.id);
say('8910=3');
say('1');
db.prepare('UPDATE conversations SET last_prompt = NULL').run();
expect(say('?')).toEqual([]);
expect(say('thanks')).toEqual([]);
expect(say('7810=1')).toEqual([msg.numberFirst()]);
});
it('an unexpected reply repeats the prompt and changes nothing', () => {
const say = chatAs(f.multiUser.id);
say('8910=3');
const [list] = say('1');
expect(say('thanks')).toEqual([list]);
expect(say('7')).toEqual([list]);
expect(state(f.multiUser.id)).toBe('AWAITING_SHOP');
const [summary] = say('2');
expect(say('banana')).toEqual([summary]);
expect(say('9')).toEqual([summary]);
expect(orders()).toHaveLength(0);
});
});
describe('cancel in every state', () => {
it('COLLECTING', () => {
const say = chatAs(f.multiUser.id);
say('8910=3');
expect(say('cancel')).toEqual([msg.cancelled()]);
expect(state(f.multiUser.id)).toBe('IDLE');
});
it('AWAITING_SHOP', () => {
const say = chatAs(f.multiUser.id);
say('8910=3');
say('1');
expect(say('Cancel')).toEqual([msg.cancelled()]);
expect(state(f.multiUser.id)).toBe('IDLE');
});
it('ASKING (draw)', () => {
const say = chatAs(f.shopUser.id);
say('8910=3', ASK_TIME);
say('done', ASK_TIME);
expect(state(f.shopUser.id)).toBe('ASKING');
expect(say('cancel', ASK_TIME)).toEqual([msg.cancelled()]);
expect(state(f.shopUser.id)).toBe('IDLE');
});
it('CONFIRM', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
expect(say('cancel')).toEqual([msg.cancelled()]);
expect(state(f.shopUser.id)).toBe('IDLE');
});
it('leaves a clean slate: a new order starts from nothing', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
say('cancel');
say('7810=1');
expect(conv(f.shopUser.id)!.buffer_text).toBe('7810=1');
expect(conv(f.shopUser.id)!.parsed_json).toBeNull();
});
});
describe('the draw date', () => {
it('before 15:00 is today, no question', () => {
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');
});
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');
});
it('between 15:00 and 15:30 asks, and Today is accepted inside the window', () => {
const say = chatAs(f.shopUser.id);
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');
say('1', STILL_ASKING);
expect(orders()[0]!.draw_date).toBe('2026-11-03');
});
it('Tomorrow is accepted', () => {
const say = chatAs(f.shopUser.id);
say('8910=3', ASK_TIME);
say('done', ASK_TIME);
const [summary] = say('2', STILL_ASKING);
expect(summary).toContain('draw of TOMORROW 4 Nov');
say('1', STILL_ASKING);
expect(orders()[0]!.draw_date).toBe('2026-11-04');
});
it('Today answered after the window is refused and tomorrow or cancel is offered', () => {
const say = chatAs(f.shopUser.id);
say('8910=3', ASK_TIME);
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');
});
it('after a refusal, 2 cancels', () => {
const say = chatAs(f.shopUser.id);
say('8910=3', ASK_TIME);
say('done', ASK_TIME);
say('1', TOO_LATE);
expect(say('2', TOO_LATE)).toEqual([msg.cancelled()]);
expect(orders()).toHaveLength(0);
});
it('Today answered after midnight is refused (it is a different day by then)', () => {
const say = chatAs(f.shopUser.id);
say('8910=3', ASK_TIME);
say('done', ASK_TIME);
// 00:05 IST on 4 Nov
expect(say('1', new Date('2026-11-03T18:35:00Z'))).toEqual([msg.tooLateForToday('2026-11-04')]);
});
it('the draw is fixed when Done was sent: a Save after 15:30 keeps today', () => {
const say = chatAs(f.shopUser.id);
say('8910=3', ASK_TIME);
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(orders()[0]!.draw_date).toBe('2026-11-03');
});
it('does not offer Today when the shop pick used up the window: straight to tomorrow or cancel', () => {
const say = chatAs(f.multiUser.id);
say('8910=3', new Date('2026-11-03T09:55:00Z')); // 15:25 IST
say('done', new Date('2026-11-03T09:55:00Z'));
// picks the shop at 15:40, after the window closed
expect(say('1', TOO_LATE)).toEqual([msg.tooLateForToday('2026-11-04')]);
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');
say('1', TOO_LATE);
expect(orders()[0]!.draw_date).toBe('2026-11-04');
});
it('after that early refusal, 2 cancels and the draw is never today', () => {
const say = chatAs(f.multiUser.id);
say('8910=3', new Date('2026-11-03T09:55:00Z'));
say('done', new Date('2026-11-03T09:55:00Z'));
say('1', TOO_LATE);
expect(say('2', TOO_LATE)).toEqual([msg.cancelled()]);
expect(orders()).toHaveLength(0);
});
it('still asks normally when the shop pick finishes inside the window', () => {
const say = chatAs(f.multiUser.id);
say('8910=3', ASK_TIME);
say('done', ASK_TIME);
expect(say('1', STILL_ASKING)).toEqual([msg.drawQuestion('2026-11-03', '2026-11-04')]);
});
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('1', ASK_TIME)[0]).toContain("today's draw or tomorrow's");
});
});
describe('saving', () => {
it('a double-sent 1 saves once', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
say('1');
say('1');
expect(orders()).toHaveLength(1);
});
it('a failure while saving rolls everything back, including the conversation', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
db.exec(`CREATE TRIGGER boom BEFORE INSERT ON order_lines BEGIN SELECT RAISE(ABORT, 'boom'); END;`);
expect(() => say('1')).toThrow(/boom/);
expect(orders()).toHaveLength(0);
expect(state(f.shopUser.id)).toBe('CONFIRM');
});
it('two users do not disturb each other', () => {
const a = chatAs(f.shopUser.id);
const b = chatAs(f.multiUser.id);
a('8910=3');
b('7810=1');
a('done');
b('done');
expect(state(f.shopUser.id)).toBe('CONFIRM');
expect(state(f.multiUser.id)).toBe('AWAITING_SHOP');
});
});
describe('late messages after the reminder', () => {
const remind = (userId: number) =>
db.prepare("UPDATE conversations SET reminded_at = '2026-11-03T05:10:00Z' WHERE user_id = ?").run(userId);
it('before the reminder, a second order is simply added', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
expect(say('7810=1')).toEqual([]);
expect(state(f.shopUser.id)).toBe('COLLECTING');
});
it('after the reminder, order text is held and the bot asks', () => {
const say = chatAs(f.shopUser.id);
say('8910=3\n7810=1');
remind(f.shopUser.id);
const reply = say('0838=2');
expect(reply).toEqual([msg.splitQuestion(2, 4)]);
const c = conv(f.shopUser.id)!;
expect(c.state).toBe('ASKING');
expect(c.ask_kind).toBe('split');
expect(c.held_text).toBe('0838=2');
expect(c.buffer_text).toBe('8910=3\n7810=1');
});
it('1 adds the message to the old order and collecting continues', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
remind(f.shopUser.id);
say('0838=2');
expect(say('1')).toEqual([]);
const c = conv(f.shopUser.id)!;
expect(c.state).toBe('COLLECTING');
expect(c.buffer_text).toBe('8910=3\n0838=2');
expect(c.held_text).toBeNull();
expect(c.reminded_at).toBeNull();
});
it('2 finishes the old order first, then starts the held one', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
remind(f.shopUser.id);
say('0838=2');
const [summary] = say('2');
expect(summary).toContain('8910 - 3');
expect(summary).not.toContain('0838');
const replies = say('1');
expect(replies[0]).toContain('Saved #1');
expect(replies[1]).toBe(msg.startingHeld());
expect(replies[2]).toBe(msg.collectingMenu());
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');
});
it('2 then Cancel on the old order still starts the held one', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
remind(f.shopUser.id);
say('0838=2');
say('2');
const replies = say('3');
expect(replies).toEqual([msg.cancelled(), msg.startingHeld(), msg.collectingMenu()]);
expect(conv(f.shopUser.id)!.buffer_text).toBe('0838=2');
expect(orders()).toHaveLength(0);
});
it('3 throws the old order away and starts the new one', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
remind(f.shopUser.id);
say('0838=2');
expect(say('3')).toEqual([msg.collectingMenu()]);
const c = conv(f.shopUser.id)!;
expect(c.state).toBe('COLLECTING');
expect(c.buffer_text).toBe('0838=2');
expect(c.reminded_at).toBeNull();
});
it('cancel while a message is held cancels the old order and starts the held one', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
remind(f.shopUser.id);
say('0838=2');
expect(say('cancel')).toEqual([msg.cancelled(), msg.startingHeld(), msg.collectingMenu()]);
expect(conv(f.shopUser.id)!.buffer_text).toBe('0838=2');
});
it('done, cancel and chatter after the reminder are not held', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
remind(f.shopUser.id);
expect(say('ok')).toEqual([]);
expect(conv(f.shopUser.id)!.state).toBe('COLLECTING');
expect(say('done')[0]).toContain('Shop A');
});
it('a late message that ends in done is held whole and finishes as its own order', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
remind(f.shopUser.id);
say('0838=2\ndone');
expect(conv(f.shopUser.id)!.held_text).toBe('0838=2\ndone');
const [summary] = say('3'); // throw the old one away; the held order is finished as a whole
expect(summary).toContain('0838 - 2');
expect(summary).not.toContain('8910');
});
it('a menu number while the question is open that is not 1-3 repeats the question', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
remind(f.shopUser.id);
const [question] = say('0838=2');
expect(say('7')).toEqual([question]);
expect(say('more text 0838')).toEqual([`${msg.numberFirst()}\n${question}`]);
});
});

View File

@ -1,14 +1,27 @@
import type { OrderSettings } from '../config';
import type { Db } from '../db'; import type { Db } from '../db';
import { parseOrder } from '../parser/parseOrder';
import type { OrderLine } from '../parser/types';
import { drawChoiceAt } from '../services/drawDate';
import { istDate } from '../services/ist';
import { saveOrder, ShopNotAllowedError } from '../services/orders';
import { canUseShop, shopsForUser } from '../services/shopScope';
import type { User } from '../services/users'; import type { User } from '../services/users';
import { classify, hasOrderContent, type Command } from './commands';
import * as msg from './messages';
/** /**
* Where a person is in the WhatsApp conversation. One row per user in `conversations`. * The WhatsApp conversation, one row per user in `conversations`. See docs/WHATSAPP-FLOW-PLAN.md.
* *
* IDLE nothing in progress * IDLE nothing in progress
* COLLECTING order text is arriving; the ~20 s quiet timer is running * COLLECTING order text is being buffered; no timer. The user ends it with Done
* ASKING the bot asked a question about the parse (repeated endings, a failed total, ...) * AWAITING_SHOP the numbered shop list was sent; waiting for a number
* AWAITING_SHOP the bot showed the numbered shop list and waits for a number * ASKING waiting on a question, told apart by `ask_kind`:
* CONFIRM the bot showed "Save ...? 1 Yes / 2 Edit / 3 Cancel" and waits * draw "today or tomorrow?" (only between the cutoff and ask-until)
* split "is this late message part of the unfinished order?"
* CONFIRM the summary was sent; waiting for 1 Save / 2 Add more / 3 Cancel
*
* The engine is a function over the database that RETURNS the replies; it never sends anything.
*/ */
export type ConversationState = 'IDLE' | 'COLLECTING' | 'ASKING' | 'AWAITING_SHOP' | 'CONFIRM'; export type ConversationState = 'IDLE' | 'COLLECTING' | 'ASKING' | 'AWAITING_SHOP' | 'CONFIRM';
@ -24,45 +37,391 @@ export interface ConversationRow {
selected_shop_id: number | null; selected_shop_id: number | null;
last_activity_at: string; last_activity_at: string;
created_at: string; created_at: string;
last_prompt: string | null;
reminded_at: string | null;
ask_kind: 'draw' | 'split' | null;
held_text: string | null;
} }
export type ReceiveResult = interface FlowData {
| { kind: 'collecting'; conversation: ConversationRow } segments: string[];
| { kind: 'answer'; conversation: ConversationRow }; /**
* Segments before this index had their unreadable items shown and the user chose Add more. Those items stay out
* of the order (the bot never guesses a correction) and are listed as "Left out earlier" in every later summary.
*/
acked: number;
/** True after "2 Add more": the next text message is a new segment. */
addPending: boolean;
/** When the user said Done (ISO). The draw is decided from this moment. */
doneAt: string;
drawDate?: string;
/** Set while the draw question is open. */
drawOptions?: { today: string; tomorrow: string; tooLate: boolean };
}
const WAITING_FOR_ANSWER: ConversationState[] = ['ASKING', 'AWAITING_SHOP', 'CONFIRM']; interface Ctx {
db: Db;
s: OrderSettings;
user: User;
chatId: string;
now: Date;
}
const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); const iso = (d: Date): string => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
function getConversation(db: Db, userId: number): ConversationRow | undefined { export function getConversation(db: Db, userId: number): ConversationRow | undefined {
return db.prepare('SELECT * FROM conversations WHERE user_id = ?').get(userId) as ConversationRow | undefined; return db.prepare('SELECT * FROM conversations WHERE user_id = ?').get(userId) as ConversationRow | undefined;
} }
export function receiveText(db: Db, user: User, chatId: string, text: string): ReceiveResult { function update(db: Db, id: number, fields: Record<string, string | number | null>): void {
const existing = getConversation(db, user.id); const keys = Object.keys(fields); // internal constants only, never user input
db.prepare(`UPDATE conversations SET ${keys.map((k) => `${k} = @${k}`).join(', ')} WHERE id = @id`).run({
if (!existing) { ...fields,
db.prepare( id,
`INSERT INTO conversations (user_id, chat_id, state, buffer_text, source, last_activity_at) });
VALUES (?, ?, 'COLLECTING', ?, 'whatsapp_text', ?)`, }
).run(user.id, chatId, text, nowIso());
return { kind: 'collecting', conversation: getConversation(db, user.id)! }; export function resetConversation(db: Db, id: number, now: Date): void {
} update(db, id, {
state: 'IDLE',
if (WAITING_FOR_ANSWER.includes(existing.state)) { buffer_text: '',
db.prepare('UPDATE conversations SET chat_id = ?, last_activity_at = ? WHERE id = ?').run( parsed_json: null,
chatId, shop_options_json: null,
nowIso(), selected_shop_id: null,
existing.id, ask_kind: null,
); held_text: null,
return { kind: 'answer', conversation: getConversation(db, user.id)! }; reminded_at: null,
} last_prompt: null,
last_activity_at: iso(now),
const buffer = existing.state === 'COLLECTING' ? `${existing.buffer_text}\n${text}` : text; });
db.prepare( }
`UPDATE conversations
SET state = 'COLLECTING', buffer_text = ?, chat_id = ?, source = 'whatsapp_text', last_activity_at = ? const readData = (c: ConversationRow): FlowData => JSON.parse(c.parsed_json!) as FlowData;
WHERE id = ?`, const writeData = (db: Db, id: number, d: FlowData): void => update(db, id, { parsed_json: JSON.stringify(d) });
).run(buffer, chatId, nowIso(), existing.id);
return { kind: 'collecting', conversation: getConversation(db, user.id)! }; function prompt(db: Db, id: number, text: string): string {
update(db, id, { last_prompt: text });
return text;
}
interface Parsed {
lines: OrderLine[];
tickets: number;
unreadable: string[][];
}
function parseSegments(segments: string[]): Parsed {
const out: Parsed = { lines: [], tickets: 0, unreadable: [] };
for (const seg of segments) {
const r = parseOrder(seg);
out.lines.push(...r.lines);
out.tickets += r.totalTickets;
out.unreadable.push(r.unreadable.map((u) => u.text));
}
return out;
}
export function bufferProgress(text: string): { lines: number; tickets: number } {
const r = parseOrder(text);
return { lines: r.lines.length, tickets: r.totalTickets };
}
function afterReset(c: Ctx, held: string | null, replies: string[]): string[] {
if (!held) return replies;
return [...replies, msg.startingHeld(), ...step(c, held)];
}
function endConversation(c: Ctx, conv: ConversationRow, replies: string[]): string[] {
const held = conv.held_text;
resetConversation(c.db, conv.id, c.now);
return afterReset(c, held, replies);
}
function repeatPrompt(conv: ConversationRow): string[] {
return conv.last_prompt ? [conv.last_prompt] : [];
}
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()];
}
// ---------------------------------------------------------------------------------------------------------------
/** Handle one inbound text message from a known, active user and return the replies to send. */
export function handleMessage(
db: Db,
settings: OrderSettings,
user: User,
chatId: string,
text: string,
now: Date = new Date(),
): string[] {
return db.transaction(() => step({ db, s: settings, user, chatId, now }, text))();
}
function step(c: Ctx, text: string): string[] {
const conv = getConversation(c.db, c.user.id);
if (!conv || conv.state === 'IDLE') return fromIdle(c, text);
update(c.db, conv.id, { chat_id: c.chatId, last_activity_at: iso(c.now) });
const cmd = classify(text, { collecting: conv.state === 'COLLECTING' });
if (cmd.kind === 'cancel') return endConversation(c, conv, [msg.cancelled()]);
switch (conv.state) {
case 'COLLECTING':
return collecting(c, conv, cmd, text);
case 'AWAITING_SHOP':
return awaitingShop(c, conv, cmd, text);
case 'ASKING':
return conv.ask_kind === 'split' ? askingSplit(c, conv, cmd, text) : askingDraw(c, conv, cmd, text);
case 'CONFIRM':
return confirm(c, conv, cmd, text);
default:
return [];
}
}
// IDLE ----------------------------------------------------------------------------------------------------------
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)
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`,
)
.run({ user_id: c.user.id, chat_id: c.chatId, text, at: iso(c.now) });
return getConversation(c.db, c.user.id)!;
}
function fromIdle(c: Ctx, text: string): string[] {
const cmd = classify(text, { collecting: false });
if (cmd.kind === 'submit') {
if (!hasOrderContent(cmd.body)) return [msg.nothingToSubmit()];
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())];
}
// COLLECTING ----------------------------------------------------------------------------------------------------
function collecting(c: Ctx, conv: ConversationRow, cmd: Command, text: string): string[] {
const orderText = cmd.kind === 'text' ? text : cmd.kind === 'submit' ? cmd.body : '';
if (conv.reminded_at && orderText && hasOrderContent(orderText)) {
const p = bufferProgress(conv.buffer_text);
update(c.db, conv.id, { state: 'ASKING', ask_kind: 'split', held_text: text });
return [prompt(c.db, conv.id, msg.splitQuestion(p.lines, p.tickets))];
}
switch (cmd.kind) {
case 'submit': {
const body = hasOrderContent(cmd.body) ? `${conv.buffer_text}\n${cmd.body}` : conv.buffer_text;
update(c.db, conv.id, { buffer_text: body });
return done(c, { ...conv, buffer_text: body });
}
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()];
case 'help':
return [conv.last_prompt ?? msg.collectingMenu()];
default:
update(c.db, conv.id, { buffer_text: `${conv.buffer_text}\n${text}` });
return [];
}
}
/** 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()]);
}
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) });
if (shops.length === 1) {
update(c.db, conv.id, { selected_shop_id: shops[0]!.id });
return chooseDraw(c, conv.id, data);
}
update(c.db, conv.id, {
state: 'AWAITING_SHOP',
shop_options_json: JSON.stringify(shops.map((s) => s.id)),
});
return [prompt(c.db, conv.id, msg.shopList(shops.map((s) => s.name)))];
}
// AWAITING_SHOP -------------------------------------------------------------------------------------------------
function awaitingShop(c: Ctx, conv: ConversationRow, cmd: Command, text: string): string[] {
if (cmd.kind === 'help') return repeatPrompt(conv);
if (cmd.kind !== 'number') return unexpected(conv, text);
const ids = JSON.parse(conv.shop_options_json ?? '[]') as number[];
const shopId = ids[cmd.n - 1];
if (shopId === undefined) return unexpected(conv, text);
if (!canUseShop(c.db, c.user, shopId)) {
const shops = shopsForUser(c.db, c.user);
if (shops.length === 0) return endConversation(c, conv, [msg.noShop()]);
update(c.db, conv.id, { shop_options_json: JSON.stringify(shops.map((s) => s.id)) });
return [prompt(c.db, conv.id, msg.shopList(shops.map((s) => s.name)))];
}
update(c.db, conv.id, { selected_shop_id: shopId });
return chooseDraw(c, conv.id, readData(conv));
}
// Draw date -----------------------------------------------------------------------------------------------------
function todayStillOpen(c: Ctx, today: string): boolean {
const now = drawChoiceAt(c.now, c.s);
return now.kind === 'ask' && now.today === today;
}
function chooseDraw(c: Ctx, id: number, data: FlowData): string[] {
const choice = drawChoiceAt(new Date(data.doneAt), c.s);
if (choice.kind === 'ask') {
const open = todayStillOpen(c, choice.today);
data.drawOptions = { today: choice.today, tomorrow: choice.tomorrow, tooLate: !open };
update(c.db, id, { state: 'ASKING', ask_kind: 'draw' });
writeData(c.db, id, data);
const text = open ? msg.drawQuestion(choice.today, choice.tomorrow) : msg.tooLateForToday(choice.tomorrow);
return [prompt(c.db, id, text)];
}
data.drawDate = choice.date;
return showSummary(c, id, data);
}
function askingDraw(c: Ctx, conv: ConversationRow, cmd: Command, text: string): string[] {
const data = readData(conv);
const opt = data.drawOptions!;
if (cmd.kind === 'help') return repeatPrompt(conv);
if (cmd.kind !== 'number') return unexpected(conv, text);
if (opt.tooLate) {
if (cmd.n === 1) data.drawDate = opt.tomorrow;
else if (cmd.n === 2) return endConversation(c, conv, [msg.cancelled()]);
else return unexpected(conv, text);
} else if (cmd.n === 1) {
if (todayStillOpen(c, opt.today)) {
data.drawDate = opt.today;
} else {
opt.tooLate = true;
writeData(c.db, conv.id, data);
return [prompt(c.db, conv.id, msg.tooLateForToday(opt.tomorrow))];
}
} else if (cmd.n === 2) {
data.drawDate = opt.tomorrow;
} else {
return unexpected(conv, text);
}
delete data.drawOptions;
update(c.db, conv.id, { ask_kind: null });
return showSummary(c, conv.id, data);
}
// Late message (split) ------------------------------------------------------------------------------------------
function askingSplit(c: Ctx, conv: ConversationRow, cmd: Command, text: string): string[] {
if (cmd.kind === 'help') return repeatPrompt(conv);
if (cmd.kind !== 'number') return unexpected(conv, text);
const held = conv.held_text ?? '';
switch (cmd.n) {
case 1:
update(c.db, conv.id, { state: 'COLLECTING', ask_kind: null, held_text: null, reminded_at: null });
return step(c, held);
case 2:
update(c.db, conv.id, { state: 'COLLECTING', ask_kind: null });
return done(c, getConversation(c.db, c.user.id)!);
case 3:
resetConversation(c.db, conv.id, c.now);
return step(c, held);
default:
return unexpected(conv, text);
}
}
// CONFIRM -------------------------------------------------------------------------------------------------------
function showSummary(c: Ctx, id: number, data: FlowData): string[] {
const p = parseSegments(data.segments);
const shop = c.db
.prepare('SELECT s.name FROM shops s JOIN conversations v ON v.selected_shop_id = s.id WHERE v.id = ?')
.get(id) as { name: string };
const text = msg.summary({
shopName: shop.name,
drawDate: data.drawDate!,
today: istDate(c.now),
lineCount: p.lines.length,
ticketCount: p.tickets,
entries: msg.entryTotals(p.lines),
unreadable: p.unreadable.slice(data.acked).flat(),
leftOut: p.unreadable.slice(0, data.acked).flat(),
});
update(c.db, id, { state: 'CONFIRM' });
writeData(c.db, id, data);
return [prompt(c.db, id, text)];
}
function confirm(c: Ctx, conv: ConversationRow, cmd: Command, text: string): string[] {
const data = readData(conv);
if (cmd.kind === 'help') return repeatPrompt(conv);
if (cmd.kind === 'number') {
if (cmd.n === 1) return save(c, conv, data);
if (cmd.n === 3) return endConversation(c, conv, [msg.cancelled()]);
if (cmd.n === 2) {
data.addPending = true;
data.acked = data.segments.length;
writeData(c.db, conv.id, data);
return [prompt(c.db, conv.id, msg.addPrompt())];
}
return unexpected(conv, text);
}
const added = cmd.kind === 'text' ? text : cmd.kind === 'submit' ? cmd.body : '';
if (data.addPending && added && hasOrderContent(added)) {
data.segments.push(added);
data.addPending = false;
return showSummary(c, conv.id, data);
}
return unexpected(conv, text);
}
function save(c: Ctx, conv: ConversationRow, data: FlowData): string[] {
const p = parseSegments(data.segments);
if (p.lines.length === 0) return endConversation(c, conv, [msg.noTickets()]);
const shopId = conv.selected_shop_id!;
let orderId: number;
try {
orderId = saveOrder(c.db, {
user: c.user,
shopId,
drawDate: data.drawDate!,
rawText: data.segments.join('\n'),
lines: p.lines.map((l) => ({ ending: l.ending, qty: l.qty })),
});
} catch (e) {
if (e instanceof ShopNotAllowedError) return endConversation(c, conv, [msg.noShop()]);
throw e;
}
const shop = c.db.prepare('SELECT name FROM shops WHERE id = ?').get(shopId) as { name: string };
return endConversation(c, conv, [
msg.saved({ orderId, shopName: shop.name, ticketCount: p.tickets, drawDate: data.drawDate! }),
]);
} }

View File

@ -0,0 +1,185 @@
import { describe, expect, it } from 'vitest';
import * as m from './messages';
describe('formatDay / drawLabel', () => {
it('formats without a leading zero', () => {
expect(m.formatDay('2026-11-03')).toBe('3 Nov');
expect(m.formatDay('2026-01-31')).toBe('31 Jan');
expect(m.formatDay('2026-12-01')).toBe('1 Dec');
});
it('says TODAY, TOMORROW, or just the date', () => {
expect(m.drawLabel('2026-11-03', '2026-11-03')).toBe('TODAY 3 Nov');
expect(m.drawLabel('2026-11-04', '2026-11-03')).toBe('TOMORROW 4 Nov');
expect(m.drawLabel('2026-11-10', '2026-11-03')).toBe('10 Nov');
});
it('knows tomorrow across a month end', () => {
expect(m.drawLabel('2026-12-01', '2026-11-30')).toBe('TOMORROW 1 Dec');
});
});
describe('entryTotals', () => {
it('keeps first-seen order and adds repeats up', () => {
const e = m.entryTotals([
{ ending: '8910', qty: 3 },
{ ending: '7810', qty: 1 },
{ ending: '8910', qty: 3 },
]);
expect(e).toEqual([
{ ending: '8910', qty: 6, times: 2 },
{ ending: '7810', qty: 1, times: 1 },
]);
});
it('keeps leading zeros', () => {
expect(m.entryTotals([{ ending: '0057', qty: 2 }])[0]!.ending).toBe('0057');
});
});
describe('summary', () => {
const base = {
shopName: 'Shop B',
drawDate: '2026-11-03',
today: '2026-11-03',
lineCount: 2,
ticketCount: 4,
entries: [
{ ending: '8910', qty: 3, times: 1 },
{ ending: '7810', qty: 1, times: 1 },
],
unreadable: ['08?8=2'],
leftOut: [] as string[],
};
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',
'8910 - 3',
'7810 - 1',
'Not read, not in the order:',
' 08?8=2',
'1 Save 2 Add more 3 Cancel',
].join('\n'),
);
});
it('says everything was read when nothing was ever left out', () => {
const t = m.summary({
...base,
lineCount: 3,
ticketCount: 6,
entries: [...base.entries, { ending: '0838', qty: 2, times: 1 }],
unreadable: [],
});
expect(t).toBe(
[
'Shop B - draw of TODAY 3 Nov',
'3 lines, 6 tickets. Everything was read.',
'8910 - 3',
'7810 - 1',
'0838 - 2',
'1 Save 2 Add more 3 Cancel',
].join('\n'),
);
});
it('keeps listing text left out earlier and does not claim everything was read', () => {
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',
'8910 - 3',
'7810 - 1',
'Left out earlier, not in the order:',
' 08?8=2',
'1 Save 2 Add more 3 Cancel',
].join('\n'),
);
expect(t).not.toContain('Everything was read');
});
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');
expect(fresh).toBeGreaterThan(-1);
expect(older).toBeGreaterThan(fresh);
});
it('marks a repeated ending once with its total', () => {
const t = m.summary({ ...base, entries: [{ ending: '8910', qty: 6, times: 2 }], unreadable: [] });
expect(t).toContain('8910 - 6 (2 times)');
expect(t.match(/8910/g)).toHaveLength(1);
});
it('labels tomorrow', () => {
expect(m.summary({ ...base, drawDate: '2026-11-04' })).toContain('draw of TOMORROW 4 Nov');
});
it('uses singular forms', () => {
const t = m.summary({
...base,
lineCount: 1,
ticketCount: 1,
entries: [{ ending: '0001', qty: 1, times: 1 }],
unreadable: [],
});
expect(t).toContain('1 line, 1 ticket. Everything was read.');
});
it('caps a long unreadable list and counts the rest', () => {
const many = Array.from({ length: m.MAX_UNREADABLE_SHOWN + 5 }, (_, i) => `x${i}`);
const lines = m.summary({ ...base, unreadable: many }).split('\n');
expect(lines).toContain(' x0');
expect(lines).toContain(` x${m.MAX_UNREADABLE_SHOWN - 1}`);
expect(lines).not.toContain(` x${m.MAX_UNREADABLE_SHOWN}`);
expect(lines).toContain(' ...and 5 more');
});
it('always ends with the menu', () => {
expect(m.summary(base).endsWith(m.SUMMARY_MENU)).toBe(true);
});
});
describe('prompts', () => {
it('collecting menu offers Done and Cancel', () => {
expect(m.collectingMenu()).toBe('Got it. Forward more, or reply 1 Done 2 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');
});
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",
);
});
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");
});
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',
);
});
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.');
});
it('reminder omits progress when nothing was read', () => {
expect(m.reminder(0, 0)).toBe('You have an unfinished order. Reply 1 Done 2 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)',
].join('\n'),
);
});
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');
});
});

124
src/whatsapp/messages.ts Normal file
View File

@ -0,0 +1,124 @@
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export const MAX_UNREADABLE_SHOWN = 20;
const plural = (n: number, one: string, many = `${one}s`): string => `${n} ${n === 1 ? one : many}`;
export function formatDay(date: string): string {
const [, m, d] = date.split('-');
return `${Number(d)} ${MONTHS[Number(m) - 1]}`;
}
const nextDay = (date: string): string =>
new Date(Date.parse(`${date}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10);
export function drawLabel(drawDate: string, today: string): string {
if (drawDate === today) return `TODAY ${formatDay(drawDate)}`;
if (drawDate === nextDay(today)) return `TOMORROW ${formatDay(drawDate)}`;
return formatDay(drawDate);
}
export interface Entry {
ending: string;
qty: number;
times: number;
}
export function entryTotals(lines: { ending: string; qty: number }[]): Entry[] {
const by = new Map<string, Entry>();
for (const l of lines) {
const e = by.get(l.ending);
if (e) {
e.qty += l.qty;
e.times += 1;
} else {
by.set(l.ending, { ending: l.ending, qty: l.qty, times: 1 });
}
}
return [...by.values()];
}
export const COLLECTING_MENU = '1 Done 2 Cancel';
export const SUMMARY_MENU = '1 Save 2 Add more 3 Cancel';
export const collectingMenu = (): string => `Got it. Forward more, or reply ${COLLECTING_MENU}`;
export function shopList(shopNames: string[]): string {
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)}`,
].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');
}
export interface SummaryInput {
shopName: string;
drawDate: string;
today: string;
lineCount: number;
ticketCount: number;
entries: Entry[];
unreadable: string[];
leftOut: string[];
}
function pushList(out: string[], heading: string, items: string[]): void {
if (items.length === 0) return;
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);
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);
return out.join('\n');
}
export const addPrompt = (): string =>
'Send the lines to add. Send 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)}`;
}
export const cancelled = (): string => 'Cancelled. Nothing 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 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 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}.`;
}
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)',
].join('\n');
}

View File

@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import type { Bridge } from './bridge';
import { createSendQueue } from './sendQueue';
interface Sent {
chatId: string;
message: string;
}
function fakeBridge(opts: { fail?: (m: string) => boolean; gate?: (m: string) => Promise<void> } = {}) {
const sent: Sent[] = [];
const bridge: Bridge = {
async sendText(chatId, message) {
if (opts.gate) await opts.gate(message);
if (opts.fail?.(message)) throw new Error('bridge /send failed with HTTP 500');
sent.push({ chatId, message });
return { messageIds: [] };
},
async health() {
return { status: 'connected' };
},
};
return { bridge, sent };
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
describe('createSendQueue', () => {
it('sends a batch in order', async () => {
const { bridge, sent } = fakeBridge();
const q = createSendQueue(bridge, () => {});
q.enqueue('a@lid', ['one', 'two', 'three']);
await q.idle();
expect(sent.map((s) => s.message)).toEqual(['one', 'two', 'three']);
expect(sent.every((s) => s.chatId === 'a@lid')).toBe(true);
});
it('keeps replies to one chat in order even when the first send is slow', async () => {
const { bridge, sent } = fakeBridge({ gate: (m) => (m === 'first' ? sleep(30) : Promise.resolve()) });
const q = createSendQueue(bridge, () => {});
q.enqueue('a@lid', ['first']);
q.enqueue('a@lid', ['second']);
await q.idle();
expect(sent.map((s) => s.message)).toEqual(['first', 'second']);
});
it('does not make one chat wait for another', async () => {
let release!: () => void;
const held = new Promise<void>((r) => (release = r));
const { bridge, sent } = fakeBridge({ gate: (m) => (m === 'slow' ? held : Promise.resolve()) });
const q = createSendQueue(bridge, () => {});
q.enqueue('a@lid', ['slow']);
q.enqueue('b@lid', ['fast']);
await sleep(10);
expect(sent.map((s) => s.message)).toEqual(['fast']);
release();
await q.idle();
expect(sent.map((s) => s.message)).toEqual(['fast', 'slow']);
});
it('drops blank messages and does nothing for an empty batch', async () => {
const { bridge, sent } = fakeBridge();
const q = createSendQueue(bridge, () => {});
q.enqueue('a@lid', ['', ' ', 'real', '\n']);
q.enqueue('a@lid', []);
await q.idle();
expect(sent.map((s) => s.message)).toEqual(['real']);
});
it('returns at once and never throws, even when the bridge fails', async () => {
const { bridge } = fakeBridge({ fail: () => true });
const q = createSendQueue(bridge, () => {});
expect(() => q.enqueue('a@lid', ['x'])).not.toThrow();
await expect(q.idle()).resolves.toBeUndefined();
});
it('logs a failure without the chat id or the text, and drops the rest of that batch', async () => {
const logs: Array<Record<string, unknown>> = [];
const { bridge, sent } = fakeBridge({ fail: (m) => m === 'secret order 8910=3' });
const q = createSendQueue(bridge, (e) => logs.push(e));
q.enqueue('145483561476218@lid', ['secret order 8910=3', 'menu 1 2 3']);
await q.idle();
expect(sent).toEqual([]);
expect(logs).toHaveLength(1);
const line = JSON.stringify(logs[0]);
expect(line).toContain('reply not sent');
expect(line).not.toContain('8910');
expect(line).not.toContain('145483561476218');
});
it('keeps working after a failure', async () => {
const { bridge, sent } = fakeBridge({ fail: (m) => m === 'bad' });
const q = createSendQueue(bridge, () => {});
q.enqueue('a@lid', ['bad']);
q.enqueue('a@lid', ['good']);
await q.idle();
expect(sent.map((s) => s.message)).toEqual(['good']);
});
it('idle resolves at once when nothing is queued', async () => {
const { bridge } = fakeBridge();
await expect(createSendQueue(bridge, () => {}).idle()).resolves.toBeUndefined();
});
});

41
src/whatsapp/sendQueue.ts Normal file
View File

@ -0,0 +1,41 @@
import type { Bridge } from './bridge';
export type LogFn = (entry: Record<string, unknown>) => void;
export interface SendQueue {
enqueue(chatId: string, messages: string[]): void;
idle(): Promise<void>;
}
export function createSendQueue(bridge: Bridge, log: LogFn): SendQueue {
const tails = new Map<string, Promise<void>>();
async function sendAll(chatId: string, messages: string[]): Promise<void> {
for (const message of messages) {
try {
await bridge.sendText(chatId, message);
} catch (err) {
log({ level: 'error', msg: 'whatsapp: reply not sent', error: (err as Error).message });
return;
}
}
}
return {
enqueue(chatId, messages) {
const toSend = messages.filter((m) => m.trim() !== '');
if (toSend.length === 0) return;
const previous = tails.get(chatId) ?? Promise.resolve();
const next = previous.then(() => sendAll(chatId, toSend));
tails.set(chatId, next);
void next.then(() => {
if (tails.get(chatId) === next) tails.delete(chatId);
});
},
async idle() {
while (tails.size > 0) await Promise.all([...tails.values()]);
},
};
}

272
src/whatsapp/sweep.test.ts Normal file
View File

@ -0,0 +1,272 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Db } from '../db';
import { getUserById } from '../services/users';
import { testConfig } from '../testing/config';
import { makeDb, seedFixture, type Fixture } from '../testing/helpers';
import type { Bridge } from './bridge';
import { getConversation, handleMessage } from './conversation';
import * as msg from './messages';
import { createSendQueue, type SendQueue } from './sendQueue';
import { createConversationSweeper } from './sweep';
const S = testConfig().orders; // remind 10, expire 30
const T0 = new Date('2026-11-03T05:00:00Z'); // 10:30 IST
const after = (min: number, sec = 0) => new Date(T0.getTime() + min * 60_000 + sec * 1000);
let db: Db;
let f: Fixture;
let queue: SendQueue;
let sent: Array<{ chatId: string; message: string }>;
let logs: Array<Record<string, unknown>>;
let clock: Date;
const bridge: Bridge = {
async sendText(chatId, message) {
sent.push({ chatId, message });
return { messageIds: [] };
},
async health() {
return { status: 'connected' };
},
};
beforeEach(() => {
db = makeDb();
f = seedFixture(db);
sent = [];
logs = [];
clock = T0;
queue = createSendQueue(bridge, (e) => logs.push(e));
});
afterEach(() => db.close());
const sweeper = (settings = S) =>
createConversationSweeper({ db, settings, queue, log: (e) => logs.push(e), now: () => clock });
function chatAs(userId: number, chatId = 'chat-1@lid') {
const user = getUserById(db, userId)!;
return (text: string, at: Date) => handleMessage(db, S, user, chatId, text, at);
}
async function sweepAt(at: Date, settings = S) {
clock = at;
const r = sweeper(settings).tick();
await queue.idle();
return r;
}
const state = (userId: number) => getConversation(db, userId)?.state ?? 'IDLE';
describe('reminder', () => {
it('is sent once, at the reminder time, with progress', async () => {
chatAs(f.shopUser.id)('8910=3\n7810=1', T0);
expect(await sweepAt(after(9, 59))).toEqual({ reminded: 0, expired: 0 });
expect(sent).toEqual([]);
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(await sweepAt(after(11))).toEqual({ reminded: 0, expired: 0 });
expect(await sweepAt(after(20))).toEqual({ reminded: 0, expired: 0 });
expect(sent).toHaveLength(1);
});
it('never saves anything', async () => {
chatAs(f.shopUser.id)('8910=3', T0);
await sweepAt(after(10));
expect(db.prepare('SELECT COUNT(*) AS n FROM orders').get()).toEqual({ n: 0 });
expect(state(f.shopUser.id)).toBe('COLLECTING');
});
it('counts idle time from the last message, not the first', async () => {
const say = chatAs(f.shopUser.id);
say('8910=3', T0);
say('7810=1', after(9));
expect(await sweepAt(after(10))).toEqual({ reminded: 0, expired: 0 });
expect(await sweepAt(after(18, 59))).toEqual({ reminded: 0, expired: 0 });
expect(await sweepAt(after(19))).toEqual({ reminded: 1, expired: 0 });
});
it('goes to the chat of the latest message', async () => {
chatAs(f.shopUser.id, 'old@lid')('8910=3', T0);
chatAs(f.shopUser.id, 'new@lid')('7810=1', after(1));
await sweepAt(after(11));
expect(sent.map((s) => s.chatId)).toEqual(['new@lid']);
});
it('is only for orders still being collected', async () => {
chatAs(f.multiUser.id)('8910=3\ndone', T0); // AWAITING_SHOP
chatAs(f.shopUser.id)('8910=3\ndone', T0); // CONFIRM
expect(state(f.multiUser.id)).toBe('AWAITING_SHOP');
expect(state(f.shopUser.id)).toBe('CONFIRM');
expect(await sweepAt(after(15))).toEqual({ reminded: 0, expired: 0 });
expect(sent).toEqual([]);
});
it('turns the next order message into the late-message question', async () => {
const say = chatAs(f.shopUser.id);
say('8910=3', T0);
await sweepAt(after(10));
const [reply] = say('0838=2', after(12));
expect(reply).toBe(msg.splitQuestion(1, 3));
expect(getConversation(db, f.shopUser.id)!.held_text).toBe('0838=2');
});
it('uses the configured time', async () => {
chatAs(f.shopUser.id)('8910=3', T0);
const custom = { ...S, remindMin: 3, expireMin: 6 };
expect(await sweepAt(after(2, 59), custom)).toEqual({ reminded: 0, expired: 0 });
expect(await sweepAt(after(3), custom)).toEqual({ reminded: 1, expired: 0 });
});
});
describe('expiry', () => {
it('clears the conversation at the expiry time and says nothing was saved', async () => {
chatAs(f.shopUser.id)('8910=3', T0);
expect(await sweepAt(after(29, 59))).toMatchObject({ expired: 0 });
expect(await sweepAt(after(30))).toEqual({ reminded: 0, expired: 1 });
expect(sent.map((s) => s.message)).toEqual([msg.reminder(1, 3), msg.expired()]);
expect(state(f.shopUser.id)).toBe('IDLE');
expect(getConversation(db, f.shopUser.id)).toMatchObject({
buffer_text: '',
parsed_json: null,
held_text: null,
reminded_at: null,
last_prompt: null,
});
});
it('happens once; an idle conversation is left alone afterwards', async () => {
chatAs(f.shopUser.id)('8910=3', T0);
await sweepAt(after(30));
sent.length = 0;
expect(await sweepAt(after(31))).toEqual({ reminded: 0, expired: 0 });
expect(await sweepAt(after(600))).toEqual({ reminded: 0, expired: 0 });
expect(sent).toEqual([]);
});
it.each([
['AWAITING_SHOP', 'multiUser', '8910=3\ndone'],
['CONFIRM', 'shopUser', '8910=3\ndone'],
] as const)('applies to %s too', async (expected, who, text) => {
chatAs(f[who].id)(text, T0);
expect(state(f[who].id)).toBe(expected);
expect(await sweepAt(after(30))).toEqual({ reminded: 0, expired: 1 });
expect(state(f[who].id)).toBe('IDLE');
});
it('applies to an open draw question', async () => {
const askTime = new Date('2026-11-03T09:40:00Z'); // 15:10 IST
chatAs(f.shopUser.id)('8910=3\ndone', askTime);
expect(state(f.shopUser.id)).toBe('ASKING');
expect(await sweepAt(new Date(askTime.getTime() + 30 * 60_000))).toEqual({ reminded: 0, expired: 1 });
});
it('drops a held late message too', async () => {
const say = chatAs(f.shopUser.id);
say('8910=3', T0);
await sweepAt(after(10));
say('0838=2', after(12));
await sweepAt(after(42));
expect(getConversation(db, f.shopUser.id)).toMatchObject({ state: 'IDLE', held_text: null, buffer_text: '' });
expect(sent.at(-1)!.message).toBe(msg.expired());
});
it('a new order after expiry starts clean', async () => {
const say = chatAs(f.shopUser.id);
say('8910=3', T0);
await sweepAt(after(30));
expect(say('7810=1', after(31))).toEqual([msg.collectingMenu()]);
expect(getConversation(db, f.shopUser.id)!.buffer_text).toBe('7810=1');
});
it('uses the configured time', async () => {
chatAs(f.shopUser.id)('8910=3', T0);
const custom = { ...S, remindMin: 3, expireMin: 6 };
expect(await sweepAt(after(6), custom)).toMatchObject({ expired: 1 });
});
});
describe('a sweep', () => {
it('leaves IDLE conversations alone', async () => {
const say = chatAs(f.shopUser.id);
say('8910=3', T0);
say('done', T0);
say('1', T0); // saved, back to IDLE
expect(state(f.shopUser.id)).toBe('IDLE');
expect(await sweepAt(after(120))).toEqual({ reminded: 0, expired: 0 });
expect(sent).toEqual([]);
});
it('does nothing when there are no conversations', async () => {
expect(await sweepAt(after(1))).toEqual({ reminded: 0, expired: 0 });
});
it('treats each user separately', async () => {
chatAs(f.shopUser.id, 'a@lid')('8910=3', T0);
chatAs(f.admin.id, 'b@lid')('7810=1', after(5));
expect(await sweepAt(after(10))).toEqual({ reminded: 1, expired: 0 });
expect(await sweepAt(after(15))).toEqual({ reminded: 1, expired: 0 });
expect(await sweepAt(after(30))).toEqual({ reminded: 0, expired: 1 });
expect(await sweepAt(after(35))).toEqual({ reminded: 0, expired: 1 });
expect(sent.map((s) => s.chatId)).toEqual(['a@lid', 'b@lid', 'a@lid', 'b@lid']);
});
it('catches up after downtime in one pass', async () => {
chatAs(f.shopUser.id)('8910=3', T0);
expect(await sweepAt(after(600))).toEqual({ reminded: 0, expired: 1 });
});
it('logs counts only, never text or chat ids', async () => {
chatAs(f.shopUser.id, '145483561476218@lid')('8910=3', T0);
await sweepAt(after(10));
await sweepAt(after(30));
const all = JSON.stringify(logs);
expect(all).toContain('conversations swept');
expect(all).not.toContain('8910');
expect(all).not.toContain('145483561476218');
});
});
describe('start / stop', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('sweeps at once, then every minute, until stopped', async () => {
chatAs(f.shopUser.id)('8910=3', T0);
clock = after(10);
const stop = sweeper().start();
await queue.idle();
expect(sent.map((s) => s.message)).toEqual([msg.reminder(1, 3)]); // the immediate pass
clock = after(30);
await vi.advanceTimersByTimeAsync(60_000);
await queue.idle();
expect(sent.at(-1)!.message).toBe(msg.expired());
stop();
chatAs(f.admin.id)('7810=1', after(30));
clock = after(100);
await vi.advanceTimersByTimeAsync(5 * 60_000);
await queue.idle();
expect(state(f.admin.id)).toBe('COLLECTING'); // no more sweeping after stop
});
it('a failing sweep is logged and does not stop the timer or throw', async () => {
const broken = createConversationSweeper({
db,
settings: S,
queue,
log: (e) => logs.push(e),
now: () => {
throw new Error('clock exploded');
},
});
const stop = broken.start();
await vi.advanceTimersByTimeAsync(120_000);
stop();
expect(logs.filter((l) => l.msg === 'conversation sweep failed').length).toBeGreaterThanOrEqual(2);
});
});

74
src/whatsapp/sweep.ts Normal file
View File

@ -0,0 +1,74 @@
import type { OrderSettings } from '../config';
import type { Db } from '../db';
import { bufferProgress, resetConversation, type ConversationRow } from './conversation';
import * as msg from './messages';
import type { LogFn, SendQueue } from './sendQueue';
export interface SweeperOptions {
db: Db;
settings: OrderSettings;
queue: SendQueue;
log: LogFn;
now?: () => Date;
}
export interface SweepResult {
reminded: number;
expired: number;
}
const EVERY_MS = 60_000;
const iso = (d: Date): string => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
export function createConversationSweeper(opts: SweeperOptions) {
const now = opts.now ?? (() => new Date());
function tick(): SweepResult {
const t = now();
const outbox: Array<{ chatId: string; message: string }> = [];
const result: SweepResult = { reminded: 0, expired: 0 };
opts.db.transaction(() => {
const rows = opts.db
.prepare("SELECT * FROM conversations WHERE state != 'IDLE'")
.all() as ConversationRow[];
for (const c of rows) {
const idleMin = (t.getTime() - Date.parse(c.last_activity_at)) / 60_000;
if (idleMin >= opts.settings.expireMin) {
resetConversation(opts.db, c.id, t);
outbox.push({ chatId: c.chat_id, message: msg.expired() });
result.expired++;
} else if (c.state === 'COLLECTING' && !c.reminded_at && idleMin >= opts.settings.remindMin) {
opts.db.prepare('UPDATE conversations SET reminded_at = ? WHERE id = ?').run(iso(t), c.id);
const p = bufferProgress(c.buffer_text);
outbox.push({ chatId: c.chat_id, message: msg.reminder(p.lines, p.tickets) });
result.reminded++;
}
}
})();
for (const o of outbox) opts.queue.enqueue(o.chatId, [o.message]);
if (result.reminded + result.expired > 0) {
opts.log({ level: 'info', msg: 'conversations swept', ...result });
}
return result;
}
function start(): () => void {
const safeTick = (): void => {
try {
tick();
} catch (err) {
opts.log({ level: 'error', msg: 'conversation sweep failed', error: (err as Error).message });
}
};
const timer = setInterval(safeTick, EVERY_MS);
timer.unref();
safeTick();
return () => clearInterval(timer);
}
return { tick, start };
}