@webotme/react-native 0.1.1 → 0.1.2

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/src/index.ts CHANGED
@@ -10,7 +10,61 @@ export type { WebotMeLauncherProps, LauncherPosition } from "./launcher";
10
10
  export { WebotMeClient } from "./client";
11
11
  export type { SendResult } from "./client";
12
12
 
13
+ export {
14
+ chatWithUs,
15
+ configureChat,
16
+ resetChat,
17
+ updateChatScreenContext,
18
+ useChat,
19
+ subscribeChat,
20
+ getChatState,
21
+ getChatSessionId,
22
+ } from "./chat";
23
+ export type { ChatResult } from "./chat";
24
+
25
+ export {
26
+ configureVoice,
27
+ isVoiceSupported,
28
+ setVoiceReplies,
29
+ speakReplyIfEnabled,
30
+ speakWithUs,
31
+ stopVoice,
32
+ useVoiceChat,
33
+ voiceWithUs,
34
+ } from "./voice";
35
+ export type { VoiceOptions, VoiceResult, VoiceState } from "./voice";
36
+
13
37
  export { WebotMeError } from "./types";
38
+ export {
39
+ executeAction,
40
+ executeActions,
41
+ resetExecutorState,
42
+ setupExecutor,
43
+ type ExecutorOptions,
44
+ } from "./executor";
45
+ export {
46
+ getCurrentRoute,
47
+ goBack,
48
+ navigateTo,
49
+ setCurrentRoute,
50
+ useNavigationAdapter,
51
+ } from "./navigationAdapter";
52
+ export {
53
+ isActionAllowed,
54
+ isBlocked,
55
+ needsConfirmation,
56
+ requireConfirmation,
57
+ setupSafetyLayer,
58
+ } from "./safety";
59
+ export {
60
+ registerScreenTree,
61
+ scanScreen,
62
+ setupFiberScanner,
63
+ stopFiberScanner,
64
+ useFiberScanner,
65
+ findClickHandler,
66
+ findFillHandler,
67
+ } from "./screenScanner";
14
68
  export type {
15
69
  WebotMeAction,
16
70
  WebotMeButton,
@@ -0,0 +1,59 @@
1
+ import React from "react";
2
+ import { StyleSheet, Text, View } from "react-native";
3
+
4
+ /**
5
+ * `react-native-webview` is a native module, so importing it at the top of a
6
+ * file breaks any app that runs in Expo Go or a plain JS bundle. The web
7
+ * variant is the only thing that needs it, so it is resolved on first render
8
+ * instead — apps that only use `chatWithUs` never touch it.
9
+ */
10
+
11
+ type AnyProps = Record<string, unknown>;
12
+
13
+ function MissingWebView() {
14
+ return (
15
+ <View style={styles.wrap}>
16
+ <Text style={styles.title}>Web chat needs a development build</Text>
17
+ <Text style={styles.body}>
18
+ react-native-webview is a native module, so it is not available in Expo Go.
19
+ </Text>
20
+ <Text style={styles.body}>
21
+ Run "npx expo run:android" / "npx expo run:ios", or use an EAS build. If you
22
+ only need text chat, use variant="native" or the chatWithUs function.
23
+ </Text>
24
+ </View>
25
+ );
26
+ }
27
+
28
+ let resolved: React.ComponentType<AnyProps> | null = null;
29
+
30
+ export function loadWebView(): React.ComponentType<AnyProps> {
31
+ if (resolved) return resolved;
32
+
33
+ try {
34
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
35
+ const mod = require("react-native-webview");
36
+ const candidate = mod?.WebView ?? mod?.default ?? mod;
37
+ resolved =
38
+ candidate && (typeof candidate === "function" || typeof candidate === "object")
39
+ ? (candidate as React.ComponentType<AnyProps>)
40
+ : MissingWebView;
41
+ } catch {
42
+ resolved = MissingWebView;
43
+ }
44
+
45
+ return resolved;
46
+ }
47
+
48
+ const styles = StyleSheet.create({
49
+ wrap: {
50
+ flex: 1,
51
+ alignItems: "center",
52
+ justifyContent: "center",
53
+ gap: 8,
54
+ padding: 24,
55
+ backgroundColor: "#0f172a",
56
+ },
57
+ title: { color: "#f8fafc", fontSize: 15, fontWeight: "700", textAlign: "center" },
58
+ body: { color: "#94a3b8", fontSize: 13, textAlign: "center", lineHeight: 19 },
59
+ });
@@ -0,0 +1,58 @@
1
+ import { useEffect } from "react";
2
+
3
+ let lastRoute = "";
4
+
5
+ /** Last route seen by the app. */
6
+ export function getCurrentRoute(): string {
7
+ return lastRoute;
8
+ }
9
+
10
+ export function setCurrentRoute(route: string): void {
11
+ lastRoute = route;
12
+ }
13
+
14
+ /** Navigate imperatively using Expo Router's router. */
15
+ export function navigateTo(route: string, params?: Record<string, any>): void {
16
+ try {
17
+ const router =
18
+ require("expo-router").default ?? require("expo-router").router;
19
+ if (params) (router as any)?.push?.({ pathname: route as any, params } as any);
20
+ else (router as any)?.push?.(route as any);
21
+ } catch {
22
+ /* no router available */
23
+ }
24
+ }
25
+
26
+ export function goBack(): void {
27
+ try {
28
+ const router =
29
+ require("expo-router").default ?? require("expo-router").router;
30
+ (router as any)?.back?.();
31
+ } catch {
32
+ /* noop */
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Bridge the live Expo Router pathname into the scanner's screen map. Call
38
+ * this once from a component that sits inside the router context.
39
+ */
40
+ export function useNavigationAdapter(): { route: string | undefined; back: () => void } {
41
+ let pathname: string | undefined;
42
+ try {
43
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
44
+ const usePathname = require("expo-router").usePathname as () => string | undefined;
45
+ // Expo Router provides this hook inside the app root; if it is missing the
46
+ // scanner/scanner route just stays blank.
47
+ // eslint-disable-next-line react-hooks/rules-of-hooks
48
+ pathname = usePathname();
49
+ } catch {
50
+ pathname = undefined;
51
+ }
52
+
53
+ useEffect(() => {
54
+ if (pathname) setCurrentRoute(pathname);
55
+ }, [pathname]);
56
+
57
+ return { route: pathname, back: goBack };
58
+ }
package/src/provider.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import React, {
2
2
  createContext,
3
+ useCallback,
3
4
  useContext,
4
5
  useEffect,
5
6
  useMemo,
@@ -7,6 +8,17 @@ import React, {
7
8
  useState,
8
9
  } from "react";
9
10
  import { WebotMeClient } from "./client";
11
+ import {
12
+ chatWithUs,
13
+ configureChat,
14
+ resetChat,
15
+ updateChatScreenContext,
16
+ useChat,
17
+ } from "./chat";
18
+ import { registerScreenTree, setupFiberScanner } from "./screenScanner";
19
+ import { setupExecutor } from "./executor";
20
+ import { setupSafetyLayer } from "./safety";
21
+ import { useNavigationAdapter } from "./navigationAdapter";
10
22
  import type {
11
23
  WebotMeConfig,
12
24
  WebotMeContract,
@@ -85,16 +97,50 @@ export function WebotMeProvider({
85
97
  [botId, apiBaseUrl, timeoutMs],
86
98
  );
87
99
 
100
+ // Feed the same bot into the one-function chat engine so `chatWithUs` works
101
+ // in apps that only mounted the provider and nothing else.
102
+ useEffect(() => {
103
+ configureChat({ botId, apiBaseUrl, userId, theme, welcome, screenContext, timeoutMs });
104
+ // Only re-run when the bot itself changes; message sends must not reset it.
105
+ }, [botId, apiBaseUrl, userId, welcome, timeoutMs]);
106
+
107
+ // Keep the element tree snapshot fresh for the screen scanner.
108
+ useEffect(() => {
109
+ registerScreenTree(children);
110
+ }, [children]);
111
+
112
+ // Auto-start the screen scanner so it can feed live screen context.
113
+ useEffect(() => {
114
+ const stop = setupFiberScanner({ intervalMs: 2500 });
115
+ return stop;
116
+ }, []);
117
+
118
+ // Arm the action executor so server-returned actions run on their own.
119
+ useEffect(() => {
120
+ const unsubscribe = setupExecutor();
121
+ return unsubscribe;
122
+ }, []);
123
+
124
+ // Default safety gate: confirm sensitive actions, block configured keywords.
125
+ useEffect(() => {
126
+ const reset = setupSafetyLayer();
127
+ return reset;
128
+ }, []);
129
+
130
+ useEffect(() => {
131
+ updateChatScreenContext(screenContext);
132
+ }, [screenContext]);
133
+
88
134
  // Stable visitor id for the lifetime of the app install.
89
135
  const visitorRef = useRef<string>(userId || makeId("v"));
90
- const [sessionId, setSessionId] = useState(() => makeId("s"));
91
- const [messages, setMessages] = useState<WebotMeMessage[]>([]);
92
- const [sending, setSending] = useState(false);
93
- const [error, setError] = useState<string | null>(null);
94
136
  const [contract, setContract] = useState<WebotMeContract | null>(null);
95
137
  const [attempt, setAttempt] = useState(0);
96
- const [pendingWorkflow, setPendingWorkflow] = useState<WebotMePendingWorkflow | null>(null);
97
- const [autoTask, setAutoTask] = useState<WebotMeAutoTask | null>(null);
138
+
139
+ // The engine owns the conversation so `chatWithUs` and `useWebotMeChat`
140
+ // always share one session and one transcript.
141
+ const engineState = useChat();
142
+ // Track the live route so the scanner + executor act on the current screen.
143
+ useNavigationAdapter();
98
144
 
99
145
  const chatBaseUrl = useMemo(
100
146
  () => (chatBaseUrlProp || apiBaseUrl).replace(/\/+$/, ""),
@@ -122,76 +168,27 @@ export function WebotMeProvider({
122
168
  // New session when the caller bumps resetKey.
123
169
  useEffect(() => {
124
170
  if (resetKey === undefined) return;
125
- setSessionId(makeId("s"));
126
- setMessages(welcome ? [botMessage(welcome)] : []);
127
- setError(null);
128
- setPendingWorkflow(null);
129
- setAutoTask(null);
171
+ resetChat();
130
172
  }, [resetKey, welcome]);
131
173
 
132
- const send = async (text: string) => {
133
- const trimmed = text.trim();
134
- if (!trimmed || sending) return;
135
-
136
- const userMsg: WebotMeMessage = {
137
- id: makeId("m"),
138
- from: "user",
139
- text: trimmed,
140
- createdAt: Date.now(),
141
- };
142
- const pendingId = makeId("m");
143
- setMessages((prev) => [
144
- ...prev,
145
- userMsg,
146
- { id: pendingId, from: "bot", text: "", createdAt: Date.now(), pending: true },
147
- ]);
148
- setSending(true);
149
- setError(null);
150
-
151
- try {
152
- const result = await client.send({
153
- message: trimmed,
154
- sessionId,
155
- userId: visitorRef.current,
156
- screenContext,
157
- });
158
-
159
- // Handle new response format with actions, pendingWorkflow, autoTask
160
- const botMsg: WebotMeMessage = {
161
- id: pendingId,
162
- from: "bot",
163
- text: result.text || result.reply || "",
164
- createdAt: Date.now(),
165
- action: result.action ? result.actions?.[0] : undefined,
166
- };
167
-
168
- setMessages((prev) =>
169
- prev.map((m) =>
170
- m.id === pendingId ? botMsg : m,
171
- ),
172
- );
173
-
174
- // Update workflow state
175
- if (result.pendingWorkflow) {
176
- setPendingWorkflow(result.pendingWorkflow);
177
- } else if (result.autoTask) {
178
- setAutoTask(result.autoTask);
179
- } else if (!result.pendingWorkflow && !result.autoTask) {
180
- // Only clear if server explicitly says no workflow
181
- setPendingWorkflow(null);
182
- setAutoTask(null);
183
- }
184
- } catch (err) {
185
- const message = err instanceof Error ? err.message : "Something went wrong";
186
- setError(message);
187
- setMessages((prev) => prev.filter((m) => m.id !== pendingId));
188
- } finally {
189
- setSending(false);
190
- }
191
- };
174
+ const send = useCallback(
175
+ async (text: string) => {
176
+ await chatWithUs(text);
177
+ },
178
+ [],
179
+ );
192
180
 
193
181
  const retry = () => setAttempt((n) => n + 1);
194
- const reset = () => setSessionId(makeId("s"));
182
+ const reset = () => resetChat();
183
+
184
+ const {
185
+ sessionId,
186
+ messages,
187
+ sending,
188
+ error,
189
+ pendingWorkflow,
190
+ autoTask,
191
+ } = engineState;
195
192
 
196
193
  const value = useMemo<WebotMeContextValue>(
197
194
  () => ({
@@ -221,6 +218,9 @@ export function WebotMeProvider({
221
218
  sending,
222
219
  error,
223
220
  contract,
221
+ send,
222
+ retry,
223
+ reset,
224
224
  pendingWorkflow,
225
225
  autoTask,
226
226
  ],
@@ -231,8 +231,4 @@ export function WebotMeProvider({
231
231
  );
232
232
  }
233
233
 
234
- function botMessage(text: string): WebotMeMessage {
235
- return { id: makeId("m"), from: "bot", text, createdAt: Date.now() };
236
- }
237
-
238
234
  export type { WebotMeTheme } from "./types";
package/src/safety.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { Alert } from "react-native";
2
+ import type { WebotMeAction } from "./types";
3
+
4
+ type SafetyOptions = {
5
+ /** Show a confirmation before running any action. Default false. */
6
+ alwaysConfirm?: boolean;
7
+ /** URL/label substrings that make an action require confirmation. */
8
+ confirmKeywords?: string[];
9
+ /** URL/label substrings whose actions are blocked entirely. */
10
+ blockKeywords?: string[];
11
+ };
12
+
13
+ let options: SafetyOptions = {
14
+ alwaysConfirm: false,
15
+ confirmKeywords: ["delete", "remove", "cancel", "purchase", "checkout", "payment", "order", "logout"],
16
+ blockKeywords: [],
17
+ };
18
+
19
+ export function setupSafetyLayer(next?: Partial<SafetyOptions>): () => void {
20
+ const previous = options;
21
+ options = { ...options, ...next };
22
+ return () => {
23
+ options = previous;
24
+ };
25
+ }
26
+
27
+ function hazardText(action: WebotMeAction): string {
28
+ const parts = [
29
+ action.type,
30
+ (action as any).label ?? "",
31
+ (action as any).data?.url ?? "",
32
+ (action as any).data?.text ?? "",
33
+ (action as any).data?.label ?? "",
34
+ (action as any).data?.selector ?? "",
35
+ ];
36
+ return parts.join(" ").toLowerCase();
37
+ }
38
+
39
+ export function isBlocked(action: WebotMeAction): boolean {
40
+ const hay = hazardText(action);
41
+ return (options.blockKeywords ?? []).some((k) => k && hay.includes(k.toLowerCase()));
42
+ }
43
+
44
+ export function needsConfirmation(action: WebotMeAction): boolean {
45
+ if (options.alwaysConfirm) return true;
46
+ if (action.type === "checkout") return true;
47
+ const hay = hazardText(action);
48
+ return (options.confirmKeywords ?? []).some((k) => k && hay.includes(k.toLowerCase()));
49
+ }
50
+
51
+ /** Ask the user before running an action. Returns true if approved. */
52
+ export function requireConfirmation(action: WebotMeAction): Promise<boolean> {
53
+ const label = (action as any).label ?? (action as any).data?.url ?? (action as any).data?.text ?? action.type;
54
+ return new Promise<boolean>((resolve) => {
55
+ Alert.alert(
56
+ "Allow this action?",
57
+ `${action.type}: ${label}`,
58
+ [
59
+ { text: "Cancel", style: "cancel", onPress: () => resolve(false) },
60
+ { text: "Allow", onPress: () => resolve(true) },
61
+ ],
62
+ { cancelable: true, onDismiss: () => resolve(false) },
63
+ );
64
+ });
65
+ }
66
+
67
+ /** Full gate used by the executor before every action run. */
68
+ export async function isActionAllowed(action: WebotMeAction): Promise<boolean> {
69
+ if (isBlocked(action)) return false;
70
+ if (needsConfirmation(action)) return requireConfirmation(action);
71
+ return true;
72
+ }
package/src/scan.txt ADDED
@@ -0,0 +1 @@
1
+ ��
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Screen hook: grab the live React tree by installing a minimal React
3
+ * DevTools global hook (the same seam the React DevTools overlay uses)
4
+ * *before* React / RN get to wire themselves in. An app that wants the
5
+ * full automatic screen scan should require this module as the very first
6
+ * import — typically via a tiny `entry.js` that replaces the package `main`.
7
+ *
8
+ * The package's own entry point can't do this alone: by the time
9
+ * `<WebotMeProvider>` renders, RN's ReactFabric module has already decided
10
+ * not to call into the hook. A file loaded *before* expo-router/entry can.
11
+ */
12
+
13
+ let capturedRoot: any = null;
14
+
15
+ function capture(root: any) {
16
+ try {
17
+ capturedRoot = root || null;
18
+ } catch {
19
+ /* noop */
20
+ }
21
+ }
22
+
23
+ export function installScreenHook(): void {
24
+ const g = globalThis as any;
25
+ const existing = g.__REACT_DEVTOOLS_GLOBAL_HOOK__;
26
+
27
+ if (existing) {
28
+ // DevTools attached already: wrap commits so we capture the root too.
29
+ if (!existing.__weBotCapturedHook) {
30
+ const prev = existing.onCommitFiberRoot;
31
+ existing.onCommitFiberRoot = (id: any, root: any, ...rest: any[]) => {
32
+ capture(root);
33
+ try {
34
+ prev?.(id, root, ...rest);
35
+ } catch {
36
+ /* ignore */
37
+ }
38
+ };
39
+ existing.__weBotCapturedHook = true;
40
+ }
41
+ return;
42
+ }
43
+
44
+ const hook: any = {
45
+ isDisabled: false,
46
+ supportsFiber: true,
47
+ checkDCE: false,
48
+ renderers: new Map(),
49
+ internalModuleStop: undefined,
50
+ getFiberRoots(_id: any) {
51
+ return capturedRoot ? new Set([capturedRoot]) : new Set();
52
+ },
53
+ inject(internals: any) {
54
+ hook.injectedInternals = internals;
55
+ return 1;
56
+ },
57
+ onScheduleFiberRoot() {},
58
+ onCommitFiberRoot(_id: any, root: any) {
59
+ capture(root);
60
+ },
61
+ onCommitFiberUnmount() {},
62
+ onPostCommitFiberRoot() {},
63
+ setStrictMode() {},
64
+ registerInternalModuleStop() {},
65
+ overrideHookState() {},
66
+ supportsChunkDevMode: false,
67
+ };
68
+ g.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
69
+ }
70
+
71
+ installScreenHook();
72
+
73
+ /** The committed React fiber root (HostRoot) of the app, or null if not yet. */
74
+ export function getCapturedRootFiber(): any | null {
75
+ try {
76
+ const raw = capturedRoot;
77
+ if (!raw) return null;
78
+ return raw.current ? raw.current : raw;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }