feat(whatsapp): take orders end to end, from first forward to Save
Wire the order parser to the WhatsApp bot. A known user forwards an order, ends it with Done, picks a shop, confirms a summary and the order is saved.
This commit is contained in:
parent
ed4d1280e1
commit
f18779e182
@ -11,3 +11,8 @@ JWT_SECRET=change-me-to-another-long-random-string
|
|||||||
# Hermes WhatsApp bridge (local only)
|
# Hermes WhatsApp bridge (local only)
|
||||||
BRIDGE_URL=http://127.0.0.1:3000
|
BRIDGE_URL=http://127.0.0.1:3000
|
||||||
DEFAULT_COUNTRY_CODE=91
|
DEFAULT_COUNTRY_CODE=91
|
||||||
|
|
||||||
|
ORDER_CUTOFF=15:00
|
||||||
|
ORDER_ASK_UNTIL=15:30
|
||||||
|
ORDER_REMIND_MINUTES=10
|
||||||
|
ORDER_EXPIRE_MINUTES=30
|
||||||
|
|||||||
@ -5,18 +5,21 @@ import type { Db } from './db';
|
|||||||
import { healthRoutes } from './routes/health';
|
import { healthRoutes } from './routes/health';
|
||||||
import { ingestRoutes } from './routes/ingest';
|
import { ingestRoutes } from './routes/ingest';
|
||||||
import type { Bridge } from './whatsapp/bridge';
|
import type { Bridge } from './whatsapp/bridge';
|
||||||
|
import { createSendQueue, type SendQueue } from './whatsapp/sendQueue';
|
||||||
|
|
||||||
export interface AppDeps {
|
export interface AppDeps {
|
||||||
db: Db;
|
db: Db;
|
||||||
config: Config;
|
config: Config;
|
||||||
bridge: Bridge;
|
bridge: Bridge;
|
||||||
|
sendQueue?: SendQueue;
|
||||||
|
now?: () => Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppOptions {
|
export interface AppOptions {
|
||||||
logger?: boolean;
|
logger?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApp({ db, config, bridge }: AppDeps, options: AppOptions = {}): FastifyInstance {
|
export function createApp({ db, config, bridge, sendQueue, now }: AppDeps, options: AppOptions = {}): FastifyInstance {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: options.logger ?? false,
|
logger: options.logger ?? false,
|
||||||
bodyLimit: 12 * 1024 * 1024,
|
bodyLimit: 12 * 1024 * 1024,
|
||||||
@ -24,7 +27,8 @@ export function createApp({ db, config, bridge }: AppDeps, options: AppOptions =
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.register(healthRoutes({ db, bridge, secret: config.srsSecret }));
|
app.register(healthRoutes({ db, bridge, secret: config.srsSecret }));
|
||||||
app.register(ingestRoutes({ db, secret: config.srsSecret }));
|
const queue = sendQueue ?? createSendQueue(bridge, (entry) => app.log.error(entry));
|
||||||
|
app.register(ingestRoutes({ db, secret: config.srsSecret, settings: config.orders, queue, now }));
|
||||||
|
|
||||||
|
|
||||||
app.setNotFoundHandler((_req, reply) => {
|
app.setNotFoundHandler((_req, reply) => {
|
||||||
|
|||||||
@ -20,4 +20,37 @@ describe('loadConfig', () => {
|
|||||||
expect(c.port).toBe(9000);
|
expect(c.port).toBe(9000);
|
||||||
expect(c.dbPath).toBe('/tmp/x.db');
|
expect(c.dbPath).toBe('/tmp/x.db');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('order settings', () => {
|
||||||
|
const base = { SRS_SECRET: 'a'.repeat(20) };
|
||||||
|
|
||||||
|
it('defaults to a 15:00 cutoff, asking until 15:30, remind 10, expire 30', () => {
|
||||||
|
expect(loadConfig(base).orders).toEqual({ cutoffMin: 900, askUntilMin: 930, remindMin: 10, expireMin: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads overrides', () => {
|
||||||
|
const c = loadConfig({
|
||||||
|
...base,
|
||||||
|
ORDER_CUTOFF: '14:30',
|
||||||
|
ORDER_ASK_UNTIL: '15:00',
|
||||||
|
ORDER_REMIND_MINUTES: '5',
|
||||||
|
ORDER_EXPIRE_MINUTES: '20',
|
||||||
|
});
|
||||||
|
expect(c.orders).toEqual({ cutoffMin: 870, askUntilMin: 900, remindMin: 5, expireMin: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a bad time format', () => {
|
||||||
|
expect(() => loadConfig({ ...base, ORDER_CUTOFF: '3pm' })).toThrow(/HH:MM/);
|
||||||
|
expect(() => loadConfig({ ...base, ORDER_CUTOFF: '25:00' })).toThrow(/HH:MM/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects ask-until that is not after the cutoff', () => {
|
||||||
|
expect(() => loadConfig({ ...base, ORDER_ASK_UNTIL: '15:00' })).toThrow(/later than ORDER_CUTOFF/);
|
||||||
|
expect(() => loadConfig({ ...base, ORDER_ASK_UNTIL: '14:00' })).toThrow(/later than ORDER_CUTOFF/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a reminder that is not before the expiry', () => {
|
||||||
|
expect(() => loadConfig({ ...base, ORDER_REMIND_MINUTES: '30' })).toThrow(/smaller than ORDER_EXPIRE/);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,14 @@
|
|||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const hhmm = (def: string) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'use HH:MM, 24-hour')
|
||||||
|
.default(def);
|
||||||
|
|
||||||
|
const toMinutes = (v: string): number => Number(v.slice(0, 2)) * 60 + Number(v.slice(3, 5));
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
PORT: z.coerce.number().int().min(1).max(65535).default(8080),
|
PORT: z.coerce.number().int().min(1).max(65535).default(8080),
|
||||||
HOST: z.string().min(1).default('127.0.0.1'),
|
HOST: z.string().min(1).default('127.0.0.1'),
|
||||||
@ -9,8 +17,27 @@ const schema = z.object({
|
|||||||
JWT_SECRET: z.string().min(16).optional(),
|
JWT_SECRET: z.string().min(16).optional(),
|
||||||
BRIDGE_URL: z.string().url().default('http://127.0.0.1:3000'),
|
BRIDGE_URL: z.string().url().default('http://127.0.0.1:3000'),
|
||||||
DEFAULT_COUNTRY_CODE: z.string().regex(/^\d{1,3}$/, 'digits only').default('91'),
|
DEFAULT_COUNTRY_CODE: z.string().regex(/^\d{1,3}$/, 'digits only').default('91'),
|
||||||
|
ORDER_CUTOFF: hhmm('15:00'),
|
||||||
|
ORDER_ASK_UNTIL: hhmm('15:30'),
|
||||||
|
ORDER_REMIND_MINUTES: z.coerce.number().int().min(1).max(1440).default(10),
|
||||||
|
ORDER_EXPIRE_MINUTES: z.coerce.number().int().min(1).max(1440).default(30),
|
||||||
|
})
|
||||||
|
.refine((e) => toMinutes(e.ORDER_ASK_UNTIL) > toMinutes(e.ORDER_CUTOFF), {
|
||||||
|
path: ['ORDER_ASK_UNTIL'],
|
||||||
|
message: 'must be later than ORDER_CUTOFF',
|
||||||
|
})
|
||||||
|
.refine((e) => e.ORDER_REMIND_MINUTES < e.ORDER_EXPIRE_MINUTES, {
|
||||||
|
path: ['ORDER_REMIND_MINUTES'],
|
||||||
|
message: 'must be smaller than ORDER_EXPIRE_MINUTES',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export interface OrderSettings {
|
||||||
|
cutoffMin: number;
|
||||||
|
askUntilMin: number;
|
||||||
|
remindMin: number;
|
||||||
|
expireMin: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
port: number;
|
port: number;
|
||||||
host: string;
|
host: string;
|
||||||
@ -19,6 +46,7 @@ export interface Config {
|
|||||||
jwtSecret: string | undefined;
|
jwtSecret: string | undefined;
|
||||||
bridgeUrl: string;
|
bridgeUrl: string;
|
||||||
defaultCountryCode: string;
|
defaultCountryCode: string;
|
||||||
|
orders: OrderSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||||
@ -41,5 +69,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
|||||||
jwtSecret: e.JWT_SECRET,
|
jwtSecret: e.JWT_SECRET,
|
||||||
bridgeUrl: e.BRIDGE_URL,
|
bridgeUrl: e.BRIDGE_URL,
|
||||||
defaultCountryCode: e.DEFAULT_COUNTRY_CODE,
|
defaultCountryCode: e.DEFAULT_COUNTRY_CODE,
|
||||||
|
orders: {
|
||||||
|
cutoffMin: toMinutes(e.ORDER_CUTOFF),
|
||||||
|
askUntilMin: toMinutes(e.ORDER_ASK_UNTIL),
|
||||||
|
remindMin: e.ORDER_REMIND_MINUTES,
|
||||||
|
expireMin: e.ORDER_EXPIRE_MINUTES,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,11 +8,23 @@ const throwsConstraint = (fn: () => unknown) => expect(fn).toThrow(/CHECK|UNIQUE
|
|||||||
describe('migrations', () => {
|
describe('migrations', () => {
|
||||||
it('apply once and are then a no-op', () => {
|
it('apply once and are then a no-op', () => {
|
||||||
const db = openDb(':memory:');
|
const db = openDb(':memory:');
|
||||||
expect(runMigrations(db)).toEqual(['001_init.sql']);
|
expect(runMigrations(db)).toEqual(['001_init.sql', '002_conversation_flow.sql']);
|
||||||
expect(runMigrations(db)).toEqual([]);
|
expect(runMigrations(db)).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('conversation flow columns (002)', () => {
|
||||||
|
it('exist, start empty, and ask_kind only takes known values', () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const f = seedFixture(db);
|
||||||
|
db.prepare("INSERT INTO conversations (user_id, chat_id, state) VALUES (?, 'c', 'IDLE')").run(f.admin.id);
|
||||||
|
const row = db.prepare('SELECT * FROM conversations').get() as Record<string, unknown>;
|
||||||
|
for (const col of ['last_prompt', 'reminded_at', 'ask_kind', 'held_text']) expect(row[col]).toBeNull();
|
||||||
|
throwsConstraint(() => db.prepare("UPDATE conversations SET ask_kind = 'other'").run());
|
||||||
|
db.prepare("UPDATE conversations SET ask_kind = 'split'").run();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('schema rules', () => {
|
describe('schema rules', () => {
|
||||||
const orderFor = (db: ReturnType<typeof makeDb>) => {
|
const orderFor = (db: ReturnType<typeof makeDb>) => {
|
||||||
const f = seedFixture(db);
|
const f = seedFixture(db);
|
||||||
|
|||||||
8
src/db/migrations/002_conversation_flow.sql
Normal file
8
src/db/migrations/002_conversation_flow.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
-- last_prompt the last question the bot asked, repeated on "?" or an unexpected reply
|
||||||
|
-- reminded_at set once the 10-minute reminder was sent; after it a late order message is held, not appended
|
||||||
|
-- ask_kind which question an ASKING conversation is waiting on
|
||||||
|
-- held_text a late message waiting while the user decides whether it belongs to the unfinished order
|
||||||
|
ALTER TABLE conversations ADD COLUMN last_prompt TEXT;
|
||||||
|
ALTER TABLE conversations ADD COLUMN reminded_at TEXT;
|
||||||
|
ALTER TABLE conversations ADD COLUMN ask_kind TEXT CHECK (ask_kind IN ('draw', 'split'));
|
||||||
|
ALTER TABLE conversations ADD COLUMN held_text TEXT;
|
||||||
@ -44,9 +44,7 @@ export function selectRecent(entries: Entry[], today: string, days: number): Ent
|
|||||||
return entries.filter((e) => e.date >= lo && e.date <= today).sort((a, b) => a.date.localeCompare(b.date));
|
return entries.filter((e) => e.date >= lo && e.date <= today).sort((a, b) => a.date.localeCompare(b.date));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function todayIst(now = new Date()): string {
|
export { istDate as todayIst } from '../services/ist';
|
||||||
return new Date(now.getTime() + 5.5 * 3_600_000).toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FetchLike = typeof fetch;
|
export type FetchLike = typeof fetch;
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { Db } from '../db';
|
import type { Db } from '../db';
|
||||||
import { runResultsJob, type DrawReport } from './job';
|
import { runResultsJob, type DrawReport } from './job';
|
||||||
|
import { istMinutes } from '../services/ist';
|
||||||
import { todayIst } from './portal';
|
import { todayIst } from './portal';
|
||||||
|
|
||||||
const WINDOW_START_MIN = 16 * 60; // 4 PM IST
|
const WINDOW_START_MIN = 16 * 60; // 4 PM IST
|
||||||
@ -13,11 +14,6 @@ export interface SchedulerOptions {
|
|||||||
run?: (db: Db) => Promise<DrawReport[]>;
|
run?: (db: Db) => Promise<DrawReport[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const minutesIst = (d: Date): number => {
|
|
||||||
const s = new Date(d.getTime() + 5.5 * 3_600_000);
|
|
||||||
return s.getUTCHours() * 60 + s.getUTCMinutes();
|
|
||||||
};
|
|
||||||
|
|
||||||
function todaysDrawReady(db: Db, today: string): boolean {
|
function todaysDrawReady(db: Db, today: string): boolean {
|
||||||
return !!db.prepare('SELECT 1 FROM draws WHERE draw_date = ? LIMIT 1').get(today);
|
return !!db.prepare('SELECT 1 FROM draws WHERE draw_date = ? LIMIT 1').get(today);
|
||||||
}
|
}
|
||||||
@ -32,7 +28,7 @@ export function createResultsScheduler(opts: SchedulerOptions) {
|
|||||||
async function tick(): Promise<void> {
|
async function tick(): Promise<void> {
|
||||||
const t = now();
|
const t = now();
|
||||||
const today = todayIst(t);
|
const today = todayIst(t);
|
||||||
const minutes = minutesIst(t);
|
const minutes = istMinutes(t);
|
||||||
|
|
||||||
if (minutes >= WINDOW_END_MIN) {
|
if (minutes >= WINDOW_END_MIN) {
|
||||||
if (loggedGiveUpFor !== today && !todaysDrawReady(opts.db, today)) {
|
if (loggedGiveUpFor !== today && !todaysDrawReady(opts.db, today)) {
|
||||||
|
|||||||
@ -3,28 +3,29 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||||||
import { createApp } from '../app';
|
import { createApp } from '../app';
|
||||||
import type { Config } from '../config';
|
import type { Config } from '../config';
|
||||||
import type { Db } from '../db';
|
import type { Db } from '../db';
|
||||||
|
import { testConfig } from '../testing/config';
|
||||||
import { makeDb, seedFixture, type Fixture } from '../testing/helpers';
|
import { makeDb, seedFixture, type Fixture } from '../testing/helpers';
|
||||||
import type { Bridge } from '../whatsapp/bridge';
|
import type { Bridge } from '../whatsapp/bridge';
|
||||||
|
import { createSendQueue, type SendQueue } from '../whatsapp/sendQueue';
|
||||||
|
|
||||||
const SECRET = 'test-secret-0123456789';
|
const SECRET = 'test-secret-0123456789';
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = testConfig({ srsSecret: SECRET });
|
||||||
port: 0,
|
|
||||||
host: '127.0.0.1',
|
|
||||||
dbPath: ':memory:',
|
|
||||||
srsSecret: SECRET,
|
|
||||||
jwtSecret: undefined,
|
|
||||||
bridgeUrl: 'http://127.0.0.1:3000',
|
|
||||||
defaultCountryCode: '91',
|
|
||||||
};
|
|
||||||
|
|
||||||
let db: Db;
|
let db: Db;
|
||||||
let f: Fixture;
|
let f: Fixture;
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
let bridgeStatus: string | null;
|
let bridgeStatus: string | null;
|
||||||
|
let queue: SendQueue;
|
||||||
|
let sent: Array<{ chatId: string; message: string }>;
|
||||||
|
let sendFails: boolean;
|
||||||
|
let logs: Array<Record<string, unknown>>;
|
||||||
|
let clock: Date;
|
||||||
|
|
||||||
const fakeBridge: Bridge = {
|
const fakeBridge: Bridge = {
|
||||||
async sendText() {
|
async sendText(chatId, message) {
|
||||||
|
if (sendFails) throw new Error('bridge /send failed with HTTP 500');
|
||||||
|
sent.push({ chatId, message });
|
||||||
return { messageIds: [] };
|
return { messageIds: [] };
|
||||||
},
|
},
|
||||||
async health() {
|
async health() {
|
||||||
@ -36,7 +37,12 @@ beforeEach(async () => {
|
|||||||
db = makeDb();
|
db = makeDb();
|
||||||
f = seedFixture(db);
|
f = seedFixture(db);
|
||||||
bridgeStatus = 'connected';
|
bridgeStatus = 'connected';
|
||||||
app = createApp({ db, config, bridge: fakeBridge });
|
sent = [];
|
||||||
|
logs = [];
|
||||||
|
sendFails = false;
|
||||||
|
clock = new Date('2026-11-03T05:00:00Z'); // 10:30 IST
|
||||||
|
queue = createSendQueue(fakeBridge, (e) => logs.push(e));
|
||||||
|
app = createApp({ db, config, bridge: fakeBridge, sendQueue: queue, now: () => clock });
|
||||||
await app.ready();
|
await app.ready();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -169,6 +175,111 @@ describe('POST /ingest: collecting text', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /ingest: replies', () => {
|
||||||
|
const chat = '145483561476218@lid';
|
||||||
|
const say = async (id: string, phone: string, t: string) => {
|
||||||
|
const res = await ingest(text(id, phone, t));
|
||||||
|
await queue.idle();
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('sends the collecting menu to the chat the message came from', async () => {
|
||||||
|
await say('m1', f.shopUser.phone, '8910=3');
|
||||||
|
expect(sent).toEqual([{ chatId: chat, message: 'Got it. Forward more, or reply 1 Done 2 Cancel' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plays the whole dialog through /ingest and saves the order', async () => {
|
||||||
|
await say('m1', f.shopUser.phone, '8910=3');
|
||||||
|
await say('m2', f.shopUser.phone, '7810=1');
|
||||||
|
await say('m3', f.shopUser.phone, 'done');
|
||||||
|
expect(sent.at(-1)!.message).toContain('Shop A - draw of TODAY 3 Nov');
|
||||||
|
await say('m4', f.shopUser.phone, '1');
|
||||||
|
expect(sent.at(-1)!.message).toBe('Saved #1 - Shop A, 4 tickets, draw of 3 Nov');
|
||||||
|
expect(sent.map((s) => s.chatId).every((c) => c === chat)).toBe(true);
|
||||||
|
expect(db.prepare('SELECT COUNT(*) AS n FROM order_lines').get()).toEqual({ n: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends several replies of one message in order', async () => {
|
||||||
|
// cancelling while a late message is held answers: cancelled, starting the next order, the menu
|
||||||
|
await say('m1', f.shopUser.phone, '8910=3');
|
||||||
|
db.prepare("UPDATE conversations SET reminded_at = '2026-11-03T05:05:00Z'").run();
|
||||||
|
await say('m2', f.shopUser.phone, '0838=2');
|
||||||
|
sent.length = 0;
|
||||||
|
await say('m3', f.shopUser.phone, 'cancel');
|
||||||
|
expect(sent.map((s) => s.message)).toEqual([
|
||||||
|
'Cancelled. Nothing was saved.',
|
||||||
|
'Starting your next order with the message you sent.',
|
||||||
|
'Got it. Forward more, or reply 1 Done 2 Cancel',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends nothing for a duplicate message id', async () => {
|
||||||
|
await say('m1', f.shopUser.phone, '8910=3');
|
||||||
|
sent.length = 0;
|
||||||
|
const again = await say('m1', f.shopUser.phone, '8910=3');
|
||||||
|
expect(again.json()).toEqual({ handled: true, duplicate: true });
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends nothing to unknown, @lid and inactive senders', async () => {
|
||||||
|
await say('m1', '919111111111', '8910=3');
|
||||||
|
await ingest({ ...text('m2', f.admin.phone, '8910=3'), sender_jid: '145483561476218@lid' });
|
||||||
|
await say('m3', f.disabledUser.phone, '8910=3');
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends nothing for chatter from a known user with no order in progress', async () => {
|
||||||
|
await say('m1', f.shopUser.phone, 'thanks');
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a failing bridge does not fail the request or lose the state', async () => {
|
||||||
|
sendFails = true;
|
||||||
|
const res = await say('m1', f.shopUser.phone, '8910=3');
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual({ handled: true, duplicate: false });
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
expect(db.prepare('SELECT state FROM conversations').get()).toEqual({ state: 'COLLECTING' });
|
||||||
|
expect(logs).toHaveLength(1);
|
||||||
|
expect(JSON.stringify(logs[0])).not.toContain('8910');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('after a failed send, ? gives the user the last prompt again', async () => {
|
||||||
|
sendFails = true;
|
||||||
|
await say('m1', f.shopUser.phone, '8910=3');
|
||||||
|
sendFails = false;
|
||||||
|
await say('m2', f.shopUser.phone, '?');
|
||||||
|
expect(sent.map((s) => s.message)).toEqual(['Got it. Forward more, or reply 1 Done 2 Cancel']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not wait for the bridge before answering', async () => {
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((r) => (release = r));
|
||||||
|
const slow: Bridge = { ...fakeBridge, sendText: async () => (await gate, { messageIds: [] }) };
|
||||||
|
const slowApp = createApp({ db, config, bridge: slow, sendQueue: createSendQueue(slow, () => {}), now: () => clock });
|
||||||
|
await slowApp.ready();
|
||||||
|
const res = await slowApp.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/ingest',
|
||||||
|
headers: { 'content-type': 'application/json', 'x-srs-secret': SECRET },
|
||||||
|
payload: JSON.stringify(text('m1', f.shopUser.phone, '8910=3')),
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200); // answered while the send is still blocked
|
||||||
|
release();
|
||||||
|
await slowApp.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a retry after a crash rolls back with the transaction and replies once', async () => {
|
||||||
|
db.exec(`CREATE TRIGGER boom BEFORE INSERT ON conversations BEGIN SELECT RAISE(ABORT, 'boom'); END;`);
|
||||||
|
const failed = await ingest(text('m1', f.shopUser.phone, '8910=3'));
|
||||||
|
expect(failed.statusCode).toBe(500);
|
||||||
|
expect(db.prepare('SELECT COUNT(*) AS n FROM ingest_log').get()).toEqual({ n: 0 });
|
||||||
|
db.exec('DROP TRIGGER boom');
|
||||||
|
await say('m1', f.shopUser.phone, '8910=3');
|
||||||
|
expect(sent).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('GET /api/health and /internal/health', () => {
|
describe('GET /api/health and /internal/health', () => {
|
||||||
it('reports ok on the public route without details', async () => {
|
it('reports ok on the public route without details', async () => {
|
||||||
const res = await app.inject({ method: 'GET', url: '/api/health' });
|
const res = await app.inject({ method: 'GET', url: '/api/health' });
|
||||||
|
|||||||
@ -3,7 +3,9 @@ import { z } from 'zod';
|
|||||||
import type { Db } from '../db';
|
import type { Db } from '../db';
|
||||||
import { phoneFromJid } from '../services/phone';
|
import { phoneFromJid } from '../services/phone';
|
||||||
import { findUserByPhone } from '../services/users';
|
import { findUserByPhone } from '../services/users';
|
||||||
import { receiveText } from '../whatsapp/conversation';
|
import type { OrderSettings } from '../config';
|
||||||
|
import { handleMessage } from '../whatsapp/conversation';
|
||||||
|
import type { SendQueue } from '../whatsapp/sendQueue';
|
||||||
import { requireSecret } from './auth';
|
import { requireSecret } from './auth';
|
||||||
|
|
||||||
const base = z.object({
|
const base = z.object({
|
||||||
@ -24,13 +26,16 @@ const payloadSchema = z.discriminatedUnion('kind', [
|
|||||||
interface IngestDeps {
|
interface IngestDeps {
|
||||||
db: Db;
|
db: Db;
|
||||||
secret: string;
|
secret: string;
|
||||||
|
settings: OrderSettings;
|
||||||
|
queue: SendQueue;
|
||||||
|
now?: () => Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isUniqueViolation = (e: unknown): boolean =>
|
const isUniqueViolation = (e: unknown): boolean =>
|
||||||
typeof e === 'object' && e !== null && 'code' in e && String((e as { code: unknown }).code).startsWith('SQLITE_CONSTRAINT');
|
typeof e === 'object' && e !== null && 'code' in e && String((e as { code: unknown }).code).startsWith('SQLITE_CONSTRAINT');
|
||||||
|
|
||||||
export const ingestRoutes =
|
export const ingestRoutes =
|
||||||
({ db, secret }: IngestDeps): FastifyPluginAsync =>
|
({ db, secret, settings, queue, now = () => new Date() }: IngestDeps): FastifyPluginAsync =>
|
||||||
async (app) => {
|
async (app) => {
|
||||||
const insertLog = db.prepare(
|
const insertLog = db.prepare(
|
||||||
`INSERT INTO ingest_log (wa_message_id, sender_phone, user_id, kind, outcome)
|
`INSERT INTO ingest_log (wa_message_id, sender_phone, user_id, kind, outcome)
|
||||||
@ -57,7 +62,7 @@ export const ingestRoutes =
|
|||||||
|
|
||||||
const accepted = outcome === 'accepted' && user !== undefined;
|
const accepted = outcome === 'accepted' && user !== undefined;
|
||||||
|
|
||||||
const apply = db.transaction((): { duplicate: boolean } => {
|
const apply = db.transaction((): { duplicate: boolean; replies: string[] } => {
|
||||||
try {
|
try {
|
||||||
insertLog.run({
|
insertLog.run({
|
||||||
wa_message_id: msg.message_id,
|
wa_message_id: msg.message_id,
|
||||||
@ -67,17 +72,17 @@ export const ingestRoutes =
|
|||||||
outcome,
|
outcome,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isUniqueViolation(e)) return { duplicate: true };
|
if (isUniqueViolation(e)) return { duplicate: true, replies: [] };
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accepted && msg.kind === 'text') {
|
const replies =
|
||||||
receiveText(db, user, msg.chat_id, msg.text);
|
accepted && msg.kind === 'text' ? handleMessage(db, settings, user, msg.chat_id, msg.text, now()) : [];
|
||||||
}
|
return { duplicate: false, replies };
|
||||||
return { duplicate: false };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { duplicate } = apply();
|
const { duplicate, replies } = apply();
|
||||||
|
queue.enqueue(msg.chat_id, replies);
|
||||||
|
|
||||||
if (duplicate) return { handled: true, duplicate: true };
|
if (duplicate) return { handled: true, duplicate: true };
|
||||||
if (!accepted) return { handled: false };
|
if (!accepted) return { handled: false };
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { openDb } from './db';
|
|||||||
import { runMigrations } from './db/migrate';
|
import { runMigrations } from './db/migrate';
|
||||||
import { createResultsScheduler } from './results/scheduler';
|
import { createResultsScheduler } from './results/scheduler';
|
||||||
import { createBridge } from './whatsapp/bridge';
|
import { createBridge } from './whatsapp/bridge';
|
||||||
|
import { createSendQueue } from './whatsapp/sendQueue';
|
||||||
|
import { createConversationSweeper } from './whatsapp/sweep';
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@ -15,12 +17,18 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bridge = createBridge(config.bridgeUrl);
|
const bridge = createBridge(config.bridgeUrl);
|
||||||
const app = createApp({ db, config, bridge }, { logger: true });
|
let log: (entry: Record<string, unknown>) => void = (e) => console.log(JSON.stringify(e));
|
||||||
|
const sendQueue = createSendQueue(bridge, (e) => log(e));
|
||||||
// TODO(conversation): on startup, re-arm the ~20 s collection timers for conversations that are
|
const app = createApp({ db, config, bridge, sendQueue }, { logger: true });
|
||||||
// still COLLECTING, so a restart does not lose half-finished orders.
|
log = (e) => app.log.info(e);
|
||||||
|
|
||||||
const stopScheduler = createResultsScheduler({ db, log: (e) => app.log.info(e) }).start();
|
const stopScheduler = createResultsScheduler({ db, log: (e) => app.log.info(e) }).start();
|
||||||
|
const stopSweeper = createConversationSweeper({
|
||||||
|
db,
|
||||||
|
settings: config.orders,
|
||||||
|
queue: sendQueue,
|
||||||
|
log: (e) => app.log.info(e),
|
||||||
|
}).start();
|
||||||
|
|
||||||
await app.listen({ port: config.port, host: config.host });
|
await app.listen({ port: config.port, host: config.host });
|
||||||
|
|
||||||
@ -31,7 +39,9 @@ async function main(): Promise<void> {
|
|||||||
app.log.info({ signal }, 'shutting down');
|
app.log.info({ signal }, 'shutting down');
|
||||||
setTimeout(() => process.exit(1), 10_000).unref();
|
setTimeout(() => process.exit(1), 10_000).unref();
|
||||||
stopScheduler();
|
stopScheduler();
|
||||||
|
stopSweeper();
|
||||||
await app.close();
|
await app.close();
|
||||||
|
await sendQueue.idle();
|
||||||
db.close();
|
db.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { drawDateForOrder } from './drawDate';
|
import { drawChoiceAt, drawDateForOrder } from './drawDate';
|
||||||
|
|
||||||
const at = (iso: string) => drawDateForOrder(new Date(iso));
|
const at = (iso: string) => drawDateForOrder(new Date(iso));
|
||||||
|
|
||||||
@ -24,3 +24,46 @@ describe('drawDateForOrder (3 PM IST cutoff)', () => {
|
|||||||
expect(at('2026-12-31T12:00:00Z')).toBe('2027-01-01');
|
expect(at('2026-12-31T12:00:00Z')).toBe('2027-01-01');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('drawDateForOrder with a custom cutoff', () => {
|
||||||
|
it('uses the given cutoff', () => {
|
||||||
|
expect(drawDateForOrder(new Date('2026-11-02T09:00:00Z'), 14 * 60 + 30)).toBe('2026-11-02');
|
||||||
|
expect(drawDateForOrder(new Date('2026-11-02T09:00:01Z'), 14 * 60 + 30)).toBe('2026-11-03');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('drawChoiceAt (15:00 cutoff, ask until 15:30)', () => {
|
||||||
|
const w = { cutoffMin: 15 * 60, askUntilMin: 15 * 60 + 30 };
|
||||||
|
const at = (iso: string) => drawChoiceAt(new Date(iso), w);
|
||||||
|
|
||||||
|
it('before the cutoff is today, no question', () => {
|
||||||
|
expect(at('2026-11-03T09:29:59Z')).toEqual({ kind: 'today', date: '2026-11-03' });
|
||||||
|
});
|
||||||
|
it('exactly 15:00:00 is still today', () => {
|
||||||
|
expect(at('2026-11-03T09:30:00Z')).toEqual({ kind: 'today', date: '2026-11-03' });
|
||||||
|
});
|
||||||
|
it('15:00:01 asks', () => {
|
||||||
|
expect(at('2026-11-03T09:30:01Z')).toEqual({ kind: 'ask', today: '2026-11-03', tomorrow: '2026-11-04' });
|
||||||
|
});
|
||||||
|
it('exactly 15:30:00 still asks', () => {
|
||||||
|
expect(at('2026-11-03T10:00:00Z')).toEqual({ kind: 'ask', today: '2026-11-03', tomorrow: '2026-11-04' });
|
||||||
|
});
|
||||||
|
it('15:30:01 is tomorrow, no question', () => {
|
||||||
|
expect(at('2026-11-03T10:00:01Z')).toEqual({ kind: 'tomorrow', date: '2026-11-04' });
|
||||||
|
});
|
||||||
|
it('late evening is tomorrow', () => {
|
||||||
|
expect(at('2026-11-03T17:00:00Z')).toEqual({ kind: 'tomorrow', date: '2026-11-04' });
|
||||||
|
});
|
||||||
|
it('uses the Indian date, not the UTC date', () => {
|
||||||
|
expect(at('2026-11-03T20:00:00Z')).toEqual({ kind: 'today', date: '2026-11-04' });
|
||||||
|
expect(at('2026-11-03T17:00:00Z')).toEqual({ kind: 'tomorrow', date: '2026-11-04' });
|
||||||
|
expect(at('2026-11-02T19:00:00Z')).toEqual({ kind: 'today', date: '2026-11-03' });
|
||||||
|
});
|
||||||
|
it('rolls over a month end when asking', () => {
|
||||||
|
expect(at('2026-11-30T09:45:00Z')).toEqual({ kind: 'ask', today: '2026-11-30', tomorrow: '2026-12-01' });
|
||||||
|
});
|
||||||
|
it('honours other windows', () => {
|
||||||
|
const early = drawChoiceAt(new Date('2026-11-03T09:00:01Z'), { cutoffMin: 14 * 60 + 30, askUntilMin: 15 * 60 });
|
||||||
|
expect(early).toEqual({ kind: 'ask', today: '2026-11-03', tomorrow: '2026-11-04' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,18 +1,31 @@
|
|||||||
const IST_OFFSET_MS = 5.5 * 3_600_000;
|
import { istParts } from './ist';
|
||||||
const CUTOFF_MINUTES = 15 * 60;
|
|
||||||
|
|
||||||
function ist(at: Date): { date: string; minutes: number; seconds: number } {
|
const DEFAULT_CUTOFF_MINUTES = 15 * 60;
|
||||||
const shifted = new Date(at.getTime() + IST_OFFSET_MS);
|
|
||||||
return {
|
const nextDay = (date: string): string =>
|
||||||
date: shifted.toISOString().slice(0, 10),
|
new Date(Date.parse(`${date}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10);
|
||||||
minutes: shifted.getUTCHours() * 60 + shifted.getUTCMinutes(),
|
|
||||||
seconds: shifted.getUTCSeconds(),
|
const isAfter = (t: { minutes: number; seconds: number }, limitMinutes: number): boolean =>
|
||||||
};
|
t.minutes > limitMinutes || (t.minutes === limitMinutes && t.seconds > 0);
|
||||||
|
|
||||||
|
export function drawDateForOrder(receivedAt: Date, cutoffMinutes: number = DEFAULT_CUTOFF_MINUTES): string {
|
||||||
|
const t = istParts(receivedAt);
|
||||||
|
return isAfter(t, cutoffMinutes) ? nextDay(t.date) : t.date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function drawDateForOrder(receivedAt: Date): string {
|
export type DrawChoice =
|
||||||
const t = ist(receivedAt);
|
| { kind: 'today'; date: string }
|
||||||
const afterCutoff = t.minutes > CUTOFF_MINUTES || (t.minutes === CUTOFF_MINUTES && t.seconds > 0);
|
| { kind: 'tomorrow'; date: string }
|
||||||
if (!afterCutoff) return t.date;
|
| { kind: 'ask'; today: string; tomorrow: string };
|
||||||
return new Date(Date.parse(`${t.date}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10);
|
|
||||||
|
export interface DrawWindow {
|
||||||
|
cutoffMin: number;
|
||||||
|
askUntilMin: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawChoiceAt(at: Date, w: DrawWindow): DrawChoice {
|
||||||
|
const t = istParts(at);
|
||||||
|
if (!isAfter(t, w.cutoffMin)) return { kind: 'today', date: t.date };
|
||||||
|
if (!isAfter(t, w.askUntilMin)) return { kind: 'ask', today: t.date, tomorrow: nextDay(t.date) };
|
||||||
|
return { kind: 'tomorrow', date: nextDay(t.date) };
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/services/ist.test.ts
Normal file
55
src/services/ist.test.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { todayIst } from '../results/portal';
|
||||||
|
import { istDate, istMinutes, istParts } from './ist';
|
||||||
|
|
||||||
|
describe('istParts', () => {
|
||||||
|
it('shifts UTC by 5 hours 30 minutes', () => {
|
||||||
|
expect(istParts(new Date('2026-11-03T05:00:00Z'))).toEqual({ date: '2026-11-03', minutes: 10 * 60 + 30, seconds: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the seconds', () => {
|
||||||
|
expect(istParts(new Date('2026-11-03T09:30:01Z')).seconds).toBe(1);
|
||||||
|
expect(istParts(new Date('2026-11-03T09:30:59Z')).seconds).toBe(59);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls the date at 18:30 UTC, which is midnight in India', () => {
|
||||||
|
expect(istParts(new Date('2026-11-02T18:29:59Z'))).toEqual({ date: '2026-11-02', minutes: 23 * 60 + 59, seconds: 59 });
|
||||||
|
expect(istParts(new Date('2026-11-02T18:30:00Z'))).toEqual({ date: '2026-11-03', minutes: 0, seconds: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls month and year ends', () => {
|
||||||
|
expect(istParts(new Date('2026-12-31T18:30:00Z')).date).toBe('2027-01-01');
|
||||||
|
expect(istParts(new Date('2026-11-30T20:00:00Z')).date).toBe('2026-12-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not depend on the machine timezone', () => {
|
||||||
|
const before = process.env.TZ;
|
||||||
|
try {
|
||||||
|
for (const tz of ['UTC', 'Asia/Kolkata', 'America/Los_Angeles', 'Pacific/Auckland']) {
|
||||||
|
process.env.TZ = tz;
|
||||||
|
expect(istParts(new Date('2026-11-02T20:00:00Z'))).toEqual({ date: '2026-11-03', minutes: 90, seconds: 0 });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (before === undefined) delete process.env.TZ;
|
||||||
|
else process.env.TZ = before;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('istDate / istMinutes', () => {
|
||||||
|
it('give the Indian date and minutes after midnight', () => {
|
||||||
|
const at = new Date('2026-11-03T09:40:00Z'); // 15:10 IST
|
||||||
|
expect(istDate(at)).toBe('2026-11-03');
|
||||||
|
expect(istMinutes(at)).toBe(15 * 60 + 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('istDate defaults to now', () => {
|
||||||
|
expect(istDate()).toBe(istDate(new Date()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the results code uses the same function', () => {
|
||||||
|
const at = new Date('2026-11-02T20:00:00Z');
|
||||||
|
expect(todayIst(at)).toBe('2026-11-03');
|
||||||
|
expect(todayIst(at)).toBe(istDate(at));
|
||||||
|
});
|
||||||
|
});
|
||||||
19
src/services/ist.ts
Normal file
19
src/services/ist.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export const IST_OFFSET_MS = 5.5 * 3_600_000;
|
||||||
|
|
||||||
|
export interface IstParts {
|
||||||
|
date: string;
|
||||||
|
minutes: number;
|
||||||
|
seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istParts(at: Date): IstParts {
|
||||||
|
const shifted = new Date(at.getTime() + IST_OFFSET_MS);
|
||||||
|
return {
|
||||||
|
date: shifted.toISOString().slice(0, 10),
|
||||||
|
minutes: shifted.getUTCHours() * 60 + shifted.getUTCMinutes(),
|
||||||
|
seconds: shifted.getUTCSeconds(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const istDate = (at: Date = new Date()): string => istParts(at).date;
|
||||||
|
export const istMinutes = (at: Date): number => istParts(at).minutes;
|
||||||
98
src/services/orders.test.ts
Normal file
98
src/services/orders.test.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import type { Db } from '../db';
|
||||||
|
import { makeDb, seedFixture, type Fixture } from '../testing/helpers';
|
||||||
|
import { saveOrder, ShopNotAllowedError } from './orders';
|
||||||
|
import { getUserById } from './users';
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let f: Fixture;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = makeDb();
|
||||||
|
f = seedFixture(db);
|
||||||
|
});
|
||||||
|
afterEach(() => db.close());
|
||||||
|
|
||||||
|
const count = (table: string) => (db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
|
||||||
|
|
||||||
|
describe('saveOrder', () => {
|
||||||
|
it('stores the order and every line as written, repeats not merged', () => {
|
||||||
|
const user = getUserById(db, f.shopUser.id)!;
|
||||||
|
const id = saveOrder(db, {
|
||||||
|
user,
|
||||||
|
shopId: f.shops.a,
|
||||||
|
drawDate: '2026-11-03',
|
||||||
|
rawText: '8910=3\n7810=1\n8910=3',
|
||||||
|
lines: [
|
||||||
|
{ ending: '8910', qty: 3 },
|
||||||
|
{ ending: '7810', qty: 1 },
|
||||||
|
{ ending: '8910', qty: 3 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(db.prepare('SELECT * FROM orders WHERE id = ?').get(id)).toMatchObject({
|
||||||
|
shop_id: f.shops.a,
|
||||||
|
draw_date: '2026-11-03',
|
||||||
|
status: 'confirmed',
|
||||||
|
source: 'whatsapp_text',
|
||||||
|
created_by_user_id: f.shopUser.id,
|
||||||
|
raw_text: '8910=3\n7810=1\n8910=3',
|
||||||
|
});
|
||||||
|
expect(db.prepare('SELECT ending, qty FROM order_lines WHERE order_id = ? ORDER BY id').all(id)).toEqual([
|
||||||
|
{ ending: '8910', qty: 3 },
|
||||||
|
{ ending: '7810', qty: 1 },
|
||||||
|
{ ending: '8910', qty: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps leading zeros in endings', () => {
|
||||||
|
const user = getUserById(db, f.shopUser.id)!;
|
||||||
|
const id = saveOrder(db, {
|
||||||
|
user,
|
||||||
|
shopId: f.shops.a,
|
||||||
|
drawDate: '2026-11-03',
|
||||||
|
rawText: '0057',
|
||||||
|
lines: [{ ending: '0057', qty: 1 }],
|
||||||
|
});
|
||||||
|
expect((db.prepare('SELECT ending FROM order_lines WHERE order_id = ?').get(id) as { ending: string }).ending).toBe('0057');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a shop the user cannot use and writes nothing', () => {
|
||||||
|
const user = getUserById(db, f.shopUser.id)!; // only has shop A
|
||||||
|
expect(() =>
|
||||||
|
saveOrder(db, { user, shopId: f.shops.b, drawDate: '2026-11-03', rawText: 'x', lines: [{ ending: '8910', qty: 1 }] }),
|
||||||
|
).toThrow(ShopNotAllowedError);
|
||||||
|
expect(count('orders')).toBe(0);
|
||||||
|
expect(count('order_lines')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an inactive user', () => {
|
||||||
|
const user = getUserById(db, f.disabledUser.id)!;
|
||||||
|
expect(() =>
|
||||||
|
saveOrder(db, { user, shopId: f.shops.a, drawDate: '2026-11-03', rawText: 'x', lines: [{ ending: '8910', qty: 1 }] }),
|
||||||
|
).toThrow(ShopNotAllowedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an admin use any shop', () => {
|
||||||
|
const admin = getUserById(db, f.admin.id)!;
|
||||||
|
saveOrder(db, { user: admin, shopId: f.shops.c, drawDate: '2026-11-03', rawText: 'x', lines: [{ ending: '8910', qty: 1 }] });
|
||||||
|
expect(count('orders')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves no partial order when a line fails to insert', () => {
|
||||||
|
const user = getUserById(db, f.shopUser.id)!;
|
||||||
|
expect(() =>
|
||||||
|
saveOrder(db, {
|
||||||
|
user,
|
||||||
|
shopId: f.shops.a,
|
||||||
|
drawDate: '2026-11-03',
|
||||||
|
rawText: 'x',
|
||||||
|
lines: [
|
||||||
|
{ ending: '8910', qty: 1 },
|
||||||
|
{ ending: 'bad', qty: 1 }, // violates the 4-digit CHECK
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toThrow(/CHECK|constraint/i);
|
||||||
|
expect(count('orders')).toBe(0);
|
||||||
|
expect(count('order_lines')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
src/services/orders.ts
Normal file
39
src/services/orders.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import type { Db } from '../db';
|
||||||
|
import { canUseShop } from './shopScope';
|
||||||
|
import type { User } from './users';
|
||||||
|
|
||||||
|
export class ShopNotAllowedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('user cannot use this shop');
|
||||||
|
this.name = 'ShopNotAllowedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveOrderInput {
|
||||||
|
user: User;
|
||||||
|
shopId: number;
|
||||||
|
drawDate: string;
|
||||||
|
rawText: string;
|
||||||
|
lines: { ending: string; qty: number }[];
|
||||||
|
source?: 'whatsapp_text' | 'whatsapp_photo';
|
||||||
|
}
|
||||||
|
export function saveOrder(db: Db, input: SaveOrderInput): number {
|
||||||
|
const run = db.transaction((): number => {
|
||||||
|
if (!canUseShop(db, input.user, input.shopId)) throw new ShopNotAllowedError();
|
||||||
|
|
||||||
|
const orderId = Number(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO orders (shop_id, draw_date, source, created_by_user_id, raw_text)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(input.shopId, input.drawDate, input.source ?? 'whatsapp_text', input.user.id, input.rawText)
|
||||||
|
.lastInsertRowid,
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertLine = db.prepare('INSERT INTO order_lines (order_id, ending, qty) VALUES (?, ?, ?)');
|
||||||
|
for (const l of input.lines) insertLine.run(orderId, l.ending, l.qty);
|
||||||
|
return orderId;
|
||||||
|
});
|
||||||
|
return run();
|
||||||
|
}
|
||||||
15
src/testing/config.ts
Normal file
15
src/testing/config.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import type { Config } from '../config';
|
||||||
|
|
||||||
|
export function testConfig(overrides: Partial<Config> = {}): Config {
|
||||||
|
return {
|
||||||
|
port: 0,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
dbPath: ':memory:',
|
||||||
|
srsSecret: 'test-secret-0123456789',
|
||||||
|
jwtSecret: undefined,
|
||||||
|
bridgeUrl: 'http://127.0.0.1:3000',
|
||||||
|
defaultCountryCode: '91',
|
||||||
|
orders: { cutoffMin: 15 * 60, askUntilMin: 15 * 60 + 30, remindMin: 10, expireMin: 30 },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ export interface Fixture {
|
|||||||
admin: { id: number; phone: string };
|
admin: { id: number; phone: string };
|
||||||
admin2: { id: number; phone: string };
|
admin2: { id: number; phone: string };
|
||||||
shopUser: { id: number; phone: string };
|
shopUser: { id: number; phone: string };
|
||||||
|
multiUser: { id: number; phone: string };
|
||||||
disabledUser: { id: number; phone: string };
|
disabledUser: { id: number; phone: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,10 +37,13 @@ export function seedFixture(db: Db): Fixture {
|
|||||||
const admin2 = user('admin2', '919000000004', 1, 1);
|
const admin2 = user('admin2', '919000000004', 1, 1);
|
||||||
const shopUser = user('shop-a', '919000000002', 0, 1);
|
const shopUser = user('shop-a', '919000000002', 0, 1);
|
||||||
const disabledUser = user('gone', '919000000003', 0, 0);
|
const disabledUser = user('gone', '919000000003', 0, 0);
|
||||||
|
const multiUser = user('shop-ab', '919000000005', 0, 1);
|
||||||
|
|
||||||
db.prepare('INSERT INTO user_shops (user_id, shop_id) VALUES (?, ?)').run(shopUser.id, shops.a);
|
db.prepare('INSERT INTO user_shops (user_id, shop_id) VALUES (?, ?)').run(shopUser.id, shops.a);
|
||||||
|
db.prepare('INSERT INTO user_shops (user_id, shop_id) VALUES (?, ?)').run(multiUser.id, shops.a);
|
||||||
|
db.prepare('INSERT INTO user_shops (user_id, shop_id) VALUES (?, ?)').run(multiUser.id, shops.b);
|
||||||
|
|
||||||
return { shops, admin, admin2, shopUser, disabledUser };
|
return { shops, admin, admin2, shopUser, multiUser, disabledUser };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const hasPdftotext: boolean = (() => {
|
export const hasPdftotext: boolean = (() => {
|
||||||
|
|||||||
88
src/whatsapp/commands.test.ts
Normal file
88
src/whatsapp/commands.test.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { classify, hasOrderContent } from './commands';
|
||||||
|
|
||||||
|
const idle = { collecting: false };
|
||||||
|
const col = { collecting: true };
|
||||||
|
|
||||||
|
describe('classify: single-word commands', () => {
|
||||||
|
it.each(['done', 'DONE', 'Done', ' done ', 'done.', 'Done!', 'submit', 'Submit.'])('%j is submit', (t) => {
|
||||||
|
expect(classify(t, idle)).toEqual({ kind: 'submit', body: '' });
|
||||||
|
});
|
||||||
|
it.each(['cancel', 'Cancel', 'CANCEL!', ' cancel. '])('%j is cancel', (t) => {
|
||||||
|
expect(classify(t, idle)).toEqual({ kind: 'cancel' });
|
||||||
|
});
|
||||||
|
it.each(['?', 'help', 'Help', 'HELP'])('%j is help', (t) => {
|
||||||
|
expect(classify(t, idle)).toEqual({ kind: 'help' });
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
['1', 1],
|
||||||
|
['2', 2],
|
||||||
|
['3', 3],
|
||||||
|
[' 4 ', 4],
|
||||||
|
['12', 12],
|
||||||
|
['1.', 1],
|
||||||
|
])('%j is number %i', (t, n) => {
|
||||||
|
expect(classify(t, idle)).toEqual({ kind: 'number', n });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('classify: order text and look-alikes', () => {
|
||||||
|
it.each(['0888=2', '0888', '123', 'hello', 'thanks', '👍', 'done please', 'cancelled', 'yes', ''])(
|
||||||
|
'%j is text',
|
||||||
|
(t) => {
|
||||||
|
expect(classify(t, idle)).toEqual({ kind: 'text' });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it('whitespace only is text', () => {
|
||||||
|
expect(classify(' \n \n', idle)).toEqual({ kind: 'text' });
|
||||||
|
});
|
||||||
|
it('three or more digits are never a menu number', () => {
|
||||||
|
expect(classify('100', idle)).toEqual({ kind: 'text' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('classify: done as the last line', () => {
|
||||||
|
it('splits order text from Done', () => {
|
||||||
|
expect(classify('0888\n0057\ndone', idle)).toEqual({ kind: 'submit', body: '0888\n0057' });
|
||||||
|
});
|
||||||
|
it('ignores case, punctuation, CRLF and trailing blank lines', () => {
|
||||||
|
expect(classify('0888=2\r\n7810=1\r\nSubmit.\n\n', idle)).toEqual({ kind: 'submit', body: '0888=2\n7810=1' });
|
||||||
|
});
|
||||||
|
it('done only counts on the last line', () => {
|
||||||
|
expect(classify('done\n0888', idle)).toEqual({ kind: 'text' });
|
||||||
|
});
|
||||||
|
it('cancel on a later line of a longer message is not a command', () => {
|
||||||
|
expect(classify('0888\ncancel', idle)).toEqual({ kind: 'text' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('classify: trailing 1 (Hermes merges quick messages)', () => {
|
||||||
|
it('is Done when the conversation was already collecting', () => {
|
||||||
|
expect(classify('0888=2\n7810=1\n1', col)).toEqual({ kind: 'submit', body: '0888=2\n7810=1' });
|
||||||
|
});
|
||||||
|
it('stays order text in the first message of an order', () => {
|
||||||
|
expect(classify('0888=2\n7810=1\n1', idle)).toEqual({ kind: 'text' });
|
||||||
|
});
|
||||||
|
it('a lone 1 is a number either way (the engine decides what it means)', () => {
|
||||||
|
expect(classify('1', col)).toEqual({ kind: 'number', n: 1 });
|
||||||
|
expect(classify('1', idle)).toEqual({ kind: 'number', n: 1 });
|
||||||
|
});
|
||||||
|
it('only 1 works this way, not other numbers', () => {
|
||||||
|
expect(classify('0888=2\n2', col)).toEqual({ kind: 'text' });
|
||||||
|
});
|
||||||
|
it('a line ending in 1 is not a trailing 1', () => {
|
||||||
|
expect(classify('0888=2\n7801', col)).toEqual({ kind: 'text' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasOrderContent', () => {
|
||||||
|
it('is true for an entry', () => {
|
||||||
|
expect(hasOrderContent('0888=2')).toBe(true);
|
||||||
|
});
|
||||||
|
it('is true for something unreadable', () => {
|
||||||
|
expect(hasOrderContent('08?8=2')).toBe(true);
|
||||||
|
});
|
||||||
|
it('is false for chatter, emoji, a lone number and totals', () => {
|
||||||
|
for (const t of ['ok', 'thanks', '👍', '1', '12', 'Total 50', '']) expect(hasOrderContent(t)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
50
src/whatsapp/commands.ts
Normal file
50
src/whatsapp/commands.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { parseOrder } from '../parser/parseOrder';
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* submit the order is finished; `body` is whatever order text came before the command ('' if none)
|
||||||
|
* cancel clear the conversation
|
||||||
|
* help repeat the last prompt
|
||||||
|
* number 1-2 digits on their own: a menu answer, never order text (endings are 4 digits)
|
||||||
|
* text anything else: order text, or an unexpected reply
|
||||||
|
*/
|
||||||
|
export type Command =
|
||||||
|
| { kind: 'submit'; body: string }
|
||||||
|
| { kind: 'cancel' }
|
||||||
|
| { kind: 'help' }
|
||||||
|
| { kind: 'number'; n: number }
|
||||||
|
| { kind: 'text' };
|
||||||
|
|
||||||
|
export interface ClassifyOptions {
|
||||||
|
collecting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUBMIT_WORDS = new Set(['done', 'submit']);
|
||||||
|
const word = (s: string): string => s.trim().toLowerCase().replace(/[.,!]+$/, '').trim();
|
||||||
|
|
||||||
|
export function classify(text: string, opts: ClassifyOptions): Command {
|
||||||
|
const lines = text.split(/\r?\n/).map((l) => l.trim());
|
||||||
|
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
||||||
|
const nonEmpty = lines.filter((l) => l !== '');
|
||||||
|
if (nonEmpty.length === 0) return { kind: 'text' };
|
||||||
|
|
||||||
|
if (nonEmpty.length === 1) {
|
||||||
|
const w = word(nonEmpty[0]!);
|
||||||
|
if (SUBMIT_WORDS.has(w)) return { kind: 'submit', body: '' };
|
||||||
|
if (w === 'cancel') return { kind: 'cancel' };
|
||||||
|
if (w === '?' || w === 'help') return { kind: 'help' };
|
||||||
|
if (/^\d{1,2}$/.test(w)) return { kind: 'number', n: Number(w) };
|
||||||
|
return { kind: 'text' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = word(lines[lines.length - 1]!);
|
||||||
|
const body = lines.slice(0, -1).join('\n').trim();
|
||||||
|
if (SUBMIT_WORDS.has(last)) return { kind: 'submit', body };
|
||||||
|
if (last === '1' && opts.collecting) return { kind: 'submit', body };
|
||||||
|
return { kind: 'text' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasOrderContent(text: string): boolean {
|
||||||
|
const r = parseOrder(text);
|
||||||
|
return r.lines.length > 0 || r.unreadable.length > 0;
|
||||||
|
}
|
||||||
747
src/whatsapp/conversation.test.ts
Normal file
747
src/whatsapp/conversation.test.ts
Normal file
@ -0,0 +1,747 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import type { Db } from '../db';
|
||||||
|
import { getUserById } from '../services/users';
|
||||||
|
import { testConfig } from '../testing/config';
|
||||||
|
import { makeDb, seedFixture, type Fixture } from '../testing/helpers';
|
||||||
|
import { getConversation, handleMessage } from './conversation';
|
||||||
|
import * as msg from './messages';
|
||||||
|
|
||||||
|
const S = testConfig().orders;
|
||||||
|
|
||||||
|
// 2026-11-03 10:30 IST: well before the 15:00 cutoff
|
||||||
|
const MORNING = new Date('2026-11-03T05:00:00Z');
|
||||||
|
// 15:10 IST: inside the "ask today or tomorrow" window
|
||||||
|
const ASK_TIME = new Date('2026-11-03T09:40:00Z');
|
||||||
|
// 15:20 IST, 15:40 IST
|
||||||
|
const STILL_ASKING = new Date('2026-11-03T09:50:00Z');
|
||||||
|
const TOO_LATE = new Date('2026-11-03T10:10:00Z');
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let f: Fixture;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = makeDb();
|
||||||
|
f = seedFixture(db);
|
||||||
|
});
|
||||||
|
afterEach(() => db.close());
|
||||||
|
|
||||||
|
/** A person chatting with the bot: `say(text, at)` returns the bot's replies. */
|
||||||
|
function chatAs(userId: number, chatId = 'chat-1@lid') {
|
||||||
|
const user = getUserById(db, userId)!;
|
||||||
|
return (text: string, at: Date = MORNING): string[] => handleMessage(db, S, user, chatId, text, at);
|
||||||
|
}
|
||||||
|
|
||||||
|
const conv = (userId: number) => getConversation(db, userId);
|
||||||
|
const state = (userId: number) => conv(userId)?.state ?? 'IDLE';
|
||||||
|
const orders = () => db.prepare('SELECT * FROM orders ORDER BY id').all() as Array<Record<string, unknown>>;
|
||||||
|
const lines = (orderId: number) =>
|
||||||
|
db.prepare('SELECT ending, qty FROM order_lines WHERE order_id = ? ORDER BY id').all(orderId) as Array<{
|
||||||
|
ending: string;
|
||||||
|
qty: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const SUMMARY_MENU = msg.SUMMARY_MENU;
|
||||||
|
|
||||||
|
describe('the walk-through from the plan (section 3.3)', () => {
|
||||||
|
it('plays out word for word for a user with two shops', () => {
|
||||||
|
const say = chatAs(f.multiUser.id);
|
||||||
|
|
||||||
|
expect(say('8910=3')).toEqual(['Got it. Forward more, or reply 1 Done 2 Cancel']);
|
||||||
|
expect(say('7810=1')).toEqual([]);
|
||||||
|
expect(say('08?8=2')).toEqual([]);
|
||||||
|
expect(say('Total 50')).toEqual([]);
|
||||||
|
expect(say('1')).toEqual(['Which shop?\n1) Shop A\n2) Shop B']);
|
||||||
|
|
||||||
|
expect(say('2')).toEqual([
|
||||||
|
[
|
||||||
|
'Shop B - draw of TODAY 3 Nov',
|
||||||
|
'2 lines, 4 tickets',
|
||||||
|
'8910 - 3',
|
||||||
|
'7810 - 1',
|
||||||
|
'Not read, not in the order:',
|
||||||
|
' 08?8=2',
|
||||||
|
'1 Save 2 Add more 3 Cancel',
|
||||||
|
].join('\n'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(say('2')).toEqual(['Send the lines to add. Send only the new or corrected lines, not the whole order.']);
|
||||||
|
|
||||||
|
expect(say('0838=2')).toEqual([
|
||||||
|
[
|
||||||
|
'Shop B - draw of TODAY 3 Nov',
|
||||||
|
'3 lines, 6 tickets',
|
||||||
|
'8910 - 3',
|
||||||
|
'7810 - 1',
|
||||||
|
'0838 - 2',
|
||||||
|
'Left out earlier, not in the order:',
|
||||||
|
' 08?8=2',
|
||||||
|
'1 Save 2 Add more 3 Cancel',
|
||||||
|
].join('\n'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(say('1')).toEqual(['Saved #1 - Shop B, 6 tickets, draw of 3 Nov']);
|
||||||
|
|
||||||
|
const [order] = orders();
|
||||||
|
expect(order).toMatchObject({
|
||||||
|
shop_id: f.shops.b,
|
||||||
|
draw_date: '2026-11-03',
|
||||||
|
status: 'confirmed',
|
||||||
|
source: 'whatsapp_text',
|
||||||
|
created_by_user_id: f.multiUser.id,
|
||||||
|
});
|
||||||
|
expect(lines(1)).toEqual([
|
||||||
|
{ ending: '8910', qty: 3 },
|
||||||
|
{ ending: '7810', qty: 1 },
|
||||||
|
{ ending: '0838', qty: 2 },
|
||||||
|
]);
|
||||||
|
expect(state(f.multiUser.id)).toBe('IDLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the original text and the added lines together in raw_text', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3\n08?8=2');
|
||||||
|
say('1');
|
||||||
|
say('2');
|
||||||
|
say('0838=2');
|
||||||
|
say('1');
|
||||||
|
expect(orders()[0]!.raw_text).toBe('8910=3\n08?8=2\n0838=2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('who sees which shops', () => {
|
||||||
|
it('a one-shop user skips the shop pick', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
const [summary] = say('done');
|
||||||
|
expect(summary).toContain('Shop A - draw of TODAY 3 Nov');
|
||||||
|
expect(state(f.shopUser.id)).toBe('CONFIRM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an admin sees every active shop', () => {
|
||||||
|
const say = chatAs(f.admin.id);
|
||||||
|
say('8910=3');
|
||||||
|
expect(say('1')).toEqual(['Which shop?\n1) Shop A\n2) Shop B\n3) Shop C']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an admin can pick any shop and save', () => {
|
||||||
|
const say = chatAs(f.admin.id);
|
||||||
|
say('8910=3');
|
||||||
|
say('1');
|
||||||
|
expect(say('3')[0]).toContain('Shop C - draw of TODAY 3 Nov');
|
||||||
|
expect(say('1')).toEqual(['Saved #1 - Shop C, 3 tickets, draw of 3 Nov']);
|
||||||
|
expect(orders()[0]).toMatchObject({ shop_id: f.shops.c, created_by_user_id: f.admin.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a user with no shop is told so and the conversation resets', () => {
|
||||||
|
db.prepare('DELETE FROM user_shops WHERE user_id = ?').run(f.shopUser.id);
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
expect(say('done')).toEqual([msg.noShop()]);
|
||||||
|
expect(state(f.shopUser.id)).toBe('IDLE');
|
||||||
|
expect(orders()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an out-of-range shop number repeats the list', () => {
|
||||||
|
const say = chatAs(f.multiUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
say('1');
|
||||||
|
expect(say('9')).toEqual(['Which shop?\n1) Shop A\n2) Shop B']);
|
||||||
|
expect(state(f.multiUser.id)).toBe('AWAITING_SHOP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a shop removed after the list was shown is refused and a fresh list is sent', () => {
|
||||||
|
const say = chatAs(f.multiUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
say('1');
|
||||||
|
db.prepare('DELETE FROM user_shops WHERE user_id = ? AND shop_id = ?').run(f.multiUser.id, f.shops.b);
|
||||||
|
expect(say('2')).toEqual(['Which shop?\n1) Shop A']);
|
||||||
|
expect(state(f.multiUser.id)).toBe('AWAITING_SHOP');
|
||||||
|
const [summary] = say('1');
|
||||||
|
expect(summary).toContain('Shop A -');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-checks shop access at the moment of saving', () => {
|
||||||
|
const say = chatAs(f.multiUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
say('1');
|
||||||
|
say('2');
|
||||||
|
db.prepare('DELETE FROM user_shops WHERE user_id = ? AND shop_id = ?').run(f.multiUser.id, f.shops.b);
|
||||||
|
expect(say('1')).toEqual([msg.noShop()]);
|
||||||
|
expect(orders()).toHaveLength(0);
|
||||||
|
expect(state(f.multiUser.id)).toBe('IDLE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('collecting', () => {
|
||||||
|
it('sends the collecting menu once, on the first message only', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
expect(say('8910=3')).toHaveLength(1);
|
||||||
|
expect(say('7810=1')).toEqual([]);
|
||||||
|
expect(say('0838=2')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts done and submit typed as words', () => {
|
||||||
|
for (const word of ['done', 'Submit.']) {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
expect(say(word)[0]).toContain('Shop A');
|
||||||
|
say('3'); // cancel
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats "done" as the last line of an order message', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
const [summary] = say('7810=1\ndone');
|
||||||
|
expect(summary).toContain('2 lines, 4 tickets');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a whole order with done in the very first message needs no menu', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
const [summary] = say('0888\n0057\ndone');
|
||||||
|
expect(summary).toContain('2 lines, 2 tickets');
|
||||||
|
expect(state(f.shopUser.id)).toBe('CONFIRM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a trailing 1 is Done when already collecting (Hermes merged two quick messages)', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
const [summary] = say('0888=2\n7810=1\n1');
|
||||||
|
expect(summary).toContain('3 lines, 6 tickets');
|
||||||
|
expect(summary).toContain('Everything was read.');
|
||||||
|
expect(state(f.shopUser.id)).toBe('CONFIRM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a trailing 1 in the first message is order text, not Done', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
expect(say('0888=2\n1')).toEqual([msg.collectingMenu()]);
|
||||||
|
expect(state(f.shopUser.id)).toBe('COLLECTING');
|
||||||
|
expect(conv(f.shopUser.id)!.buffer_text).toBe('0888=2\n1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('1 Done, 2 Cancel from the menu', () => {
|
||||||
|
const a = chatAs(f.shopUser.id);
|
||||||
|
a('8910=3');
|
||||||
|
a('2');
|
||||||
|
expect(state(f.shopUser.id)).toBe('IDLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chatter while collecting is kept out of the way: no reply', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
expect(say('ok')).toEqual([]);
|
||||||
|
expect(state(f.shopUser.id)).toBe('COLLECTING');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records the latest chat id', () => {
|
||||||
|
chatAs(f.shopUser.id, 'old@lid')('8910=3');
|
||||||
|
chatAs(f.shopUser.id, 'new@lid')('7810=1');
|
||||||
|
expect(conv(f.shopUser.id)!.chat_id).toBe('new@lid');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('IDLE chatter does not start an order', () => {
|
||||||
|
it.each(['ok', 'thanks', '👍', '1', '2', 'yes', 'cancel', '?', 'Total 50'])('%j', (t) => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
expect(say(t)).toEqual([]);
|
||||||
|
expect(conv(f.shopUser.id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('done with nothing to submit says so', () => {
|
||||||
|
expect(chatAs(f.shopUser.id)('done')).toEqual([msg.nothingToSubmit()]);
|
||||||
|
expect(conv(f.shopUser.id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a double-tapped Save after Saved creates nothing', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
say('done');
|
||||||
|
expect(say('1')[0]).toContain('Saved #1');
|
||||||
|
expect(say('1')).toEqual([]);
|
||||||
|
expect(say('thanks')).toEqual([]);
|
||||||
|
expect(orders()).toHaveLength(1);
|
||||||
|
expect(state(f.shopUser.id)).toBe('IDLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a message with one unreadable item does start an order', () => {
|
||||||
|
expect(chatAs(f.shopUser.id)('08?8=2')).toEqual([msg.collectingMenu()]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unreadable text', () => {
|
||||||
|
it('saving without fixing it leaves it out', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3\n08?8=2');
|
||||||
|
const [summary] = say('done');
|
||||||
|
expect(summary).toContain('Not read, not in the order:\n 08?8=2');
|
||||||
|
say('1');
|
||||||
|
expect(lines(1)).toEqual([{ ending: '8910', qty: 3 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Add more keeps listing earlier unreadable text, and new unreadable text separately', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3\n08?8=2');
|
||||||
|
say('done');
|
||||||
|
say('2');
|
||||||
|
const [again] = say('0x38=1');
|
||||||
|
expect(again).toContain('Not read, not in the order:\n 0x38=1');
|
||||||
|
expect(again).toContain('Left out earlier, not in the order:\n 08?8=2');
|
||||||
|
// the new line is unreadable too, so nothing was added to the order
|
||||||
|
expect(again).toContain('1 line, 3 tickets');
|
||||||
|
expect(again).not.toContain('Everything was read');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adding a forgotten line does not hide the earlier unreadable one', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3\n08?8=2');
|
||||||
|
say('done');
|
||||||
|
say('2');
|
||||||
|
const [again] = say('1234=1'); // a forgotten line, not a correction
|
||||||
|
expect(again).toContain('2 lines, 4 tickets');
|
||||||
|
expect(again).toContain('Left out earlier, not in the order:\n 08?8=2');
|
||||||
|
expect(again).not.toContain('Everything was read');
|
||||||
|
say('1');
|
||||||
|
expect(lines(1)).toEqual([
|
||||||
|
{ ending: '8910', qty: 3 },
|
||||||
|
{ ending: '1234', qty: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says everything was read only when nothing was ever left out', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
say('done');
|
||||||
|
say('2');
|
||||||
|
const [again] = say('1234=1');
|
||||||
|
expect(again).toContain('2 lines, 4 tickets. Everything was read.');
|
||||||
|
expect(again).not.toContain('Left out earlier');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an order that is only unreadable cannot be saved', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('08?8=2');
|
||||||
|
say('done');
|
||||||
|
expect(say('1')).toEqual([msg.noTickets()]);
|
||||||
|
expect(orders()).toHaveLength(0);
|
||||||
|
expect(state(f.shopUser.id)).toBe('IDLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an order with nothing readable at all is refused at Done', () => {
|
||||||
|
// started with a leading chatter line so the order opened, then everything is words
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('08?8=2');
|
||||||
|
db.prepare("UPDATE conversations SET buffer_text = 'just words'").run();
|
||||||
|
expect(say('done')).toEqual([msg.noTickets()]);
|
||||||
|
expect(state(f.shopUser.id)).toBe('IDLE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('repeated endings', () => {
|
||||||
|
it('are added up and shown once, not asked about', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3\n7810=1\n8910=3');
|
||||||
|
const [summary] = say('done');
|
||||||
|
expect(summary).toContain('3 lines, 7 tickets');
|
||||||
|
expect(summary).toContain('8910 - 6 (2 times)');
|
||||||
|
expect(summary).toContain('7810 - 1');
|
||||||
|
expect(state(f.shopUser.id)).toBe('CONFIRM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('are saved as written, not merged', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3\n7810=1\n8910=3');
|
||||||
|
say('done');
|
||||||
|
say('1');
|
||||||
|
expect(lines(1)).toEqual([
|
||||||
|
{ ending: '8910', qty: 3 },
|
||||||
|
{ ending: '7810', qty: 1 },
|
||||||
|
{ ending: '8910', qty: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a whole order resent through Add more is visible as repeats', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3\n7810=1');
|
||||||
|
say('done');
|
||||||
|
say('2');
|
||||||
|
const [summary] = say('8910=3\n7810=1');
|
||||||
|
expect(summary).toContain('8910 - 6 (2 times)');
|
||||||
|
expect(summary).toContain('7810 - 2 (2 times)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the menus', () => {
|
||||||
|
it('Add more asks for new lines only and the next message is added', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
say('done');
|
||||||
|
expect(say('2')).toEqual([msg.addPrompt()]);
|
||||||
|
const [summary] = say('7810=1');
|
||||||
|
expect(summary).toContain('2 lines, 4 tickets');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('1 and 3 keep their meaning while an Add more is pending', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
say('done');
|
||||||
|
say('2');
|
||||||
|
expect(say('1')[0]).toContain('Saved #1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Cancel at the summary clears everything', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
say('done');
|
||||||
|
expect(say('3')).toEqual([msg.cancelled()]);
|
||||||
|
expect(state(f.shopUser.id)).toBe('IDLE');
|
||||||
|
expect(orders()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('order text at the summary is refused and not added', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
const [summary] = say('done');
|
||||||
|
const [reply] = say('7810=1');
|
||||||
|
expect(reply).toBe(`${msg.numberFirst()}\n${summary}`);
|
||||||
|
expect(JSON.parse(conv(f.shopUser.id)!.parsed_json!).segments).toEqual(['8910=3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('order text while the shop list is open is refused and not added', () => {
|
||||||
|
const say = chatAs(f.multiUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
const [list] = say('1');
|
||||||
|
expect(say('7810=1')).toEqual([`${msg.numberFirst()}\n${list}`]);
|
||||||
|
expect(conv(f.multiUser.id)!.buffer_text).toBe('8910=3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('? repeats the last prompt in every waiting state', () => {
|
||||||
|
const say = chatAs(f.multiUser.id);
|
||||||
|
const [menu] = say('8910=3');
|
||||||
|
expect(say('?')).toEqual([menu]);
|
||||||
|
const [list] = say('1');
|
||||||
|
expect(say('help')).toEqual([list]);
|
||||||
|
const [summary] = say('2');
|
||||||
|
expect(say('?')).toEqual([summary]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never sends a blank message when there is no prompt on record', () => {
|
||||||
|
const say = chatAs(f.multiUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
say('1');
|
||||||
|
db.prepare('UPDATE conversations SET last_prompt = NULL').run();
|
||||||
|
expect(say('?')).toEqual([]);
|
||||||
|
expect(say('thanks')).toEqual([]);
|
||||||
|
expect(say('7810=1')).toEqual([msg.numberFirst()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unexpected reply repeats the prompt and changes nothing', () => {
|
||||||
|
const say = chatAs(f.multiUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
const [list] = say('1');
|
||||||
|
expect(say('thanks')).toEqual([list]);
|
||||||
|
expect(say('7')).toEqual([list]);
|
||||||
|
expect(state(f.multiUser.id)).toBe('AWAITING_SHOP');
|
||||||
|
const [summary] = say('2');
|
||||||
|
expect(say('banana')).toEqual([summary]);
|
||||||
|
expect(say('9')).toEqual([summary]);
|
||||||
|
expect(orders()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cancel in every state', () => {
|
||||||
|
it('COLLECTING', () => {
|
||||||
|
const say = chatAs(f.multiUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
expect(say('cancel')).toEqual([msg.cancelled()]);
|
||||||
|
expect(state(f.multiUser.id)).toBe('IDLE');
|
||||||
|
});
|
||||||
|
it('AWAITING_SHOP', () => {
|
||||||
|
const say = chatAs(f.multiUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
say('1');
|
||||||
|
expect(say('Cancel')).toEqual([msg.cancelled()]);
|
||||||
|
expect(state(f.multiUser.id)).toBe('IDLE');
|
||||||
|
});
|
||||||
|
it('ASKING (draw)', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3', ASK_TIME);
|
||||||
|
say('done', ASK_TIME);
|
||||||
|
expect(state(f.shopUser.id)).toBe('ASKING');
|
||||||
|
expect(say('cancel', ASK_TIME)).toEqual([msg.cancelled()]);
|
||||||
|
expect(state(f.shopUser.id)).toBe('IDLE');
|
||||||
|
});
|
||||||
|
it('CONFIRM', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
say('done');
|
||||||
|
expect(say('cancel')).toEqual([msg.cancelled()]);
|
||||||
|
expect(state(f.shopUser.id)).toBe('IDLE');
|
||||||
|
});
|
||||||
|
it('leaves a clean slate: a new order starts from nothing', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
say('done');
|
||||||
|
say('cancel');
|
||||||
|
say('7810=1');
|
||||||
|
expect(conv(f.shopUser.id)!.buffer_text).toBe('7810=1');
|
||||||
|
expect(conv(f.shopUser.id)!.parsed_json).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the draw date', () => {
|
||||||
|
it('before 15:00 is today, no question', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3', new Date('2026-11-03T09:30:00Z')); // 15:00:00 exactly
|
||||||
|
const [summary] = say('done', new Date('2026-11-03T09:30:00Z'));
|
||||||
|
expect(summary).toContain('draw of TODAY 3 Nov');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('after 15:30 is tomorrow, no question', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3', TOO_LATE);
|
||||||
|
const [summary] = say('done', TOO_LATE);
|
||||||
|
expect(summary).toContain('draw of TOMORROW 4 Nov');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('between 15:00 and 15:30 asks, and Today is accepted inside the window', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3', ASK_TIME);
|
||||||
|
expect(say('done', ASK_TIME)).toEqual([msg.drawQuestion('2026-11-03', '2026-11-04')]);
|
||||||
|
const [summary] = say('1', STILL_ASKING);
|
||||||
|
expect(summary).toContain('draw of TODAY 3 Nov');
|
||||||
|
say('1', STILL_ASKING);
|
||||||
|
expect(orders()[0]!.draw_date).toBe('2026-11-03');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Tomorrow is accepted', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3', ASK_TIME);
|
||||||
|
say('done', ASK_TIME);
|
||||||
|
const [summary] = say('2', STILL_ASKING);
|
||||||
|
expect(summary).toContain('draw of TOMORROW 4 Nov');
|
||||||
|
say('1', STILL_ASKING);
|
||||||
|
expect(orders()[0]!.draw_date).toBe('2026-11-04');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Today answered after the window is refused and tomorrow or cancel is offered', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3', ASK_TIME);
|
||||||
|
say('done', ASK_TIME);
|
||||||
|
expect(say('1', TOO_LATE)).toEqual([msg.tooLateForToday('2026-11-04')]);
|
||||||
|
const [summary] = say('1', TOO_LATE);
|
||||||
|
expect(summary).toContain('draw of TOMORROW 4 Nov');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('after a refusal, 2 cancels', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3', ASK_TIME);
|
||||||
|
say('done', ASK_TIME);
|
||||||
|
say('1', TOO_LATE);
|
||||||
|
expect(say('2', TOO_LATE)).toEqual([msg.cancelled()]);
|
||||||
|
expect(orders()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Today answered after midnight is refused (it is a different day by then)', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3', ASK_TIME);
|
||||||
|
say('done', ASK_TIME);
|
||||||
|
// 00:05 IST on 4 Nov
|
||||||
|
expect(say('1', new Date('2026-11-03T18:35:00Z'))).toEqual([msg.tooLateForToday('2026-11-04')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the draw is fixed when Done was sent: a Save after 15:30 keeps today', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3', ASK_TIME);
|
||||||
|
say('done', ASK_TIME);
|
||||||
|
say('1', STILL_ASKING);
|
||||||
|
const [saved] = say('1', new Date('2026-11-03T11:00:00Z')); // 16:30 IST
|
||||||
|
expect(saved).toContain('draw of 3 Nov');
|
||||||
|
expect(orders()[0]!.draw_date).toBe('2026-11-03');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer Today when the shop pick used up the window: straight to tomorrow or cancel', () => {
|
||||||
|
const say = chatAs(f.multiUser.id);
|
||||||
|
say('8910=3', new Date('2026-11-03T09:55:00Z')); // 15:25 IST
|
||||||
|
say('done', new Date('2026-11-03T09:55:00Z'));
|
||||||
|
// picks the shop at 15:40, after the window closed
|
||||||
|
expect(say('1', TOO_LATE)).toEqual([msg.tooLateForToday('2026-11-04')]);
|
||||||
|
const c = conv(f.multiUser.id)!;
|
||||||
|
expect(c.state).toBe('ASKING');
|
||||||
|
const [summary] = say('1', TOO_LATE);
|
||||||
|
expect(summary).toContain('draw of TOMORROW 4 Nov');
|
||||||
|
say('1', TOO_LATE);
|
||||||
|
expect(orders()[0]!.draw_date).toBe('2026-11-04');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('after that early refusal, 2 cancels and the draw is never today', () => {
|
||||||
|
const say = chatAs(f.multiUser.id);
|
||||||
|
say('8910=3', new Date('2026-11-03T09:55:00Z'));
|
||||||
|
say('done', new Date('2026-11-03T09:55:00Z'));
|
||||||
|
say('1', TOO_LATE);
|
||||||
|
expect(say('2', TOO_LATE)).toEqual([msg.cancelled()]);
|
||||||
|
expect(orders()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still asks normally when the shop pick finishes inside the window', () => {
|
||||||
|
const say = chatAs(f.multiUser.id);
|
||||||
|
say('8910=3', ASK_TIME);
|
||||||
|
say('done', ASK_TIME);
|
||||||
|
expect(say('1', STILL_ASKING)).toEqual([msg.drawQuestion('2026-11-03', '2026-11-04')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the shop pick comes before the draw question', () => {
|
||||||
|
const say = chatAs(f.multiUser.id);
|
||||||
|
say('8910=3', ASK_TIME);
|
||||||
|
expect(say('done', ASK_TIME)[0]).toContain('Which shop?');
|
||||||
|
expect(say('1', ASK_TIME)[0]).toContain("today's draw or tomorrow's");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saving', () => {
|
||||||
|
it('a double-sent 1 saves once', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
say('done');
|
||||||
|
say('1');
|
||||||
|
say('1');
|
||||||
|
expect(orders()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a failure while saving rolls everything back, including the conversation', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
say('done');
|
||||||
|
db.exec(`CREATE TRIGGER boom BEFORE INSERT ON order_lines BEGIN SELECT RAISE(ABORT, 'boom'); END;`);
|
||||||
|
expect(() => say('1')).toThrow(/boom/);
|
||||||
|
expect(orders()).toHaveLength(0);
|
||||||
|
expect(state(f.shopUser.id)).toBe('CONFIRM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two users do not disturb each other', () => {
|
||||||
|
const a = chatAs(f.shopUser.id);
|
||||||
|
const b = chatAs(f.multiUser.id);
|
||||||
|
a('8910=3');
|
||||||
|
b('7810=1');
|
||||||
|
a('done');
|
||||||
|
b('done');
|
||||||
|
expect(state(f.shopUser.id)).toBe('CONFIRM');
|
||||||
|
expect(state(f.multiUser.id)).toBe('AWAITING_SHOP');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('late messages after the reminder', () => {
|
||||||
|
const remind = (userId: number) =>
|
||||||
|
db.prepare("UPDATE conversations SET reminded_at = '2026-11-03T05:10:00Z' WHERE user_id = ?").run(userId);
|
||||||
|
|
||||||
|
it('before the reminder, a second order is simply added', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
expect(say('7810=1')).toEqual([]);
|
||||||
|
expect(state(f.shopUser.id)).toBe('COLLECTING');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('after the reminder, order text is held and the bot asks', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3\n7810=1');
|
||||||
|
remind(f.shopUser.id);
|
||||||
|
const reply = say('0838=2');
|
||||||
|
expect(reply).toEqual([msg.splitQuestion(2, 4)]);
|
||||||
|
const c = conv(f.shopUser.id)!;
|
||||||
|
expect(c.state).toBe('ASKING');
|
||||||
|
expect(c.ask_kind).toBe('split');
|
||||||
|
expect(c.held_text).toBe('0838=2');
|
||||||
|
expect(c.buffer_text).toBe('8910=3\n7810=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('1 adds the message to the old order and collecting continues', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
remind(f.shopUser.id);
|
||||||
|
say('0838=2');
|
||||||
|
expect(say('1')).toEqual([]);
|
||||||
|
const c = conv(f.shopUser.id)!;
|
||||||
|
expect(c.state).toBe('COLLECTING');
|
||||||
|
expect(c.buffer_text).toBe('8910=3\n0838=2');
|
||||||
|
expect(c.held_text).toBeNull();
|
||||||
|
expect(c.reminded_at).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2 finishes the old order first, then starts the held one', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
remind(f.shopUser.id);
|
||||||
|
say('0838=2');
|
||||||
|
const [summary] = say('2');
|
||||||
|
expect(summary).toContain('8910 - 3');
|
||||||
|
expect(summary).not.toContain('0838');
|
||||||
|
const replies = say('1');
|
||||||
|
expect(replies[0]).toContain('Saved #1');
|
||||||
|
expect(replies[1]).toBe(msg.startingHeld());
|
||||||
|
expect(replies[2]).toBe(msg.collectingMenu());
|
||||||
|
expect(lines(1)).toEqual([{ ending: '8910', qty: 3 }]);
|
||||||
|
expect(conv(f.shopUser.id)!.state).toBe('COLLECTING');
|
||||||
|
expect(conv(f.shopUser.id)!.buffer_text).toBe('0838=2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2 then Cancel on the old order still starts the held one', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
remind(f.shopUser.id);
|
||||||
|
say('0838=2');
|
||||||
|
say('2');
|
||||||
|
const replies = say('3');
|
||||||
|
expect(replies).toEqual([msg.cancelled(), msg.startingHeld(), msg.collectingMenu()]);
|
||||||
|
expect(conv(f.shopUser.id)!.buffer_text).toBe('0838=2');
|
||||||
|
expect(orders()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3 throws the old order away and starts the new one', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
remind(f.shopUser.id);
|
||||||
|
say('0838=2');
|
||||||
|
expect(say('3')).toEqual([msg.collectingMenu()]);
|
||||||
|
const c = conv(f.shopUser.id)!;
|
||||||
|
expect(c.state).toBe('COLLECTING');
|
||||||
|
expect(c.buffer_text).toBe('0838=2');
|
||||||
|
expect(c.reminded_at).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel while a message is held cancels the old order and starts the held one', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
remind(f.shopUser.id);
|
||||||
|
say('0838=2');
|
||||||
|
expect(say('cancel')).toEqual([msg.cancelled(), msg.startingHeld(), msg.collectingMenu()]);
|
||||||
|
expect(conv(f.shopUser.id)!.buffer_text).toBe('0838=2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('done, cancel and chatter after the reminder are not held', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
remind(f.shopUser.id);
|
||||||
|
expect(say('ok')).toEqual([]);
|
||||||
|
expect(conv(f.shopUser.id)!.state).toBe('COLLECTING');
|
||||||
|
expect(say('done')[0]).toContain('Shop A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a late message that ends in done is held whole and finishes as its own order', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
remind(f.shopUser.id);
|
||||||
|
say('0838=2\ndone');
|
||||||
|
expect(conv(f.shopUser.id)!.held_text).toBe('0838=2\ndone');
|
||||||
|
const [summary] = say('3'); // throw the old one away; the held order is finished as a whole
|
||||||
|
expect(summary).toContain('0838 - 2');
|
||||||
|
expect(summary).not.toContain('8910');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a menu number while the question is open that is not 1-3 repeats the question', () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3');
|
||||||
|
remind(f.shopUser.id);
|
||||||
|
const [question] = say('0838=2');
|
||||||
|
expect(say('7')).toEqual([question]);
|
||||||
|
expect(say('more text 0838')).toEqual([`${msg.numberFirst()}\n${question}`]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,14 +1,27 @@
|
|||||||
|
import type { OrderSettings } from '../config';
|
||||||
import type { Db } from '../db';
|
import type { Db } from '../db';
|
||||||
|
import { parseOrder } from '../parser/parseOrder';
|
||||||
|
import type { OrderLine } from '../parser/types';
|
||||||
|
import { drawChoiceAt } from '../services/drawDate';
|
||||||
|
import { istDate } from '../services/ist';
|
||||||
|
import { saveOrder, ShopNotAllowedError } from '../services/orders';
|
||||||
|
import { canUseShop, shopsForUser } from '../services/shopScope';
|
||||||
import type { User } from '../services/users';
|
import type { User } from '../services/users';
|
||||||
|
import { classify, hasOrderContent, type Command } from './commands';
|
||||||
|
import * as msg from './messages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a person is in the WhatsApp conversation. One row per user in `conversations`.
|
* The WhatsApp conversation, one row per user in `conversations`. See docs/WHATSAPP-FLOW-PLAN.md.
|
||||||
*
|
*
|
||||||
* IDLE nothing in progress
|
* IDLE nothing in progress
|
||||||
* COLLECTING order text is arriving; the ~20 s quiet timer is running
|
* COLLECTING order text is being buffered; no timer. The user ends it with Done
|
||||||
* ASKING the bot asked a question about the parse (repeated endings, a failed total, ...)
|
* AWAITING_SHOP the numbered shop list was sent; waiting for a number
|
||||||
* AWAITING_SHOP the bot showed the numbered shop list and waits for a number
|
* ASKING waiting on a question, told apart by `ask_kind`:
|
||||||
* CONFIRM the bot showed "Save ...? 1 Yes / 2 Edit / 3 Cancel" and waits
|
* draw "today or tomorrow?" (only between the cutoff and ask-until)
|
||||||
|
* split "is this late message part of the unfinished order?"
|
||||||
|
* CONFIRM the summary was sent; waiting for 1 Save / 2 Add more / 3 Cancel
|
||||||
|
*
|
||||||
|
* The engine is a function over the database that RETURNS the replies; it never sends anything.
|
||||||
*/
|
*/
|
||||||
export type ConversationState = 'IDLE' | 'COLLECTING' | 'ASKING' | 'AWAITING_SHOP' | 'CONFIRM';
|
export type ConversationState = 'IDLE' | 'COLLECTING' | 'ASKING' | 'AWAITING_SHOP' | 'CONFIRM';
|
||||||
|
|
||||||
@ -24,45 +37,391 @@ export interface ConversationRow {
|
|||||||
selected_shop_id: number | null;
|
selected_shop_id: number | null;
|
||||||
last_activity_at: string;
|
last_activity_at: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
last_prompt: string | null;
|
||||||
|
reminded_at: string | null;
|
||||||
|
ask_kind: 'draw' | 'split' | null;
|
||||||
|
held_text: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReceiveResult =
|
interface FlowData {
|
||||||
| { kind: 'collecting'; conversation: ConversationRow }
|
segments: string[];
|
||||||
| { kind: 'answer'; conversation: ConversationRow };
|
/**
|
||||||
|
* Segments before this index had their unreadable items shown and the user chose Add more. Those items stay out
|
||||||
|
* of the order (the bot never guesses a correction) and are listed as "Left out earlier" in every later summary.
|
||||||
|
*/
|
||||||
|
acked: number;
|
||||||
|
/** True after "2 Add more": the next text message is a new segment. */
|
||||||
|
addPending: boolean;
|
||||||
|
/** When the user said Done (ISO). The draw is decided from this moment. */
|
||||||
|
doneAt: string;
|
||||||
|
drawDate?: string;
|
||||||
|
/** Set while the draw question is open. */
|
||||||
|
drawOptions?: { today: string; tomorrow: string; tooLate: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
const WAITING_FOR_ANSWER: ConversationState[] = ['ASKING', 'AWAITING_SHOP', 'CONFIRM'];
|
interface Ctx {
|
||||||
|
db: Db;
|
||||||
|
s: OrderSettings;
|
||||||
|
user: User;
|
||||||
|
chatId: string;
|
||||||
|
now: Date;
|
||||||
|
}
|
||||||
|
|
||||||
const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
const iso = (d: Date): string => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
|
||||||
function getConversation(db: Db, userId: number): ConversationRow | undefined {
|
export function getConversation(db: Db, userId: number): ConversationRow | undefined {
|
||||||
return db.prepare('SELECT * FROM conversations WHERE user_id = ?').get(userId) as ConversationRow | undefined;
|
return db.prepare('SELECT * FROM conversations WHERE user_id = ?').get(userId) as ConversationRow | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function receiveText(db: Db, user: User, chatId: string, text: string): ReceiveResult {
|
function update(db: Db, id: number, fields: Record<string, string | number | null>): void {
|
||||||
const existing = getConversation(db, user.id);
|
const keys = Object.keys(fields); // internal constants only, never user input
|
||||||
|
db.prepare(`UPDATE conversations SET ${keys.map((k) => `${k} = @${k}`).join(', ')} WHERE id = @id`).run({
|
||||||
|
...fields,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!existing) {
|
export function resetConversation(db: Db, id: number, now: Date): void {
|
||||||
db.prepare(
|
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)
|
`INSERT INTO conversations (user_id, chat_id, state, buffer_text, source, last_activity_at)
|
||||||
VALUES (?, ?, 'COLLECTING', ?, 'whatsapp_text', ?)`,
|
VALUES (@user_id, @chat_id, 'COLLECTING', @text, 'whatsapp_text', @at)
|
||||||
).run(user.id, chatId, text, nowIso());
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
return { kind: 'collecting', conversation: getConversation(db, user.id)! };
|
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)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (WAITING_FOR_ANSWER.includes(existing.state)) {
|
function fromIdle(c: Ctx, text: string): string[] {
|
||||||
db.prepare('UPDATE conversations SET chat_id = ?, last_activity_at = ? WHERE id = ?').run(
|
const cmd = classify(text, { collecting: false });
|
||||||
chatId,
|
if (cmd.kind === 'submit') {
|
||||||
nowIso(),
|
if (!hasOrderContent(cmd.body)) return [msg.nothingToSubmit()];
|
||||||
existing.id,
|
return done(c, begin(c, cmd.body));
|
||||||
);
|
}
|
||||||
return { kind: 'answer', conversation: getConversation(db, user.id)! };
|
if (cmd.kind !== 'text' || !hasOrderContent(text)) return [];
|
||||||
|
const conv = begin(c, text);
|
||||||
|
return [prompt(c.db, conv.id, msg.collectingMenu())];
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = existing.state === 'COLLECTING' ? `${existing.buffer_text}\n${text}` : text;
|
// COLLECTING ----------------------------------------------------------------------------------------------------
|
||||||
db.prepare(
|
|
||||||
`UPDATE conversations
|
function collecting(c: Ctx, conv: ConversationRow, cmd: Command, text: string): string[] {
|
||||||
SET state = 'COLLECTING', buffer_text = ?, chat_id = ?, source = 'whatsapp_text', last_activity_at = ?
|
const orderText = cmd.kind === 'text' ? text : cmd.kind === 'submit' ? cmd.body : '';
|
||||||
WHERE id = ?`,
|
|
||||||
).run(buffer, chatId, nowIso(), existing.id);
|
if (conv.reminded_at && orderText && hasOrderContent(orderText)) {
|
||||||
return { kind: 'collecting', conversation: getConversation(db, user.id)! };
|
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! }),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
185
src/whatsapp/messages.test.ts
Normal file
185
src/whatsapp/messages.test.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as m from './messages';
|
||||||
|
|
||||||
|
describe('formatDay / drawLabel', () => {
|
||||||
|
it('formats without a leading zero', () => {
|
||||||
|
expect(m.formatDay('2026-11-03')).toBe('3 Nov');
|
||||||
|
expect(m.formatDay('2026-01-31')).toBe('31 Jan');
|
||||||
|
expect(m.formatDay('2026-12-01')).toBe('1 Dec');
|
||||||
|
});
|
||||||
|
it('says TODAY, TOMORROW, or just the date', () => {
|
||||||
|
expect(m.drawLabel('2026-11-03', '2026-11-03')).toBe('TODAY 3 Nov');
|
||||||
|
expect(m.drawLabel('2026-11-04', '2026-11-03')).toBe('TOMORROW 4 Nov');
|
||||||
|
expect(m.drawLabel('2026-11-10', '2026-11-03')).toBe('10 Nov');
|
||||||
|
});
|
||||||
|
it('knows tomorrow across a month end', () => {
|
||||||
|
expect(m.drawLabel('2026-12-01', '2026-11-30')).toBe('TOMORROW 1 Dec');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('entryTotals', () => {
|
||||||
|
it('keeps first-seen order and adds repeats up', () => {
|
||||||
|
const e = m.entryTotals([
|
||||||
|
{ ending: '8910', qty: 3 },
|
||||||
|
{ ending: '7810', qty: 1 },
|
||||||
|
{ ending: '8910', qty: 3 },
|
||||||
|
]);
|
||||||
|
expect(e).toEqual([
|
||||||
|
{ ending: '8910', qty: 6, times: 2 },
|
||||||
|
{ ending: '7810', qty: 1, times: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it('keeps leading zeros', () => {
|
||||||
|
expect(m.entryTotals([{ ending: '0057', qty: 2 }])[0]!.ending).toBe('0057');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('summary', () => {
|
||||||
|
const base = {
|
||||||
|
shopName: 'Shop B',
|
||||||
|
drawDate: '2026-11-03',
|
||||||
|
today: '2026-11-03',
|
||||||
|
lineCount: 2,
|
||||||
|
ticketCount: 4,
|
||||||
|
entries: [
|
||||||
|
{ ending: '8910', qty: 3, times: 1 },
|
||||||
|
{ ending: '7810', qty: 1, times: 1 },
|
||||||
|
],
|
||||||
|
unreadable: ['08?8=2'],
|
||||||
|
leftOut: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('matches the walk-through when something was not read', () => {
|
||||||
|
expect(m.summary(base)).toBe(
|
||||||
|
[
|
||||||
|
'Shop B - draw of TODAY 3 Nov',
|
||||||
|
'2 lines, 4 tickets',
|
||||||
|
'8910 - 3',
|
||||||
|
'7810 - 1',
|
||||||
|
'Not read, not in the order:',
|
||||||
|
' 08?8=2',
|
||||||
|
'1 Save 2 Add more 3 Cancel',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says everything was read when nothing was ever left out', () => {
|
||||||
|
const t = m.summary({
|
||||||
|
...base,
|
||||||
|
lineCount: 3,
|
||||||
|
ticketCount: 6,
|
||||||
|
entries: [...base.entries, { ending: '0838', qty: 2, times: 1 }],
|
||||||
|
unreadable: [],
|
||||||
|
});
|
||||||
|
expect(t).toBe(
|
||||||
|
[
|
||||||
|
'Shop B - draw of TODAY 3 Nov',
|
||||||
|
'3 lines, 6 tickets. Everything was read.',
|
||||||
|
'8910 - 3',
|
||||||
|
'7810 - 1',
|
||||||
|
'0838 - 2',
|
||||||
|
'1 Save 2 Add more 3 Cancel',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps listing text left out earlier and does not claim everything was read', () => {
|
||||||
|
const t = m.summary({ ...base, unreadable: [], leftOut: ['08?8=2'], lineCount: 3, ticketCount: 6 });
|
||||||
|
expect(t).toBe(
|
||||||
|
[
|
||||||
|
'Shop B - draw of TODAY 3 Nov',
|
||||||
|
'3 lines, 6 tickets',
|
||||||
|
'8910 - 3',
|
||||||
|
'7810 - 1',
|
||||||
|
'Left out earlier, not in the order:',
|
||||||
|
' 08?8=2',
|
||||||
|
'1 Save 2 Add more 3 Cancel',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
expect(t).not.toContain('Everything was read');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists new and earlier unreadable text separately, new first', () => {
|
||||||
|
const t = m.summary({ ...base, unreadable: ['0x38=1'], leftOut: ['08?8=2'] });
|
||||||
|
const fresh = t.indexOf('Not read, not in the order:\n 0x38=1');
|
||||||
|
const older = t.indexOf('Left out earlier, not in the order:\n 08?8=2');
|
||||||
|
expect(fresh).toBeGreaterThan(-1);
|
||||||
|
expect(older).toBeGreaterThan(fresh);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a repeated ending once with its total', () => {
|
||||||
|
const t = m.summary({ ...base, entries: [{ ending: '8910', qty: 6, times: 2 }], unreadable: [] });
|
||||||
|
expect(t).toContain('8910 - 6 (2 times)');
|
||||||
|
expect(t.match(/8910/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels tomorrow', () => {
|
||||||
|
expect(m.summary({ ...base, drawDate: '2026-11-04' })).toContain('draw of TOMORROW 4 Nov');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses singular forms', () => {
|
||||||
|
const t = m.summary({
|
||||||
|
...base,
|
||||||
|
lineCount: 1,
|
||||||
|
ticketCount: 1,
|
||||||
|
entries: [{ ending: '0001', qty: 1, times: 1 }],
|
||||||
|
unreadable: [],
|
||||||
|
});
|
||||||
|
expect(t).toContain('1 line, 1 ticket. Everything was read.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps a long unreadable list and counts the rest', () => {
|
||||||
|
const many = Array.from({ length: m.MAX_UNREADABLE_SHOWN + 5 }, (_, i) => `x${i}`);
|
||||||
|
const lines = m.summary({ ...base, unreadable: many }).split('\n');
|
||||||
|
expect(lines).toContain(' x0');
|
||||||
|
expect(lines).toContain(` x${m.MAX_UNREADABLE_SHOWN - 1}`);
|
||||||
|
expect(lines).not.toContain(` x${m.MAX_UNREADABLE_SHOWN}`);
|
||||||
|
expect(lines).toContain(' ...and 5 more');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always ends with the menu', () => {
|
||||||
|
expect(m.summary(base).endsWith(m.SUMMARY_MENU)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prompts', () => {
|
||||||
|
it('collecting menu offers Done and Cancel', () => {
|
||||||
|
expect(m.collectingMenu()).toBe('Got it. Forward more, or reply 1 Done 2 Cancel');
|
||||||
|
});
|
||||||
|
it('shop list is numbered in the order given', () => {
|
||||||
|
expect(m.shopList(['Shop A', 'Shop B'])).toBe('Which shop?\n1) Shop A\n2) Shop B');
|
||||||
|
});
|
||||||
|
it('draw question names both days', () => {
|
||||||
|
expect(m.drawQuestion('2026-11-03', '2026-11-04')).toBe(
|
||||||
|
"Is this order for today's draw or tomorrow's?\n1 Today 3 Nov\n2 Tomorrow 4 Nov",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('too late offers tomorrow or cancel', () => {
|
||||||
|
expect(m.tooLateForToday('2026-11-04')).toBe("It is too late for today's draw.\n1 Tomorrow 4 Nov\n2 Cancel");
|
||||||
|
});
|
||||||
|
it('saved names the order, shop, tickets and draw', () => {
|
||||||
|
expect(m.saved({ orderId: 123, shopName: 'Shop B', ticketCount: 6, drawDate: '2026-11-03' })).toBe(
|
||||||
|
'Saved #123 - Shop B, 6 tickets, draw of 3 Nov',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('reminder shows progress when something was read', () => {
|
||||||
|
expect(m.reminder(5, 12)).toBe('You have an unfinished order (5 lines, 12 tickets). Reply 1 Done 2 Cancel.');
|
||||||
|
});
|
||||||
|
it('reminder omits progress when nothing was read', () => {
|
||||||
|
expect(m.reminder(0, 0)).toBe('You have an unfinished order. Reply 1 Done 2 Cancel.');
|
||||||
|
});
|
||||||
|
it('split question lists the three choices', () => {
|
||||||
|
expect(m.splitQuestion(5, 12)).toBe(
|
||||||
|
[
|
||||||
|
'You have an unfinished order (5 lines, 12 tickets).',
|
||||||
|
'Is this message:',
|
||||||
|
'1 Part of that order',
|
||||||
|
'2 A new order (finish the old one first)',
|
||||||
|
'3 A new order (throw the old one away)',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('fixed messages say nothing was saved where it matters', () => {
|
||||||
|
for (const t of [m.cancelled(), m.noTickets(), m.expired()]) expect(t).toContain('Nothing was saved');
|
||||||
|
});
|
||||||
|
});
|
||||||
124
src/whatsapp/messages.ts
Normal file
124
src/whatsapp/messages.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
export const MAX_UNREADABLE_SHOWN = 20;
|
||||||
|
|
||||||
|
const plural = (n: number, one: string, many = `${one}s`): string => `${n} ${n === 1 ? one : many}`;
|
||||||
|
|
||||||
|
export function formatDay(date: string): string {
|
||||||
|
const [, m, d] = date.split('-');
|
||||||
|
return `${Number(d)} ${MONTHS[Number(m) - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDay = (date: string): string =>
|
||||||
|
new Date(Date.parse(`${date}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
export function drawLabel(drawDate: string, today: string): string {
|
||||||
|
if (drawDate === today) return `TODAY ${formatDay(drawDate)}`;
|
||||||
|
if (drawDate === nextDay(today)) return `TOMORROW ${formatDay(drawDate)}`;
|
||||||
|
return formatDay(drawDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Entry {
|
||||||
|
ending: string;
|
||||||
|
qty: number;
|
||||||
|
times: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entryTotals(lines: { ending: string; qty: number }[]): Entry[] {
|
||||||
|
const by = new Map<string, Entry>();
|
||||||
|
for (const l of lines) {
|
||||||
|
const e = by.get(l.ending);
|
||||||
|
if (e) {
|
||||||
|
e.qty += l.qty;
|
||||||
|
e.times += 1;
|
||||||
|
} else {
|
||||||
|
by.set(l.ending, { ending: l.ending, qty: l.qty, times: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...by.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COLLECTING_MENU = '1 Done 2 Cancel';
|
||||||
|
export const SUMMARY_MENU = '1 Save 2 Add more 3 Cancel';
|
||||||
|
|
||||||
|
export const collectingMenu = (): string => `Got it. Forward more, or reply ${COLLECTING_MENU}`;
|
||||||
|
|
||||||
|
export function shopList(shopNames: string[]): string {
|
||||||
|
return ['Which shop?', ...shopNames.map((n, i) => `${i + 1}) ${n}`)].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawQuestion(today: string, tomorrow: string): string {
|
||||||
|
return [
|
||||||
|
"Is this order for today's draw or tomorrow's?",
|
||||||
|
`1 Today ${formatDay(today)}`,
|
||||||
|
`2 Tomorrow ${formatDay(tomorrow)}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tooLateForToday(tomorrow: string): string {
|
||||||
|
return ["It is too late for today's draw.", `1 Tomorrow ${formatDay(tomorrow)}`, '2 Cancel'].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SummaryInput {
|
||||||
|
shopName: string;
|
||||||
|
drawDate: string;
|
||||||
|
today: string;
|
||||||
|
lineCount: number;
|
||||||
|
ticketCount: number;
|
||||||
|
entries: Entry[];
|
||||||
|
unreadable: string[];
|
||||||
|
leftOut: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushList(out: string[], heading: string, items: string[]): void {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
out.push(heading);
|
||||||
|
for (const u of items.slice(0, MAX_UNREADABLE_SHOWN)) out.push(` ${u}`);
|
||||||
|
const more = items.length - MAX_UNREADABLE_SHOWN;
|
||||||
|
if (more > 0) out.push(` ...and ${more} more`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summary(s: SummaryInput): string {
|
||||||
|
const head = `${plural(s.lineCount, 'line')}, ${plural(s.ticketCount, 'ticket')}`;
|
||||||
|
const out = [`${s.shopName} - draw of ${drawLabel(s.drawDate, s.today)}`];
|
||||||
|
out.push(s.unreadable.length === 0 && s.leftOut.length === 0 ? `${head}. Everything was read.` : head);
|
||||||
|
for (const e of s.entries) {
|
||||||
|
out.push(e.times > 1 ? `${e.ending} - ${e.qty} (${e.times} times)` : `${e.ending} - ${e.qty}`);
|
||||||
|
}
|
||||||
|
pushList(out, 'Not read, not in the order:', s.unreadable);
|
||||||
|
pushList(out, 'Left out earlier, not in the order:', s.leftOut);
|
||||||
|
out.push(SUMMARY_MENU);
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addPrompt = (): string =>
|
||||||
|
'Send the lines to add. Send only the new or corrected lines, not the whole order.';
|
||||||
|
|
||||||
|
export function saved(o: { orderId: number; shopName: string; ticketCount: number; drawDate: string }): string {
|
||||||
|
return `Saved #${o.orderId} - ${o.shopName}, ${plural(o.ticketCount, 'ticket')}, draw of ${formatDay(o.drawDate)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cancelled = (): string => 'Cancelled. Nothing was saved.';
|
||||||
|
export const nothingToSubmit = (): string => 'Nothing to submit yet.';
|
||||||
|
export const noTickets = (): string => 'I did not find any tickets. Nothing was saved.';
|
||||||
|
export const noShop = (): string => 'No shop is assigned to your account, please contact the owner.';
|
||||||
|
export const numberFirst = (): string => 'Reply with a number from the list first. Your last message was not added.';
|
||||||
|
export const expired = (): string => 'Order cancelled because there was no reply. Nothing was saved.';
|
||||||
|
export const startingHeld = (): string => 'Starting your next order with the message you sent.';
|
||||||
|
|
||||||
|
function progress(lineCount: number, ticketCount: number): string {
|
||||||
|
return lineCount > 0 ? ` (${plural(lineCount, 'line')}, ${plural(ticketCount, 'ticket')})` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reminder(lineCount: number, ticketCount: number): string {
|
||||||
|
return `You have an unfinished order${progress(lineCount, ticketCount)}. Reply ${COLLECTING_MENU}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitQuestion(lineCount: number, ticketCount: number): string {
|
||||||
|
return [
|
||||||
|
`You have an unfinished order${progress(lineCount, ticketCount)}.`,
|
||||||
|
'Is this message:',
|
||||||
|
'1 Part of that order',
|
||||||
|
'2 A new order (finish the old one first)',
|
||||||
|
'3 A new order (throw the old one away)',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
104
src/whatsapp/sendQueue.test.ts
Normal file
104
src/whatsapp/sendQueue.test.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { Bridge } from './bridge';
|
||||||
|
import { createSendQueue } from './sendQueue';
|
||||||
|
|
||||||
|
interface Sent {
|
||||||
|
chatId: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeBridge(opts: { fail?: (m: string) => boolean; gate?: (m: string) => Promise<void> } = {}) {
|
||||||
|
const sent: Sent[] = [];
|
||||||
|
const bridge: Bridge = {
|
||||||
|
async sendText(chatId, message) {
|
||||||
|
if (opts.gate) await opts.gate(message);
|
||||||
|
if (opts.fail?.(message)) throw new Error('bridge /send failed with HTTP 500');
|
||||||
|
sent.push({ chatId, message });
|
||||||
|
return { messageIds: [] };
|
||||||
|
},
|
||||||
|
async health() {
|
||||||
|
return { status: 'connected' };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { bridge, sent };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
describe('createSendQueue', () => {
|
||||||
|
it('sends a batch in order', async () => {
|
||||||
|
const { bridge, sent } = fakeBridge();
|
||||||
|
const q = createSendQueue(bridge, () => {});
|
||||||
|
q.enqueue('a@lid', ['one', 'two', 'three']);
|
||||||
|
await q.idle();
|
||||||
|
expect(sent.map((s) => s.message)).toEqual(['one', 'two', 'three']);
|
||||||
|
expect(sent.every((s) => s.chatId === 'a@lid')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps replies to one chat in order even when the first send is slow', async () => {
|
||||||
|
const { bridge, sent } = fakeBridge({ gate: (m) => (m === 'first' ? sleep(30) : Promise.resolve()) });
|
||||||
|
const q = createSendQueue(bridge, () => {});
|
||||||
|
q.enqueue('a@lid', ['first']);
|
||||||
|
q.enqueue('a@lid', ['second']);
|
||||||
|
await q.idle();
|
||||||
|
expect(sent.map((s) => s.message)).toEqual(['first', 'second']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not make one chat wait for another', async () => {
|
||||||
|
let release!: () => void;
|
||||||
|
const held = new Promise<void>((r) => (release = r));
|
||||||
|
const { bridge, sent } = fakeBridge({ gate: (m) => (m === 'slow' ? held : Promise.resolve()) });
|
||||||
|
const q = createSendQueue(bridge, () => {});
|
||||||
|
q.enqueue('a@lid', ['slow']);
|
||||||
|
q.enqueue('b@lid', ['fast']);
|
||||||
|
await sleep(10);
|
||||||
|
expect(sent.map((s) => s.message)).toEqual(['fast']);
|
||||||
|
release();
|
||||||
|
await q.idle();
|
||||||
|
expect(sent.map((s) => s.message)).toEqual(['fast', 'slow']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops blank messages and does nothing for an empty batch', async () => {
|
||||||
|
const { bridge, sent } = fakeBridge();
|
||||||
|
const q = createSendQueue(bridge, () => {});
|
||||||
|
q.enqueue('a@lid', ['', ' ', 'real', '\n']);
|
||||||
|
q.enqueue('a@lid', []);
|
||||||
|
await q.idle();
|
||||||
|
expect(sent.map((s) => s.message)).toEqual(['real']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns at once and never throws, even when the bridge fails', async () => {
|
||||||
|
const { bridge } = fakeBridge({ fail: () => true });
|
||||||
|
const q = createSendQueue(bridge, () => {});
|
||||||
|
expect(() => q.enqueue('a@lid', ['x'])).not.toThrow();
|
||||||
|
await expect(q.idle()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs a failure without the chat id or the text, and drops the rest of that batch', async () => {
|
||||||
|
const logs: Array<Record<string, unknown>> = [];
|
||||||
|
const { bridge, sent } = fakeBridge({ fail: (m) => m === 'secret order 8910=3' });
|
||||||
|
const q = createSendQueue(bridge, (e) => logs.push(e));
|
||||||
|
q.enqueue('145483561476218@lid', ['secret order 8910=3', 'menu 1 2 3']);
|
||||||
|
await q.idle();
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
expect(logs).toHaveLength(1);
|
||||||
|
const line = JSON.stringify(logs[0]);
|
||||||
|
expect(line).toContain('reply not sent');
|
||||||
|
expect(line).not.toContain('8910');
|
||||||
|
expect(line).not.toContain('145483561476218');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps working after a failure', async () => {
|
||||||
|
const { bridge, sent } = fakeBridge({ fail: (m) => m === 'bad' });
|
||||||
|
const q = createSendQueue(bridge, () => {});
|
||||||
|
q.enqueue('a@lid', ['bad']);
|
||||||
|
q.enqueue('a@lid', ['good']);
|
||||||
|
await q.idle();
|
||||||
|
expect(sent.map((s) => s.message)).toEqual(['good']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('idle resolves at once when nothing is queued', async () => {
|
||||||
|
const { bridge } = fakeBridge();
|
||||||
|
await expect(createSendQueue(bridge, () => {}).idle()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
41
src/whatsapp/sendQueue.ts
Normal file
41
src/whatsapp/sendQueue.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import type { Bridge } from './bridge';
|
||||||
|
|
||||||
|
export type LogFn = (entry: Record<string, unknown>) => void;
|
||||||
|
|
||||||
|
export interface SendQueue {
|
||||||
|
enqueue(chatId: string, messages: string[]): void;
|
||||||
|
idle(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSendQueue(bridge: Bridge, log: LogFn): SendQueue {
|
||||||
|
const tails = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
async function sendAll(chatId: string, messages: string[]): Promise<void> {
|
||||||
|
for (const message of messages) {
|
||||||
|
try {
|
||||||
|
await bridge.sendText(chatId, message);
|
||||||
|
} catch (err) {
|
||||||
|
log({ level: 'error', msg: 'whatsapp: reply not sent', error: (err as Error).message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enqueue(chatId, messages) {
|
||||||
|
const toSend = messages.filter((m) => m.trim() !== '');
|
||||||
|
if (toSend.length === 0) return;
|
||||||
|
|
||||||
|
const previous = tails.get(chatId) ?? Promise.resolve();
|
||||||
|
const next = previous.then(() => sendAll(chatId, toSend));
|
||||||
|
tails.set(chatId, next);
|
||||||
|
void next.then(() => {
|
||||||
|
if (tails.get(chatId) === next) tails.delete(chatId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async idle() {
|
||||||
|
while (tails.size > 0) await Promise.all([...tails.values()]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
272
src/whatsapp/sweep.test.ts
Normal file
272
src/whatsapp/sweep.test.ts
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { Db } from '../db';
|
||||||
|
import { getUserById } from '../services/users';
|
||||||
|
import { testConfig } from '../testing/config';
|
||||||
|
import { makeDb, seedFixture, type Fixture } from '../testing/helpers';
|
||||||
|
import type { Bridge } from './bridge';
|
||||||
|
import { getConversation, handleMessage } from './conversation';
|
||||||
|
import * as msg from './messages';
|
||||||
|
import { createSendQueue, type SendQueue } from './sendQueue';
|
||||||
|
import { createConversationSweeper } from './sweep';
|
||||||
|
|
||||||
|
const S = testConfig().orders; // remind 10, expire 30
|
||||||
|
const T0 = new Date('2026-11-03T05:00:00Z'); // 10:30 IST
|
||||||
|
const after = (min: number, sec = 0) => new Date(T0.getTime() + min * 60_000 + sec * 1000);
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let f: Fixture;
|
||||||
|
let queue: SendQueue;
|
||||||
|
let sent: Array<{ chatId: string; message: string }>;
|
||||||
|
let logs: Array<Record<string, unknown>>;
|
||||||
|
let clock: Date;
|
||||||
|
|
||||||
|
const bridge: Bridge = {
|
||||||
|
async sendText(chatId, message) {
|
||||||
|
sent.push({ chatId, message });
|
||||||
|
return { messageIds: [] };
|
||||||
|
},
|
||||||
|
async health() {
|
||||||
|
return { status: 'connected' };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = makeDb();
|
||||||
|
f = seedFixture(db);
|
||||||
|
sent = [];
|
||||||
|
logs = [];
|
||||||
|
clock = T0;
|
||||||
|
queue = createSendQueue(bridge, (e) => logs.push(e));
|
||||||
|
});
|
||||||
|
afterEach(() => db.close());
|
||||||
|
|
||||||
|
const sweeper = (settings = S) =>
|
||||||
|
createConversationSweeper({ db, settings, queue, log: (e) => logs.push(e), now: () => clock });
|
||||||
|
|
||||||
|
function chatAs(userId: number, chatId = 'chat-1@lid') {
|
||||||
|
const user = getUserById(db, userId)!;
|
||||||
|
return (text: string, at: Date) => handleMessage(db, S, user, chatId, text, at);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sweepAt(at: Date, settings = S) {
|
||||||
|
clock = at;
|
||||||
|
const r = sweeper(settings).tick();
|
||||||
|
await queue.idle();
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = (userId: number) => getConversation(db, userId)?.state ?? 'IDLE';
|
||||||
|
|
||||||
|
describe('reminder', () => {
|
||||||
|
it('is sent once, at the reminder time, with progress', async () => {
|
||||||
|
chatAs(f.shopUser.id)('8910=3\n7810=1', T0);
|
||||||
|
|
||||||
|
expect(await sweepAt(after(9, 59))).toEqual({ reminded: 0, expired: 0 });
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
|
||||||
|
expect(await sweepAt(after(10))).toEqual({ reminded: 1, expired: 0 });
|
||||||
|
expect(sent).toEqual([{ chatId: 'chat-1@lid', message: msg.reminder(2, 4) }]);
|
||||||
|
expect(sent[0]!.message).toBe('You have an unfinished order (2 lines, 4 tickets). Reply 1 Done 2 Cancel.');
|
||||||
|
|
||||||
|
expect(await sweepAt(after(11))).toEqual({ reminded: 0, expired: 0 });
|
||||||
|
expect(await sweepAt(after(20))).toEqual({ reminded: 0, expired: 0 });
|
||||||
|
expect(sent).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never saves anything', async () => {
|
||||||
|
chatAs(f.shopUser.id)('8910=3', T0);
|
||||||
|
await sweepAt(after(10));
|
||||||
|
expect(db.prepare('SELECT COUNT(*) AS n FROM orders').get()).toEqual({ n: 0 });
|
||||||
|
expect(state(f.shopUser.id)).toBe('COLLECTING');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts idle time from the last message, not the first', async () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3', T0);
|
||||||
|
say('7810=1', after(9));
|
||||||
|
expect(await sweepAt(after(10))).toEqual({ reminded: 0, expired: 0 });
|
||||||
|
expect(await sweepAt(after(18, 59))).toEqual({ reminded: 0, expired: 0 });
|
||||||
|
expect(await sweepAt(after(19))).toEqual({ reminded: 1, expired: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goes to the chat of the latest message', async () => {
|
||||||
|
chatAs(f.shopUser.id, 'old@lid')('8910=3', T0);
|
||||||
|
chatAs(f.shopUser.id, 'new@lid')('7810=1', after(1));
|
||||||
|
await sweepAt(after(11));
|
||||||
|
expect(sent.map((s) => s.chatId)).toEqual(['new@lid']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is only for orders still being collected', async () => {
|
||||||
|
chatAs(f.multiUser.id)('8910=3\ndone', T0); // AWAITING_SHOP
|
||||||
|
chatAs(f.shopUser.id)('8910=3\ndone', T0); // CONFIRM
|
||||||
|
expect(state(f.multiUser.id)).toBe('AWAITING_SHOP');
|
||||||
|
expect(state(f.shopUser.id)).toBe('CONFIRM');
|
||||||
|
expect(await sweepAt(after(15))).toEqual({ reminded: 0, expired: 0 });
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns the next order message into the late-message question', async () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3', T0);
|
||||||
|
await sweepAt(after(10));
|
||||||
|
const [reply] = say('0838=2', after(12));
|
||||||
|
expect(reply).toBe(msg.splitQuestion(1, 3));
|
||||||
|
expect(getConversation(db, f.shopUser.id)!.held_text).toBe('0838=2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the configured time', async () => {
|
||||||
|
chatAs(f.shopUser.id)('8910=3', T0);
|
||||||
|
const custom = { ...S, remindMin: 3, expireMin: 6 };
|
||||||
|
expect(await sweepAt(after(2, 59), custom)).toEqual({ reminded: 0, expired: 0 });
|
||||||
|
expect(await sweepAt(after(3), custom)).toEqual({ reminded: 1, expired: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('expiry', () => {
|
||||||
|
it('clears the conversation at the expiry time and says nothing was saved', async () => {
|
||||||
|
chatAs(f.shopUser.id)('8910=3', T0);
|
||||||
|
expect(await sweepAt(after(29, 59))).toMatchObject({ expired: 0 });
|
||||||
|
expect(await sweepAt(after(30))).toEqual({ reminded: 0, expired: 1 });
|
||||||
|
expect(sent.map((s) => s.message)).toEqual([msg.reminder(1, 3), msg.expired()]);
|
||||||
|
expect(state(f.shopUser.id)).toBe('IDLE');
|
||||||
|
expect(getConversation(db, f.shopUser.id)).toMatchObject({
|
||||||
|
buffer_text: '',
|
||||||
|
parsed_json: null,
|
||||||
|
held_text: null,
|
||||||
|
reminded_at: null,
|
||||||
|
last_prompt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('happens once; an idle conversation is left alone afterwards', async () => {
|
||||||
|
chatAs(f.shopUser.id)('8910=3', T0);
|
||||||
|
await sweepAt(after(30));
|
||||||
|
sent.length = 0;
|
||||||
|
expect(await sweepAt(after(31))).toEqual({ reminded: 0, expired: 0 });
|
||||||
|
expect(await sweepAt(after(600))).toEqual({ reminded: 0, expired: 0 });
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['AWAITING_SHOP', 'multiUser', '8910=3\ndone'],
|
||||||
|
['CONFIRM', 'shopUser', '8910=3\ndone'],
|
||||||
|
] as const)('applies to %s too', async (expected, who, text) => {
|
||||||
|
chatAs(f[who].id)(text, T0);
|
||||||
|
expect(state(f[who].id)).toBe(expected);
|
||||||
|
expect(await sweepAt(after(30))).toEqual({ reminded: 0, expired: 1 });
|
||||||
|
expect(state(f[who].id)).toBe('IDLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies to an open draw question', async () => {
|
||||||
|
const askTime = new Date('2026-11-03T09:40:00Z'); // 15:10 IST
|
||||||
|
chatAs(f.shopUser.id)('8910=3\ndone', askTime);
|
||||||
|
expect(state(f.shopUser.id)).toBe('ASKING');
|
||||||
|
expect(await sweepAt(new Date(askTime.getTime() + 30 * 60_000))).toEqual({ reminded: 0, expired: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a held late message too', async () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3', T0);
|
||||||
|
await sweepAt(after(10));
|
||||||
|
say('0838=2', after(12));
|
||||||
|
await sweepAt(after(42));
|
||||||
|
expect(getConversation(db, f.shopUser.id)).toMatchObject({ state: 'IDLE', held_text: null, buffer_text: '' });
|
||||||
|
expect(sent.at(-1)!.message).toBe(msg.expired());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a new order after expiry starts clean', async () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3', T0);
|
||||||
|
await sweepAt(after(30));
|
||||||
|
expect(say('7810=1', after(31))).toEqual([msg.collectingMenu()]);
|
||||||
|
expect(getConversation(db, f.shopUser.id)!.buffer_text).toBe('7810=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the configured time', async () => {
|
||||||
|
chatAs(f.shopUser.id)('8910=3', T0);
|
||||||
|
const custom = { ...S, remindMin: 3, expireMin: 6 };
|
||||||
|
expect(await sweepAt(after(6), custom)).toMatchObject({ expired: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a sweep', () => {
|
||||||
|
it('leaves IDLE conversations alone', async () => {
|
||||||
|
const say = chatAs(f.shopUser.id);
|
||||||
|
say('8910=3', T0);
|
||||||
|
say('done', T0);
|
||||||
|
say('1', T0); // saved, back to IDLE
|
||||||
|
expect(state(f.shopUser.id)).toBe('IDLE');
|
||||||
|
expect(await sweepAt(after(120))).toEqual({ reminded: 0, expired: 0 });
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when there are no conversations', async () => {
|
||||||
|
expect(await sweepAt(after(1))).toEqual({ reminded: 0, expired: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats each user separately', async () => {
|
||||||
|
chatAs(f.shopUser.id, 'a@lid')('8910=3', T0);
|
||||||
|
chatAs(f.admin.id, 'b@lid')('7810=1', after(5));
|
||||||
|
expect(await sweepAt(after(10))).toEqual({ reminded: 1, expired: 0 });
|
||||||
|
expect(await sweepAt(after(15))).toEqual({ reminded: 1, expired: 0 });
|
||||||
|
expect(await sweepAt(after(30))).toEqual({ reminded: 0, expired: 1 });
|
||||||
|
expect(await sweepAt(after(35))).toEqual({ reminded: 0, expired: 1 });
|
||||||
|
expect(sent.map((s) => s.chatId)).toEqual(['a@lid', 'b@lid', 'a@lid', 'b@lid']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches up after downtime in one pass', async () => {
|
||||||
|
chatAs(f.shopUser.id)('8910=3', T0);
|
||||||
|
expect(await sweepAt(after(600))).toEqual({ reminded: 0, expired: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs counts only, never text or chat ids', async () => {
|
||||||
|
chatAs(f.shopUser.id, '145483561476218@lid')('8910=3', T0);
|
||||||
|
await sweepAt(after(10));
|
||||||
|
await sweepAt(after(30));
|
||||||
|
const all = JSON.stringify(logs);
|
||||||
|
expect(all).toContain('conversations swept');
|
||||||
|
expect(all).not.toContain('8910');
|
||||||
|
expect(all).not.toContain('145483561476218');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('start / stop', () => {
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it('sweeps at once, then every minute, until stopped', async () => {
|
||||||
|
chatAs(f.shopUser.id)('8910=3', T0);
|
||||||
|
clock = after(10);
|
||||||
|
const stop = sweeper().start();
|
||||||
|
await queue.idle();
|
||||||
|
expect(sent.map((s) => s.message)).toEqual([msg.reminder(1, 3)]); // the immediate pass
|
||||||
|
|
||||||
|
clock = after(30);
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
await queue.idle();
|
||||||
|
expect(sent.at(-1)!.message).toBe(msg.expired());
|
||||||
|
|
||||||
|
stop();
|
||||||
|
chatAs(f.admin.id)('7810=1', after(30));
|
||||||
|
clock = after(100);
|
||||||
|
await vi.advanceTimersByTimeAsync(5 * 60_000);
|
||||||
|
await queue.idle();
|
||||||
|
expect(state(f.admin.id)).toBe('COLLECTING'); // no more sweeping after stop
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a failing sweep is logged and does not stop the timer or throw', async () => {
|
||||||
|
const broken = createConversationSweeper({
|
||||||
|
db,
|
||||||
|
settings: S,
|
||||||
|
queue,
|
||||||
|
log: (e) => logs.push(e),
|
||||||
|
now: () => {
|
||||||
|
throw new Error('clock exploded');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const stop = broken.start();
|
||||||
|
await vi.advanceTimersByTimeAsync(120_000);
|
||||||
|
stop();
|
||||||
|
expect(logs.filter((l) => l.msg === 'conversation sweep failed').length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
74
src/whatsapp/sweep.ts
Normal file
74
src/whatsapp/sweep.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import type { OrderSettings } from '../config';
|
||||||
|
import type { Db } from '../db';
|
||||||
|
import { bufferProgress, resetConversation, type ConversationRow } from './conversation';
|
||||||
|
import * as msg from './messages';
|
||||||
|
import type { LogFn, SendQueue } from './sendQueue';
|
||||||
|
|
||||||
|
export interface SweeperOptions {
|
||||||
|
db: Db;
|
||||||
|
settings: OrderSettings;
|
||||||
|
queue: SendQueue;
|
||||||
|
log: LogFn;
|
||||||
|
now?: () => Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SweepResult {
|
||||||
|
reminded: number;
|
||||||
|
expired: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVERY_MS = 60_000;
|
||||||
|
|
||||||
|
const iso = (d: Date): string => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
export function createConversationSweeper(opts: SweeperOptions) {
|
||||||
|
const now = opts.now ?? (() => new Date());
|
||||||
|
|
||||||
|
function tick(): SweepResult {
|
||||||
|
const t = now();
|
||||||
|
const outbox: Array<{ chatId: string; message: string }> = [];
|
||||||
|
const result: SweepResult = { reminded: 0, expired: 0 };
|
||||||
|
|
||||||
|
opts.db.transaction(() => {
|
||||||
|
const rows = opts.db
|
||||||
|
.prepare("SELECT * FROM conversations WHERE state != 'IDLE'")
|
||||||
|
.all() as ConversationRow[];
|
||||||
|
|
||||||
|
for (const c of rows) {
|
||||||
|
const idleMin = (t.getTime() - Date.parse(c.last_activity_at)) / 60_000;
|
||||||
|
|
||||||
|
if (idleMin >= opts.settings.expireMin) {
|
||||||
|
resetConversation(opts.db, c.id, t);
|
||||||
|
outbox.push({ chatId: c.chat_id, message: msg.expired() });
|
||||||
|
result.expired++;
|
||||||
|
} else if (c.state === 'COLLECTING' && !c.reminded_at && idleMin >= opts.settings.remindMin) {
|
||||||
|
opts.db.prepare('UPDATE conversations SET reminded_at = ? WHERE id = ?').run(iso(t), c.id);
|
||||||
|
const p = bufferProgress(c.buffer_text);
|
||||||
|
outbox.push({ chatId: c.chat_id, message: msg.reminder(p.lines, p.tickets) });
|
||||||
|
result.reminded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
for (const o of outbox) opts.queue.enqueue(o.chatId, [o.message]);
|
||||||
|
if (result.reminded + result.expired > 0) {
|
||||||
|
opts.log({ level: 'info', msg: 'conversations swept', ...result });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(): () => void {
|
||||||
|
const safeTick = (): void => {
|
||||||
|
try {
|
||||||
|
tick();
|
||||||
|
} catch (err) {
|
||||||
|
opts.log({ level: 'error', msg: 'conversation sweep failed', error: (err as Error).message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const timer = setInterval(safeTick, EVERY_MS);
|
||||||
|
timer.unref();
|
||||||
|
safeTick();
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tick, start };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user