Skip to content
64 linesCodeBlameRaw
1/**
2 * What a card may hold. Pure, so it is tested apart from the service.
3 */
4import type { CardAction, MessageCard } from "@g1t/contracts";
5
6/** A card as kept, or null when what was sent is not one. */
7export function cleanCard(card: unknown): MessageCard | null {
8 if (!card || typeof card !== "object") return null;
9 const c = card as Record<string, unknown>;
10 const text = (value: unknown, max: number) => (typeof value === "string" && value.trim() ? value.trim().slice(0, max) : null);
11 const kind = text(c.kind, 40);
12 const title = text(c.title, 300);
13 if (!kind || !title) return null;
14 const href = text(c.href, 2000);
15 const local = (value: string | null) => (value && value.startsWith("/") && !value.startsWith("//") ? value : null);
16 const owner = c.owner === "agents" ? "agents" : null;
17 const fields = Array.isArray(c.fields)
18 ? c.fields
19 .map((f) => (f && typeof f === "object" ? { label: text((f as Record<string, unknown>).label, 40), value: text((f as Record<string, unknown>).value, 200) } : null))
20 .filter((f): f is { label: string; value: string } => !!f?.label && !!f.value)
21 .slice(0, 8)
22 : [];
23 const actions = Array.isArray(c.actions)
24 ? c.actions
25 .map((raw) => {
26 if (!raw || typeof raw !== "object") return null;
27 const a = raw as Record<string, unknown>;
28 const id = text(a.id, 40);
29 const label = text(a.label, 40);
30 if (!id || !label || !/^[a-z0-9_-]+$/.test(id)) return null;
31 // A link that leaves the site is dropped, not turned into an action.
32 const rawHref = text(a.href, 2000);
33 if (rawHref && !local(rawHref)) return null;
34 const input = a.input && typeof a.input === "object" ? (a.input as Record<string, unknown>) : null;
35 return {
36 id,
37 label,
38 style: a.style === "primary" || a.style === "danger" ? a.style : "default",
39 confirm: text(a.confirm, 200),
40 input:
41 input && (input.kind === "money" || input.kind === "text")
42 ? { kind: input.kind, label: text(input.label, 60) ?? "", placeholder: text(input.placeholder, 80), initial: text(input.initial, 80) }
43 : null,
44 href: local(rawHref),
45 } as CardAction;
46 })
47 .filter((a): a is CardAction => !!a)
48 // Actions other than links need an owner to answer them.
49 .filter((a) => !!a.href || !!owner)
50 .slice(0, 5)
51 : [];
52 return {
53 kind,
54 title,
55 detail: text(c.detail, 500),
56 state: text(c.state, 80),
57 // Relative to the site only: a card never links somewhere else.
58 href: local(href),
59 ...(text(c.body, 4000) ? { body: text(c.body, 4000) } : {}),
60 ...(fields.length ? { fields } : {}),
61 ...(actions.length ? { actions } : {}),
62 ...(owner ? { owner, ref: text(c.ref, 100) } : {}),
63 };
64}