@wix/himalaya-cli 0.1444.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.
- package/dist/cli.mjs +31 -35
- package/dist/preview-assets.generated.bin +0 -0
- package/fixtures/wix-canned.json +20 -0
- package/package.json +2 -2
- package/templates/beauty-salon/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/branded-lite/config.ts +8 -1
- package/templates/branded-lite/dev/index.ts +1120 -0
- package/templates/branded-lite/himalaya.content.json +3 -0
- package/templates/branded-lite/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/branded-lite/tier5-src/_shared/business-chat-message-model.ts +177 -0
- package/templates/branded-lite/tier5-src/_shared/business-chat-model.ts +22 -0
- package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +427 -0
- package/templates/branded-lite/tier5-src/_shared/business-chat-transport.ts +102 -0
- package/templates/branded-lite/tier5-src/business-chat/index.ts +304 -0
- package/templates/community-club/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/events-meetups/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/gym-fitness/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/nonprofit-cause/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/podcast-creator/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/portfolio-creative/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/restaurant/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/retail-store/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/yoga-studio/tier5-src/_shared/branded-lite-hosted.ts +1 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The business chat's Wix calls: the member-role chat-web SDK for rooms, history and sends, plus the two
|
|
2
|
+
// calls the SDK has no helper for (a tapped button, a private attachment's download URL).
|
|
3
|
+
import { getChatToken, getMessagesPage, listChatroomsPage, sendChatMessage } from "@himalaya/wix-chat-web";
|
|
4
|
+
import { assertMemberAccess, memberAccess } from "./member-access.js";
|
|
5
|
+
|
|
6
|
+
const MANAGE = "https://manage.wix.com";
|
|
7
|
+
const MEMBER = { role: "member" } as const;
|
|
8
|
+
type Raw = Record<string, unknown>;
|
|
9
|
+
type Bridge = { call(method: string, path: string, body?: unknown, headers?: Record<string, string>): Promise<unknown> };
|
|
10
|
+
|
|
11
|
+
/** Who is signed in. `userId` is empty until the host reports it; the chat then tells the business
|
|
12
|
+
* side apart by the room's participant id instead. */
|
|
13
|
+
export interface ChatIdentity { authenticated: boolean; userId: string; email: string; scope: string }
|
|
14
|
+
export async function chatIdentity(): Promise<ChatIdentity> {
|
|
15
|
+
const member = await memberAccess();
|
|
16
|
+
return { authenticated: !!member.email, userId: "", email: member.email, scope: member.scope };
|
|
17
|
+
}
|
|
18
|
+
export const assertChatIdentity = (identity: ChatIdentity): Promise<void> => assertMemberAccess(identity);
|
|
19
|
+
|
|
20
|
+
/** Every request is fenced: the member is re-checked before it goes out and again before its response is
|
|
21
|
+
* used, so a sign-out or account switch mid-request never leaks one member's data to the next. */
|
|
22
|
+
async function fenced<T>(identity: ChatIdentity, run: () => Promise<T>): Promise<T> {
|
|
23
|
+
await assertChatIdentity(identity);
|
|
24
|
+
const result = await run();
|
|
25
|
+
await assertChatIdentity(identity);
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Any other member-scoped call (the AI chat's) goes through the same fence. */
|
|
30
|
+
export const fence = fenced;
|
|
31
|
+
|
|
32
|
+
// Minting a chat token is a network round trip, and every chat call used to mint its own. One token
|
|
33
|
+
// per member is reused until the member changes or a request using it is refused.
|
|
34
|
+
let cachedToken: { key: string; token: string } | null = null;
|
|
35
|
+
const tokenKey = (identity: ChatIdentity): string => `${identity.email}|${identity.scope}`;
|
|
36
|
+
async function chatTokenFor(identity: ChatIdentity): Promise<string> {
|
|
37
|
+
const key = tokenKey(identity);
|
|
38
|
+
if (cachedToken?.key === key) return cachedToken.token;
|
|
39
|
+
const { chatToken } = await getChatToken(MEMBER);
|
|
40
|
+
// The host mints for whoever is signed in NOW: a token minted while the member switched belongs to the
|
|
41
|
+
// new member and must not be cached under the old one.
|
|
42
|
+
await assertChatIdentity(identity);
|
|
43
|
+
cachedToken = { key, token: chatToken };
|
|
44
|
+
return chatToken;
|
|
45
|
+
}
|
|
46
|
+
/** Runs `run` with the member's cached token; a refused request mints a fresh token and tries once more. */
|
|
47
|
+
async function withToken<T>(identity: ChatIdentity, run: (chatToken: string) => Promise<T>): Promise<T> {
|
|
48
|
+
const reused = cachedToken?.key === tokenKey(identity);
|
|
49
|
+
try {
|
|
50
|
+
return await run(await chatTokenFor(identity));
|
|
51
|
+
} catch (error) {
|
|
52
|
+
cachedToken = null;
|
|
53
|
+
if (!reused || !/\b(?:401|403)\b|token/i.test(String((error as Error)?.message ?? error))) throw error;
|
|
54
|
+
return run(await chatTokenFor(identity));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const MAX_ROOM_PAGES = 20;
|
|
59
|
+
/** Every chatroom the member can see: the cursor is followed so the business room is found on any page. */
|
|
60
|
+
export const listRooms = (identity: ChatIdentity): Promise<{ items: Raw[]; more: boolean }> => fenced(identity, async () => {
|
|
61
|
+
const items: Raw[] = [];
|
|
62
|
+
let nextPointer: number | string | undefined;
|
|
63
|
+
for (let page = 0; page < MAX_ROOM_PAGES; page++) {
|
|
64
|
+
const result = await withToken(identity, (chatToken) => listChatroomsPage({ ...MEMBER, chatToken, pageSize: 50, ...(nextPointer !== undefined ? { nextPointer } : {}) }));
|
|
65
|
+
items.push(...(result.items as unknown as Raw[]));
|
|
66
|
+
nextPointer = result.nextPointer;
|
|
67
|
+
if (nextPointer === undefined) return { items, more: false };
|
|
68
|
+
}
|
|
69
|
+
return { items, more: true };
|
|
70
|
+
});
|
|
71
|
+
export const messagesPage = (identity: ChatIdentity, roomId: string): Promise<Raw[]> => fenced(identity, async () =>
|
|
72
|
+
(await withToken(identity, (chatToken) => getMessagesPage(roomId, { ...MEMBER, chatToken, pageSize: 50, ordering: "Desc" }))).items as unknown as Raw[]);
|
|
73
|
+
export const sendMessage = (identity: ChatIdentity, roomId: string, intent: { id: string; text: string }): Promise<void> =>
|
|
74
|
+
fenced(identity, () => withToken(identity, (chatToken) => sendChatMessage(roomId, intent.text, { ...MEMBER, chatToken, messageId: intent.id })));
|
|
75
|
+
|
|
76
|
+
function bridge(): Bridge {
|
|
77
|
+
const api = (globalThis as unknown as { himalaya?: { wixApi?: Bridge } }).himalaya?.wixApi;
|
|
78
|
+
if (!api) throw new Error("wix_api_bridge_unavailable");
|
|
79
|
+
return api;
|
|
80
|
+
}
|
|
81
|
+
/** A tapped button is a silent, ephemeral message carrying the button's chat action. */
|
|
82
|
+
export const sendButton = (identity: ChatIdentity, roomId: string, input: { id: string; text: string; appId: string; interactionId: string }): Promise<void> => fenced(identity, async () => {
|
|
83
|
+
await withToken(identity, (chatToken) => bridge().call("POST", `${MANAGE}/_api/chat-web/v2/chatrooms/${encodeURIComponent(roomId)}/messages`, {
|
|
84
|
+
sendMessageBody: { id: input.id, messageType: "message", messageTypeVersion: 1, summary: input.text, isEphemeral: true, isSilent: true, data: [{ mimeType: "text/plain", data: { text: input.text } }] },
|
|
85
|
+
sendMessageOptions: { chatAction: { actionType: "BUTTON_INTERACTION", appId: input.appId, interactionId: input.interactionId } },
|
|
86
|
+
}, { ChatToken: chatToken }));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const https = (value: unknown): string => (typeof value === "string" && /^https:\/\/[^\s\\]+$/.test(value) ? value : "");
|
|
90
|
+
/** A fresh, authorized URL for an attachment. A private file is always fetched by its id. */
|
|
91
|
+
export const attachmentUrl = (identity: ChatIdentity, media: { mediaId?: string; mediaProvider?: string; url?: string; fileName?: string }): Promise<string> => fenced(identity, async () => {
|
|
92
|
+
if (media.mediaProvider === "WixMediaPrivate") {
|
|
93
|
+
if (!media.mediaId) throw new Error("attachment_has_no_media_id");
|
|
94
|
+
const response = await bridge().call("POST", "/communication-channels-media/v1/download-url", {
|
|
95
|
+
fileId: media.mediaId, ...(media.fileName ? { downloadFileName: media.fileName } : {}),
|
|
96
|
+
}) as Raw | null;
|
|
97
|
+
const url = https(response?.downloadUrl);
|
|
98
|
+
if (!url) throw new Error("attachment_download_url_invalid");
|
|
99
|
+
return url;
|
|
100
|
+
}
|
|
101
|
+
return https(media.url);
|
|
102
|
+
});
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { defineService } from '@himalaya/state';
|
|
2
|
+
import * as chat from '../_shared/business-chat-transport.js';
|
|
3
|
+
import type { ChatIdentity } from '../_shared/business-chat-transport.js';
|
|
4
|
+
import { type MessageRow } from '../_shared/business-chat-message-model.js';
|
|
5
|
+
import { businessChatSendRejected, draftAfterAcceptedSend, pendingIntentWasObserved, chronologicalMessages, newBusinessChatIntent, type BusinessChatIntent } from '../_shared/business-chat-model.js';
|
|
6
|
+
import { subscribeRealtime, unsubscribeRealtime, getRealtimeStatus, type RealtimeStatus, type RealtimeEventEnvelope } from '@himalaya/wix-chat';
|
|
7
|
+
import { dispatchBusinessSuggestion, businessRealtimeStatusLabel, businessVisitorOptionSuggestions, presentBusinessMessages, selectExistingBusinessRoom, shouldRefreshBusinessHistory, type BusinessSuggestion, type BusinessVisitorOptions, businessVisitorOptionRequest, businessVisitorOptionsFromAiMessages, newBusinessSelectionId} from '../_shared/business-chat-presenter.js';
|
|
8
|
+
import { listMessages, streamMessages } from '@himalaya/wix-ai-site-chat';
|
|
9
|
+
import { hostSiteFromInput } from '../_shared/branded-lite-hosted.js';
|
|
10
|
+
|
|
11
|
+
type PendingIntent = BusinessChatIntent;
|
|
12
|
+
type BusinessMessageRow = MessageRow & {
|
|
13
|
+
senderAvatar:string; senderName:string; senderInitials:string; senderBadge:string; imageUrl:string; imageDescription:string; noImage:boolean; bodyText:string; bodyMarkdown:string; bodyRuns:{text:string;marks?:string[]}[]; isAiGenerated:boolean; suggestions:BusinessSuggestion[]; visitorOptions:BusinessVisitorOptions|null; notMine:boolean;
|
|
14
|
+
dayLabel:string; dayHidden:boolean; startsDay:boolean; scrollAnchor:string;
|
|
15
|
+
};
|
|
16
|
+
type State = {
|
|
17
|
+
phase:'loading'|'ready'|'error'|'guest'; error:string; notice:string; member:boolean;
|
|
18
|
+
roomId:string; principal:string; scope:string; messages:BusinessMessageRow[]; draft:string; busy:boolean;
|
|
19
|
+
liveState:string; liveReason:string; realtimeEventCount:number; lastRealtimeEventType:string; pendingIntent:PendingIntent|null; scrollAnchor:string; scrollSend:number; usedSuggestionIds:string[]; visitorOptions:BusinessVisitorOptions|null; quickReplies:BusinessSuggestion[]; hasQuickReplies:boolean; noQuickReplies:boolean;
|
|
20
|
+
liveLabel:string; hasActiveRoom:boolean; noActiveRoom:boolean; isGuest:boolean; notGuest:boolean; realtimeDiagnostic:string; noError:boolean; noNotice:boolean; noMessages:boolean; hasMessages:boolean; noEmpty:boolean; notLoading:boolean;
|
|
21
|
+
hostedName:string; hasHostedName:boolean; noHostedName:boolean;
|
|
22
|
+
sendDisabled:boolean; sendEnabled:boolean; noLive:boolean; loadingHidden:boolean; errorText:string; loadFailure:string;
|
|
23
|
+
};
|
|
24
|
+
// A hosted build serves many businesses: the title is the site the host opened, not the app's own name.
|
|
25
|
+
const hostedName=hostSiteFromInput((globalThis as {himalaya?:{input?:unknown}}).himalaya?.input)?.name??'';
|
|
26
|
+
const initial:State={hostedName,hasHostedName:!!hostedName,noHostedName:!hostedName,phase:'loading',error:'',notice:'',member:false,roomId:'',principal:'',scope:'',messages:[],draft:'',busy:false,
|
|
27
|
+
liveState:'unavailable',liveReason:'',realtimeEventCount:0,lastRealtimeEventType:'',liveLabel:'',hasActiveRoom:false,noActiveRoom:true,isGuest:false,notGuest:true,realtimeDiagnostic:'',pendingIntent:null,scrollAnchor:'',scrollSend:0,usedSuggestionIds:[],visitorOptions:null,quickReplies:[],hasQuickReplies:false,noQuickReplies:true,noError:true,noNotice:true,noMessages:true,hasMessages:false,noEmpty:true,notLoading:false,sendDisabled:true,sendEnabled:false,noLive:true,loadingHidden:false,errorText:'',loadFailure:''};
|
|
28
|
+
let currentIdentity:ChatIdentity|null=null;
|
|
29
|
+
let subscriptionId='';
|
|
30
|
+
let initFlight=false;
|
|
31
|
+
let sendFlight=false;
|
|
32
|
+
let suggestionFlight=false;
|
|
33
|
+
let refreshFlight=false;
|
|
34
|
+
let refreshQueued=false;
|
|
35
|
+
let epoch=0;
|
|
36
|
+
let statusSeq=0;
|
|
37
|
+
let eventDuringInit=false;
|
|
38
|
+
let statusDuringInit:{state:string;reason:string}|null=null;
|
|
39
|
+
// Read through functions: a plain read after init's own assignment is narrowed by the compiler to its initial value.
|
|
40
|
+
function takeEventDuringInit():boolean{const seen=eventDuringInit;eventDuringInit=false;return seen;}
|
|
41
|
+
function takeStatusDuringInit():{state:string;reason:string}|null{const seen=statusDuringInit;statusDuringInit=null;return seen;}
|
|
42
|
+
let draftRevision=0;
|
|
43
|
+
let businessParticipantId='';
|
|
44
|
+
let loadStage:'room_selection'|'history_request'|'history_normalization'|'ai_enrichment'='room_selection';
|
|
45
|
+
let readServiceState:()=>State=()=>shape(initial);
|
|
46
|
+
|
|
47
|
+
/** The member-scoped AI conversation's current unanswered quick replies. */
|
|
48
|
+
async function loadBusinessVisitorOptions():Promise<BusinessVisitorOptions|null>{
|
|
49
|
+
const response=await chat.fence(me(),()=>listMessages({cursorPaging:{limit:20}}));
|
|
50
|
+
return businessVisitorOptionsFromAiMessages(response.messages??[]);
|
|
51
|
+
}
|
|
52
|
+
/** Submit a displayed quick reply through the AI endpoint; a label counts only while it is still a current option. */
|
|
53
|
+
async function sendBusinessVisitorOption(options:BusinessVisitorOptions|null|undefined,label:string):Promise<boolean>{
|
|
54
|
+
const request=businessVisitorOptionRequest(options,label,newBusinessSelectionId());
|
|
55
|
+
if(!request)return false;
|
|
56
|
+
await chat.fence(me(),()=>streamMessages(request));
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
function liveDescription(state:string,reason:string):string{return businessRealtimeStatusLabel(state,reason);}
|
|
60
|
+
function shape(s:State):State{const used=new Set(s.usedSuggestionIds);const messages=s.messages.map(row=>({...row,suggestions:(row.suggestions||[]).map(button=>used.has(button.id)?{...button,enabled:false,disabled:true}:button)}));const quickReplies=(s.quickReplies||[]).map(button=>used.has(button.id)?{...button,enabled:false,disabled:true}:button);const quickEnabled=quickReplies.some(button=>button.enabled);return {...s,messages,quickReplies,hasQuickReplies:quickEnabled,noQuickReplies:!quickEnabled,scrollAnchor:messages[messages.length-1]?.scrollAnchor||'',liveLabel:liveDescription(s.liveState,s.liveReason),hasActiveRoom:!!s.roomId,noActiveRoom:!s.roomId,hasHostedName:!!s.hostedName,noHostedName:!s.hostedName,isGuest:s.phase==='guest',notGuest:s.phase!=='guest',realtimeDiagnostic:`state=${s.liveState}; reason=${s.liveReason||'none'}; events=${s.realtimeEventCount}; lastType=${/^(message|messagesent)$/i.test(s.lastRealtimeEventType)?s.lastRealtimeEventType:'none'}`,errorText:s.error,loadingHidden:s.phase!=='loading'||!!s.error,noError:!s.error,noNotice:!s.notice,noMessages:!messages.length,hasMessages:!!messages.length,noEmpty:s.phase!=='ready'||!!messages.length,
|
|
61
|
+
notLoading:s.phase!=='loading',sendDisabled:!s.member||!s.roomId||s.busy||!s.draft.trim()||!!s.pendingIntent,sendEnabled:!!s.member&&!!s.roomId&&!s.busy&&!!s.draft.trim()&&!s.pendingIntent,noLive:!s.roomId||s.liveState==='connected'};}
|
|
62
|
+
function safeLoadFailure(error:unknown):string{
|
|
63
|
+
const name=error instanceof Error?error.name:'UnknownError';
|
|
64
|
+
const code=(error as {code?:unknown}|null)?.code;
|
|
65
|
+
const safeCode=typeof code==='string'&&/^[a-z0-9_]{1,48}$/i.test(code)?`:${code}`:'';
|
|
66
|
+
return `${loadStage}:${/^(TypeError|SyntaxError|ProtocolError|Unsupported|Error)$/.test(name)?name:'OtherError'}${safeCode}`;
|
|
67
|
+
}
|
|
68
|
+
function value(arg:unknown):string{if(typeof arg==='string')return arg;const a=arg as Record<string,unknown>|null;return typeof a?.value==='string'?a.value:'';}
|
|
69
|
+
function param(arg:unknown,key:string):string{const a=arg as Record<string,unknown>|null;const p=a?.params as Record<string,unknown>|undefined;return typeof a?.[key]==='string'?a[key] as string:typeof p?.[key]==='string'?p[key] as string:'';}
|
|
70
|
+
async function presentMessages(existing:BusinessMessageRow[],raw:unknown[],memberId?:string):Promise<BusinessMessageRow[]>{
|
|
71
|
+
const rows=chronologicalMessages(existing,raw,memberId) as BusinessMessageRow[];
|
|
72
|
+
const prepared=await Promise.all(rows.map(async row=>{
|
|
73
|
+
const parts=await Promise.all(row.parts.map(async part=>{
|
|
74
|
+
if(part.kind!=='image')return part;
|
|
75
|
+
if(part.mediaProvider==='WixMediaPrivate'&&part.mediaId){
|
|
76
|
+
try{return {...part,url:await chat.attachmentUrl(me(),part)};}catch{return {...part,url:''};}
|
|
77
|
+
}
|
|
78
|
+
return part;
|
|
79
|
+
}));
|
|
80
|
+
return {...row,parts};
|
|
81
|
+
}));
|
|
82
|
+
return presentBusinessMessages(prepared as unknown as Parameters<typeof presentBusinessMessages>[0],raw,businessParticipantId) as unknown as BusinessMessageRow[];
|
|
83
|
+
}
|
|
84
|
+
function me():ChatIdentity{
|
|
85
|
+
const identity=currentIdentity;
|
|
86
|
+
if(!identity?.authenticated)throw new Error('member_sign_in_required');
|
|
87
|
+
return identity;
|
|
88
|
+
}
|
|
89
|
+
async function getCurrentMember():Promise<ChatIdentity>{return chat.chatIdentity();}
|
|
90
|
+
async function resolveExistingBusinessRoom(startEpoch:number):Promise<{id:string;participantId:string}>{
|
|
91
|
+
businessParticipantId='';
|
|
92
|
+
const page=await chat.listRooms(me());
|
|
93
|
+
if(startEpoch!==epoch)throw new Error('MemberAccessChanged');
|
|
94
|
+
const room=selectExistingBusinessRoom(page.items);
|
|
95
|
+
if(!room)throw new Error('existing_business_conversation_unavailable');
|
|
96
|
+
return room;
|
|
97
|
+
}
|
|
98
|
+
function safeError(error:unknown,operation:'load'|'send'|'init'):string{
|
|
99
|
+
const text=String((error as Error)?.message||error);
|
|
100
|
+
if(/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(text))return 'Your member session changed. Sign in again to continue.';
|
|
101
|
+
if(/approved first-party|webwix_owner_only|chat_setup_scope_unavailable/i.test(text))return 'This app host cannot reach the inbox yet.';
|
|
102
|
+
return operation==='send'?'Message delivery could not be confirmed. Check the conversation before trying again.':
|
|
103
|
+
operation==='init'?'Couldn’t open the conversation. Try again.':'Couldn’t load the conversation. Try again.';
|
|
104
|
+
}
|
|
105
|
+
async function endSubscription():Promise<void>{const id=subscriptionId;subscriptionId='';if(id)try{await unsubscribeRealtime({subscriptionId:id});}catch{}}
|
|
106
|
+
function clearPrivate(s:State,phase:'guest'|'error'='guest'):State{
|
|
107
|
+
currentIdentity=null;businessParticipantId='';epoch++;void endSubscription();
|
|
108
|
+
return shape({...initial,phase,error:phase==='guest'?'Sign in to send a message.':s.error});
|
|
109
|
+
}
|
|
110
|
+
let refreshTail:Promise<unknown>=Promise.resolve();
|
|
111
|
+
/** One refresh runs at a time. A caller that needs the data (init, a send) waits for the one in flight and
|
|
112
|
+
* then runs its own; a realtime event only asks the running one to go again, and gets nothing back. */
|
|
113
|
+
async function loadHistory(s:State,wait=false):Promise<State|undefined>{
|
|
114
|
+
if(!s.roomId)return s;
|
|
115
|
+
while(refreshFlight){
|
|
116
|
+
if(!wait){refreshQueued=true;return withLiveDraft(s);}
|
|
117
|
+
await refreshTail.catch(()=>{});
|
|
118
|
+
}
|
|
119
|
+
const run=refreshHistory(s);
|
|
120
|
+
refreshTail=run;
|
|
121
|
+
return run;
|
|
122
|
+
}
|
|
123
|
+
async function refreshHistory(s:State):Promise<State|undefined>{
|
|
124
|
+
refreshFlight=true;const startEpoch=epoch;const roomId=s.roomId;const principal=s.principal;const startDraftRevision=draftRevision;
|
|
125
|
+
// A refresh that outlived its member or session says nothing: returning a state would overwrite the new one.
|
|
126
|
+
const stale=()=>startEpoch!==epoch||(currentIdentity?.userId||currentIdentity?.email)!==principal;
|
|
127
|
+
try{
|
|
128
|
+
loadStage='history_request';
|
|
129
|
+
// The room history and the AI's open options are independent reads: fetch them together.
|
|
130
|
+
const optionsRead:Promise<BusinessVisitorOptions|null>=currentIdentity?.authenticated?loadBusinessVisitorOptions().catch(()=>null/* Optional AI list failure must not hide Inbox history. */):Promise.resolve(null);
|
|
131
|
+
const page=await chat.messagesPage(me(),roomId);
|
|
132
|
+
const visitorOptions=await optionsRead;
|
|
133
|
+
if(stale())return undefined;
|
|
134
|
+
loadStage='history_normalization';
|
|
135
|
+
// Presented from the page alone: a row kept from an earlier page would be re-presented without the
|
|
136
|
+
// metadata that only the page carries (sender, avatar, AI badge, article buttons).
|
|
137
|
+
const messages=await presentMessages([],page,currentIdentity?.userId||undefined);
|
|
138
|
+
if(stale())return undefined;
|
|
139
|
+
// Read the live state last: what other actions changed while this one awaited is theirs to keep.
|
|
140
|
+
const latest=readServiceState();
|
|
141
|
+
const same=latest.roomId===roomId&&latest.principal===principal;
|
|
142
|
+
// What was typed while this ran is kept even on the first load, when the live state is still empty.
|
|
143
|
+
const liveDraft=draftRevision!==startDraftRevision?latest.draft:s.draft;
|
|
144
|
+
const base:State=same?withLiveFields(s,latest,liveDraft):{...s,draft:liveDraft};
|
|
145
|
+
const intentObserved=pendingIntentWasObserved(messages,base.pendingIntent);
|
|
146
|
+
const draft=intentObserved&&base.pendingIntent&&base.draft.trim()===base.pendingIntent.text?'':base.draft;
|
|
147
|
+
return shape({...base,messages,visitorOptions,quickReplies:businessVisitorOptionSuggestions(visitorOptions),draft,pendingIntent:intentObserved?null:base.pendingIntent,phase:'ready',error:'',loadFailure:''});
|
|
148
|
+
}catch(error){
|
|
149
|
+
const wrapped=error instanceof Error?error:new Error('load_failed');
|
|
150
|
+
Object.assign(wrapped,{businessLoadFailure:safeLoadFailure(error)});
|
|
151
|
+
throw wrapped;
|
|
152
|
+
}finally{refreshFlight=false;}
|
|
153
|
+
}
|
|
154
|
+
/** What other actions own and may have changed while this one awaited, taken from the live state. A state
|
|
155
|
+
* built from a snapshot taken before an await must carry these forward or it rolls them back. */
|
|
156
|
+
function withLiveFields(st:State,latest:State,draft:string):State{
|
|
157
|
+
return {...st,draft,pendingIntent:latest.pendingIntent||st.pendingIntent,
|
|
158
|
+
usedSuggestionIds:[...new Set([...st.usedSuggestionIds,...latest.usedSuggestionIds])],
|
|
159
|
+
liveState:latest.liveState,liveReason:latest.liveReason,realtimeEventCount:Math.max(st.realtimeEventCount,latest.realtimeEventCount),lastRealtimeEventType:latest.lastRealtimeEventType,
|
|
160
|
+
scrollSend:Math.max(st.scrollSend,latest.scrollSend)};
|
|
161
|
+
}
|
|
162
|
+
function withLiveDraft(st:State):State{const latest=readServiceState();return withLiveFields(st,latest,latest.draft);}
|
|
163
|
+
const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefined|Promise<State|undefined>>>('businessChat',{
|
|
164
|
+
state:shape(initial),notHydrated:Object.keys(initial) as (keyof State)[],
|
|
165
|
+
actions:{
|
|
166
|
+
init:async s=>{
|
|
167
|
+
if(initFlight)return s;initFlight=true;eventDuringInit=false;statusDuringInit=null;
|
|
168
|
+
const oldPrincipal=s.principal;
|
|
169
|
+
const initRevision=draftRevision;
|
|
170
|
+
// Text typed at any point of the load, subscribe step included, outlives the snapshot this started from.
|
|
171
|
+
const keepTyped=(st:State):State=>draftRevision!==initRevision?{...st,draft:readServiceState().draft}:st;
|
|
172
|
+
let myEpoch=epoch;
|
|
173
|
+
try{
|
|
174
|
+
loadStage='room_selection';
|
|
175
|
+
const identity=await getCurrentMember();
|
|
176
|
+
if(myEpoch!==epoch)return undefined;
|
|
177
|
+
if(!identity.authenticated)throw new Error('member_sign_in_required');
|
|
178
|
+
if(oldPrincipal&&(oldPrincipal!==(identity.userId||identity.email)||s.scope!==identity.scope)){s=clearPrivate(s,'guest');myEpoch=epoch;}
|
|
179
|
+
currentIdentity=identity;
|
|
180
|
+
s=shape({...s,phase:'loading',member:true,principal:identity.userId||identity.email,scope:identity.scope,error:'',notice:''});
|
|
181
|
+
const existingRoom=await resolveExistingBusinessRoom(myEpoch);
|
|
182
|
+
if(myEpoch!==epoch)return undefined;
|
|
183
|
+
businessParticipantId=existingRoom.participantId;
|
|
184
|
+
s=shape({...s,roomId:existingRoom.id});
|
|
185
|
+
// Subscribe before reading history, so anything that lands after the read arrives as a realtime event.
|
|
186
|
+
if(!subscriptionId){try{const result=await subscribeRealtime({onEvent:'businessChat.onEvent',onStatus:'businessChat.onStatus'});if(myEpoch!==epoch){if(result.subscriptionId)await unsubscribeRealtime({subscriptionId:result.subscriptionId});return undefined;}subscriptionId=result.subscriptionId;const status=subscriptionId?await getRealtimeStatus({subscriptionId}):result;s=shape({...s,liveState:status.state,liveReason:status.reason||''});}catch{s=shape({...s,liveState:'unavailable',liveReason:'runtime_unavailable'});}}
|
|
187
|
+
let loaded=await loadLatest(s,true);
|
|
188
|
+
if(!loaded||myEpoch!==epoch)return undefined;
|
|
189
|
+
// Until this returns the stored state has no room, so events are ignored; one that arrived meanwhile asks for another pass.
|
|
190
|
+
for(let pass=0;pass<3&&takeEventDuringInit();pass++){const again=await loadLatest(loaded,true);if(!again||myEpoch!==epoch)return undefined;loaded=again;}
|
|
191
|
+
const opened=takeStatusDuringInit();
|
|
192
|
+
return shape(keepTyped(opened?{...loaded,liveState:opened.state,liveReason:opened.reason}:loaded));
|
|
193
|
+
}catch(error){
|
|
194
|
+
const sessionLost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));
|
|
195
|
+
const loadFailure=typeof (error as {businessLoadFailure?:unknown})?.businessLoadFailure==='string'?(error as {businessLoadFailure:string}).businessLoadFailure:safeLoadFailure(error);
|
|
196
|
+
return sessionLost?clearPrivate(s,'guest'):shape(keepTyped({...s,phase:'error',member:!!currentIdentity?.authenticated,error:safeError(error,'init'),loadFailure}));
|
|
197
|
+
}finally{initFlight=false;}
|
|
198
|
+
},
|
|
199
|
+
// Runs on every keystroke, so it must not rebuild the message list the way `shape` does.
|
|
200
|
+
setDraft:(s,a)=>{draftRevision++;const draft=value(a);const sendEnabled=!!s.member&&!!s.roomId&&!s.busy&&!!draft.trim()&&!s.pendingIntent;return {...s,draft,sendEnabled,sendDisabled:!sendEnabled};},
|
|
201
|
+
send:async s=>{
|
|
202
|
+
const text=s.draft.trim();
|
|
203
|
+
if(!text||sendFlight||!s.roomId||!s.member||s.pendingIntent)return s;
|
|
204
|
+
sendFlight=true;const pending=newBusinessChatIntent(text,s.roomId,s.principal);
|
|
205
|
+
const draftAtSend=s.draft;const sendEpoch=epoch;
|
|
206
|
+
try{
|
|
207
|
+
await chat.sendMessage(me(),pending.roomId,{id:pending.id,text:pending.text});
|
|
208
|
+
if(sendEpoch!==epoch)return undefined;
|
|
209
|
+
const latest=readServiceState();
|
|
210
|
+
if(latest.roomId!==pending.roomId||latest.principal!==pending.principal)return undefined;
|
|
211
|
+
draftRevision++;// a refresh that began before this send must not bring the sent text back into the composer
|
|
212
|
+
s=shape({...latest,draft:draftAfterAcceptedSend(latest.draft,draftAtSend),pendingIntent:null,error:'',notice:'Message sent.',scrollSend:latest.scrollSend+1});
|
|
213
|
+
try{const loaded=await loadLatest(s,true);return loaded?shape({...loaded,notice:'Message sent.'}):undefined;}catch(error){
|
|
214
|
+
// A session lost by an older operation says nothing about the member who is signed in now.
|
|
215
|
+
if(sendEpoch!==epoch)return undefined;
|
|
216
|
+
if(/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error)))return clearPrivate(s,'guest');
|
|
217
|
+
return withLiveDraft(s);
|
|
218
|
+
}
|
|
219
|
+
}catch(error){
|
|
220
|
+
if(sendEpoch!==epoch)return undefined;
|
|
221
|
+
const lost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));
|
|
222
|
+
if(lost)return clearPrivate(s,'guest');
|
|
223
|
+
const definitive=businessChatSendRejected(error);
|
|
224
|
+
const latest=readServiceState();
|
|
225
|
+
if(latest.roomId!==pending.roomId||latest.principal!==pending.principal)return undefined;
|
|
226
|
+
return shape({...latest,pendingIntent:definitive?null:pending,error:safeError(error,'send'),notice:definitive?'The message was rejected. Your draft is retained.':'No automatic retry was made. Reopen the conversation to check whether it arrived.'});
|
|
227
|
+
}finally{sendFlight=false;}
|
|
228
|
+
},
|
|
229
|
+
sendSuggestion:async(s,a)=>{
|
|
230
|
+
if(suggestionFlight||!s.member||!s.roomId||!!s.pendingIntent)return s;
|
|
231
|
+
const id=param(a,'id');
|
|
232
|
+
if(!id||s.usedSuggestionIds.includes(id))return s;
|
|
233
|
+
const rows=[{suggestions:s.quickReplies} as {suggestions?:BusinessSuggestion[]},...s.messages];
|
|
234
|
+
const options=s.visitorOptions;
|
|
235
|
+
const intent=newBusinessChatIntent('suggestion',s.roomId,s.principal);
|
|
236
|
+
const sendEpoch=epoch;
|
|
237
|
+
suggestionFlight=true;
|
|
238
|
+
s=shape({...s,usedSuggestionIds:[...s.usedSuggestionIds,id],error:'',notice:''});
|
|
239
|
+
try{
|
|
240
|
+
const sent=await dispatchBusinessSuggestion(rows,id,{member:s.member,roomId:s.roomId,principal:s.principal,currentPrincipal:currentIdentity?.userId||currentIdentity?.email||'',scope:s.scope,currentScope:currentIdentity?.scope||''},suggestion=>suggestion.kind==='visitor_option'
|
|
241
|
+
? sendBusinessVisitorOption(options,suggestion.label)
|
|
242
|
+
: chat.sendButton(me(),s.roomId,{id:intent.id,text:suggestion.label,appId:suggestion.appId!,interactionId:suggestion.interactionId!}));
|
|
243
|
+
if(sendEpoch!==epoch)return undefined;
|
|
244
|
+
if(!sent)return withLiveDraft(s);
|
|
245
|
+
const latest=readServiceState();
|
|
246
|
+
if(latest.roomId!==s.roomId||latest.principal!==s.principal)return undefined;
|
|
247
|
+
try{const loaded=await loadLatest(withLiveDraft(shape({...s,scrollSend:s.scrollSend+1,error:'',notice:'Suggestion sent.'})),true);return loaded?shape({...loaded,notice:'Suggestion sent.'}):undefined;}
|
|
248
|
+
catch{return withLiveDraft(shape({...s,scrollSend:s.scrollSend+1,error:'',notice:'Suggestion sent.'}));}
|
|
249
|
+
}catch(error){
|
|
250
|
+
if(sendEpoch!==epoch)return undefined;
|
|
251
|
+
if(/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error)))return clearPrivate(s,'guest');
|
|
252
|
+
const definitive=businessChatSendRejected(error);
|
|
253
|
+
return shape({...withLiveDraft(s),usedSuggestionIds:definitive?s.usedSuggestionIds.filter(value=>value!==id):s.usedSuggestionIds,error:safeError(error,'send'),notice:definitive?'The suggestion was rejected.':'Suggestion delivery is unconfirmed; it will not be sent again automatically.'});
|
|
254
|
+
}finally{suggestionFlight=false;}
|
|
255
|
+
},
|
|
256
|
+
onEvent:async(s,a)=>{
|
|
257
|
+
if(initFlight){eventDuringInit=true;return undefined;}
|
|
258
|
+
const startEpoch=epoch;
|
|
259
|
+
const envelope=a as RealtimeEventEnvelope|undefined;
|
|
260
|
+
const eventType=typeof envelope?.event?.type==='string'?envelope.event.type:'';
|
|
261
|
+
s=shape({...s,realtimeEventCount:s.realtimeEventCount+1,lastRealtimeEventType:eventType});
|
|
262
|
+
if(!currentIdentity||!shouldRefreshBusinessHistory(envelope,s.roomId,{member:s.member,principal:s.principal,currentPrincipal:currentIdentity.userId||currentIdentity.email,scope:s.scope,currentScope:currentIdentity.scope}))return s;
|
|
263
|
+
if(refreshFlight){refreshQueued=true;return withLiveDraft(s);}
|
|
264
|
+
try{return await loadLatest(s);}catch(error){
|
|
265
|
+
if(startEpoch!==epoch)return undefined;
|
|
266
|
+
return /member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error))?clearPrivate(s,'guest'):shape({...withLiveDraft(s),error:safeError(error,'load'),loadFailure:typeof (error as {businessLoadFailure?:unknown})?.businessLoadFailure==='string'?(error as {businessLoadFailure:string}).businessLoadFailure:safeLoadFailure(error)});
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
onStatus:async(s,a)=>{
|
|
270
|
+
const startEpoch=epoch;const mySeq=++statusSeq;
|
|
271
|
+
const status=a as RealtimeStatus|undefined;
|
|
272
|
+
const liveState=status?.state||'unavailable';
|
|
273
|
+
// While init is opening, the stored state has no room yet: remember the status and let init apply it.
|
|
274
|
+
if(initFlight){statusDuringInit={state:liveState,reason:liveState==='connected'?'':status?.reason||''};return undefined;}
|
|
275
|
+
let next=shape({...s,liveState,liveReason:liveState==='connected'?'':status?.reason||''});
|
|
276
|
+
if(liveState!=='connected'||s.liveState==='connected'||!s.member||!s.roomId||!currentIdentity||s.principal!==(currentIdentity.userId||currentIdentity.email)||s.scope!==currentIdentity.scope)return next;
|
|
277
|
+
try{
|
|
278
|
+
const loaded=await loadLatest(next);
|
|
279
|
+
if(!loaded)return undefined;
|
|
280
|
+
// A newer status applied while the refresh ran wins over the one this handler started with.
|
|
281
|
+
const live=readServiceState();
|
|
282
|
+
const newer=statusSeq!==mySeq;
|
|
283
|
+
return shape({...loaded,liveState:newer?live.liveState:liveState,liveReason:newer?live.liveReason:(liveState==='connected'?'':status?.reason||''),error:''});
|
|
284
|
+
}
|
|
285
|
+
catch(error){if(startEpoch!==epoch)return undefined;const lost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));return lost?clearPrivate(next,'guest'):shape({...withLiveDraft(next),error:safeError(error,'load'),loadFailure:typeof (error as {businessLoadFailure?:unknown})?.businessLoadFailure==='string'?(error as {businessLoadFailure:string}).businessLoadFailure:safeLoadFailure(error)});}
|
|
286
|
+
},
|
|
287
|
+
dispose:async s=>{epoch++;currentIdentity=null;businessParticipantId='';await endSubscription();return shape({...initial,phase:'guest'});},
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
readServiceState=()=>svc.getState();
|
|
291
|
+
async function loadLatest(s:State,wait=false):Promise<State|undefined>{
|
|
292
|
+
// A caller that does not need the data asks the running refresh to go again and keeps its own change
|
|
293
|
+
// (a status, an event count): returning nothing would drop it.
|
|
294
|
+
if(!wait&&refreshFlight){refreshQueued=true;return withLiveDraft(s);}
|
|
295
|
+
let next=await loadHistory(s,wait);
|
|
296
|
+
// Events that arrived during a refresh ask for another pass, a few at most.
|
|
297
|
+
for(let pass=0;next&&refreshQueued&&pass<3;pass++){
|
|
298
|
+
refreshQueued=false;
|
|
299
|
+
next=await loadHistory(next,true);
|
|
300
|
+
}
|
|
301
|
+
if(!next)refreshQueued=false;
|
|
302
|
+
return next;
|
|
303
|
+
}
|
|
304
|
+
export { svc };
|
|
@@ -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"],
|
|
@@ -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"],
|
|
@@ -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"],
|
|
@@ -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"],
|
|
@@ -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"],
|
|
@@ -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"],
|
|
@@ -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"],
|
|
@@ -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"],
|
|
@@ -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"],
|