From f18779e182507b0379d4c4e859e53575b95d7012 Mon Sep 17 00:00:00 2001 From: Aswin Biju Date: Tue, 3 Nov 2026 17:29:44 +0530 Subject: [PATCH] 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. --- .env.example | 5 + src/app.ts | 8 +- src/config.test.ts | 33 + src/config.ts | 36 +- src/db/migrate.test.ts | 14 +- src/db/migrations/002_conversation_flow.sql | 8 + src/results/portal.ts | 4 +- src/results/scheduler.ts | 8 +- src/routes/ingest.test.ts | 133 +++- src/routes/ingest.ts | 23 +- src/server.ts | 18 +- src/services/drawDate.test.ts | 45 +- src/services/drawDate.ts | 41 +- src/services/ist.test.ts | 55 ++ src/services/ist.ts | 19 + src/services/orders.test.ts | 98 +++ src/services/orders.ts | 39 + src/testing/config.ts | 15 + src/testing/helpers.ts | 6 +- src/whatsapp/commands.test.ts | 88 +++ src/whatsapp/commands.ts | 50 ++ src/whatsapp/conversation.test.ts | 747 ++++++++++++++++++++ src/whatsapp/conversation.ts | 435 +++++++++++- src/whatsapp/messages.test.ts | 185 +++++ src/whatsapp/messages.ts | 124 ++++ src/whatsapp/sendQueue.test.ts | 104 +++ src/whatsapp/sendQueue.ts | 41 ++ src/whatsapp/sweep.test.ts | 272 +++++++ src/whatsapp/sweep.ts | 74 ++ 29 files changed, 2637 insertions(+), 91 deletions(-) create mode 100644 src/db/migrations/002_conversation_flow.sql create mode 100644 src/services/ist.test.ts create mode 100644 src/services/ist.ts create mode 100644 src/services/orders.test.ts create mode 100644 src/services/orders.ts create mode 100644 src/testing/config.ts create mode 100644 src/whatsapp/commands.test.ts create mode 100644 src/whatsapp/commands.ts create mode 100644 src/whatsapp/conversation.test.ts create mode 100644 src/whatsapp/messages.test.ts create mode 100644 src/whatsapp/messages.ts create mode 100644 src/whatsapp/sendQueue.test.ts create mode 100644 src/whatsapp/sendQueue.ts create mode 100644 src/whatsapp/sweep.test.ts create mode 100644 src/whatsapp/sweep.ts diff --git a/.env.example b/.env.example index 013748e..0cf3d13 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,8 @@ JWT_SECRET=change-me-to-another-long-random-string # Hermes WhatsApp bridge (local only) BRIDGE_URL=http://127.0.0.1:3000 DEFAULT_COUNTRY_CODE=91 + +ORDER_CUTOFF=15:00 +ORDER_ASK_UNTIL=15:30 +ORDER_REMIND_MINUTES=10 +ORDER_EXPIRE_MINUTES=30 diff --git a/src/app.ts b/src/app.ts index 6a1c528..fb3a968 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,18 +5,21 @@ import type { Db } from './db'; import { healthRoutes } from './routes/health'; import { ingestRoutes } from './routes/ingest'; import type { Bridge } from './whatsapp/bridge'; +import { createSendQueue, type SendQueue } from './whatsapp/sendQueue'; export interface AppDeps { db: Db; config: Config; bridge: Bridge; + sendQueue?: SendQueue; + now?: () => Date; } export interface AppOptions { 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({ logger: options.logger ?? false, 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(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) => { diff --git a/src/config.test.ts b/src/config.test.ts index 7c05b71..704fc39 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -20,4 +20,37 @@ describe('loadConfig', () => { expect(c.port).toBe(9000); 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/); + }); + }); }); diff --git a/src/config.ts b/src/config.ts index b3f7ffc..55103b7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,14 @@ import { existsSync } from 'node:fs'; 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({ PORT: z.coerce.number().int().min(1).max(65535).default(8080), 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(), 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'), -}); + 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 { port: number; @@ -19,6 +46,7 @@ export interface Config { jwtSecret: string | undefined; bridgeUrl: string; defaultCountryCode: string; + orders: OrderSettings; } 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, bridgeUrl: e.BRIDGE_URL, 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, + }, }; } diff --git a/src/db/migrate.test.ts b/src/db/migrate.test.ts index b95d39c..08319ef 100644 --- a/src/db/migrate.test.ts +++ b/src/db/migrate.test.ts @@ -8,11 +8,23 @@ const throwsConstraint = (fn: () => unknown) => expect(fn).toThrow(/CHECK|UNIQUE describe('migrations', () => { it('apply once and are then a no-op', () => { const db = openDb(':memory:'); - expect(runMigrations(db)).toEqual(['001_init.sql']); + expect(runMigrations(db)).toEqual(['001_init.sql', '002_conversation_flow.sql']); 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; + 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', () => { const orderFor = (db: ReturnType) => { const f = seedFixture(db); diff --git a/src/db/migrations/002_conversation_flow.sql b/src/db/migrations/002_conversation_flow.sql new file mode 100644 index 0000000..2ec0148 --- /dev/null +++ b/src/db/migrations/002_conversation_flow.sql @@ -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; diff --git a/src/results/portal.ts b/src/results/portal.ts index d44445b..bc9397d 100644 --- a/src/results/portal.ts +++ b/src/results/portal.ts @@ -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)); } -export function todayIst(now = new Date()): string { - return new Date(now.getTime() + 5.5 * 3_600_000).toISOString().slice(0, 10); -} +export { istDate as todayIst } from '../services/ist'; export type FetchLike = typeof fetch; diff --git a/src/results/scheduler.ts b/src/results/scheduler.ts index 61f1815..c48e1ea 100644 --- a/src/results/scheduler.ts +++ b/src/results/scheduler.ts @@ -1,5 +1,6 @@ import type { Db } from '../db'; import { runResultsJob, type DrawReport } from './job'; +import { istMinutes } from '../services/ist'; import { todayIst } from './portal'; const WINDOW_START_MIN = 16 * 60; // 4 PM IST @@ -13,11 +14,6 @@ export interface SchedulerOptions { run?: (db: Db) => Promise; } -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 { 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 { const t = now(); const today = todayIst(t); - const minutes = minutesIst(t); + const minutes = istMinutes(t); if (minutes >= WINDOW_END_MIN) { if (loggedGiveUpFor !== today && !todaysDrawReady(opts.db, today)) { diff --git a/src/routes/ingest.test.ts b/src/routes/ingest.test.ts index cca14df..c64d7db 100644 --- a/src/routes/ingest.test.ts +++ b/src/routes/ingest.test.ts @@ -3,28 +3,29 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createApp } from '../app'; import type { Config } from '../config'; import type { Db } from '../db'; +import { testConfig } from '../testing/config'; import { makeDb, seedFixture, type Fixture } from '../testing/helpers'; import type { Bridge } from '../whatsapp/bridge'; +import { createSendQueue, type SendQueue } from '../whatsapp/sendQueue'; const SECRET = 'test-secret-0123456789'; -const config: Config = { - port: 0, - host: '127.0.0.1', - dbPath: ':memory:', - srsSecret: SECRET, - jwtSecret: undefined, - bridgeUrl: 'http://127.0.0.1:3000', - defaultCountryCode: '91', -}; +const config: Config = testConfig({ srsSecret: SECRET }); let db: Db; let f: Fixture; let app: FastifyInstance; let bridgeStatus: string | null; +let queue: SendQueue; +let sent: Array<{ chatId: string; message: string }>; +let sendFails: boolean; +let logs: Array>; +let clock: Date; 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: [] }; }, async health() { @@ -36,7 +37,12 @@ beforeEach(async () => { db = makeDb(); f = seedFixture(db); 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(); }); @@ -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((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', () => { it('reports ok on the public route without details', async () => { const res = await app.inject({ method: 'GET', url: '/api/health' }); diff --git a/src/routes/ingest.ts b/src/routes/ingest.ts index feed32e..e95ce64 100644 --- a/src/routes/ingest.ts +++ b/src/routes/ingest.ts @@ -3,7 +3,9 @@ import { z } from 'zod'; import type { Db } from '../db'; import { phoneFromJid } from '../services/phone'; 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'; const base = z.object({ @@ -24,13 +26,16 @@ const payloadSchema = z.discriminatedUnion('kind', [ interface IngestDeps { db: Db; secret: string; + settings: OrderSettings; + queue: SendQueue; + now?: () => Date; } const isUniqueViolation = (e: unknown): boolean => typeof e === 'object' && e !== null && 'code' in e && String((e as { code: unknown }).code).startsWith('SQLITE_CONSTRAINT'); export const ingestRoutes = - ({ db, secret }: IngestDeps): FastifyPluginAsync => + ({ db, secret, settings, queue, now = () => new Date() }: IngestDeps): FastifyPluginAsync => async (app) => { const insertLog = db.prepare( `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 apply = db.transaction((): { duplicate: boolean } => { + const apply = db.transaction((): { duplicate: boolean; replies: string[] } => { try { insertLog.run({ wa_message_id: msg.message_id, @@ -67,17 +72,17 @@ export const ingestRoutes = outcome, }); } catch (e) { - if (isUniqueViolation(e)) return { duplicate: true }; + if (isUniqueViolation(e)) return { duplicate: true, replies: [] }; throw e; } - if (accepted && msg.kind === 'text') { - receiveText(db, user, msg.chat_id, msg.text); - } - return { duplicate: false }; + const replies = + accepted && msg.kind === 'text' ? handleMessage(db, settings, user, msg.chat_id, msg.text, now()) : []; + return { duplicate: false, replies }; }); - const { duplicate } = apply(); + const { duplicate, replies } = apply(); + queue.enqueue(msg.chat_id, replies); if (duplicate) return { handled: true, duplicate: true }; if (!accepted) return { handled: false }; diff --git a/src/server.ts b/src/server.ts index b1b30d0..2342678 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,6 +4,8 @@ import { openDb } from './db'; import { runMigrations } from './db/migrate'; import { createResultsScheduler } from './results/scheduler'; import { createBridge } from './whatsapp/bridge'; +import { createSendQueue } from './whatsapp/sendQueue'; +import { createConversationSweeper } from './whatsapp/sweep'; async function main(): Promise { const config = loadConfig(); @@ -15,12 +17,18 @@ async function main(): Promise { } const bridge = createBridge(config.bridgeUrl); - const app = createApp({ db, config, bridge }, { logger: true }); - - // TODO(conversation): on startup, re-arm the ~20 s collection timers for conversations that are - // still COLLECTING, so a restart does not lose half-finished orders. + let log: (entry: Record) => void = (e) => console.log(JSON.stringify(e)); + const sendQueue = createSendQueue(bridge, (e) => log(e)); + const app = createApp({ db, config, bridge, sendQueue }, { logger: true }); + log = (e) => app.log.info(e); 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 }); @@ -31,7 +39,9 @@ async function main(): Promise { app.log.info({ signal }, 'shutting down'); setTimeout(() => process.exit(1), 10_000).unref(); stopScheduler(); + stopSweeper(); await app.close(); + await sendQueue.idle(); db.close(); process.exit(0); }; diff --git a/src/services/drawDate.test.ts b/src/services/drawDate.test.ts index 4519214..b4055c4 100644 --- a/src/services/drawDate.test.ts +++ b/src/services/drawDate.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { drawDateForOrder } from './drawDate'; +import { drawChoiceAt, drawDateForOrder } from './drawDate'; 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'); }); }); + +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' }); + }); +}); diff --git a/src/services/drawDate.ts b/src/services/drawDate.ts index 3e12617..d6cdd6e 100644 --- a/src/services/drawDate.ts +++ b/src/services/drawDate.ts @@ -1,18 +1,31 @@ -const IST_OFFSET_MS = 5.5 * 3_600_000; -const CUTOFF_MINUTES = 15 * 60; +import { istParts } from './ist'; -function ist(at: Date): { date: string; minutes: number; seconds: number } { - 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(), - }; +const DEFAULT_CUTOFF_MINUTES = 15 * 60; + +const nextDay = (date: string): string => + new Date(Date.parse(`${date}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10); + +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 { - const t = ist(receivedAt); - const afterCutoff = t.minutes > CUTOFF_MINUTES || (t.minutes === CUTOFF_MINUTES && t.seconds > 0); - if (!afterCutoff) return t.date; - return new Date(Date.parse(`${t.date}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10); +export type DrawChoice = + | { kind: 'today'; date: string } + | { kind: 'tomorrow'; date: string } + | { kind: 'ask'; today: string; tomorrow: string }; + +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) }; } diff --git a/src/services/ist.test.ts b/src/services/ist.test.ts new file mode 100644 index 0000000..8cb2ad1 --- /dev/null +++ b/src/services/ist.test.ts @@ -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)); + }); +}); diff --git a/src/services/ist.ts b/src/services/ist.ts new file mode 100644 index 0000000..7ddb5e8 --- /dev/null +++ b/src/services/ist.ts @@ -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; diff --git a/src/services/orders.test.ts b/src/services/orders.test.ts new file mode 100644 index 0000000..9552b47 --- /dev/null +++ b/src/services/orders.test.ts @@ -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); + }); +}); diff --git a/src/services/orders.ts b/src/services/orders.ts new file mode 100644 index 0000000..4faeb03 --- /dev/null +++ b/src/services/orders.ts @@ -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(); +} diff --git a/src/testing/config.ts b/src/testing/config.ts new file mode 100644 index 0000000..548c590 --- /dev/null +++ b/src/testing/config.ts @@ -0,0 +1,15 @@ +import type { Config } from '../config'; + +export function testConfig(overrides: Partial = {}): 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, + }; +} diff --git a/src/testing/helpers.ts b/src/testing/helpers.ts index d1a30f5..35bb940 100644 --- a/src/testing/helpers.ts +++ b/src/testing/helpers.ts @@ -15,6 +15,7 @@ export interface Fixture { admin: { id: number; phone: string }; admin2: { id: number; phone: string }; shopUser: { id: number; phone: string }; + multiUser: { 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 shopUser = user('shop-a', '919000000002', 0, 1); 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(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 = (() => { diff --git a/src/whatsapp/commands.test.ts b/src/whatsapp/commands.test.ts new file mode 100644 index 0000000..d7327c8 --- /dev/null +++ b/src/whatsapp/commands.test.ts @@ -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); + }); +}); diff --git a/src/whatsapp/commands.ts b/src/whatsapp/commands.ts new file mode 100644 index 0000000..9455678 --- /dev/null +++ b/src/whatsapp/commands.ts @@ -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; +} diff --git a/src/whatsapp/conversation.test.ts b/src/whatsapp/conversation.test.ts new file mode 100644 index 0000000..5e92f49 --- /dev/null +++ b/src/whatsapp/conversation.test.ts @@ -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>; +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}`]); + }); +}); diff --git a/src/whatsapp/conversation.ts b/src/whatsapp/conversation.ts index 26a9bf9..795e33a 100644 --- a/src/whatsapp/conversation.ts +++ b/src/whatsapp/conversation.ts @@ -1,14 +1,27 @@ +import type { OrderSettings } from '../config'; 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 { 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 - * COLLECTING order text is arriving; the ~20 s quiet timer is running - * ASKING the bot asked a question about the parse (repeated endings, a failed total, ...) - * AWAITING_SHOP the bot showed the numbered shop list and waits for a number - * CONFIRM the bot showed "Save ...? 1 Yes / 2 Edit / 3 Cancel" and waits + * COLLECTING order text is being buffered; no timer. The user ends it with Done + * AWAITING_SHOP the numbered shop list was sent; waiting for a number + * ASKING waiting on a question, told apart by `ask_kind`: + * 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'; @@ -24,45 +37,391 @@ export interface ConversationRow { selected_shop_id: number | null; last_activity_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 = - | { kind: 'collecting'; conversation: ConversationRow } - | { kind: 'answer'; conversation: ConversationRow }; +interface FlowData { + segments: string[]; + /** + * 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; } -export function receiveText(db: Db, user: User, chatId: string, text: string): ReceiveResult { - const existing = getConversation(db, user.id); - - if (!existing) { - db.prepare( - `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)! }; - } - - if (WAITING_FOR_ANSWER.includes(existing.state)) { - db.prepare('UPDATE conversations SET chat_id = ?, last_activity_at = ? WHERE id = ?').run( - chatId, - nowIso(), - existing.id, - ); - return { kind: 'answer', conversation: getConversation(db, user.id)! }; - } - - 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 = ? - WHERE id = ?`, - ).run(buffer, chatId, nowIso(), existing.id); - return { kind: 'collecting', conversation: getConversation(db, user.id)! }; +function update(db: Db, id: number, fields: Record): void { + 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({ + ...fields, + id, + }); +} + +export function resetConversation(db: Db, id: number, now: Date): void { + update(db, id, { + state: 'IDLE', + buffer_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: iso(now), + }); +} + +const readData = (c: ConversationRow): FlowData => JSON.parse(c.parsed_json!) as FlowData; +const writeData = (db: Db, id: number, d: FlowData): void => update(db, id, { parsed_json: JSON.stringify(d) }); + +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! }), + ]); } diff --git a/src/whatsapp/messages.test.ts b/src/whatsapp/messages.test.ts new file mode 100644 index 0000000..7c35abf --- /dev/null +++ b/src/whatsapp/messages.test.ts @@ -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'); + }); +}); diff --git a/src/whatsapp/messages.ts b/src/whatsapp/messages.ts new file mode 100644 index 0000000..5224fbd --- /dev/null +++ b/src/whatsapp/messages.ts @@ -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(); + 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'); +} diff --git a/src/whatsapp/sendQueue.test.ts b/src/whatsapp/sendQueue.test.ts new file mode 100644 index 0000000..41f1e26 --- /dev/null +++ b/src/whatsapp/sendQueue.test.ts @@ -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 } = {}) { + 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((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((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> = []; + 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(); + }); +}); diff --git a/src/whatsapp/sendQueue.ts b/src/whatsapp/sendQueue.ts new file mode 100644 index 0000000..7384873 --- /dev/null +++ b/src/whatsapp/sendQueue.ts @@ -0,0 +1,41 @@ +import type { Bridge } from './bridge'; + +export type LogFn = (entry: Record) => void; + +export interface SendQueue { + enqueue(chatId: string, messages: string[]): void; + idle(): Promise; +} + +export function createSendQueue(bridge: Bridge, log: LogFn): SendQueue { + const tails = new Map>(); + + async function sendAll(chatId: string, messages: string[]): Promise { + 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()]); + }, + }; +} diff --git a/src/whatsapp/sweep.test.ts b/src/whatsapp/sweep.test.ts new file mode 100644 index 0000000..a6b6932 --- /dev/null +++ b/src/whatsapp/sweep.test.ts @@ -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>; +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); + }); +}); diff --git a/src/whatsapp/sweep.ts b/src/whatsapp/sweep.ts new file mode 100644 index 0000000..f39b9fb --- /dev/null +++ b/src/whatsapp/sweep.ts @@ -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 }; +}