@wix/himalaya-cli 0.1445.0 → 0.1446.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/dist/cli.mjs +26 -27
  2. package/dist/preview-assets.generated.bin +0 -0
  3. package/fixtures/wix-canned.json +20 -0
  4. package/package.json +2 -2
  5. package/templates/beauty-salon/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  6. package/templates/branded-lite/config.ts +7 -0
  7. package/templates/branded-lite/dev/index.ts +1120 -0
  8. package/templates/branded-lite/himalaya.content.json +3 -0
  9. package/templates/branded-lite/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  10. package/templates/branded-lite/tier5-src/_shared/business-chat-message-model.ts +177 -0
  11. package/templates/branded-lite/tier5-src/_shared/business-chat-model.ts +22 -0
  12. package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +427 -0
  13. package/templates/branded-lite/tier5-src/_shared/business-chat-transport.ts +102 -0
  14. package/templates/branded-lite/tier5-src/business-chat/index.ts +304 -0
  15. package/templates/community-club/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  16. package/templates/events-meetups/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  17. package/templates/gym-fitness/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  18. package/templates/nonprofit-cause/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  19. package/templates/podcast-creator/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  20. package/templates/portfolio-creative/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  21. package/templates/restaurant/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  22. package/templates/retail-store/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  23. package/templates/yoga-studio/tier5-src/_shared/branded-lite-hosted.ts +1 -0
@@ -40,6 +40,9 @@
40
40
  "himi_branded_home": {
41
41
  "displayName": "Home"
42
42
  },
43
+ "himi_business_chat": {
44
+ "displayName": "Business Chat"
45
+ },
43
46
  "himi_cart": {
44
47
  "displayName": "Cart"
45
48
  },
@@ -72,6 +72,7 @@ export const HOSTED_SCREEN_VERTICALS: Readonly<Record<string, readonly BrandedLi
72
72
  himi_blog: ["blog"],
73
73
  himi_bookings: ["bookings"],
74
74
  himi_cart: ["store"],
75
+ himi_business_chat: ["inbox"],
75
76
  himi_chat_thread: ["inbox", "members"],
76
77
  himi_cms: ["cms"],
77
78
  himi_collection: ["store"],
@@ -0,0 +1,177 @@
1
+ /** Read-only presentation model. HTML stays text; remote actions require a separate dispatcher. */
2
+ export type PartKind = 'text' | 'image' | 'file' | 'audio' | 'video' | 'card' | 'form' | 'system' | 'unsupported';
3
+ export interface MessageButton { id: string; label: string; url: string; interactionId?: string; unavailableReason?: string }
4
+ export interface PartRow {
5
+ id: string; kind: PartKind; text: string; url: string; fileName: string; caption: string;
6
+ buttons?: MessageButton[]; fields?: { name: string; value: string }[];
7
+ mimeType?: string; iconUrl?: string; mediaId?: string; urlExpiration?: string; unavailableReason?: string;
8
+ mediaProvider?: string; thumbnailUrl?: string;
9
+ }
10
+ export interface MessageRow {
11
+ id: string; sequence: string; sender: string; senderId: string; when: string; createdDate: string;
12
+ mine: boolean; status: string; direction: string; parts: PartRow[]; plainText: string;
13
+ }
14
+ type Obj = Record<string, unknown>;
15
+ function obj(value: unknown): Obj { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Obj : {}; }
16
+ function str(value: unknown): string { return typeof value === 'string' ? value : typeof value === 'number' && Number.isFinite(value) ? String(value) : ''; }
17
+ function first(...values: unknown[]): string { for (const value of values) { const s = str(value); if (s) return s; } return ''; }
18
+ function stable(value: unknown, ancestors: unknown[] = []): string {
19
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? String(value);
20
+ if (ancestors.includes(value)) return '[circular]';
21
+ const next = [...ancestors, value];
22
+ return Array.isArray(value) ? '[' + value.map(v => stable(v, next)).join(',') + ']' : '{' + Object.keys(value as Obj).sort().map(k => JSON.stringify(k) + ':' + stable((value as Obj)[k], next)).join(',') + '}';
23
+ }
24
+ function hash(value: unknown): string {
25
+ const text = stable(value); let a = 2166136261; let b = 5381;
26
+ for (let i = 0; i < text.length; i++) { a = Math.imul(a ^ text.charCodeAt(i), 16777619); b = Math.imul(b, 33) ^ text.charCodeAt(i); }
27
+ return (a >>> 0).toString(16).padStart(8, '0') + (b >>> 0).toString(16).padStart(8, '0');
28
+ }
29
+ /** HTTPS only, no credentials/control characters/backslashes. Does not depend on worker URL globals. */
30
+ function safeUrl(value: unknown): string {
31
+ if (typeof value !== 'string' || /[\s\\\u0000-\u001f\u007f-\u009f]/u.test(value)) return '';
32
+ const match = /^https:\/\/([^/?#]+)(?:[/?#].*)?$/iu.exec(value);
33
+ if (!match) return '';
34
+ const authority = match[1];
35
+ if (authority.includes('@') || !/^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\[[0-9a-f:]+\])(?::[0-9]{1,5})?$/iu.test(authority)) return '';
36
+ const port = /:([0-9]+)$/.exec(authority);
37
+ if (port && Number(port[1]) > 65535) return '';
38
+ return value;
39
+ }
40
+ function htmlText(value: string): string {
41
+ // Output is plain text, never markup. No href/src extraction or HTML insertion.
42
+ return value.replace(/<!--[\s\S]*?-->/g, '').replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '')
43
+ .replace(/<\s*br\s*\/?\s*>/gi, '\n').replace(/<\/(p|div|li|h[1-6])\s*>/gi, '\n').replace(/<[^>]*>/g, '')
44
+ .replace(/&(#x[0-9a-f]+|#[0-9]+|amp|lt|gt|quot|apos|nbsp);/gi, (whole, entity: string) => {
45
+ const names: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
46
+ if (entity[0] !== '#') return names[entity.toLowerCase()] ?? whole;
47
+ const n = entity[1]?.toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : Number(entity.slice(1));
48
+ return n > 0 && n <= 0x10ffff && !(n >= 0xd800 && n <= 0xdfff) ? String.fromCodePoint(n) : whole;
49
+ });
50
+ }
51
+ function base(id: string, kind: PartKind, text = ''): PartRow { return { id, kind, text, url: '', fileName: '', caption: '' }; }
52
+ function unsupported(id: string, value: unknown, reason = 'Unsupported message content'): PartRow {
53
+ return { ...base(id, 'unsupported', typeof value === 'string' ? value : stable(value)), caption: reason, unavailableReason: reason };
54
+ }
55
+ function media(id: string, kind: 'image' | 'file' | 'audio' | 'video', input: unknown): PartRow {
56
+ const m = obj(input); const candidate = first(m.url, m.uri, m.downloadUrl, m.src, typeof input === 'string' ? input : '');
57
+ const url = safeUrl(candidate); const mime = first(m.mimeType, m.contentType);
58
+ const resolvedKind = kind === 'file' && /^audio\//i.test(mime) ? 'audio' : kind === 'file' && /^video\//i.test(mime) ? 'video' : kind;
59
+ const missing = url ? '' : candidate ? 'Attachment URL is unavailable: only safe HTTPS links are supported.' : 'Attachment requires a download URL.';
60
+ return { ...base(id, resolvedKind, first(m.text, m.title)), url, fileName: first(m.filename, m.fileName, m.name), caption: first(m.caption, m.alt, missing, resolvedKind === 'image' ? 'Image attachment' : ''), mimeType: mime, mediaId: first(m.mediaFileId, m.id, m._id, m.fileId), mediaProvider: str(m.mediaProvider), thumbnailUrl: safeUrl(m.thumbnailUrl), urlExpiration: str(m.urlExpiration), ...(missing ? { unavailableReason: missing } : {}) };
61
+ }
62
+ function buttons(value: unknown, prefix: string): MessageButton[] {
63
+ if (!Array.isArray(value)) return [];
64
+ return value.map((value, i) => {
65
+ const b = obj(value); const raw = first(b.launchUrl, b.url, b.href, obj(b.action).url); const url = safeUrl(raw);
66
+ const interactionId = first(obj(b.postbackProperties).interactionId, b.interactionId);
67
+ return { id: prefix + '-button-' + i, label: first(b.label, b.title, b.text, 'Action'), url,
68
+ ...(interactionId ? { interactionId } : {}),
69
+ ...(!url ? { unavailableReason: raw ? 'Only safe HTTPS links are supported.' : interactionId ? 'This interaction requires a supported server action.' : 'Action has no supported link.' } : {}) };
70
+ });
71
+ }
72
+ function card(id: string, input: unknown, kind: 'card' | 'form' | 'system' = 'card'): PartRow[] {
73
+ const c = obj(input); const lines = Array.isArray(c.textLines) ? c.textLines.map(str) : [];
74
+ const text = [first(c.title, c.name), first(c.text, c.description, c.body), ...lines].filter(Boolean).join('\n');
75
+ const fields = Array.isArray(c.fields) ? c.fields.map(f => ({ name: first(obj(f).name, obj(f).label), value: typeof obj(f).value === 'string' ? obj(f).value as string : stable(obj(f).value) })) : [];
76
+ const result: PartRow = { ...base(id, kind, text), buttons: buttons(c.buttons, id), ...(fields.length ? { fields } : {}) };
77
+ const image = first(c.imageUrl, c.iconUrl, obj(c.image).url, obj(c.image).uri);
78
+ if (image) { result.url = safeUrl(image); if (!result.url) result.unavailableReason = 'Card image requires a safe HTTPS URL.'; }
79
+ const parts = [result];
80
+ if (Array.isArray(c.media)) c.media.forEach((v, i) => parts.push(...basicItem(`${id}-media-${i}`, v)));
81
+ if (!text && !fields.length && !result.buttons?.length && !image && parts.length === 1) return [unsupported(id, input)];
82
+ return parts;
83
+ }
84
+ function basicItem(id: string, value: unknown): PartRow[] {
85
+ if (typeof value === 'string') return [base(id, 'text', value)];
86
+ const v = obj(value); const parts: PartRow[] = [];
87
+ if (typeof v.text === 'string') parts.push(base(id + '-text', 'text', v.text));
88
+ for (const kind of ['image', 'file', 'audio', 'video'] as const) if (v[kind] !== undefined && v[kind] !== null) parts.push(media(id + '-' + kind, kind, v[kind]));
89
+ for (const key of Object.keys(v).filter(k => !['text', 'image', 'file', 'audio', 'video', 'id', '_id', 'type'].includes(k))) parts.push(unsupported(id + '-extra-' + key, { [key]: v[key] }));
90
+ return parts.length ? parts : [unsupported(id, value)];
91
+ }
92
+ function publicParts(content: Obj, id: string): PartRow[] {
93
+ const parts: PartRow[] = [];
94
+ if (content.basic !== undefined) {
95
+ const items = obj(content.basic).items;
96
+ if (Array.isArray(items) && items.length) items.forEach((v, i) => parts.push(...basicItem(`${id}-basic-${i}`, v)));
97
+ else parts.push(unsupported(id + '-basic', content.basic));
98
+ }
99
+ for (const key of ['template', 'form', 'system'] as const) if (content[key] !== undefined) parts.push(...card(id + '-' + key, content[key], key === 'template' ? 'card' : key));
100
+ if (content.minimal !== undefined) {
101
+ const m = obj(content.minimal); const part = base(id + '-minimal', 'system', str(m.text)); const rawUrl = str(m.url);
102
+ if (rawUrl) { part.url = safeUrl(rawUrl); if (!part.url) part.unavailableReason = 'Only safe HTTPS links are supported.'; }
103
+ const icon = str(m.iconUrl);
104
+ if (icon) { part.iconUrl = safeUrl(icon); if (!part.iconUrl) part.caption = 'Activity icon requires a safe HTTPS URL.'; }
105
+ parts.push(part.text || part.url ? part : unsupported(part.id, content.minimal));
106
+ }
107
+ const unknown = Object.keys(content).filter(k => !['basic', 'template', 'form', 'system', 'minimal', 'title', 'previewText'].includes(k));
108
+ for (const key of unknown) parts.push(unsupported(id + '-content-' + key, { [key]: content[key] }));
109
+ return parts;
110
+ }
111
+ function chatPart(id: string, value: unknown): PartRow[] {
112
+ const v = obj(value); const mime = first(v.mimeType, v.contentType).toLowerCase().split(';')[0].trim(); const d = v.data;
113
+ const data = obj(d);
114
+ if (Array.isArray(data.cards)) return data.cards.length ? data.cards.flatMap((item, i) => card(`${id}-card-${i}`, item)) : [unsupported(id, value)];
115
+ if (mime === 'text/plain') return [base(id, 'text', typeof d === 'string' ? d : first(data.text, v.text))];
116
+ if (mime === 'text/html') return [{ ...base(id, 'text', htmlText(typeof d === 'string' ? d : first(data.html, data.text, v.text))), caption: 'HTML message shown as plain text' }];
117
+ // Branded chat separates the outer message-part kind from the file's MIME.
118
+ // mobile-apps-inbox MIME_TYPE: uri/image, uri/file; actual MIME lives in data.type.
119
+ if (mime === 'uri/image' || mime === 'uri/file') {
120
+ return [media(id, mime === 'uri/image' ? 'image' : 'file', typeof d === 'string' ? d : {
121
+ ...data, mimeType: first(data.type, data.mimeType, data.contentType),
122
+ })];
123
+ }
124
+ if (/^(image|audio|video)\//.test(mime)) {
125
+ const kind = mime.split('/')[0] as 'image' | 'audio' | 'video';
126
+ return [media(id, kind, typeof d === 'string' ? d : { ...data, mimeType: mime })];
127
+ }
128
+ if (/card|template|form|system/.test(mime)) {
129
+ let payload: unknown = d;
130
+ if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch { return [unsupported(id, payload)]; } }
131
+ return card(id, payload, /form/.test(mime) ? 'form' : /system/.test(mime) ? 'system' : 'card');
132
+ }
133
+ if (typeof d === 'string' && /^application\//.test(mime) && safeUrl(d)) return [media(id, 'file', { url: d, mimeType: mime })];
134
+ if (first(data.uri, data.url, data.downloadUrl) && (first(data.fileName, data.filename, data.name) || /^(application|text)\//.test(mime))) return [media(id, 'file', { ...data, mimeType: mime })];
135
+ return [unsupported(id, value, mime ? `Unsupported message part (${mime})` : 'Unsupported message part')];
136
+ }
137
+ function decimal(value: string): { negative: boolean; digits: string } | null {
138
+ if (!/^-?\d+$/.test(value)) return null;
139
+ const digits = value.replace(/^-/, '').replace(/^0+(?=\d)/, ''); return { negative: value[0] === '-' && digits !== '0', digits };
140
+ }
141
+ /** Exact decimal ordering, including int64 values beyond JavaScript's safe integer range. */
142
+ export function compareSequence(a: string, b: string): number {
143
+ const x = decimal(a); const y = decimal(b);
144
+ if (x && y) { if (x.negative !== y.negative) return x.negative ? -1 : 1; const n = x.digits.length === y.digits.length ? x.digits < y.digits ? -1 : x.digits > y.digits ? 1 : 0 : x.digits.length < y.digits.length ? -1 : 1; return x.negative ? -n : n; }
145
+ return a < b ? -1 : a > b ? 1 : 0;
146
+ }
147
+ function dateText(raw: unknown): string {
148
+ if (typeof raw === 'string') return raw;
149
+ if (typeof raw === 'number' && Number.isFinite(raw)) { const date = new Date(raw); return Number.isFinite(date.getTime()) ? date.toISOString() : ''; }
150
+ return '';
151
+ }
152
+ function normalize(value: unknown, myMemberId?: string): MessageRow {
153
+ const m = obj(value); const sender = obj(m.sender); const display = obj(m.senderDisplayData);
154
+ const id = typeof m.id === 'string' && m.id ? m.id : typeof m._id === 'string' && m._id ? m._id : typeof m.id === 'number' && Number.isSafeInteger(m.id) ? String(m.id) : 'local-' + hash(value);
155
+ const senderId = first(sender.memberId, sender.userId, sender.wixUserId, sender.contactId, sender.anonymousVisitorId, m.senderId);
156
+ const direction = str(m.direction); const senderName = first(display.name, obj(m.senderDisplayData).displayName, sender.name, typeof m.sender === 'string' ? m.sender : '', sender.type);
157
+ let parts: PartRow[] = publicParts(obj(m.content), id);
158
+ if (Array.isArray(m.data)) m.data.forEach((v, i) => parts.push(...chatPart(`${id}-chat-${i}`, v)));
159
+ if (!parts.length) { // A chat-web template message (a card) keeps its visible text in the top-level templateData.
160
+ const body = first(m.body, m.text, obj(m.templateData).text, m.summary, obj(m.content).previewText); parts = body ? [base(id + '-text', 'text', body)] : [unsupported(id + '-unknown', m.content ?? m.data ?? { messageType: m.messageType ?? 'unknown' })]; }
161
+ const sequence = typeof m.sequence === 'string' ? m.sequence : typeof m.sequence === 'number' && Number.isSafeInteger(m.sequence) ? String(m.sequence) : '';
162
+ const createdDate = dateText(m.createdDate ?? m._createdDate ?? m.createdAt);
163
+ const mine = !!myMemberId && [sender.memberId, sender.userId, sender.wixUserId, sender.contactId, sender.anonymousVisitorId, m.senderId].includes(myMemberId);
164
+ const status = first(m.status, m.deliveryStatus, obj(m.transit).status, obj(m.transit).state, 'stored');
165
+ return { id, sequence, sender: senderName || (senderId ? senderId : 'Unknown sender'), senderId, when: createdDate, createdDate, mine, status, direction, parts, plainText: parts.map(p => [p.text, ...(p.fields ?? []).map(f => `${f.name}: ${f.value}`), p.fileName || p.caption].filter(Boolean).join('\n')).filter(Boolean).join('\n') };
166
+ }
167
+ /** Newest representation replaces the same message ID; deterministic tie ordering never uses wall clock. */
168
+ export function mergeMessages(oldRows: MessageRow[], newRows: MessageRow[]): MessageRow[] {
169
+ const byId = new Map<string, MessageRow>(); for (const row of [...oldRows, ...newRows]) byId.set(row.id, row);
170
+ return [...byId.values()].sort((a, b) => {
171
+ if (!!a.sequence !== !!b.sequence) return a.sequence ? 1 : -1;
172
+ if (a.sequence && b.sequence) { const order = compareSequence(a.sequence, b.sequence); if (order) return order; }
173
+ if (a.createdDate !== b.createdDate) return a.createdDate < b.createdDate ? -1 : 1;
174
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
175
+ });
176
+ }
177
+ export function normalizeMessages(raw: unknown[], myMemberId?: string): MessageRow[] { return mergeMessages([], raw.map(value => normalize(value, myMemberId))); }
@@ -0,0 +1,22 @@
1
+ import { formatClock } from './engine-intl.js';
2
+ import { mergeMessages, normalizeMessages, type MessageRow } from './business-chat-message-model.js';
3
+
4
+ export interface BusinessChatIntent { id:string; text:string; roomId:string; principal:string }
5
+
6
+ export function newBusinessChatIntent(text:string,roomId:string,principal:string,random=Math.random):BusinessChatIntent{
7
+ return {id:'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,c=>{const r=Math.floor(random()*16);return(c==='x'?r:(r&3)|8).toString(16);}),text,roomId,principal};
8
+ }
9
+ export function businessChatSendRejected(error:unknown):boolean{
10
+ return /\b(400|401|403|404|409|413|422|429)\b|approved first-party|webwix_owner_only|sign_in_required|owner_preview_read_only/i.test(String((error as Error)?.message||error));
11
+ }
12
+ export function draftAfterAcceptedSend(currentDraft:string,sentDraft:string):string{return currentDraft===sentDraft?'':currentDraft;}
13
+ export function pendingIntentWasObserved(messages:MessageRow[],intent:BusinessChatIntent|null):boolean{
14
+ return !!intent&&messages.some(message=>message.id===intent.id);
15
+ }
16
+ export function messageTimeLabel(value:string):string{
17
+ const date=new Date(value);
18
+ return Number.isFinite(date.getTime())?formatClock(date):'';
19
+ }
20
+ export function chronologicalMessages(existing:MessageRow[],raw:unknown[],memberId?:string):MessageRow[]{
21
+ return mergeMessages(existing,normalizeMessages(raw,memberId)).map(row=>({...row,when:messageTimeLabel(row.createdDate),side:row.mine?'mine':'other'}));
22
+ }
@@ -0,0 +1,427 @@
1
+ import { formatMonthDay } from "./engine-intl.js";
2
+ type RecordValue = Record<string, unknown>;
3
+
4
+ export type BusinessMessagePart = {
5
+ kind?: string;
6
+ text?: string;
7
+ fileName?: string;
8
+ caption?: string;
9
+ url?: string;
10
+ thumbnailUrl?: string;
11
+ fields?: { name: string; value: string }[];
12
+ buttons?: { id: string; label: string; url: string; interactionId?: string; enabled: boolean }[];
13
+ };
14
+
15
+ export type BusinessTextRun = { text: string; marks?: string[] };
16
+ export type BusinessSuggestion = { id: string; messageId: string; label: string; kind: "postback" | "visitor_option"; appId?: string; interactionId?: string; enabled: boolean; disabled: boolean };
17
+ export type BusinessArticleAction = { id: string; label: string; url: string };
18
+ export type BusinessVisitorOptions = { answerTo: string; labels: string[] };
19
+
20
+ export type BusinessMessage = {
21
+ id: string;
22
+ sender: string;
23
+ senderName: string;
24
+ senderId: string;
25
+ mine: boolean;
26
+ when: string;
27
+ createdDate: string;
28
+ plainText: string;
29
+ parts: BusinessMessagePart[];
30
+ };
31
+
32
+ export type BusinessRoomSummary = {
33
+ id?: unknown;
34
+ participantId?: unknown;
35
+ participantCount?: unknown;
36
+ clientReferenceType?: unknown;
37
+ businessContext?: unknown;
38
+ };
39
+
40
+ /** Keep host capability failures visible without exposing runtime error details. */
41
+ export function businessRealtimeStatusLabel(state: string, reason = ""): string {
42
+ if (state === "connected") return "";
43
+ if (state === "connecting") return "Connecting to live updates";
44
+ if (state === "reconnecting") return "Reconnecting live updates";
45
+ if (reason === "chat_setup_scope_unavailable") return "Live updates unavailable for this session";
46
+ if (reason === "runtime_unavailable") return "Live updates unavailable in this host";
47
+ if (reason === "invalid_setup_response") return "Live connection setup failed";
48
+ if (reason === "firebase_auth_failed") return "Live connection authentication failed";
49
+ if (reason === "firebase_connection_failed") return "Live connection interrupted";
50
+ if (reason === "invalid_event") return "Live update could not be read";
51
+ if (reason === "event_overflow") return "Live updates paused";
52
+ return "Live updates unavailable";
53
+ }
54
+
55
+ const object = (value: unknown): RecordValue =>
56
+ value !== null && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : {};
57
+
58
+ /** Return the only existing two-party business room; never create one or guess among matches. */
59
+ export function selectExistingBusinessRoom(rooms: unknown[]): { id: string; participantId: string } | undefined {
60
+ const candidates = rooms.filter((value) => {
61
+ const room = object(value);
62
+ return typeof room.id === "string" && !!room.id &&
63
+ typeof room.participantId === "string" && !!room.participantId &&
64
+ room.participantCount === 2 && room.clientReferenceType === "contactId" &&
65
+ Object.keys(object(room.businessContext)).length > 0;
66
+ });
67
+ if (candidates.length !== 1) return undefined;
68
+ const room = object(candidates[0]);
69
+ return { id: room.id as string, participantId: room.participantId as string };
70
+ }
71
+
72
+ /** The member's current AI quick replies: the newest assistant answer with real `visitorOptions` that no
73
+ * selection has answered yet (typed free text does not consume them, matching the website widget).
74
+ * Wix's AI `inboxId` is a different id namespace from the chat-web row id, so these are composer-level,
75
+ * not joined to a row. They must belong to the same AI conversation as the newest message in the list;
76
+ * `answerTo` is always the exact AI answer id. */
77
+ export function businessVisitorOptionsFromAiMessages(aiMessages: unknown[]): BusinessVisitorOptions | null {
78
+ const dated = aiMessages.map(object).map((message, index) => ({ message, index, at: Date.parse(String(message.createdDate)) }))
79
+ .filter(({ at }) => Number.isFinite(at))
80
+ .sort((left, right) => left.at - right.at || left.index - right.index)
81
+ .map(({ message }) => message);
82
+ const conversationId = dated.at(-1)?.conversationId;
83
+ if (typeof conversationId !== "string" || !conversationId) return null;
84
+ const answered = new Set(dated.filter((message) => message.messageType === "VISITOR_OPTION_SELECTED" && typeof message.answerTo === "string")
85
+ .map((message) => message.answerTo as string));
86
+ for (const message of dated.slice().reverse()) {
87
+ const id = typeof message.id === "string" ? message.id.trim() : "";
88
+ const labels = Array.isArray(message.visitorOptions)
89
+ ? message.visitorOptions.filter((value): value is string => typeof value === "string" && !!value.trim()).map((value) => value.trim())
90
+ : [];
91
+ if (message.sender !== "ASSISTANT" || message.messageType !== "ANSWER" || !id || !labels.length) continue;
92
+ return message.conversationId === conversationId && !answered.has(id) ? { answerTo: id, labels } : null;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /** Build the observed Wix Smart Chat visitor-option selection only from current,
98
+ * identity-scoped AI metadata. The ID is request-local; the answerTo anchors it
99
+ * to the exact assistant answer. */
100
+ export function businessVisitorOptionRequest(
101
+ options: BusinessVisitorOptions | null | undefined,
102
+ label: string,
103
+ selectionId: string,
104
+ ): { message: { sender: "SITE_VISITOR"; messageType: "VISITOR_OPTION_SELECTED"; answerTo: string; body: { mainText: string; additionalData: { id: string; type: "select_option"; action: string }[] } } } | undefined {
105
+ if (!options || !options.answerTo || !options.labels.includes(label) || !/^[0-9a-f-]{36}$/i.test(selectionId)) return undefined;
106
+ return {
107
+ message: {
108
+ sender: "SITE_VISITOR",
109
+ messageType: "VISITOR_OPTION_SELECTED",
110
+ answerTo: options.answerTo,
111
+ body: { mainText: label, additionalData: [{ id: selectionId, type: "select_option", action: label }] },
112
+ },
113
+ };
114
+ }
115
+
116
+ export function newBusinessSelectionId(random: () => number = Math.random): string {
117
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (value) => {
118
+ const n = Math.floor(random() * 16);
119
+ return (value === "x" ? n : (n & 0x3) | 0x8).toString(16);
120
+ });
121
+ }
122
+
123
+ export function businessVisitorOptionSuggestions(options: BusinessVisitorOptions | null | undefined): BusinessSuggestion[] {
124
+ if (!options?.answerTo) return [];
125
+ return options.labels.map((label, index) => ({
126
+ id: `${options.answerTo}:visitor-option-${index}`,
127
+ messageId: options.answerTo,
128
+ label,
129
+ kind: "visitor_option",
130
+ enabled: true,
131
+ disabled: false,
132
+ }));
133
+ }
134
+
135
+ /** Wix Chat realtime currently sends `{type, timestamp, summary, message?}` events. */
136
+ export function isBusinessMessageEvent(envelope: unknown, roomId: string): boolean {
137
+ const event = object(object(envelope).event);
138
+ if (!roomId || typeof event.type !== "string" || !/^(message|messagesent)$/i.test(event.type)) return false;
139
+ const message = object(event.message);
140
+ const data = object(event.data);
141
+ const nested = object(data.message);
142
+ const eventRoomId = [event.chatroomId, event.chatRoomId, message.chatroomId, message.chatRoomId, data.chatroomId, nested.chatroomId]
143
+ .find((value): value is string => typeof value === "string" && value.length > 0);
144
+ return !eventRoomId || eventRoomId === roomId;
145
+ }
146
+
147
+ export function shouldRefreshBusinessHistory(
148
+ envelope: unknown,
149
+ roomId: string,
150
+ session: { member: boolean; principal: string; currentPrincipal: string; scope: string; currentScope: string },
151
+ ): boolean {
152
+ return !!session.member && !!roomId && !!session.principal && session.principal === session.currentPrincipal &&
153
+ session.scope === session.currentScope && isBusinessMessageEvent(envelope, roomId);
154
+ }
155
+
156
+ function safeAvatar(value: unknown): string {
157
+ if (typeof value !== "string" || /[\s\\\u0000-\u001f\u007f-\u009f]/u.test(value)) return "";
158
+ const match = /^https:\/\/([^/?#]+)(?:[/?#].*)?$/iu.exec(value);
159
+ // No credentials, and no percent escapes in the host: a host is plain letters, digits, dots and hyphens.
160
+ return match && !/[@%]/.test(match[1]!) ? value : "";
161
+ }
162
+
163
+ function initials(value: string): string {
164
+ const words = value.trim().split(/\s+/).filter(Boolean);
165
+ return words.length > 1 ? `${words[0]![0]}${words[words.length - 1]![0]}`.toUpperCase() : (words[0]?.slice(0, 2) ?? "").toUpperCase();
166
+ }
167
+
168
+ function decodeHtml(value: string): string {
169
+ return value.replace(/&(#x[0-9a-f]+|#[0-9]+|amp|lt|gt|quot|apos|nbsp);/gi, (whole, entity: string) => {
170
+ const names: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'", nbsp: " " };
171
+ if (entity[0] !== "#") return names[entity.toLowerCase()] ?? whole;
172
+ const code = entity[1]?.toLowerCase() === "x" ? parseInt(entity.slice(2), 16) : Number(entity.slice(1));
173
+ return code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : whole;
174
+ });
175
+ }
176
+
177
+ /** Convert Wix's text/html parts into safe inline text marks; HTML is never rendered. */
178
+ function htmlRuns(html: string): BusinessTextRun[] {
179
+ const source = html.replace(/<!--[\s\S]*?-->/g, "").replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, "");
180
+ const runs: BusinessTextRun[] = [];
181
+ const marks = new Set<string>();
182
+ const append = (text: string) => {
183
+ if (!text) return;
184
+ const active = [...marks].sort();
185
+ const last = runs[runs.length - 1];
186
+ if (last && JSON.stringify(last.marks ?? []) === JSON.stringify(active)) last.text += text;
187
+ else runs.push(active.length ? { text, marks: active } : { text });
188
+ };
189
+ const tokens = source.match(/<[^>]*>|[^<]+/g) ?? [];
190
+ for (const token of tokens) {
191
+ if (!token.startsWith("<")) { append(decodeHtml(token)); continue; }
192
+ const tag = /^<\s*(\/?)\s*([a-z0-9]+)/i.exec(token);
193
+ if (!tag) continue;
194
+ const closing = tag[1] === "/";
195
+ const name = tag[2]!.toLowerCase();
196
+ if (name === "br" || name === "p" || name === "div" || name === "li" || /^h[1-6]$/.test(name)) {
197
+ if (closing || name === "br" || name === "li") append("\n");
198
+ }
199
+ const mark = name === "b" || name === "strong" ? "bold" : name === "i" || name === "em" ? "italic" : name === "u" ? "underline" : name === "code" ? "code" : name === "a" ? "link" : "";
200
+ if (!mark) continue;
201
+ if (closing) marks.delete(mark); else marks.add(mark);
202
+ }
203
+ return runs.map((run) => ({ ...run, text: run.text.replace(/\n{3,}/g, "\n\n") })).filter((run) => run.text.length > 0);
204
+ }
205
+
206
+ function htmlMessageRuns(message: RecordValue): BusinessTextRun[] | undefined {
207
+ const parts = Array.isArray(message.data) ? message.data : [];
208
+ const htmlPart = parts.map(object).find((part) => typeof part.mimeType === "string" && part.mimeType.toLowerCase().split(";")[0] === "text/html");
209
+ if (!htmlPart) return undefined;
210
+ const data = typeof htmlPart.data === "string" ? htmlPart.data : object(htmlPart.data).html ?? object(htmlPart.data).text;
211
+ return typeof data === "string" ? htmlRuns(data) : undefined;
212
+ }
213
+
214
+ /** Recognize the inline Markdown Wix AI emits in text/plain messages; all other markers stay escaped. */
215
+ function markdownRuns(value: string): BusinessTextRun[] {
216
+ const runs: BusinessTextRun[] = [];
217
+ const pattern = /(\*\*[^*\n]+?\*\*|__[^_\n]+?__|`[^`\n]+`|\*[^*\n]+?\*|_[^_\n]+?_)/g;
218
+ let cursor = 0;
219
+ for (const match of value.matchAll(pattern)) {
220
+ const index = match.index ?? 0;
221
+ const token = match[0];
222
+ // Underscore emphasis needs a word boundary, so `my_file_name` keeps its underscores (as on Android).
223
+ if (token.startsWith("_") && (/\w/.test(value[index - 1] ?? "") || /\w/.test(value[index + token.length] ?? ""))) continue;
224
+ if (index > cursor) runs.push({ text: value.slice(cursor, index) });
225
+ const double = token.startsWith("**") || token.startsWith("__");
226
+ const code = token.startsWith("`");
227
+ const mark = code ? "code" : double ? "bold" : token.startsWith("_") ? "italic" : token.startsWith("*") ? "italic" : "";
228
+ const delimiter = code ? 1 : double ? 2 : 1;
229
+ runs.push({ text: token.slice(delimiter, -delimiter), ...(mark ? { marks: [mark] } : {}) });
230
+ cursor = index + token.length;
231
+ }
232
+ if (cursor < value.length) runs.push({ text: value.slice(cursor) });
233
+ return runs;
234
+ }
235
+
236
+ function messageButtons(message: RecordValue, messageId: string): BusinessSuggestion[] {
237
+ const template = object(message.templateData);
238
+ const candidates: unknown[] = Array.isArray(template.buttons) ? template.buttons : [];
239
+ const content = object(message.content);
240
+ const cards = Array.isArray(object(content.template).buttons) ? object(content.template).buttons as unknown[] : [];
241
+ candidates.push(...cards);
242
+ for (const raw of Array.isArray(message.data) ? message.data : []) {
243
+ const data = object(object(raw).data);
244
+ for (const card of Array.isArray(data.cards) ? data.cards : []) candidates.push(...(Array.isArray(object(card).buttons) ? object(card).buttons as unknown[] : []));
245
+ }
246
+ return candidates.flatMap((value, index) => {
247
+ const button = object(value);
248
+ const label = [button.label, button.title, button.text].find((value): value is string => typeof value === "string" && !!value.trim())?.trim() ?? "";
249
+ const payload = object(button.payload);
250
+ const appId = typeof payload.appId === "string" ? payload.appId.trim() : "";
251
+ const interactionId = typeof payload.interactionId === "string" ? payload.interactionId.trim() : "";
252
+ if (button.cta && !appId && !interactionId) return [];
253
+ const enabled = !!label && !!appId && !!interactionId;
254
+ return label ? [{ id: `${messageId}:suggestion-${index}`, messageId, label, kind: "postback", ...(appId ? { appId } : {}), ...(interactionId ? { interactionId } : {}), enabled, disabled: !enabled }] : [];
255
+ });
256
+ }
257
+
258
+ /** Article links and chat postbacks are different Wix button actions. Only expose
259
+ * payload-free HTTPS CTAs here; postbacks remain in messageButtons. */
260
+ function articleActions(message: RecordValue, messageId: string): BusinessArticleAction[] {
261
+ const buttons = object(message.templateData).buttons;
262
+ if (!Array.isArray(buttons)) return [];
263
+ return buttons.flatMap((value, index) => {
264
+ const button = object(value);
265
+ const label = typeof button.text === "string" ? button.text.trim() : "";
266
+ const rawUrl = typeof button.cta === "string" ? button.cta.trim() : "";
267
+ if (!label || !rawUrl || button.payload != null) return [];
268
+ // No `URL` global in a worker: accept only a plain https URL whose authority carries no credentials.
269
+ if (!/^https:\/\/[^\s\\@/?#]+(?:[/?#]\S*)?$/.test(rawUrl)) return [];
270
+ return [{ id: `${messageId}:article-${index}`, label, url: rawUrl }];
271
+ });
272
+ }
273
+
274
+ /** Text renders a limited inline Markdown subset, and Android and web ignore backslash escapes (they would
275
+ * print the backslash), so nothing is escaped that way. Link syntax is broken once, on the joined text so
276
+ * a `](` split across two runs is caught too, with an invisible character: message text can never become
277
+ * a tappable link. A lone marker stays literal on every renderer. */
278
+ function markdownFromRuns(runs: BusinessTextRun[]): string {
279
+ return runs.map((run) => {
280
+ let text = run.text;
281
+ const marks = run.marks ?? [];
282
+ if (marks.includes("code")) return `\`${text.replace(/`/g, "'")}\``;
283
+ if (marks.includes("bold")) text = `**${text}**`;
284
+ if (marks.includes("italic")) text = `*${text}*`;
285
+ return text;
286
+ }).join("").replace(/\]\(/g, "]\u200b(");
287
+ }
288
+
289
+ function hiddenInternalNotice(message: RecordValue): boolean {
290
+ const template = object(message.templateData);
291
+ const origin = typeof message.originMethod === "string" ? message.originMethod : "";
292
+ const summary = typeof message.summary === "string" ? message.summary.trim() : "";
293
+ const text = typeof template.text === "string" ? template.text.trim() : "";
294
+ const pauseNotice = "AI RESPONSES ARE PAUSED WHILE YOU TALK TO THIS VISITOR";
295
+ const isPauseNotice = summary.toUpperCase() === pauseNotice || text.toUpperCase() === pauseNotice;
296
+ const hasActionsOrMedia = !!template.image || !!template.icon || !!template.link ||
297
+ [template.buttons, template.fields, template.lines].some((value) => Array.isArray(value) && value.length > 0);
298
+ const content = object(message.content);
299
+ return message.messageType === "template/minimal" && !!message.appId && /:AI_SITE_CHAT$/i.test(origin) &&
300
+ isPauseNotice && (!Array.isArray(message.data) || message.data.length === 0) &&
301
+ Object.keys(content).length === 0 && !hasActionsOrMedia;
302
+ }
303
+
304
+ /** Adds only Business business-chat presentation fields to the canonical message rows. */
305
+ export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "sender" | "mine" | "createdDate" | "plainText" | "parts">>(
306
+ rows: T[],
307
+ raw: unknown[],
308
+ participantId = "",
309
+ now = new Date(),
310
+ ): (T & {
311
+ senderAvatar: string;
312
+ senderName: string;
313
+ senderInitials: string;
314
+ senderBadge: string;
315
+ imageUrl: string;
316
+ imageDescription: string;
317
+ noImage: boolean;
318
+ bodyText: string;
319
+ bodyRuns: BusinessTextRun[];
320
+ isAiGenerated: boolean;
321
+ suggestions: BusinessSuggestion[];
322
+ articleActions: BusinessArticleAction[];
323
+ bodyMarkdown: string;
324
+ dayLabel: string;
325
+ dayHidden: boolean;
326
+ startsDay: boolean;
327
+ scrollAnchor: string;
328
+ notMine: boolean;
329
+ })[] {
330
+ const metadata = new Map<string, RecordValue>();
331
+ for (const value of raw) {
332
+ const message = object(value);
333
+ const id = typeof message.id === "string" ? message.id : typeof message._id === "string" ? message._id : "";
334
+ if (id) metadata.set(id, message);
335
+ }
336
+ const dateKey = (date: Date) => `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
337
+ const today = dateKey(now);
338
+ const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
339
+ const yesterdayKey = dateKey(yesterday);
340
+ let previousDay = "";
341
+
342
+ return rows.filter((row) => !hiddenInternalNotice(metadata.get(row.id) ?? {})).map((row) => {
343
+ const message = metadata.get(row.id) ?? {};
344
+ const display = object(message.senderDisplayData);
345
+ const image = object(display.image);
346
+ const sender = object(message.sender);
347
+ const imagePart = row.parts.find((part) => part.kind === "image");
348
+ const timestamp = new Date(row.createdDate);
349
+ const key = Number.isFinite(timestamp.getTime()) ? dateKey(timestamp) : "";
350
+ const startsDay = !!key && key !== previousDay;
351
+ const dayLabel = startsDay
352
+ ? key === today ? "Today" : key === yesterdayKey ? "Yesterday" : formatMonthDay(timestamp)
353
+ : "";
354
+ if (key) previousDay = key;
355
+
356
+ const imageUrl = imagePart?.url || "";
357
+ const template = object(message.templateData);
358
+ const standardEventText = message.messageType === "template/standard-event"
359
+ ? [template.title, ...(Array.isArray(template.lines) ? template.lines : [])]
360
+ .filter((value): value is string => typeof value === "string" && !!value.trim())
361
+ .join("\n")
362
+ : "";
363
+ const bodyText = imageUrl
364
+ ? row.parts.filter((part) => part.kind === "text").map((part) => [part.text, ...(part.fields ?? []).map((field) => `${field.name}: ${field.value}`)].filter(Boolean).join("\n")).filter(Boolean).join("\n")
365
+ : standardEventText || row.plainText;
366
+ const senderAvatar = safeAvatar(image.url);
367
+ const mine = participantId && typeof sender.userId === "string" ? sender.userId === participantId : row.mine;
368
+ const isAiGenerated = !!participantId && !mine && !!message.appId && typeof message.originMethod === "string" && /:AI_SITE_CHAT$/i.test(message.originMethod) &&
369
+ (message.messageType === "message" || message.messageType === "template/standard-event" || message.messageType === "template/card");
370
+ const rawSenderName = typeof display.name === "string" && display.name.trim()
371
+ ? display.name.trim()
372
+ : typeof display.displayName === "string" && display.displayName.trim() ? display.displayName.trim() : "";
373
+ const senderName = !mine && /^visitor(?:\s*#?\d+)?$/i.test(rawSenderName) ? "" : rawSenderName;
374
+ const plainTextRuns = standardEventText
375
+ ? markdownRuns(standardEventText)
376
+ : row.parts.filter((part) => part.kind === "text" && typeof part.text === "string").flatMap((part, at) => [...(at > 0 ? [{ text: "\n" }] : []), ...markdownRuns(part.text as string)]);
377
+ const bodyRuns = htmlMessageRuns(message) ?? plainTextRuns;
378
+ const bodyMarkdown = markdownFromRuns(bodyRuns);
379
+ const buttons = messageButtons(message, row.id);
380
+ const articles = articleActions(message, row.id);
381
+
382
+ return {
383
+ ...row,
384
+ mine,
385
+ side: mine ? "mine" : "other",
386
+ senderAvatar,
387
+ senderName,
388
+ senderInitials: typeof display.initials === "string" && display.initials.trim() ? display.initials.trim() : initials(senderName),
389
+ senderBadge: participantId && !mine ? isAiGenerated ? "AI assistant" : "Staff Response" : "",
390
+ imageUrl,
391
+ imageDescription: imagePart?.caption || "Image attachment",
392
+ noImage: !imageUrl,
393
+ bodyText,
394
+ bodyRuns,
395
+ bodyMarkdown,
396
+ isAiGenerated,
397
+ suggestions: buttons,
398
+ articleActions: articles,
399
+ dayLabel,
400
+ dayHidden: !dayLabel,
401
+ startsDay,
402
+ scrollAnchor: `himi_business_chat.message_${row.id}`,
403
+ notMine: !mine,
404
+ };
405
+ });
406
+ }
407
+
408
+ /** Resolve a suggestion only from the currently normalized rows and only when the server supplied both postback fields. */
409
+ export function findBusinessSuggestion<T extends { suggestions?: BusinessSuggestion[] }>(rows: T[], suggestionId: string): BusinessSuggestion | undefined {
410
+ if (!suggestionId) return undefined;
411
+ return rows.flatMap((row) => row.suggestions ?? []).find((suggestion) => suggestion.id === suggestionId && suggestion.enabled &&
412
+ (suggestion.kind === "visitor_option" || (!!suggestion.appId && !!suggestion.interactionId)));
413
+ }
414
+
415
+ /** Execute a row's verified semantic button only inside its still-current member room/session. */
416
+ export async function dispatchBusinessSuggestion<T extends { suggestions?: BusinessSuggestion[] }>(
417
+ rows: T[],
418
+ suggestionId: string,
419
+ session: { member: boolean; roomId: string; principal: string; currentPrincipal: string; scope: string; currentScope: string },
420
+ submit: (suggestion: BusinessSuggestion) => Promise<unknown>,
421
+ ): Promise<boolean> {
422
+ if (!session.member || !session.roomId || !session.principal || session.principal !== session.currentPrincipal || session.scope !== session.currentScope) return false;
423
+ const suggestion = findBusinessSuggestion(rows, suggestionId);
424
+ if (!suggestion) return false;
425
+ // A submit that reports `false` (a stale quick reply nobody posted) is not a send.
426
+ return (await submit(suggestion)) !== false;
427
+ }