@inploi/plugin-chatbot 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.env.example +3 -0
  2. package/.eslintrc.cjs +10 -0
  3. package/CHANGELOG.md +17 -0
  4. package/bunfig.toml +2 -0
  5. package/happydom.ts +10 -0
  6. package/index.html +28 -0
  7. package/package.json +58 -0
  8. package/postcss.config.cjs +7 -0
  9. package/public/mockServiceWorker.js +292 -0
  10. package/src/chatbot.api.ts +46 -0
  11. package/src/chatbot.constants.ts +6 -0
  12. package/src/chatbot.css +100 -0
  13. package/src/chatbot.dom.ts +27 -0
  14. package/src/chatbot.state.ts +180 -0
  15. package/src/chatbot.ts +77 -0
  16. package/src/chatbot.utils.ts +38 -0
  17. package/src/index.cdn.ts +12 -0
  18. package/src/index.dev.ts +32 -0
  19. package/src/index.ts +1 -0
  20. package/src/interpreter/interpreter.test.ts +69 -0
  21. package/src/interpreter/interpreter.ts +241 -0
  22. package/src/mocks/browser.ts +5 -0
  23. package/src/mocks/example.flows.ts +763 -0
  24. package/src/mocks/handlers.ts +28 -0
  25. package/src/ui/chat-bubble.tsx +36 -0
  26. package/src/ui/chat-input/chat-input.boolean.tsx +57 -0
  27. package/src/ui/chat-input/chat-input.file.tsx +211 -0
  28. package/src/ui/chat-input/chat-input.multiple-choice.tsx +92 -0
  29. package/src/ui/chat-input/chat-input.text.tsx +105 -0
  30. package/src/ui/chat-input/chat-input.tsx +57 -0
  31. package/src/ui/chatbot-header.tsx +89 -0
  32. package/src/ui/chatbot.tsx +53 -0
  33. package/src/ui/input-error.tsx +39 -0
  34. package/src/ui/job-application-content.tsx +122 -0
  35. package/src/ui/job-application-messages.tsx +56 -0
  36. package/src/ui/loading-indicator.tsx +37 -0
  37. package/src/ui/send-button.tsx +27 -0
  38. package/src/ui/transition.tsx +1 -0
  39. package/src/ui/typing-indicator.tsx +12 -0
  40. package/src/ui/useChatService.ts +82 -0
  41. package/src/vite-env.d.ts +1 -0
  42. package/tailwind.config.ts +119 -0
  43. package/tsconfig.json +33 -0
  44. package/tsconfig.node.json +10 -0
  45. package/types.d.ts +2 -0
  46. package/vite.config.ts +18 -0
@@ -0,0 +1,180 @@
1
+ import { del, get, set } from 'idb-keyval';
2
+ import { produce } from 'immer';
3
+ import { create } from 'zustand';
4
+ import { StateStorage, createJSONStorage, persist } from 'zustand/middleware';
5
+
6
+ import { JobApplication } from './chatbot.api';
7
+ import { DistributivePick, getHeadOrThrow } from './chatbot.utils';
8
+ import { ChatInput } from './ui/chat-input/chat-input';
9
+ import { ChatbotInput } from './ui/chat-input/chat-input';
10
+
11
+ const storage: StateStorage = {
12
+ getItem: async (name: string): Promise<string | null> => {
13
+ return (await get(name)) || null;
14
+ },
15
+ setItem: async (name: string, value: string): Promise<void> => {
16
+ await set(name, value);
17
+ },
18
+ removeItem: async (name: string): Promise<void> => {
19
+ await del(name);
20
+ },
21
+ };
22
+
23
+ export type ViewState = 'maximised' | 'minimised';
24
+
25
+ type ChatbotStore = {
26
+ currentApplication: (JobApplication & { startedAt: string }) | null;
27
+ viewState: ViewState;
28
+ startApplication: (application: JobApplication) => void;
29
+ cancelCurrentApplication: () => void;
30
+ setViewState: (newViewState: ViewState) => void;
31
+ };
32
+
33
+ export const useChatbotStore = create<ChatbotStore>((set, get) => ({
34
+ currentApplication: null,
35
+ viewState: 'maximised',
36
+ startApplication: application => {
37
+ // if the application is already running, just maximise it
38
+ if (get().currentApplication?.job.id === application.job.id) return set({ viewState: 'maximised' });
39
+ return set({ currentApplication: { ...application, startedAt: new Date().toISOString() }, viewState: 'maximised' });
40
+ },
41
+ cancelCurrentApplication: () => set({ currentApplication: null }),
42
+ setViewState: viewState => set({ viewState }),
43
+ }));
44
+
45
+ /** Returns the current application or throws if no application has been set. */
46
+ export const useCurrentJobApplication = () => {
47
+ const application = useChatbotStore(s => s.currentApplication);
48
+ if (!application) throw new Error('No current application found');
49
+ return application;
50
+ };
51
+
52
+ export type MessageAuthor = 'bot' | 'user';
53
+
54
+ type SystemMessage = { type: 'system'; text: string; variant: 'info' | 'warning' | 'error' | 'success' };
55
+ type TextMessage = { author: MessageAuthor; type: 'text'; text: string };
56
+ type ImageMessage = { author: MessageAuthor; type: 'image'; url: string; width: number; height: number };
57
+ type FileMessage = { author: MessageAuthor; type: 'file'; fileName: string; fileSizeKb: number };
58
+ export type ChatMessage = TextMessage | ImageMessage | SystemMessage | FileMessage;
59
+
60
+ export type ApplicationSubmission = DistributivePick<ChatbotInput, 'type' | 'value'>;
61
+
62
+ export type KeyToSubmissionMap = {
63
+ [key: string]: ApplicationSubmission;
64
+ };
65
+
66
+ /** Local state saved for an application */
67
+ export type ApplicationLocalState = {
68
+ /** History of messages left in the chat */
69
+ messages: ChatMessage[];
70
+ submissions: KeyToSubmissionMap;
71
+ currentNodeId: string;
72
+ /** Needs to be separate because a node can have many inputs */
73
+ currentInput?: ChatInput;
74
+ isFinished: boolean;
75
+ };
76
+
77
+ type ApplicationsStore = {
78
+ applicationsLocalState: Record<string, ApplicationLocalState>;
79
+ getApplicationLocalState: (application: JobApplication) => ApplicationLocalState | undefined;
80
+ updateApplicationLocalState: (params: { application: JobApplication; data: Partial<ApplicationLocalState> }) => void;
81
+ updateApplicationCurrentNode: (params: { application: JobApplication; currentNodeId: string }) => void;
82
+ resetApplicationState: (application: JobApplication) => void;
83
+ updateSubmission: (params: { application: JobApplication; fieldKey: string; data: ApplicationSubmission }) => void;
84
+ addMessage: (params: { application: JobApplication; data: ChatMessage }) => void;
85
+ resetLocalState: () => void;
86
+ setInput: (params: { application: JobApplication; data: ChatInput | null }) => void;
87
+ resetInput: (params: { application: JobApplication }) => void;
88
+ };
89
+
90
+ const getOrCreateApplication = (params: { application: JobApplication }, state: ApplicationsStore) => {
91
+ const existingApplication = state.applicationsLocalState[getCacheKey(params.application)];
92
+ if (existingApplication) return existingApplication;
93
+ const newApplication: ApplicationLocalState = {
94
+ messages: [],
95
+ submissions: {},
96
+ currentNodeId: getHeadOrThrow(params.application.flow.nodes).id,
97
+ isFinished: false,
98
+ };
99
+ state.applicationsLocalState[getCacheKey(params.application)] = newApplication;
100
+ return newApplication;
101
+ };
102
+
103
+ export const getCacheKey = (application: JobApplication) =>
104
+ [application.job.id, application.flow.id, application.flow.version].join('/');
105
+
106
+ export const useLocalState = create<ApplicationsStore>()(
107
+ persist(
108
+ (set, get) => ({
109
+ applicationsLocalState: {},
110
+ getApplicationLocalState: application => get().applicationsLocalState[getCacheKey(application)],
111
+ updateApplicationLocalState: params =>
112
+ set(
113
+ produce((state: ApplicationsStore) => {
114
+ const existingApplication = state.applicationsLocalState[getCacheKey(params.application)];
115
+ if (!existingApplication) throw new Error('Application not found');
116
+ Object.assign(existingApplication, params.data);
117
+ }),
118
+ ),
119
+ updateApplicationCurrentNode: params =>
120
+ set(
121
+ produce((state: ApplicationsStore) => {
122
+ const application = getOrCreateApplication(params, state);
123
+ application.currentNodeId = params.currentNodeId;
124
+ }),
125
+ ),
126
+ resetApplicationState: application =>
127
+ set(
128
+ produce((state: ApplicationsStore) => {
129
+ const cacheKey = getCacheKey(application);
130
+ delete state.applicationsLocalState[cacheKey];
131
+ }),
132
+ ),
133
+ updateSubmission: params =>
134
+ set(
135
+ produce((state: ApplicationsStore) => {
136
+ const application = getOrCreateApplication(params, state);
137
+ application.submissions[params.fieldKey] = params.data;
138
+ }),
139
+ ),
140
+ addMessage: params =>
141
+ set(
142
+ produce((state: ApplicationsStore) => {
143
+ const application = getOrCreateApplication(params, state);
144
+ application.messages.push(params.data);
145
+ state.applicationsLocalState[getCacheKey(params.application)] = application;
146
+ }),
147
+ ),
148
+ resetLocalState: () => set({ applicationsLocalState: {} }),
149
+ resetInput: params =>
150
+ set(
151
+ produce((state: ApplicationsStore) => {
152
+ const application = getOrCreateApplication(params, state);
153
+ application.currentInput = undefined;
154
+ }),
155
+ ),
156
+ setInput: params =>
157
+ set(
158
+ produce((state: ApplicationsStore) => {
159
+ const application = getOrCreateApplication(params, state);
160
+ application.currentInput = params.data ?? undefined;
161
+ }),
162
+ ),
163
+ }),
164
+ {
165
+ name: 'inploi-chatbot-application',
166
+ storage: createJSONStorage(() => storage),
167
+ // only store the jobApplications part of the state
168
+ partialize: state => ({ applicationsLocalState: state.applicationsLocalState }),
169
+ },
170
+ ),
171
+ );
172
+
173
+ export const useApplicationLocalState = (application: JobApplication) =>
174
+ useLocalState(s => s.applicationsLocalState[getCacheKey(application)]);
175
+
176
+ export const useApplicationInput = (application: JobApplication) =>
177
+ useLocalState(s => s.applicationsLocalState[getCacheKey(application)]?.currentInput);
178
+
179
+ export const useApplicationSubmission = (application: JobApplication, key: string) =>
180
+ useLocalState(s => s.applicationsLocalState[getCacheKey(application)]?.submissions[key]);
package/src/chatbot.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { ApiClient, Logger, createPlugin } from '@inploi/sdk';
2
+ import { h, render } from 'preact';
3
+
4
+ import { getApplicationData } from './chatbot.api';
5
+ import './chatbot.css';
6
+ import { ChatbotDomManager, createChatbotDomManager } from './chatbot.dom';
7
+ import { useChatbotStore, useLocalState } from './chatbot.state';
8
+
9
+ type CreateChatbotActionsParams = {
10
+ apiClient: ApiClient;
11
+ dom: ChatbotDomManager;
12
+ logger?: Logger;
13
+ };
14
+ const createChatbotActions = ({ apiClient, dom, logger }: CreateChatbotActionsParams) => {
15
+ const { resetLocalState } = useLocalState.getState();
16
+ const { startApplication, cancelCurrentApplication } = useChatbotStore.getState();
17
+
18
+ return {
19
+ /** Optionally eagerly loads the code to handle applications. */
20
+ prepare: async () => {
21
+ try {
22
+ await import('~/ui/chatbot');
23
+ logger?.info('Chatbot plugin prepared');
24
+ } catch (error) {
25
+ console.error(error);
26
+ logger?.error('Error preparing chatbot plugin', error);
27
+ }
28
+ },
29
+ startApplication: async ({ jobId }: { jobId: string }) => {
30
+ try {
31
+ const chatbotElement = dom.getOrCreateChatbotElement();
32
+ cancelCurrentApplication();
33
+
34
+ dom.renderLoading(chatbotElement);
35
+
36
+ /** We concurrently lazy load the module and data */
37
+ const [dataPromise, chatbotPromise] = await Promise.allSettled([
38
+ getApplicationData({ jobId, apiClient }),
39
+ import('~/ui/chatbot').then(m => m.Chatbot),
40
+ ]);
41
+
42
+ if (dataPromise.status === 'rejected') throw dataPromise.reason;
43
+ if (chatbotPromise.status === 'rejected') throw chatbotPromise.reason;
44
+
45
+ const Chatbot = chatbotPromise.value;
46
+ const application = dataPromise.value;
47
+ startApplication(application);
48
+ logger?.info(`Starting application for job "${application.job.id}" using flow "${application.flow.id}"`);
49
+ chatbotElement.innerHTML = '';
50
+
51
+ render(h(Chatbot, { apiClient, logger }), chatbotElement);
52
+ } catch (error) {
53
+ console.error(error);
54
+ logger?.error('Error starting application', error);
55
+ }
56
+ },
57
+ closeApplication: async () => {
58
+ const chatbotElement = dom.getOrCreateChatbotElement();
59
+ chatbotElement.innerHTML = '';
60
+ cancelCurrentApplication();
61
+ },
62
+ /** Resets the user saved data for every application. */
63
+ resetLocalState: async () => {
64
+ resetLocalState();
65
+ cancelCurrentApplication();
66
+ },
67
+ };
68
+ };
69
+
70
+ export const chatbotPlugin = createPlugin('chatbot', {
71
+ pure_createActions: ({ apiClient, logger }) =>
72
+ createChatbotActions({
73
+ apiClient,
74
+ dom: createChatbotDomManager(),
75
+ logger,
76
+ }),
77
+ });
@@ -0,0 +1,38 @@
1
+ import { FlowNode } from '@inploi/core/flows';
2
+
3
+ import { ApplicationSubmission, KeyToSubmissionMap } from './chatbot.state';
4
+
5
+ export type DistributivePick<T, K extends keyof T> = T extends unknown ? Pick<T, K> : never;
6
+
7
+ export const getHeadOrThrow = (nodes: FlowNode[]) => {
8
+ const head = nodes.find(n => n.isHead);
9
+ if (!head) throw new Error('No head node found');
10
+ return head;
11
+ };
12
+
13
+ export const submissionsToPayload = (submissions: KeyToSubmissionMap) => {
14
+ return Object.entries(submissions).reduce(
15
+ (acc, [key, submission]) => {
16
+ acc[key] = submission.value;
17
+ return acc;
18
+ },
19
+ {} as Record<string, ApplicationSubmission['value']>,
20
+ );
21
+ };
22
+
23
+ export const isSubmissionOfType =
24
+ <T extends ApplicationSubmission['type']>(type: T) =>
25
+ (submission?: ApplicationSubmission): submission is Extract<ApplicationSubmission, { type: T }> => {
26
+ if (!submission) return false;
27
+ return submission.type === type;
28
+ };
29
+
30
+ export function gzip(string: string) {
31
+ if (!('CompressionStream' in window)) return string;
32
+ const byteArray = new TextEncoder().encode(string);
33
+ const cs = new CompressionStream('gzip');
34
+ const writer = cs.writable.getWriter();
35
+ writer.write(byteArray);
36
+ writer.close();
37
+ return new Response(cs.readable).text();
38
+ }
@@ -0,0 +1,12 @@
1
+ import { chatbotPlugin } from './chatbot';
2
+
3
+ declare global {
4
+ interface Window {
5
+ inploi?: {
6
+ chatbotPlugin?: typeof chatbotPlugin;
7
+ };
8
+ }
9
+ }
10
+
11
+ if (!window.inploi) throw new Error('Please insert the SDK script tag above the plugins.');
12
+ window.inploi.chatbotPlugin = chatbotPlugin;
@@ -0,0 +1,32 @@
1
+ /** This file is only used for dev mode. The build output is index.ts */
2
+ import { createApiClient, inploiBrandedLogger } from '@inploi/sdk';
3
+
4
+ import { chatbotPlugin } from './chatbot';
5
+
6
+ export { chatbotPlugin } from './chatbot';
7
+
8
+ declare global {
9
+ interface Window {
10
+ chatbot: ReturnType<typeof chatbotPlugin.pure_createActions>;
11
+ }
12
+ }
13
+
14
+ async function enableMocking() {
15
+ if (import.meta.env.VITE_MOCKS === 'false') return;
16
+ const { worker } = await import('./mocks/browser');
17
+
18
+ // `worker.start()` returns a Promise that resolves
19
+ // once the Service Worker is up and ready to intercept requests.
20
+ return worker.start({ onUnhandledRequest: 'bypass' });
21
+ }
22
+
23
+ enableMocking().then(() => {
24
+ window.chatbot = chatbotPlugin.pure_createActions({
25
+ apiClient: createApiClient({
26
+ baseUrl: import.meta.env.VITE_BASE_URL,
27
+ publishableKey: import.meta.env.VITE_PUBLISHABLE_KEY,
28
+ }),
29
+ logger: inploiBrandedLogger,
30
+ });
31
+ window.chatbot.prepare();
32
+ });
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { chatbotPlugin } from './chatbot';
@@ -0,0 +1,69 @@
1
+ import { FlowNode } from '@inploi/core/flows';
2
+ import { expect, mock, test } from 'bun:test';
3
+
4
+ import { ChatService, createFlowInterpreter } from './interpreter';
5
+
6
+ const chatServiceStub: ChatService = {
7
+ input: () => new Promise(resolve => resolve({ type: 'text', value: 'lol' } as any)),
8
+ send: async () => void 0,
9
+ };
10
+
11
+ test('throws if no head', async () => {
12
+ const interpreter = createFlowInterpreter({
13
+ chatService: chatServiceStub,
14
+ flow: [],
15
+ getSubmissions: () => ({}),
16
+ onFlowEnd: () => void 0,
17
+ });
18
+
19
+ try {
20
+ await interpreter.interpret();
21
+ expect().toThrow();
22
+ } catch (error) {
23
+ expect(error).toBeInstanceOf(Error);
24
+ if (error instanceof Error) {
25
+ expect(error.message).toEqual('No head node found');
26
+ }
27
+ }
28
+ });
29
+
30
+ test('calls onFinish with nodeType when there are no more nodes to interpret', async () => {
31
+ const onFinish = mock(() => void 0);
32
+ const flowNode: FlowNode = { id: '1', type: 'text', isHead: true, data: { text: 'Node one' } };
33
+ const interpreter = createFlowInterpreter({
34
+ chatService: chatServiceStub,
35
+ flow: [flowNode],
36
+ getSubmissions: () => ({}),
37
+ onFlowEnd: onFinish,
38
+ });
39
+
40
+ await interpreter.interpret();
41
+
42
+ expect(onFinish).toHaveBeenCalled();
43
+ expect(onFinish.mock.calls[0]).toEqual([flowNode]);
44
+ });
45
+
46
+ test("moves on to next node when 'next' is called", async () => {
47
+ const messageFn = mock((str: string) => {
48
+ str;
49
+ });
50
+
51
+ const interpreter = createFlowInterpreter({
52
+ chatService: {
53
+ input: () => new Promise(resolve => resolve({ type: 'text', value: 'lol' } as any)),
54
+ send: async ({ message }) => (message.type === 'text' ? messageFn(message.text) : void 0),
55
+ },
56
+ flow: [
57
+ { id: '1', type: 'text', isHead: true, nextId: '2', data: { text: 'Node one' } },
58
+ { id: '2', type: 'text', data: { text: 'Node two' } },
59
+ ],
60
+ getSubmissions: () => ({}),
61
+ onFlowEnd: () => void 0,
62
+ });
63
+
64
+ await interpreter.interpret();
65
+
66
+ expect(messageFn.mock.calls).toHaveLength(2);
67
+ expect(messageFn.mock.calls[0]).toEqual(['Node one']);
68
+ expect(messageFn.mock.calls[1]).toEqual(['Node two']);
69
+ });
@@ -0,0 +1,241 @@
1
+ import { FlowNode, FlowNodeType, IfBlockNode } from '@inploi/core/flows';
2
+ import { P, match } from 'ts-pattern';
3
+ import { getHeadOrThrow } from '~/chatbot.utils';
4
+ import { ChatInput } from '~/ui/chat-input/chat-input';
5
+
6
+ import { ApplicationSubmission, ChatMessage, KeyToSubmissionMap } from '../chatbot.state';
7
+
8
+ export type ChatServiceSendParams = { signal?: AbortSignal; message: ChatMessage };
9
+ export type ChatService = {
10
+ send: (params: ChatServiceSendParams) => Promise<void>;
11
+ input: <TType extends ChatInput['type']>(
12
+ params: Extract<ChatInput, { type: TType }>,
13
+ ) => Promise<Extract<ApplicationSubmission, { type: TType }>>;
14
+ };
15
+
16
+ type ChatbotInterpreterParams = {
17
+ flow: FlowNode[];
18
+ getSubmissions: () => KeyToSubmissionMap | undefined;
19
+ chatService: ChatService;
20
+ /** When interpreter starts */
21
+ beforeStart?: (firstNode: FlowNode) => Promise<void>;
22
+ /** When flow has no more nodes to interpret */
23
+ onFlowEnd?: (lastNode: FlowNode) => void;
24
+ /** When node is interpreted */
25
+ onInterpret?: (node: FlowNode) => void;
26
+ };
27
+ export const createFlowInterpreter = ({
28
+ flow,
29
+ getSubmissions,
30
+ chatService,
31
+ beforeStart,
32
+ onFlowEnd,
33
+ onInterpret,
34
+ }: ChatbotInterpreterParams) => {
35
+ const controller = new AbortController();
36
+ const interpretNode = async (node: FlowNode) => {
37
+ const submissions = getSubmissions();
38
+ onInterpret?.(node);
39
+ await interpret({
40
+ node,
41
+ submissions,
42
+ chat: {
43
+ sendMessage: async message => chatService.send({ message, signal: controller.signal }),
44
+ userInput: async input => chatService.input(input),
45
+ },
46
+ next: () => {
47
+ const nextNodeId = getNextNodeId(node, getSubmissions());
48
+ const nextNode = flow.find(node => node.id === nextNodeId);
49
+ if (nextNode) {
50
+ return interpretNode(nextNode);
51
+ } else onFlowEnd?.(node);
52
+ },
53
+ });
54
+ };
55
+
56
+ return {
57
+ interpret: async (startFromNodeId?: string) => {
58
+ const startNode = flow.find(node => node.id === startFromNodeId) ?? getHeadOrThrow(flow);
59
+ await beforeStart?.(startNode);
60
+ return interpretNode(startNode);
61
+ },
62
+ abort: () => {
63
+ controller.abort();
64
+ },
65
+ };
66
+ };
67
+
68
+ type InterpretNodeParams<TNodeType extends FlowNodeType> = {
69
+ node: Extract<FlowNode, { type: TNodeType }>;
70
+ submissions?: KeyToSubmissionMap;
71
+ chat: {
72
+ sendMessage: (message: ChatMessage) => Promise<void>;
73
+ userInput: <TType extends ChatInput['type']>(
74
+ params: Extract<ChatInput, { type: TType }>,
75
+ ) => Promise<Extract<ApplicationSubmission, { type: TType }>>;
76
+ };
77
+ next: () => void;
78
+ };
79
+
80
+ async function interpret(params: InterpretNodeParams<FlowNodeType>) {
81
+ return await match(params)
82
+ .with({ node: { type: 'text' } }, interpretTextNode)
83
+ .with({ node: { type: 'image' } }, interpretImageNode)
84
+ .with({ node: { type: 'question-text' } }, interpretQuestionTextNode)
85
+ .with({ node: { type: 'question-enum' } }, interpretQuestionEnumNode)
86
+ .with({ node: { type: 'question-number' } }, interpretQuestionNumberNode)
87
+ .with({ node: { type: 'question-boolean' } }, interpretQuestionBooleanNode)
88
+ .with({ node: { type: 'question-file' } }, interpretQuestionFileNode)
89
+ .with({ node: { type: 'question-address' } }, interpretQuestionAddressNode)
90
+ .with({ node: { type: 'abandon-flow' } }, interpretAbandonFlowNode)
91
+ .with({ node: { type: 'complete-flow' } }, interpretCompleteFlowNode)
92
+ .with({ node: { type: P.union('jump', 'if-block') } }, ({ next }) => next())
93
+ .exhaustive();
94
+ }
95
+
96
+ async function interpretTextNode({ chat, next, node }: InterpretNodeParams<'text'>) {
97
+ await chat.sendMessage({ author: 'bot', type: 'text', text: node.data.text });
98
+ next();
99
+ }
100
+
101
+ async function interpretImageNode({ chat, next, node }: InterpretNodeParams<'image'>) {
102
+ await chat.sendMessage({
103
+ author: 'bot',
104
+ type: 'image',
105
+ url: node.data.url,
106
+ height: node.data.height,
107
+ width: node.data.width,
108
+ });
109
+ next();
110
+ }
111
+
112
+ async function interpretQuestionTextNode({ chat, next, node }: InterpretNodeParams<'question-text'>) {
113
+ await chat.sendMessage({ author: 'bot', type: 'text', text: node.data.question });
114
+ const reply = await chat.userInput({
115
+ key: node.data.key,
116
+ type: 'text',
117
+ config: { placeholder: node.data.placeholder, format: node.data.format },
118
+ });
119
+ await chat.sendMessage({ author: 'user', type: 'text', text: reply.value });
120
+ next();
121
+ }
122
+
123
+ async function interpretQuestionNumberNode({ chat, next, node }: InterpretNodeParams<'question-number'>) {
124
+ await chat.sendMessage({ author: 'bot', type: 'text', text: node.data.question });
125
+ const reply = await chat.userInput({
126
+ key: node.data.key,
127
+ type: 'text',
128
+ config: { placeholder: node.data.placeholder, format: 'text' },
129
+ });
130
+ await chat.sendMessage({ author: 'user', type: 'text', text: reply.value });
131
+ next();
132
+ }
133
+
134
+ async function interpretQuestionEnumNode({ chat, next, node }: InterpretNodeParams<'question-enum'>) {
135
+ await chat.sendMessage({ author: 'bot', type: 'text', text: node.data.question });
136
+ const reply = await chat.userInput({
137
+ key: node.data.key,
138
+ type: 'multiple-choice',
139
+ config: node.data,
140
+ });
141
+ await chat.sendMessage({ author: 'user', type: 'text', text: reply.value.join(', ') });
142
+ next();
143
+ }
144
+
145
+ async function interpretQuestionBooleanNode({ chat, next, node }: InterpretNodeParams<'question-boolean'>) {
146
+ await chat.sendMessage({ author: 'bot', type: 'text', text: node.data.question });
147
+ const input = await chat.userInput({
148
+ key: node.data.key,
149
+ type: 'boolean',
150
+ config: {
151
+ labels: {
152
+ true: node.data.trueLabel,
153
+ false: node.data.falseLabel,
154
+ },
155
+ },
156
+ });
157
+ const reply = input.value;
158
+ const labelMap = {
159
+ true: node.data.trueLabel,
160
+ false: node.data.falseLabel,
161
+ };
162
+ await chat.sendMessage({ author: 'user', type: 'text', text: labelMap[reply] });
163
+ next();
164
+ }
165
+
166
+ async function interpretQuestionAddressNode({ chat, next }: InterpretNodeParams<'question-address'>) {
167
+ await chat.sendMessage({ author: 'bot', type: 'text', text: 'Address questions are not implemented yet' });
168
+ next();
169
+ }
170
+
171
+ async function interpretQuestionFileNode({ node, chat, next }: InterpretNodeParams<'question-file'>) {
172
+ await chat.sendMessage({ author: 'bot', type: 'text', text: node.data.question });
173
+ const files = await chat.userInput({
174
+ key: node.data.key,
175
+ type: 'file',
176
+ config: {
177
+ extensions: node.data.extensions,
178
+ fileSizeLimitKib: node.data.maxSizeKb,
179
+ allowMultiple: node.data.multiple === true,
180
+ },
181
+ });
182
+ for await (const file of files.value) {
183
+ await chat.sendMessage({ author: 'user', type: 'file', fileName: file.name, fileSizeKb: file.sizeKb });
184
+ }
185
+ next();
186
+ }
187
+
188
+ async function interpretAbandonFlowNode({ chat, next, node }: InterpretNodeParams<'abandon-flow'>) {
189
+ if (node.data.text) {
190
+ await chat.sendMessage({ author: 'bot', type: 'text', text: node.data.text });
191
+ }
192
+ next();
193
+ }
194
+
195
+ async function interpretCompleteFlowNode({ chat, next, node }: InterpretNodeParams<'complete-flow'>) {
196
+ if (node.data.text) {
197
+ await chat.sendMessage({ author: 'bot', type: 'text', text: node.data.text });
198
+ }
199
+ next();
200
+ }
201
+
202
+ const stringOrStringArray = P.union(P.string, P.array(P.string));
203
+
204
+ const isIfBlockConditionMet = (ifBlock: IfBlockNode, submissions?: KeyToSubmissionMap) => {
205
+ const answer = submissions?.[ifBlock.data.compareKey];
206
+ if (!answer) return false;
207
+
208
+ return (
209
+ match({ ...ifBlock.data, answer })
210
+ .with({ compare: 'equals' }, ({ compareValue }) => {
211
+ if (typeof answer.value === 'string' || typeof answer.value === 'boolean')
212
+ return compareValue === answer.value.toString();
213
+ return false;
214
+ })
215
+ .with({ compare: 'notEquals' }, ({ compareValue }) => {
216
+ if (typeof answer.value === 'string' || typeof answer.value === 'boolean')
217
+ return compareValue !== answer.value.toString();
218
+ return false;
219
+ })
220
+ .with({ compare: 'contains', answer: { value: stringOrStringArray } }, ({ compareValue, answer }) =>
221
+ answer.value.includes(compareValue),
222
+ )
223
+ .with(
224
+ { compare: 'notContains', answer: { value: stringOrStringArray } },
225
+ ({ compareValue, answer }) => !answer.value.includes(compareValue),
226
+ )
227
+ // There cannot be conditions for files yet
228
+ .with({ answer: { type: 'file' } }, () => false)
229
+ .exhaustive()
230
+ );
231
+ };
232
+
233
+ export function getNextNodeId(fromNode: FlowNode, submissions?: KeyToSubmissionMap) {
234
+ return match(fromNode)
235
+ .with({ type: 'jump' }, jumpNode => jumpNode.data.targetId)
236
+ .with({ type: 'if-block' }, ifBlockNode => {
237
+ if (isIfBlockConditionMet(ifBlockNode, submissions)) return ifBlockNode.branchId;
238
+ return ifBlockNode.nextId;
239
+ })
240
+ .otherwise(node => node.nextId);
241
+ }
@@ -0,0 +1,5 @@
1
+ import { setupWorker } from 'msw/browser';
2
+
3
+ import { handlers } from './handlers';
4
+
5
+ export const worker = setupWorker(...handlers);