| 1 | /** |
| 2 | * What a message may hold and how pages of them are read. Pure, so it is |
| 3 | * tested apart from the service. |
| 4 | */ |
| 5 | |
| 6 | /** The longest message body, in characters. */ |
| 7 | export const MAX_BODY = 40_000; |
| 8 | |
| 9 | /** Messages on one page when none is asked for, and the most on one page. */ |
| 10 | export const DEFAULT_PAGE = 50; |
| 11 | export const MAX_PAGE = 200; |
| 12 | |
| 13 | /** How long "is typing" shows after a person's keystroke and while an agent works. */ |
| 14 | export const PERSON_TYPING_MS = 5_000; |
| 15 | export const AGENT_TYPING_MS = 8_000; |
| 16 | |
| 17 | /** A body as kept, or why it cannot be: something to say, and not too much. */ |
| 18 | export function messageBody(body: unknown, hasCard = false): { ok: true; body: string } | { ok: false; message: string } { |
| 19 | const text = typeof body === "string" ? body.replace(/\s+$/, "") : ""; |
| 20 | if (!text.trim() && !hasCard) return { ok: false, message: "A message needs something in it." }; |
| 21 | if (text.length > MAX_BODY) return { ok: false, message: `A message is at most ${MAX_BODY.toLocaleString("en-US")} characters.` }; |
| 22 | return { ok: true, body: text }; |
| 23 | } |
| 24 | |
| 25 | /** How many messages a page holds: the default, or what was asked within 1 to the most. */ |
| 26 | export function pageSize(limit: unknown): number { |
| 27 | const n = typeof limit === "number" && Number.isFinite(limit) ? Math.floor(limit) : DEFAULT_PAGE; |
| 28 | return Math.min(MAX_PAGE, Math.max(1, n)); |
| 29 | } |
| 30 | |
| 31 | /** |
| 32 | * A page read one past its size, newest first: the page, and the cursor |
| 33 | * for the next one back (its oldest id), or null when this is the start. |
| 34 | */ |
| 35 | export function pageOf<T extends { id: string }>(rows: T[], size: number): { rows: T[]; older: string | null } { |
| 36 | if (rows.length <= size) return { rows, older: null }; |
| 37 | const page = rows.slice(0, size); |
| 38 | return { rows: page, older: page[page.length - 1].id }; |
| 39 | } |
| 40 | |
| 41 | /** Messages an agent reads before replying when none is asked for, and the most. */ |
| 42 | export const DEFAULT_HISTORY = 30; |
| 43 | export const MAX_HISTORY = 100; |
| 44 | |
| 45 | /** How many messages an agent's history holds: the default, or what was asked within 1 to the most. */ |
| 46 | export function historySize(limit: unknown): number { |
| 47 | const n = typeof limit === "number" && Number.isFinite(limit) ? Math.floor(limit) : DEFAULT_HISTORY; |
| 48 | return Math.min(MAX_HISTORY, Math.max(1, n)); |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * An agent's history, oldest first, from rows read newest first: a |
| 53 | * thread's root (when there is one) leads, then the rest in order. |
| 54 | */ |
| 55 | export function historyOf<T extends { id: string }>(newestFirst: T[], root: T | null = null): T[] { |
| 56 | const rest = [...newestFirst].reverse().filter((row) => row.id !== root?.id); |
| 57 | return root ? [root, ...rest] : rest; |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * Whether a frame from a client's socket says it is typing in `channelId`: |
| 62 | * `{"type":"typing"}`, optionally naming the channel. A frame naming |
| 63 | * another channel is ignored. |
| 64 | */ |
| 65 | export function isTypingFrame(frame: unknown, channelId: string): boolean { |
| 66 | if (typeof frame !== "string" || frame.length > 1024) return false; |
| 67 | let said: unknown; |
| 68 | try { |
| 69 | said = JSON.parse(frame); |
| 70 | } catch { |
| 71 | return false; |
| 72 | } |
| 73 | if (!said || typeof said !== "object") return false; |
| 74 | const { type, channel_id } = said as { type?: unknown; channel_id?: unknown }; |
| 75 | return type === "typing" && (channel_id === undefined || channel_id === null || channel_id === channelId); |
| 76 | } |
| 77 | |
| 78 | /** The UTC day a time falls on, as the meter keys it. */ |
| 79 | export function meterDay(iso: string): string { |
| 80 | return iso.slice(0, 10); |
| 81 | } |