@robono/server 0.21.0 → 0.23.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/CHANGELOG.md +12 -0
- package/README.md +30 -2
- package/SBOM.spdx.json +9 -9
- package/dist/calling-session-handle.d.ts +13 -0
- package/dist/calling-session-handle.d.ts.map +1 -0
- package/dist/calling-session-handle.js +37 -0
- package/dist/calling-session-handle.js.map +1 -0
- package/dist/calling.d.ts +1 -0
- package/dist/calling.d.ts.map +1 -1
- package/dist/calling.js +1 -1
- package/dist/calling.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/location.d.ts.map +1 -1
- package/dist/location.js +3 -0
- package/dist/location.js.map +1 -1
- package/dist/messaging-extras-contract.d.ts +14 -0
- package/dist/messaging-extras-contract.d.ts.map +1 -0
- package/dist/messaging-extras-contract.js +94 -0
- package/dist/messaging-extras-contract.js.map +1 -0
- package/dist/messaging-extras.d.ts +10 -0
- package/dist/messaging-extras.d.ts.map +1 -0
- package/dist/messaging-extras.js +42 -0
- package/dist/messaging-extras.js.map +1 -0
- package/dist/messaging-notification-providers.d.ts +20 -1
- package/dist/messaging-notification-providers.d.ts.map +1 -1
- package/dist/messaging-notification-providers.js +16 -5
- package/dist/messaging-notification-providers.js.map +1 -1
- package/dist/messaging-push-worker.d.ts.map +1 -1
- package/dist/messaging-push-worker.js +22 -2
- package/dist/messaging-push-worker.js.map +1 -1
- package/dist/messenger-calling.d.ts +14 -0
- package/dist/messenger-calling.d.ts.map +1 -0
- package/dist/messenger-calling.js +173 -0
- package/dist/messenger-calling.js.map +1 -0
- package/dist/messenger-check.d.ts.map +1 -1
- package/dist/messenger-check.js +3 -1
- package/dist/messenger-check.js.map +1 -1
- package/dist/messenger-connected-templates.d.ts.map +1 -1
- package/dist/messenger-connected-templates.js +5 -2
- package/dist/messenger-connected-templates.js.map +1 -1
- package/dist/messenger-connections.d.ts +31 -0
- package/dist/messenger-connections.d.ts.map +1 -0
- package/dist/messenger-connections.js +18 -0
- package/dist/messenger-connections.js.map +1 -0
- package/dist/messenger-customer-templates.d.ts.map +1 -1
- package/dist/messenger-customer-templates.js +2 -0
- package/dist/messenger-customer-templates.js.map +1 -1
- package/dist/messenger-init.d.ts.map +1 -1
- package/dist/messenger-init.js +9 -5
- package/dist/messenger-init.js.map +1 -1
- package/dist/messenger-install-cli.js +23 -6
- package/dist/messenger-install-cli.js.map +1 -1
- package/dist/messenger-install.d.ts +11 -1
- package/dist/messenger-install.d.ts.map +1 -1
- package/dist/messenger-install.js +260 -62
- package/dist/messenger-install.js.map +1 -1
- package/dist/messenger-node.d.ts +14 -0
- package/dist/messenger-node.d.ts.map +1 -0
- package/dist/messenger-node.js +127 -0
- package/dist/messenger-node.js.map +1 -0
- package/dist/messenger-upgrade.d.ts +21 -0
- package/dist/messenger-upgrade.d.ts.map +1 -0
- package/dist/messenger-upgrade.js +136 -0
- package/dist/messenger-upgrade.js.map +1 -0
- package/dist/messenger-vite-templates.d.ts +12 -0
- package/dist/messenger-vite-templates.d.ts.map +1 -0
- package/dist/messenger-vite-templates.js +89 -0
- package/dist/messenger-vite-templates.js.map +1 -0
- package/dist/messenger.d.ts +16 -0
- package/dist/messenger.d.ts.map +1 -1
- package/dist/messenger.js +28 -1
- package/dist/messenger.js.map +1 -1
- package/legacy/0.21.0/messenger-connected-templates.js +138 -0
- package/legacy/0.21.0/messenger-customer-templates.js +111 -0
- package/legacy/0.21.0/messenger-init.js +121 -0
- package/legacy/0.21.0/messenger-install.js +243 -0
- package/legacy/0.21.0/messenger-preview-template.js +43 -0
- package/legacy/0.21.0/package.json +1 -0
- package/package.json +11 -2
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { customerMessengerTemplates } from './messenger-customer-templates.js';
|
|
2
|
+
export function connectStarterTemplates(files, options) {
|
|
3
|
+
const provider = options.auth ?? 'custom', web = options.platform === 'web', q = JSON.stringify;
|
|
4
|
+
if (!['custom', 'supabase', 'firebase'].includes(provider))
|
|
5
|
+
throw Error('Choose custom, supabase or firebase sign-in.');
|
|
6
|
+
if (provider !== 'custom') {
|
|
7
|
+
const setup = provider === 'supabase' ? `import {createClient} from '@supabase/supabase-js';
|
|
8
|
+
import {supabaseMessengerAuthentication} from '@robono/server';
|
|
9
|
+
let verify:ReturnType<typeof supabaseMessengerAuthentication>|undefined;
|
|
10
|
+
export async function authenticate(request:Request){
|
|
11
|
+
if(verify)return verify(request);
|
|
12
|
+
const url=process.env.SUPABASE_URL??process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
13
|
+
const key=process.env.SUPABASE_PUBLISHABLE_KEY??process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY??process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
14
|
+
if(!url||!key)throw Error('Set your existing Supabase URL and publishable key on the server.');
|
|
15
|
+
const provider=createClient(url,key,{auth:{persistSession:false,autoRefreshToken:false,detectSessionInUrl:false}});
|
|
16
|
+
verify=supabaseMessengerAuthentication(provider.auth);
|
|
17
|
+
return verify(request);
|
|
18
|
+
}` : `import {getApps,initializeApp,applicationDefault} from 'firebase-admin/app';
|
|
19
|
+
import {getAuth} from 'firebase-admin/auth';
|
|
20
|
+
import {firebaseMessengerAuthentication} from '@robono/server';
|
|
21
|
+
// Use the same Firebase project as your existing sign-in. Keep credentials on the server.
|
|
22
|
+
let verify:ReturnType<typeof firebaseMessengerAuthentication>|undefined;
|
|
23
|
+
export async function authenticate(request:Request){
|
|
24
|
+
if(verify)return verify(request);
|
|
25
|
+
const provider=getApps().find(app=>app.name==='robono-auth')??initializeApp({credential:applicationDefault()},'robono-auth');
|
|
26
|
+
verify=firebaseMessengerAuthentication(getAuth(provider));
|
|
27
|
+
return verify(request);
|
|
28
|
+
}`;
|
|
29
|
+
files['server/robono-access.ts'] = `import type {MessengerAccess} from '@robono/server';\n${setup}\n\n// Keep YOUR current eligibility, suspension and membership checks here.
|
|
30
|
+
// Signing in does not grant permission to every conversation.
|
|
31
|
+
export async function authorize(access:MessengerAccess):Promise<boolean>{
|
|
32
|
+
throw Error('Connect your existing messaging permission check in robono-access.ts');
|
|
33
|
+
}
|
|
34
|
+
`;
|
|
35
|
+
files['.env.example'] += provider === 'supabase' ? '# Existing NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY\n# (or NEXT_PUBLIC_SUPABASE_ANON_KEY) are reused when present. Otherwise set:\n# SUPABASE_URL=\n# SUPABASE_PUBLISHABLE_KEY=\n' : '# Server only: path to your existing Firebase service-account file, or workload identity.\nGOOGLE_APPLICATION_CREDENTIALS=\n';
|
|
36
|
+
}
|
|
37
|
+
files['client/RobonoChat.tsx'] = web ? `'use client';
|
|
38
|
+
import {useEffect,useRef} from 'react';
|
|
39
|
+
import {mountRobonoMessenger} from '@robono/messaging-web';
|
|
40
|
+
type Options=Parameters<typeof mountRobonoMessenger>[1];
|
|
41
|
+
export function RobonoChat({userId,getAccessToken,contacts,onReady,onSignOut}:{userId:string;getAccessToken:Options['getAccessToken'];contacts?:Options['contacts'];onSignOut?:()=>Promise<void>;onReady?:(chat:ReturnType<typeof mountRobonoMessenger>|null)=>void}){
|
|
42
|
+
const element=useRef<HTMLDivElement>(null),latest=useRef({userId,getAccessToken,onReady,onSignOut}),active=useRef<ReturnType<typeof mountRobonoMessenger>|null>(null);
|
|
43
|
+
latest.current={userId,getAccessToken,onReady,onSignOut};
|
|
44
|
+
useEffect(()=>{
|
|
45
|
+
if(!element.current)return;
|
|
46
|
+
// Capture this account's token callback; never send another account's token for it.
|
|
47
|
+
const token=async()=>{if(latest.current.userId!==userId)throw Error('Account changed.');const value=await latest.current.getAccessToken();if(latest.current.userId!==userId)throw Error('Account changed.');return value;};
|
|
48
|
+
const chat=mountRobonoMessenger(element.current,{endpoint:'/api/robono',companyName:${q(options.companyName)},userId,getAccessToken:token,contacts,account:{name:'Account',onSignOut:async()=>{await latest.current.onSignOut?.();}}});
|
|
49
|
+
active.current=chat;latest.current.onReady?.(chat);
|
|
50
|
+
return()=>{active.current=null;latest.current.onReady?.(null);chat.unmount();};
|
|
51
|
+
},[userId]);
|
|
52
|
+
useEffect(()=>{active.current?.updateContacts(contacts);},[contacts]);
|
|
53
|
+
return <div ref={element} style={{height:'100%',minHeight:400}}/>;
|
|
54
|
+
}
|
|
55
|
+
` : `import React,{useEffect,useRef,useState} from 'react';
|
|
56
|
+
import {createExpoMessenger,ExpoMessenger,type ExpoMessengerProps} from '@robono/messaging-react-native/expo';
|
|
57
|
+
type Connection=ReturnType<typeof createExpoMessenger>;
|
|
58
|
+
export function RobonoChat({userId,getAccessToken,contacts,isCallActive,onReady,onSignOut}:{userId:string;getAccessToken:()=>Promise<string>;contacts?:ExpoMessengerProps['contacts'];isCallActive?:()=>boolean;onSignOut?:()=>Promise<void>;onReady?:(connection:Connection|null)=>void}){
|
|
59
|
+
const latest=useRef({userId,getAccessToken,isCallActive,onReady});latest.current={userId,getAccessToken,isCallActive,onReady};
|
|
60
|
+
const [active,setActive]=useState<{userId:string;connection:Connection}|null>(null);
|
|
61
|
+
useEffect(()=>{
|
|
62
|
+
const token=async()=>{if(latest.current.userId!==userId)throw Error('Account changed.');const value=await latest.current.getAccessToken();if(latest.current.userId!==userId)throw Error('Account changed.');return value;};
|
|
63
|
+
const connection=createExpoMessenger({endpoint:${q(options.origin + '/api/robono')},userId,getAccessToken:token,isCallActive:()=>latest.current.isCallActive?.()??false});
|
|
64
|
+
setActive({userId,connection});latest.current.onReady?.(connection);
|
|
65
|
+
return()=>{latest.current.onReady?.(null);connection.dispose();};
|
|
66
|
+
},[userId]);
|
|
67
|
+
if(!active||active.userId!==userId)return null;
|
|
68
|
+
return <ExpoMessenger {...active.connection} userId={userId} companyName={${q(options.companyName)}} contacts={contacts} account={onSignOut?{name:'Account',onSignOut}:undefined}/>;
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
71
|
+
files['client/ConnectedRobonoChat.tsx'] = `'use client';
|
|
72
|
+
import React,{useEffect,useRef,useState} from 'react';
|
|
73
|
+
import {RobonoChat} from './RobonoChat';
|
|
74
|
+
import type {ComponentProps} from 'react';
|
|
75
|
+
type ChatProps=ComponentProps<typeof RobonoChat>;
|
|
76
|
+
export interface RobonoAccount {id:string;getAccessToken:()=>Promise<string>}
|
|
77
|
+
export interface RobonoAccounts {
|
|
78
|
+
// Call the listener immediately with the current account, then on every account change.
|
|
79
|
+
subscribe(listener:(account:RobonoAccount|null)=>void):()=>void;
|
|
80
|
+
signOut():Promise<unknown>;
|
|
81
|
+
}
|
|
82
|
+
/** Keep mounted within your signed-in app. No second login or copied session is required. */
|
|
83
|
+
export function ConnectedRobonoChat({accounts,...props}:Omit<ChatProps,'userId'|'getAccessToken'|'onReady'|'onSignOut'>&{accounts:RobonoAccounts}){
|
|
84
|
+
const [account,setAccount]=useState<RobonoAccount|null>(null),current=useRef<RobonoAccount|null>(null);
|
|
85
|
+
const sourceVersion=useRef(0),[version,setVersion]=useState(0);
|
|
86
|
+
const chat=useRef<Parameters<NonNullable<ChatProps['onReady']>>[0]>(null);
|
|
87
|
+
useEffect(()=>{
|
|
88
|
+
let live=true;setVersion(++sourceVersion.current);
|
|
89
|
+
if(current.current){void chat.current?.signOut().catch(()=>{});chat.current=null;current.current=null;setAccount(null);}
|
|
90
|
+
const off=accounts.subscribe(next=>{
|
|
91
|
+
if(!live)return;
|
|
92
|
+
if(current.current?.id!==next?.id){void chat.current?.signOut().catch(()=>{});chat.current=null;}
|
|
93
|
+
current.current=next;setAccount(next);
|
|
94
|
+
});
|
|
95
|
+
return()=>{live=false;sourceVersion.current++;current.current=null;off();};
|
|
96
|
+
},[accounts]);
|
|
97
|
+
const token=async()=>{const active=current.current;if(!active||active.id!==account?.id||sourceVersion.current!==version)throw Error('Account changed.');const value=await active.getAccessToken();if(current.current?.id!==active.id||sourceVersion.current!==version)throw Error('Account changed.');return value;};
|
|
98
|
+
return account?<RobonoChat key={version+':'+account.id} {...props} userId={account.id} getAccessToken={token} onReady={value=>{chat.current=value;}} onSignOut={async()=>{try{await chat.current?.signOut();}finally{await accounts.signOut();}}}/>:null;
|
|
99
|
+
}
|
|
100
|
+
// Before your app signs out, await its messenger handle's signOut when available.
|
|
101
|
+
// A provider sign-out/account-change event also clears chat immediately. Notification removal
|
|
102
|
+
// may require a valid old session; revoke that session and synchronize account suspension too.
|
|
103
|
+
`;
|
|
104
|
+
if (provider === 'supabase')
|
|
105
|
+
files['client/robono-accounts.ts'] = `import type {SupabaseClient} from '@supabase/supabase-js';
|
|
106
|
+
import type {RobonoAccounts,RobonoAccount} from './ConnectedRobonoChat';
|
|
107
|
+
export function robonoAccounts(client:SupabaseClient):RobonoAccounts{
|
|
108
|
+
const account=(id:string):RobonoAccount=>({id,getAccessToken:async()=>{
|
|
109
|
+
const {data,error}=await client.auth.getSession();
|
|
110
|
+
if(error||data.session?.user.id!==id)throw Error('Sign in again.');
|
|
111
|
+
return data.session.access_token;
|
|
112
|
+
}});
|
|
113
|
+
return {subscribe(listener){
|
|
114
|
+
let live=true,events=0;
|
|
115
|
+
const {data}=client.auth.onAuthStateChange((_event,session)=>{events++;if(live)listener(session?account(session.user.id):null);});
|
|
116
|
+
const initial=events;
|
|
117
|
+
void client.auth.getSession().then(({data,error})=>{if(live&&events===initial)listener(!error&&data.session?account(data.session.user.id):null);}).catch(()=>{if(live&&events===initial)listener(null);});
|
|
118
|
+
return()=>{live=false;data.subscription.unsubscribe();};
|
|
119
|
+
},signOut:()=>client.auth.signOut()};
|
|
120
|
+
}
|
|
121
|
+
`;
|
|
122
|
+
if (provider === 'firebase')
|
|
123
|
+
files['client/robono-accounts.ts'] = `import {onIdTokenChanged,signOut,type Auth} from 'firebase/auth';
|
|
124
|
+
import type {RobonoAccounts} from './ConnectedRobonoChat';
|
|
125
|
+
export function robonoAccounts(auth:Auth):RobonoAccounts{
|
|
126
|
+
return {subscribe(listener){return onIdTokenChanged(auth,user=>listener(user?{id:user.uid,getAccessToken:async()=>{
|
|
127
|
+
if(auth.currentUser?.uid!==user.uid)throw Error('Sign in again.');
|
|
128
|
+
const token=await user.getIdToken();
|
|
129
|
+
if(auth.currentUser?.uid!==user.uid)throw Error('Sign in again.');
|
|
130
|
+
return token;
|
|
131
|
+
}}:null),()=>listener(null));},signOut:()=>signOut(auth)};
|
|
132
|
+
}
|
|
133
|
+
`;
|
|
134
|
+
files['ROBONO-SETUP.md'] += `\n## Connected accounts\n${provider === 'custom' ? 'Implement the small RobonoAccounts interface using your existing verified sign-in client.' : 'Import CustomerMessenger and pass your existing ' + (provider === 'supabase' ? 'Supabase client' : 'Firebase Auth instance') + ' as auth. The account wrapper is included.'} Pass your current contact dictionary as contacts; names and avatars update without restarting the chat. The connected screen listens for sign-in, sign-out and account switching. It uses a fresh token for each request and never substitutes one account’s token for another.\n\nKeep CustomerMessenger mounted in your signed-in application. Add setupChecks during development for a connection check using your existing login; no copied tokens are needed. The panel is hidden in production. Keep your own account suspension and permission checks in robono-access.ts; provider identity verification does not replace them. Missing permission code intentionally denies access. For explicit host sign-out, unregister messaging before revoking your session when possible. If the provider session has already expired, local content is still cleared; synchronize revoked account access on the server so notifications are also blocked.\n`;
|
|
135
|
+
Object.assign(files, customerMessengerTemplates(options));
|
|
136
|
+
return files;
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=messenger-connected-templates.js.map
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** A single customer-facing component; account wiring and diagnostics stay generated. */
|
|
2
|
+
export function customerMessengerTemplates(options) {
|
|
3
|
+
const provider = options.auth ?? 'custom';
|
|
4
|
+
const web = options.platform === 'web';
|
|
5
|
+
const type = provider === 'supabase' ? 'SupabaseClient' : provider === 'firebase' ? 'Auth' : 'RobonoAccounts';
|
|
6
|
+
const typeImport = provider === 'supabase' ? "import type {SupabaseClient} from '@supabase/supabase-js';" : provider === 'firebase' ? "import type {Auth} from 'firebase/auth';" : "import type {RobonoAccounts} from './ConnectedRobonoChat';";
|
|
7
|
+
return {
|
|
8
|
+
'client/CustomerMessenger.tsx': `'use client';
|
|
9
|
+
import React,{useMemo,type ComponentProps} from 'react';
|
|
10
|
+
import {ConnectedRobonoChat} from './ConnectedRobonoChat';
|
|
11
|
+
import {MessengerSetup} from './MessengerSetup';
|
|
12
|
+
${typeImport}
|
|
13
|
+
${provider === 'custom' ? '' : "import {robonoAccounts} from './robono-accounts';"}
|
|
14
|
+
type Props=Omit<ComponentProps<typeof ConnectedRobonoChat>,'accounts'>&{
|
|
15
|
+
${provider === 'custom' ? 'accounts' : 'auth'}:${type};
|
|
16
|
+
/** Optional development-only connection check. Never shown in a production build. */
|
|
17
|
+
setupChecks?:boolean;
|
|
18
|
+
};
|
|
19
|
+
export function CustomerMessenger({${provider === 'custom' ? 'accounts' : 'auth'},setupChecks=false,...props}:Props){
|
|
20
|
+
${provider === 'custom' ? '' : 'const accounts=useMemo(()=>robonoAccounts(auth),[auth]);'}
|
|
21
|
+
return <>
|
|
22
|
+
{setupChecks&&${web ? "process.env.NODE_ENV==='development'" : '__DEV__'}&&<MessengerSetup accounts={accounts}/>}
|
|
23
|
+
<ConnectedRobonoChat accounts={accounts} {...props}/>
|
|
24
|
+
</>;
|
|
25
|
+
}
|
|
26
|
+
`,
|
|
27
|
+
'client/check-messenger-connection.ts': `export interface ConnectionCheck {name:string;status:'passed'|'failed'|'needs-action';detail:string}
|
|
28
|
+
/** Read-only checks in the signed-in app. Tokens never enter the report or Robono's website. */
|
|
29
|
+
export async function checkMessengerConnection(getAccessToken:()=>Promise<string>,signal:AbortSignal):Promise<ConnectionCheck[]>{
|
|
30
|
+
const endpoint=${JSON.stringify(web ? '/api/robono' : options.origin + '/api/robono')};
|
|
31
|
+
const checks:ConnectionCheck[]=[];
|
|
32
|
+
const run=async(name:string,operation:string,service:string,inspect:(data:any)=>Omit<ConnectionCheck,'name'>)=>{
|
|
33
|
+
try{
|
|
34
|
+
const token=await getAccessToken();
|
|
35
|
+
if(signal.aborted)throw Error('Cancelled');
|
|
36
|
+
const response=await fetch(endpoint+'?service='+service,{method:'POST',redirect:'error',signal,
|
|
37
|
+
headers:{'content-type':'application/json',authorization:'Bearer '+token},body:JSON.stringify({operation})});
|
|
38
|
+
if(!response.ok){
|
|
39
|
+
const detail=response.status===401?'Sign in again, then check that the server uses the same sign-in provider.':
|
|
40
|
+
response.status===403?'Check the server’s account permissions and allowed website address.':
|
|
41
|
+
response.status===404?'The /api/robono route is missing. Run the installer checklist.':
|
|
42
|
+
response.status===429?'The service is busy or a usage limit was reached. Try again shortly.':
|
|
43
|
+
'The server could not complete this check. Check its private configuration and permission code.';
|
|
44
|
+
checks.push({name,status:'failed',detail});return;
|
|
45
|
+
}
|
|
46
|
+
checks.push({name,...inspect(await response.json())});
|
|
47
|
+
}catch{
|
|
48
|
+
if(signal.aborted)return;
|
|
49
|
+
checks.push({name,status:'failed',detail:'Could not connect with the current login. Check your server address and internet connection.'});
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
await run('Signed-in messaging','settings','messages',data=>({status:data.settings?'passed':'failed',detail:data.settings?'Your current account can load messaging settings.':'The route did not return messaging settings.'}));
|
|
53
|
+
if(!signal.aborted)await run('Conversations','conversations','messages',data=>({status:!Array.isArray(data.conversations)?'failed':data.conversations.length?'passed':'needs-action',detail:!Array.isArray(data.conversations)?'The route did not return conversations.':data.conversations.length?'Your account has available conversations.':'Connect a second test account and synchronize their permitted relationship from your server.'}));
|
|
54
|
+
if(!signal.aborted)await run('Notifications','notification-status','notifications',data=>({status:!Array.isArray(data.providers)?'failed':data.providers.length?'passed':'needs-action',detail:!Array.isArray(data.providers)?'Check that the server uses the current messaging package.':data.providers.length?'A notification provider is configured. Test an actual background notification next.':'Connect website or Apple/Google notification settings in Robono. Chat can work before alerts are connected.'}));
|
|
55
|
+
return checks;
|
|
56
|
+
}
|
|
57
|
+
`,
|
|
58
|
+
'client/MessengerSetup.tsx': `'use client';
|
|
59
|
+
import React,{useEffect,useRef,useState} from 'react';
|
|
60
|
+
${web ? '' : "import {View,Text,Pressable} from 'react-native';"}
|
|
61
|
+
import type {RobonoAccounts,RobonoAccount} from './ConnectedRobonoChat';
|
|
62
|
+
import {checkMessengerConnection,type ConnectionCheck} from './check-messenger-connection';
|
|
63
|
+
export function MessengerSetup({accounts}:{accounts:RobonoAccounts}){
|
|
64
|
+
const current=useRef<RobonoAccount|null>(null),generation=useRef(0),pending=useRef<AbortController|null>(null);
|
|
65
|
+
const [signedIn,setSignedIn]=useState(false),[busy,setBusy]=useState(false),[checks,setChecks]=useState<ConnectionCheck[]>([]);
|
|
66
|
+
useEffect(()=>{
|
|
67
|
+
generation.current++;current.current=null;pending.current?.abort();setBusy(false);setChecks([]);setSignedIn(false);
|
|
68
|
+
let live=true;
|
|
69
|
+
const off=accounts.subscribe(account=>{
|
|
70
|
+
if(!live)return;
|
|
71
|
+
if(current.current?.id!==account?.id){generation.current++;pending.current?.abort();setChecks([]);setBusy(false);}
|
|
72
|
+
current.current=account;setSignedIn(!!account);
|
|
73
|
+
});
|
|
74
|
+
return()=>{live=false;generation.current++;current.current=null;pending.current?.abort();off();};
|
|
75
|
+
},[accounts]);
|
|
76
|
+
const run=async()=>{
|
|
77
|
+
if(!current.current||pending.current&&!pending.current.signal.aborted)return;
|
|
78
|
+
const account=current.current,version=generation.current,controller=new AbortController();
|
|
79
|
+
pending.current=controller;setBusy(true);setChecks([]);
|
|
80
|
+
const timer=setTimeout(()=>controller.abort(),20000);
|
|
81
|
+
const token=async()=>{
|
|
82
|
+
if(current.current?.id!==account.id||generation.current!==version)throw Error('Account changed');
|
|
83
|
+
const value=await current.current.getAccessToken();
|
|
84
|
+
if(current.current?.id!==account.id||generation.current!==version)throw Error('Account changed');
|
|
85
|
+
return value;
|
|
86
|
+
};
|
|
87
|
+
try{
|
|
88
|
+
const result=await checkMessengerConnection(token,controller.signal);
|
|
89
|
+
if(generation.current===version)setChecks(controller.signal.aborted?[{name:'Connection',status:'failed',detail:'The check timed out. Check your connection and try again.'}]:result);
|
|
90
|
+
}finally{
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
if(pending.current===controller)pending.current=null;
|
|
93
|
+
if(generation.current===version)setBusy(false);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
${web ? `return <aside aria-label="Messenger setup check" style={{padding:12,border:'1px solid #b8b8b8',borderRadius:8,background:'#fff',color:'#222',fontFamily:'system-ui',marginBottom:8}}>
|
|
97
|
+
<button type="button" disabled={!signedIn||busy} onClick={()=>void run()}>{busy?'Checking connection…':'Check connection'}</button>
|
|
98
|
+
<p style={{margin:'8px 0',fontSize:14}}>{signedIn?'Uses your current login. No copied tokens or messages.':'Sign in to your app to check the connection.'} Development only.</p>
|
|
99
|
+
{checks.length>0&&<ul aria-live="polite" style={{margin:0,paddingLeft:20}}>{checks.map(check=><li key={check.name}><strong>{check.status==='passed'?'Ready':check.status==='failed'?'Fix':'Next'} — {check.name}:</strong> {check.detail}</li>)}</ul>}
|
|
100
|
+
{checks.length>0&&<p style={{fontSize:12,marginBottom:0}}>These are connection checks. They do not prove account isolation, delivery or background notifications.</p>}
|
|
101
|
+
</aside>;` : `return <View accessibilityLabel="Messenger setup check" style={{padding:12,borderWidth:1,borderColor:'#b8b8b8',borderRadius:8,backgroundColor:'#fff',marginBottom:8}}>
|
|
102
|
+
<Pressable accessibilityRole="button" accessibilityState={{disabled:!signedIn||busy}} disabled={!signedIn||busy} onPress={()=>void run()} style={{padding:8}}><Text style={{color:'#174ea6',fontWeight:'600'}}>{busy?'Checking connection…':'Check connection'}</Text></Pressable>
|
|
103
|
+
<Text style={{color:'#222',marginVertical:8}}>{signedIn?'Uses your current login. No copied tokens or messages.':'Sign in to your app to check the connection.'} Development only.</Text>
|
|
104
|
+
{checks.map(check=><Text key={check.name} style={{color:'#222',marginBottom:6}}>{check.status==='passed'?'Ready':check.status==='failed'?'Fix':'Next'} — {check.name}: {check.detail}</Text>)}
|
|
105
|
+
{checks.length>0&&<Text style={{color:'#555',fontSize:12}}>These are connection checks. They do not prove account isolation, delivery or background notifications.</Text>}
|
|
106
|
+
</View>;`}
|
|
107
|
+
}
|
|
108
|
+
`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=messenger-customer-templates.js.map
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { connectStarterTemplates } from './messenger-connected-templates.js';
|
|
2
|
+
import { mkdir, writeFile, access, realpath, lstat, rm } from 'node:fs/promises';
|
|
3
|
+
import { resolve, dirname, relative, isAbsolute, join } from 'node:path';
|
|
4
|
+
/** Pure templates contain no credentials. Host authentication deliberately fails closed. */
|
|
5
|
+
export function messengerStarterFiles(options) {
|
|
6
|
+
const origin = new URL(options.origin);
|
|
7
|
+
if (origin.origin !== options.origin || origin.username || origin.password || !(origin.protocol === 'https:' || origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname)))
|
|
8
|
+
throw Error('Use your exact HTTPS website origin, or localhost for development.');
|
|
9
|
+
if (!['web', 'expo'].includes(options.platform) || !['next', 'fetch'].includes(options.framework) || !options.companyName.trim() || options.companyName.length > 100)
|
|
10
|
+
throw Error('Choose Website or React Native, your server framework, and a company name.');
|
|
11
|
+
if (options.platform === 'expo' && origin.protocol !== 'https:')
|
|
12
|
+
throw Error('Phone apps need an HTTPS server address reachable from the phone.');
|
|
13
|
+
const q = JSON.stringify, route = options.framework === 'next' ? 'app/api/robono/route.ts' : 'server/robono-route.ts';
|
|
14
|
+
const auth = `import type {MessengerAccess} from '@robono/server';
|
|
15
|
+
// Replace ONLY these two functions with your existing account/permission service.
|
|
16
|
+
// Do not read a user ID from the request body. Verify expiry and suspension.
|
|
17
|
+
export async function authenticate(request:Request):Promise<string|null>{
|
|
18
|
+
throw Error('Connect your existing session verification in robono-access.ts');
|
|
19
|
+
}
|
|
20
|
+
// Account operations such as settings, conversations and notifications.register-device
|
|
21
|
+
// have no conversation ID. Allow them only for active, eligible accounts.
|
|
22
|
+
// Chat operations also need current membership and write/read permission checks.
|
|
23
|
+
export async function authorize(access:MessengerAccess):Promise<boolean>{
|
|
24
|
+
throw Error('Connect your existing messaging permission check in robono-access.ts');
|
|
25
|
+
}
|
|
26
|
+
`;
|
|
27
|
+
const files = {
|
|
28
|
+
'.gitignore': '.env\n.env.local\nnode_modules/\n',
|
|
29
|
+
'.env.example': `# Server only. Never prefix this with NEXT_PUBLIC_, VITE_ or EXPO_PUBLIC_.\nROBONO_API_KEY=\n# Optional comma-separated exact website origins. Set this before deployment.\nROBONO_ALLOWED_ORIGINS=${options.origin}\n`,
|
|
30
|
+
'server/robono-access.ts': auth,
|
|
31
|
+
'server/robono.ts': `import {createRobonoMessengerServer} from '@robono/server';
|
|
32
|
+
import {authenticate,authorize} from './robono-access';
|
|
33
|
+
let messenger:ReturnType<typeof createRobonoMessengerServer>|undefined;
|
|
34
|
+
export function getMessenger(){
|
|
35
|
+
if(messenger)return messenger;
|
|
36
|
+
const apiKey=process.env.ROBONO_API_KEY;
|
|
37
|
+
if(!apiKey)throw Error('Set ROBONO_API_KEY on your server.');
|
|
38
|
+
const allowedOrigins=process.env.ROBONO_ALLOWED_ORIGINS?.split(',').map((value:string)=>value.trim()).filter(Boolean)??[${q(options.origin)}];
|
|
39
|
+
return messenger=createRobonoMessengerServer({apiKey,authenticate,authorize,allowedOrigins});
|
|
40
|
+
}
|
|
41
|
+
`,
|
|
42
|
+
[route]: `import {getMessenger} from ${q(options.framework === 'next' ? '../../../server/robono' : './robono')};
|
|
43
|
+
${options.framework === 'next' ? "export const runtime = 'nodejs';\n" : ''}export const GET=(request:Request)=>getMessenger().handle(request);
|
|
44
|
+
export const POST=(request:Request)=>getMessenger().handle(request);
|
|
45
|
+
`,
|
|
46
|
+
'server/synchronize-contacts.ts': `// Run on your server when your own relationship record changes.
|
|
47
|
+
// Pass the server-only messenger instance from your application. Revisions come from YOUR database.
|
|
48
|
+
export async function synchronizeFriendship(messenger:import('@robono/server').RobonoMessengerServer,friendship:{userA:string;userB:string;canMessage:boolean;revision:number}){
|
|
49
|
+
return messenger.robono.setRelationship({externalUserIds:[friendship.userA,friendship.userB],allowed:friendship.canMessage,revision:friendship.revision});
|
|
50
|
+
}
|
|
51
|
+
`,
|
|
52
|
+
'ROBONO-SETUP.md': `# Connect ${options.companyName.replace(/[\r\n]/g, ' ')} to Robono\n\nThese are prepared integration files, not a replacement for your app. No existing files were changed.\n\n1. Install the packages listed below in the corresponding server and client projects.\n2. Copy the generated files into your app. Put the Robono key only in the server's environment.\n3. Connect the two functions in robono-access.ts to your existing sign-in and permission checks. They reject access until connected.\n4. When your app permits or removes a friendship, call synchronizeFriendship with its current revision. Supply current contact names/avatars to the client. Group memberships use messenger.robono.setGroup.\n5. Mount GET and POST at /api/robono. Add the client screen, pass your signed-in account and token-refresh function, and call its signOut() before ending a session.\n6. Run robono-messenger-check against your own route with a disposable test-user token. Then exchange a message between two authorized users.\n\nServer: npm install @robono/server\nTypeScript server: npm install --save-dev typescript @types/node\nClient: npm install @robono/messaging @robono/${options.platform === 'web' ? 'messaging-web react react-dom' : 'messaging-react-native'}\n\n${options.platform === 'expo' ? 'For React Native, follow the native dependency and Expo configuration instructions in the package README before rebuilding iPhone and Android. Your app needs its own Apple/Google notification credentials; upload private keys only in Robono’s notification setup.' : 'For website alerts, enter your HTTPS origin and chat page in Robono’s notification setup. The supplied messenger handles the browser helper. No Apple/Google private key is needed.'}\n\nKeep feature choices in Robono’s Features & preview. Do not duplicate them in frontend permission switches. Your server remains responsible for who may use the service. Never return true for every permission request.\n\n## Local connection check\nSet ROBONO_MESSENGER_URL, ROBONO_TEST_USER_TOKEN and ROBONO_WEBSITE_ORIGIN in your local environment. Then run:\n\n\`\`\`sh\nnpx --package @robono/server robono-messenger-check\n# Shareable diagnostic output; contains no token or message content:\nnpx --package @robono/server robono-messenger-check --json\n\`\`\`\n\n## Assistance\nAn AI coding assistant may use these files and package documentation to connect your existing account service. It must preserve authentication, expiry, suspension, current memberships and server-only keys. It must not replace them with a demo password or an always-allow function. Do not send credentials or customer messages to support chat.\n`,
|
|
53
|
+
};
|
|
54
|
+
if (options.platform === 'web')
|
|
55
|
+
files['client/RobonoChat.tsx'] = `'use client';
|
|
56
|
+
import {useEffect,useRef} from 'react';
|
|
57
|
+
import {mountRobonoMessenger} from '@robono/messaging-web';
|
|
58
|
+
type Options=Parameters<typeof mountRobonoMessenger>[1];
|
|
59
|
+
// Keep getAccessToken and contacts stable. The token callback must return a current token.
|
|
60
|
+
export function RobonoChat({userId,getAccessToken,contacts,onReady}:{userId:string;getAccessToken:Options['getAccessToken'];contacts:Options['contacts'];onReady:(chat:ReturnType<typeof mountRobonoMessenger>|null)=>void}){
|
|
61
|
+
const element=useRef<HTMLDivElement>(null);
|
|
62
|
+
useEffect(()=>{
|
|
63
|
+
if(!element.current)return;
|
|
64
|
+
const chat=mountRobonoMessenger(element.current,{endpoint:'/api/robono',companyName:${q(options.companyName)},userId,getAccessToken,contacts});
|
|
65
|
+
onReady(chat);return()=>{onReady(null);chat.unmount();};
|
|
66
|
+
},[userId,getAccessToken,contacts,onReady]);
|
|
67
|
+
return <div ref={element} style={{height:'100%',minHeight:400}}/>;
|
|
68
|
+
}
|
|
69
|
+
// Store onReady's chat handle. Await chat.signOut() BEFORE ending your own session.
|
|
70
|
+
`;
|
|
71
|
+
else
|
|
72
|
+
files['client/RobonoChat.tsx'] = `import React,{useEffect,useState} from 'react';
|
|
73
|
+
import {createExpoMessenger,ExpoMessenger,type ExpoMessengerProps} from '@robono/messaging-react-native/expo';
|
|
74
|
+
type Connection=ReturnType<typeof createExpoMessenger>;
|
|
75
|
+
export function RobonoChat({userId,getAccessToken,contacts,isCallActive,onReady}:{userId:string;getAccessToken:()=>Promise<string>;contacts:ExpoMessengerProps['contacts'];isCallActive:()=>boolean;onReady:(connection:Connection|null)=>void}){
|
|
76
|
+
const [active,setActive]=useState<{userId:string;connection:Connection}|null>(null);
|
|
77
|
+
useEffect(()=>{
|
|
78
|
+
const connection=createExpoMessenger({endpoint:${q(options.origin + '/api/robono')},userId,getAccessToken,isCallActive});
|
|
79
|
+
setActive({userId,connection});onReady(connection);
|
|
80
|
+
return()=>{onReady(null);connection.dispose();};
|
|
81
|
+
},[userId,getAccessToken,isCallActive,onReady]);
|
|
82
|
+
if(!active||active.userId!==userId)return null;
|
|
83
|
+
return <ExpoMessenger {...active.connection} userId={userId} companyName={${q(options.companyName)}} contacts={contacts}/>;
|
|
84
|
+
}
|
|
85
|
+
// Mount inside your app's safe area. Keep callbacks stable. Await connection.signOut()
|
|
86
|
+
// BEFORE ending your session; dispose only detaches the screen.
|
|
87
|
+
`;
|
|
88
|
+
return connectStarterTemplates(files, options);
|
|
89
|
+
}
|
|
90
|
+
/** All-or-nothing new directory; never edits a customer's existing app or env file. */
|
|
91
|
+
export async function writeMessengerStarter(destination, options) {
|
|
92
|
+
const files = messengerStarterFiles(options), target = resolve(destination), parent = await realpath(dirname(target));
|
|
93
|
+
if (join(parent, target.slice(dirname(target).length + 1)) !== target)
|
|
94
|
+
throw Error('Use a destination without symbolic-link parents.');
|
|
95
|
+
try {
|
|
96
|
+
await access(target);
|
|
97
|
+
throw Error('That directory already exists. Choose a new directory.');
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
if (e.code !== 'ENOENT')
|
|
101
|
+
throw e;
|
|
102
|
+
}
|
|
103
|
+
await mkdir(target, { mode: 0o700 });
|
|
104
|
+
try {
|
|
105
|
+
for (const [name, content] of Object.entries(files)) {
|
|
106
|
+
const path = resolve(target, name), local = relative(target, path);
|
|
107
|
+
if (local.startsWith('..') || isAbsolute(local))
|
|
108
|
+
throw Error('Invalid template path');
|
|
109
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
110
|
+
if ((await lstat(dirname(path))).isSymbolicLink())
|
|
111
|
+
throw Error('Unexpected symbolic link');
|
|
112
|
+
await writeFile(path, content, { flag: 'wx', mode: 0o600 });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
await rm(target, { recursive: true, force: true });
|
|
117
|
+
throw e;
|
|
118
|
+
}
|
|
119
|
+
return Object.keys(files);
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=messenger-init.js.map
|