Download Jar. Uses 5016K of libraries. Click here for Pure Java version (47085L/261K).
1 | !7 |
2 | |
3 | do not include class And. |
4 | set flag OurSyncCollections. |
5 | |
6 | sS dbBotID = #1028421; |
7 | sbool newDesign = true; // use new chat bot design |
8 | sbool ariaLiveTrick = false; |
9 | sbool ariaLiveTrick2 = true; |
10 | |
11 | sS templateID = #1028653; |
12 | sS cssID = #1028652; |
13 | |
14 | sS thoughtBotID = null; // thought bot is this program |
15 | sS botName = "BookBetter"; |
16 | sS heading = "Schedule"; |
17 | sS botImageID = #1102935; |
18 | sS userImageID = #1102803; |
19 | sS chatHeaderImageID = #1102802; |
20 | sS timeZone = californiaTimeZone_string(); |
21 | sS timeZoneForGoogleAPI = "America/Los_Angeles"; |
22 | |
23 | sS baseLink; |
24 | sbool botOnRight = true; |
25 | |
26 | !include once #1028434 // WorkerChat |
27 | static new WorkerChat workerChat; |
28 | |
29 | !include once #1028621 // Booking |
30 | |
31 | !include once #1028656 // TestScripting |
32 | |
33 | p { |
34 | standardTimeZone_name = timeZone; |
35 | thoughtBot = mc(); |
36 | baseLink = "https://botcompany.de/" + psI(programID()) + "/raw"; |
37 | pWebChatBot(); |
38 | booking = new Booking; |
39 | } |
40 | |
41 | svoid processParams(SS map) {} |
42 | |
43 | static new ThreadLocal<Out> out; |
44 | static new ThreadLocal<Conversation> conv; |
45 | |
46 | static transient long lastConversationChange = now(); |
47 | |
48 | sbool testFunctions = false; |
49 | |
50 | sclass FormStep { |
51 | S key; |
52 | S displayText, desc; |
53 | S defaultValue; |
54 | S placeholder; // null for same as displayText, "" for none |
55 | LS buttons; |
56 | bool allowFreeText; // only matters when there are buttons |
57 | S value; |
58 | |
59 | // called before data entry |
60 | void update(Runnable onChange) {} |
61 | |
62 | // called after data entry |
63 | // return error message or null |
64 | // call conv->cancelForm(); to cancel the form (and make sure to return a text) |
65 | S verifyData(S s) { null; } |
66 | } |
67 | |
68 | sclass FormInFlight { |
69 | Conversation conversation; |
70 | S hashTag; |
71 | new L<FormStep> steps; |
72 | int stepIndex; // in steps list |
73 | |
74 | S handleInput(S s) { null; } |
75 | |
76 | FormStep currentStep() { |
77 | ret get(steps, stepIndex); |
78 | } |
79 | |
80 | void update(Runnable onChange) { |
81 | if (currentStep() != null) currentStep().update(onChange); |
82 | } |
83 | |
84 | S complete() { ret "Form complete"; } |
85 | S cancel() { ret "Request cancelled"; } |
86 | |
87 | FormStep byKey(S key) { |
88 | ret objectWhere(steps, +key); |
89 | } |
90 | |
91 | S getValue(S key) { |
92 | FormStep step = byKey(key); |
93 | ret step?.value; |
94 | } |
95 | |
96 | SS allValues() { |
97 | SS map = litorderedmap(); |
98 | for (FormStep step : steps) |
99 | map.put(step.key, step.value); |
100 | ret map; |
101 | } |
102 | |
103 | bool allowGeneralOverride() { false; } |
104 | |
105 | Conversation cancelMe() { |
106 | Conversation conv = conversation; |
107 | conversation.cancelForm(); |
108 | ret conv; |
109 | } |
110 | |
111 | void change { |
112 | if (conversation != null) conversation.change(); |
113 | } |
114 | } |
115 | |
116 | sbool debug; |
117 | |
118 | sS answer(S s) { |
119 | out.set(new Out); |
120 | if (creator() == null) if "debug" set debug; |
121 | |
122 | S a = rawAnswer(s); |
123 | |
124 | // handle hashtags |
125 | LS tokHashtags = regexpICMatchesAsCNC(regexpNegativeLookbehind("\\w") + "#\\w+\\b", a); |
126 | for (int i = 1; i < l(tokHashtags); i += 2) { |
127 | print("Found hashtag " + tokHashtags.get(i)); |
128 | /*S a2 = Handover.handleHashtag(conv!, tokHashtags.get(i)); |
129 | if (a2 != null) { |
130 | print("Replaced with " + quote(a2)); |
131 | tokHashtags.set(i, a2); |
132 | }*/ |
133 | } |
134 | |
135 | a = join(tokHashtags); |
136 | ret deliverAnswerAndFormStep(a); |
137 | } |
138 | |
139 | sS rawAnswer(S s) { |
140 | FormInFlight form = conv->form; |
141 | S a = null; |
142 | |
143 | // enter propose mode, get general answer / booking answer |
144 | |
145 | O bot = getDBBot(); |
146 | out->proposeMode = true; |
147 | S generalAnswer; |
148 | { |
149 | // call without #default |
150 | temp tempSetTL((ThreadLocal) getOpt(bot, 'opt_noDefault), true); |
151 | generalAnswer = (S) call(bot, 'answer, s, conv->language()); |
152 | } |
153 | |
154 | // booking - use pcall because precise dates may fail to be parsed |
155 | |
156 | if (generalAnswer == null) pcall { |
157 | generalAnswer = booking.answer(s, conv!); |
158 | print("Booking answer to " + s + ": " + generalAnswer+ " with form: " + out->proposedForm); |
159 | } |
160 | |
161 | out->proposeMode = false; |
162 | |
163 | if (form != null && generalAnswer != null && form.allowGeneralOverride()) |
164 | conv->cancelForm(); |
165 | else if (form != null) { |
166 | if ((a = form.handleInput(s)) != null) ret a; |
167 | |
168 | if (eqicOneOf(s, "cancel", "Abbrechen", unicode_crossProduct())) { |
169 | S answer = form.cancel(); |
170 | conv->cancelForm(); |
171 | ret answer; |
172 | } else if (eqicOneOf(s, "back", "zurück", unicode_undoArrow()) && form.stepIndex > 0) { |
173 | --form.stepIndex; |
174 | conv->change(); |
175 | ret ""; |
176 | } else if (form.currentStep() != null) { |
177 | FormStep step = form.currentStep(); |
178 | if (!step.allowFreeText && nempty(step.buttons) && !cic(step.buttons, s)) |
179 | ret de() ? "Bitte wählen Sie eine Option!" : "Please choose an option."; |
180 | print("Verifying data " + quote(s) + " in step " + step); |
181 | S error = step.verifyData(s); |
182 | if (error != null) |
183 | ret error; |
184 | |
185 | step.value = s; |
186 | ++form.stepIndex; |
187 | conv->change(); |
188 | if (form.currentStep() == null) { |
189 | S answer = form.complete(); |
190 | if (conv->form == form) |
191 | conv->cancelForm(); // if complete() hasn't put us on a new form |
192 | ret answer; |
193 | } |
194 | ret ""; |
195 | } |
196 | } |
197 | |
198 | // process general answer, switch language |
199 | |
200 | a = generalAnswer; |
201 | if (a == null) |
202 | a = (S) call(bot, 'answer, "#default", conv->language()); |
203 | |
204 | if (out->proposedForm != null) |
205 | conv->setForm(out->proposedForm); |
206 | |
207 | ret a; |
208 | } |
209 | |
210 | sS deliverAnswerAndFormStep(S answer) { |
211 | FormInFlight form = conv->form; // form may have cancelled itself in update |
212 | if (form == null || form.currentStep() == null) ret answer; |
213 | |
214 | FormStep step = form.currentStep(); |
215 | printVars_str(+answer, displayText := step.displayText); |
216 | answer = joinNemptiesWithSpace(answer, step.displayText); |
217 | out->placeholder = or(step.placeholder, step.displayText); |
218 | print("Step " + form.stepIndex + ": " + sfu(step)); |
219 | out->defaultInput = or2(step.value, step.defaultValue); |
220 | out->buttons = cloneList(step.buttons); |
221 | if (form.stepIndex > 0) out->buttons.add(de() ? "Zurück" : "Back"); |
222 | out->buttons.add(de() ? "Abbrechen" : "Cancel"); |
223 | ret answer; |
224 | } |
225 | |
226 | sS initialMessage() { |
227 | if (out! != null) out->placeholder = template("#typeADay"); |
228 | |
229 | if (booking.conf.dateSuggestionsInGreeting == 0) |
230 | ret getCannedAnswer("#greetingWithoutSuggestions", conv!); |
231 | |
232 | if (booking.anyBookingPossible()) pcall { |
233 | print("Booking possible"); |
234 | LongRange range = booking.maxLead(nowInConv(conv!)); |
235 | |
236 | TreeSet<Long> possibleDates = booking.possibleDates(range, conv->testMode); |
237 | print("Possible dates: " + l(possibleDates)); |
238 | if (nempty(possibleDates)) { |
239 | proposeForm(new ProposeAppointmentsForm(possibleDates, booking.conf.dateSuggestionsInGreeting)); |
240 | ret getCannedAnswer("#greeting", conv!); |
241 | } |
242 | |
243 | ret template("#greetingBookedOut"); |
244 | } |
245 | |
246 | ret getCannedAnswer("#bookingClosed", conv!); |
247 | } |
248 | |
249 | sO getDBBot() { |
250 | ret getBot(dbBotID); |
251 | } |
252 | |
253 | sbool de() { |
254 | ret eqic(conv->language(), "de"); |
255 | } |
256 | |
257 | // Web Chat Bot Include |
258 | |
259 | sO thoughtBot; |
260 | |
261 | static int longPollTick = 200; |
262 | static int longPollMaxWait = 1000*30; // lowered to 30 seconds |
263 | static int activeConversationSafetyMargin = 15000; // allow client 15 seconds to reload |
264 | |
265 | static Set<S> specialButtons = litciset("Cancel", "Back", "Abbrechen", "Zurück"); |
266 | |
267 | sclass Out extends DynamicObject { |
268 | LS buttons; |
269 | bool multipleChoice; |
270 | S multipleChoiceSeparator; |
271 | S placeholder; |
272 | S defaultInput; |
273 | Int progressBarValue; |
274 | bool glow; |
275 | |
276 | transient bool proposeMode; |
277 | transient FormInFlight proposedForm; |
278 | } |
279 | |
280 | sclass Msg extends DynamicObject { |
281 | long time; |
282 | bool fromUser; |
283 | Worker fromWorker; |
284 | S text; |
285 | Out out; |
286 | |
287 | *() {} |
288 | *(bool *fromUser, S *text) { time = now(); } |
289 | *(S *text, bool *fromUser) { time = now(); } |
290 | } |
291 | |
292 | concept AuthedDialogID { |
293 | S dialogID; |
294 | Worker loggedIn; // who is logged in with this cookie |
295 | } |
296 | |
297 | //concept Session {} // LEGACY |
298 | |
299 | // our base concept - a conversation between a user and a bot or sales representative |
300 | concept Conversation { |
301 | S cookie, ip, country; |
302 | new LL<Msg> oldDialogs; |
303 | new L<Msg> msgs; |
304 | long lastPing; |
305 | bool botOn = true; |
306 | Worker worker; // who are we talking to? |
307 | transient long userTyping, botTyping; // sysNow timestamps |
308 | bool testMode; |
309 | transient bool dryRun; |
310 | transient FormInFlight proposedForm; |
311 | |
312 | FormInFlight form; |
313 | S language; |
314 | Long lastProposedDate; |
315 | |
316 | void add(Msg m) { |
317 | m.text = trim(m.text); |
318 | if (!m.fromUser && empty(m.text)) ret; // don't store empty msgs from bot |
319 | syncAdd(msgs, m); |
320 | noteConversationChange(); |
321 | change(); |
322 | vmBus_send chatBot_messageAdded(mc(), this, m); |
323 | } |
324 | |
325 | int allCount() { ret lengthLevel2(oldDialogs) + l(msgs); } |
326 | int archiveSize() { ret lengthLevel2(oldDialogs); } |
327 | |
328 | long lastMsgTime() { Msg m = last(msgs); ret m == null ? 0 : m.time; } |
329 | |
330 | void cancelForm { |
331 | if (form != null) { |
332 | print("Cancelling form " + form); |
333 | form.conversation = null; |
334 | cset(this, form := null); |
335 | } |
336 | } |
337 | |
338 | <A extends FormInFlight> A setForm(A form) { |
339 | form.conversation = this; |
340 | cset(this, +form); |
341 | ret form; |
342 | } |
343 | |
344 | S language() { ret or2(language, 'en); } |
345 | |
346 | void updateForm { |
347 | if (form != null) form.update(r change); |
348 | } |
349 | |
350 | void turnBotOff { |
351 | cset(this, botOn := false); |
352 | noteConversationChange(); |
353 | updateForm(); |
354 | } |
355 | |
356 | void turnBotOn { |
357 | cset(this, botOn := true, worker := null); |
358 | S backMsg = getCannedAnswer("#botBack", this); |
359 | if (empty(msgs) || lastMessageIsFromUser() || !eq(last(msgs).text, backMsg)) |
360 | add(new Msg(backMsg, false)); |
361 | noteConversationChange(); |
362 | } |
363 | |
364 | bool lastMessageIsFromUser() { |
365 | ret nempty(msgs) && last(msgs).fromUser; |
366 | } |
367 | |
368 | void newDialog { |
369 | cancelForm(); |
370 | lastProposedDate = null; |
371 | oldDialogs.add(msgs); |
372 | cset(this, msgs := new L); |
373 | change(); |
374 | vmBus_send chatBot_clearedSession(mc(), this); |
375 | } |
376 | } |
377 | |
378 | // a message a bot module proposes to send |
379 | concept ProposedMsg { |
380 | O authoringObject; |
381 | S text; |
382 | Conversation conversation; // can be set early |
383 | Msg said; // only set if msg was actually posted |
384 | } |
385 | |
386 | svoid pWebChatBot { |
387 | dbIndexing(Conversation, 'cookie, Conversation, 'worker, |
388 | Conversation, 'lastPing, |
389 | Worker, 'loginName, AuthedDialogID, 'dialogID); |
390 | Class envType = fieldType(thoughtBot, "env"); |
391 | if (envType != null) |
392 | setOpt(thoughtBot, "env", proxy(envType, (O) mc())); |
393 | } |
394 | |
395 | sO html(S uri, SS params) { |
396 | temp tempRegisterThread(); |
397 | |
398 | if (eqic(uri, "/twilioWebhook")) |
399 | ret booking.serveTwilioWebhook(uri, params); |
400 | |
401 | S cookie = params.get('cookie); |
402 | if (empty(cookie)) { |
403 | registerVisitor(); |
404 | cookie = cookieSent(); |
405 | } |
406 | |
407 | bool workerMode = nempty(params.get("workerMode")) || startsWith(uri, "/worker"); |
408 | |
409 | Conversation conv = nempty(cookie) ? getConv(cookie) : null; |
410 | if (conv != null && !workerMode) |
411 | cset(conv, ip := subBot_clientIP()); |
412 | print("URI: " + uri + ", cookie: " + cookie + ", msgs: " + l(conv.msgs)); |
413 | |
414 | S dialogID = getDialogID(); // for authing |
415 | |
416 | S pw = trim(params.get('pw)); |
417 | if (nempty(pw)) { |
418 | S realPW = loadSecretTextFileOrCreateWithRandomID("password.txt"); |
419 | if (neq(pw, realPW)) |
420 | ret errorMsg("Bad password, please try again"); |
421 | uniq AuthedDialogID(+dialogID); |
422 | if (nempty(params.get('redirect))) |
423 | ret hrefresh(params.get('redirect)); |
424 | } |
425 | |
426 | new Matches m; |
427 | if (startsWith(uri, "/worker-image/", m)) { |
428 | long id = parseLong(m.rest()); |
429 | ret subBot_serveFile(workerImageFile(id), "image/jpeg"); |
430 | } |
431 | |
432 | AuthedDialogID auth = authObject(); |
433 | bool requestAuthed = auth != null; |
434 | |
435 | if (eq(uri, "/stats")) { |
436 | if (!requestAuthed) ret serveAuthForm(rawLink(uri)); |
437 | ret "Threads: " + ul_htmlEncode(getThreadNames(registeredThreads())); |
438 | } |
439 | |
440 | if (eq(uri, "/logs")) { |
441 | if (!requestAuthed) ret serveAuthForm(rawLink(uri)); |
442 | ret webChatBotLogsHTML2(rawLink(uri), params); |
443 | } |
444 | |
445 | if (eq(uri, "/auth-only")) { |
446 | if (eq(params.get('logout), "1")) |
447 | cdelete(AuthedDialogID, dialogID := getDialogID()); |
448 | if (!requestAuthed) ret serveAuthForm(params.get('uri)); |
449 | ret ""; |
450 | } |
451 | |
452 | if (workerChat != null) |
453 | try object workerChat.html(uri, params, conv, auth); |
454 | |
455 | if (booking != null) |
456 | try object booking.html(uri, params, auth); |
457 | |
458 | try object TestScripting.html(uri, params, auth); |
459 | |
460 | { |
461 | lock dbLock(); |
462 | |
463 | S message = trim(params.get("btn")); |
464 | if (empty(message)) message = trim(params.get("message")); |
465 | |
466 | if (match("new dialog", message)) { |
467 | conv.newDialog(); |
468 | message = null; |
469 | } |
470 | |
471 | main.conv.set(conv); |
472 | |
473 | if (!workerMode && empty(conv.msgs)) |
474 | addReplyToConvo(conv, () -> deliverAnswerAndFormStep(initialMessage())); |
475 | |
476 | if (nempty(message) && !lastUserMessageWas(conv, message)) { |
477 | print("Adding message: " + message); |
478 | if (workerMode) { |
479 | Msg msg = new(false, message); |
480 | msg.fromWorker = auth.loggedIn; |
481 | conv.add(msg); |
482 | } else |
483 | conv.add(new Msg(true, message)); |
484 | } |
485 | |
486 | S testMode = params.get("testMode"); |
487 | if (nempty(testMode)) { |
488 | print("Setting testMode", testMode); |
489 | cset(conv, testMode := eq("1", testMode)); |
490 | } |
491 | |
492 | if (!workerMode && conv.botOn && nempty(conv.msgs) && last(conv.msgs).fromUser) |
493 | addReplyToConvo(conv, () -> makeReply(last(conv.msgs).text)); |
494 | } // locked |
495 | |
496 | if (eq(uri, "/msg")) ret withHeader("OK"); |
497 | |
498 | if (eq(uri, "/typing")) { |
499 | if (workerMode) { |
500 | conv.botTyping = sysNow(); |
501 | print(conv.botTyping + " Bot typing in: " + conv.cookie); |
502 | } else { |
503 | conv.userTyping = sysNow(); |
504 | print(conv.userTyping + " User typing in: " + conv.cookie); |
505 | } |
506 | ret withHeader("OK"); |
507 | } |
508 | |
509 | if (eq(uri, "/incremental")) { |
510 | vmBus_send chatBot_userPolling(mc(), conv); |
511 | cset(conv, lastPing := now()); |
512 | int a = parseInt(params.get("a")); |
513 | |
514 | long start = sysNow(); |
515 | L msgs; |
516 | bool first = true; |
517 | while (licensed() && sysNow() < start+longPollMaxWait) { |
518 | int as = conv.archiveSize(); |
519 | msgs = cloneSubList(conv.msgs, a-as); |
520 | bool newDialog = a <= as; |
521 | long typing = workerMode ? conv.userTyping : conv.botTyping; |
522 | bool otherPartyTyping = typing > start; |
523 | |
524 | if (empty(msgs) && !otherPartyTyping) { |
525 | if (first) { |
526 | print("Long poll starting on " + cookie + ", " + a + "/" + a); |
527 | first = false; |
528 | } |
529 | sleep(longPollTick); |
530 | } else { |
531 | if (first) print("Long poll ended."); |
532 | new StringBuilder buf; |
533 | if (otherPartyTyping) { |
534 | print("Noticed " + (workerMode ? "user" : "bot") + " typing in " + conv.cookie); |
535 | buf.append(hscript("showTyping();")); |
536 | } |
537 | renderMessages(buf, msgs); |
538 | if (ariaLiveTrick2 && !workerMode) { |
539 | Msg msg = lastBotMsg(msgs); |
540 | if (msg != null) { |
541 | S author = msg.fromWorker != null ? htmlEncode2(msg.fromWorker.displayName) : botName; |
542 | buf.append(hscript([[$("#screenreadertrick").html(]] + jsQuote(author + " says: " + msg.text) + ");")); |
543 | } |
544 | } |
545 | if (a != 0 && anyInterestingMessages(msgs, workerMode)) |
546 | buf.append(hscript( |
547 | "window.playChatNotification();\n" + |
548 | "window.setTitleStatus(" + jsQuote((workerMode ? "User" : botName) + " says…") + ");" |
549 | )); |
550 | ret withHeader("<!-- " + conv.allCount() + " " + (newDialog ? "NEW DIALOG " : "") + "-->\n" + buf); |
551 | } |
552 | } |
553 | ret withHeader(""); |
554 | } |
555 | |
556 | { |
557 | lock dbLock(); |
558 | processParams(params); |
559 | S html = loadSnippet(templateID); // TODO: cache |
560 | |
561 | S workerModeParam = workerMode ? "workerMode=1&" : ""; |
562 | S langlinks = "<!-- langlinks here -->"; |
563 | if (html.contains(langlinks)) |
564 | html = html.replace(langlinks, |
565 | ahref(rawLink("eng"), "English") + " | " + ahref(rawLink("deu"), "German")); |
566 | |
567 | html = html.replace("#BOTIMG#", imageSnippetURLOrEmptyGIF(chatHeaderImageID)); |
568 | html = html.replace("#N#", "0"); |
569 | html = html.replace("#INCREMENTALURL#", baseLink + "/incremental?" + workerModeParam + "a="); |
570 | html = html.replace("#MSGURL#", baseLink + "/msg?" + workerModeParam + "message="); |
571 | html = html.replace("#TYPINGURL#", baseLink + "/typing?" + workerModeParam); |
572 | html = html.replace("#CSS_ID#", psI_str(cssID)); |
573 | if (ariaLiveTrick || ariaLiveTrick2) |
574 | html = html.replace([[aria-live="polite">]], ">"); |
575 | html = html.replace("#OTHERSIDE#", workerMode ? "User" : "Representative"); |
576 | if (nempty(params.get("debug"))) |
577 | html = html.replace("var showActions = false;", "var showActions = true;"); |
578 | |
579 | html = html.replace("#AUTOOPEN#", jsBool(workerMode || botAutoOpen())); |
580 | html = html.replace("#BOT_ON#", jsBool(botOn())); |
581 | html = html.replace("$HEADING", heading); |
582 | html = html.replace("#WORKERMODE", jsBool(workerMode)); |
583 | html = html.replace("<!-- MSGS HERE -->", ""); |
584 | html = hreplaceTitle(html, heading); |
585 | |
586 | if (eqGet(params, "_botDemo", "1")) |
587 | ret hhtml(hhead( |
588 | htitle(heading) |
589 | + loadJQuery() |
590 | ) + hbody(hjavascript(html))); |
591 | else |
592 | ret withHeader(subBot_serveJavaScript(html)); |
593 | } |
594 | } |
595 | |
596 | svoid addReplyToConvo(Conversation conv, IF0<S> think) { |
597 | out.set(new Out); |
598 | S reply = ""; |
599 | pcall { |
600 | reply = think!; |
601 | } |
602 | Msg msg = new Msg(false, reply); |
603 | msg.out = out!; |
604 | conv.add(msg); |
605 | } |
606 | |
607 | sO withHeader(S html) { |
608 | ret withHeader(subBot_noCacheHeaders(subBot_serveHTML(html))); |
609 | } |
610 | |
611 | sO withHeader(O response) { |
612 | call(response, 'addHeader, "Access-Control-Allow-Origin", "*"); |
613 | ret response; |
614 | } |
615 | |
616 | sS renderMessageText(S text, bool htmlEncode) { |
617 | text = trim(text); |
618 | if (htmlEncode) text = htmlEncode2(text); |
619 | text = nlToBr(text); |
620 | ret replace(text, ":wave:", html_wavingHand()); |
621 | } |
622 | |
623 | svoid renderMessages(StringBuilder buf, L<Msg> msgs) { |
624 | if (empty(msgs)) ret; |
625 | new Set<S> buttonsToSkip; |
626 | new LS buttonsHtml; |
627 | for (Msg m : msgs) { |
628 | if (!m.fromUser && eq(m.text, "-")) continue; |
629 | S html = renderMessageText(m.text, shouldHtmlEncodeMsg(m)); |
630 | // pull back & cancel buttons to beginning of msg |
631 | if (m == last(msgs) && m.out != null) { |
632 | fOr (S btn : m.out.buttons) |
633 | if (specialButtons.contains(btn)) { |
634 | buttonsToSkip.add(btn); |
635 | buttonsHtml.add(renderButtons(ll(btn))); |
636 | } |
637 | } |
638 | if (nempty(buttonsHtml) && l(m.out.buttons) == l(buttonsToSkip)) |
639 | html += " " + hspan(" ", class := "chat-button-span") + lines(buttonsHtml); |
640 | else |
641 | buttonsToSkip.clear(); |
642 | appendMsg(buf, m.fromUser ? defaultUserName() : botName, formatTime(m.time), html, !m.fromUser, m.fromWorker); |
643 | } |
644 | |
645 | appendButtons(buf, last(msgs).out, buttonsToSkip); |
646 | } |
647 | |
648 | svoid appendMsg(StringBuilder buf, S name, S time, S text, bool bot, Worker fromWorker) { |
649 | bool useTrick = ariaLiveTrick; |
650 | S tag = useTrick ? "div" : "span"; |
651 | if (bot) { |
652 | S id = randomID(); |
653 | S author = fromWorker != null ? htmlEncode2(fromWorker.displayName ): botName; |
654 | if (fromWorker != null) buf.append([[<div class="chat_botname"><p>]] + author + [[</p>]]); |
655 | buf.append("<" + tag + [[ class="chat_msg_item chat_msg_item_admin"]] + (useTrick ? [[ id="]] + id + [[" aria-live="polite" tabindex="-1"]] : "") + ">"); |
656 | S imgURL = snippetImgLink(botImageID); |
657 | if (fromWorker != null && fileExists(workerImageFile(fromWorker.id))) |
658 | imgURL = fullRawLink("worker-image/" + fromWorker.id); |
659 | |
660 | if (nempty(imgURL)) |
661 | buf.append([[ |
662 | <div class="chat_avatar"> |
663 | <img src="$IMG"/> |
664 | </div>]] |
665 | .replace("$IMG", imgURL)); |
666 | |
667 | buf.append([[<span class="sr-only">]] + (fromWorker != null ? "" : botName + " ") + [[says</span>]]); |
668 | buf.append(text); |
669 | buf.append([[</]] + tag + [[>]]); |
670 | if (fromWorker != null) buf.append("</div>"); |
671 | if (useTrick) buf.append(hscript("$('#" + id + "').focus();")); |
672 | } else |
673 | buf.append(([[ |
674 | <span class="sr-only">You say</span> |
675 | <]] + tag + [[ class="chat_msg_item chat_msg_item_user"]] + (useTrick ? [[ aria-live="polite"]] : "") + [[>$TEXT</]] + tag + [[> |
676 | ]]).replace("$TEXT", text)); |
677 | } |
678 | |
679 | sS replaceButtonText(S s) { |
680 | if (eqicOneOf(s, "back", "zurück")) ret unicode_undoArrow(); |
681 | if (eqicOneOf(s, "cancel", "Abbrechen")) ret unicode_crossProduct(); |
682 | ret s; |
683 | } |
684 | |
685 | sS renderMultipleChoice(LS buttons, Cl<S> selections, S multipleChoiceSeparator) { |
686 | print(+selections); |
687 | Set<S> selectionSet = asCISet(selections); |
688 | S rand = randomID(); |
689 | S className = "chat_multiplechoice_" + rand; |
690 | S allCheckboxes = [[$(".]] + className + [[")]]; |
691 | ret joinWithBR(map(buttons, name -> |
692 | hcheckbox("", contains(selectionSet, name), |
693 | value := name, |
694 | class := className) + " " + name)) |
695 | + "<br>" + hbuttonOnClick_returnFalse("OK", "submitMsg()", style := "float: right") |
696 | + hscript(allCheckboxes |
697 | + ".change(function() {" |
698 | //+ " console.log('multiple choice change');" |
699 | + " var theList = $('." + className + ":checkbox:checked').map(function() { return this.value; }).get();" |
700 | + " console.log('theList: ' + theList);" |
701 | + " $('#chat_message').val(theList.join(" + jsQuote(multipleChoiceSeparator) + "));" |
702 | + "});"); |
703 | } |
704 | |
705 | sS renderButtons(LS buttons) { |
706 | new LS out; |
707 | for i over buttons: { |
708 | S code = buttons.get(i); |
709 | S text = replaceButtonText(code); |
710 | out.add(hbuttonOnClick_returnFalse(text, "submitAMsg(" + jsQuote(text) + ")", class := "chatbot-choice-button", title := eq(code, text) ? null : code)); |
711 | if (!specialButtons.contains(code) |
712 | && i+1 < l(buttons) && specialButtons.contains(buttons.get(i+1))) |
713 | out.add(" "); |
714 | } |
715 | ret lines(out); |
716 | } |
717 | |
718 | svoid appendButtons(StringBuilder buf, Out out, Set<S> buttonsToSkip) { |
719 | S placeholder = out == null ? "" : unnull(out.placeholder); |
720 | S defaultInput = out == null ? "" : unnull(out.defaultInput); |
721 | buf.append(hscript("chatBot_setInput(" + jsQuote(defaultInput) + ", " + jsQuote(placeholder) + ");")); |
722 | if (out == null) ret; |
723 | LS buttons = listMinusSet(out.buttons, buttonsToSkip); |
724 | if (empty(buttons)) ret; |
725 | printVars_str(+buttons, +buttonsToSkip); |
726 | S buttonsHtml; |
727 | if (out.multipleChoice) |
728 | buttonsHtml = renderMultipleChoice(buttons, |
729 | mcSplit(out.defaultInput, out.multipleChoiceSeparator), out.multipleChoiceSeparator); |
730 | else |
731 | buttonsHtml = renderButtons(buttons); |
732 | |
733 | buf.append([[<span class="chat_msg_item chat_msg_item_admin chat_buttons">]]); |
734 | buf.append(buttonsHtml); |
735 | buf.append([[</span>]]); |
736 | } |
737 | |
738 | svoid appendDate(StringBuilder buf, S date) { |
739 | buf.append([[ |
740 | <div class="chat-box-single-line"> |
741 | <abbr class="timestamp">DATE</abbr> |
742 | </div>]].replace("DATE", date)); |
743 | } |
744 | |
745 | static bool lastUserMessageWas(Conversation conv, S message) { |
746 | Msg m = last(conv.msgs); |
747 | ret m != null && m.fromUser && eq(m.text, message); |
748 | } |
749 | |
750 | sS makeReply(S message) { |
751 | try { |
752 | ret answer(message); |
753 | } catch print e { |
754 | ret "Internal error"; |
755 | } |
756 | } |
757 | |
758 | sS formatTime(long time) { |
759 | ret timeInTimeZoneWithOptionalDate_24(timeZone, time); |
760 | } |
761 | |
762 | sS formatDialog(S id, L<Msg> msgs) { |
763 | new L<S> lc; |
764 | for (Msg m : msgs) |
765 | lc.add(htmlencode((m.fromUser ? "> " : "< ") + m.text)); |
766 | ret id + ul(lc); |
767 | } |
768 | |
769 | static Conversation getConv(fS cookie) { |
770 | ret withDBLock(func -> Conversation { |
771 | uniq(Conversation, +cookie) |
772 | }); |
773 | } |
774 | |
775 | sS serveAuthForm(S redirect) { |
776 | ret hhtml(hhead(htitle("Authorization required")) + hbody(hfullcenter( |
777 | h3_htmlEncode(heading + " Admin") |
778 | + hpostform( |
779 | hhidden(+redirect) |
780 | + "Password: " + hpassword('pw) + "<br><br>" + hsubmit())))); |
781 | } |
782 | |
783 | sS errorMsg(S msg) { |
784 | ret hhtml(hhead_title("Error") + hbody(hfullcenter(msg + "<br><br>" + ahref(jsBackLink(), "Back")))); |
785 | } |
786 | |
787 | sS defaultUserName() { |
788 | ret de() ? "Sie" : "You"; |
789 | } |
790 | |
791 | sbool botOn() { |
792 | ret isTrue(call(getDBBot(), "botOn")); |
793 | } |
794 | |
795 | sbool botAutoOpen() { |
796 | ret isTrue(call(getDBBot(), "botAutoOpen")); |
797 | } |
798 | |
799 | static File workerImageFile(long id) { |
800 | ret id == 0 ? null : javaxDataDir("adaptive-bot/images/" + id + ".jpg"); |
801 | } |
802 | |
803 | static long activeConversationTimeout() { |
804 | ret longPollMaxWait+activeConversationSafetyMargin; |
805 | } |
806 | |
807 | static AuthedDialogID authObject() { |
808 | ret conceptWhere AuthedDialogID(dialogID := getDialogID()); |
809 | } |
810 | |
811 | sbool anyInterestingMessages(L<Msg> msgs, bool workerMode) { |
812 | ret any(msgs, m -> m.fromUser == workerMode); |
813 | } |
814 | |
815 | sbool shouldHtmlEncodeMsg(Msg msg) { |
816 | ret msg.fromUser; |
817 | } |
818 | |
819 | sS getCountry(Conversation c) { |
820 | if (empty(c.country) && nempty(c.ip)) |
821 | cset(c, country := ipToCountry2020_safe(c.ip)); |
822 | ret or2(c.country, "?"); |
823 | } |
824 | |
825 | svoid noteConversationChange { |
826 | lastConversationChange = now(); |
827 | } |
828 | |
829 | // TODO: check that action is persistable & persist |
830 | svoid addTimeout(double seconds, Runnable action) { |
831 | doAfter(seconds, action); |
832 | print("Timeout added: " + seconds + " => " + action); |
833 | } |
834 | |
835 | sS template(S hashtag, O... params) { |
836 | ret replaceSquareBracketVars(getCannedAnswer(hashtag), params); |
837 | } |
838 | |
839 | sS getCannedAnswer(S hashtag, Conversation conv default null) { |
840 | if (!startsWith(hashtag, "#")) ret hashtag; |
841 | S lang = conv == null ? "en" : conv.language(); |
842 | temp tempSetTL((ThreadLocal) getOpt(getDBBot(), 'opt_noDefault), true); |
843 | ret or2((S) call(getDBBot(), 'answer, hashtag, lang), hashtag); // keep hashtag if no answer found |
844 | } |
845 | |
846 | static LS mcSplit(S input, S multipleChoiceSeparator) { |
847 | ret trimAll(splitAt(input, dropSpaces(multipleChoiceSeparator))); |
848 | } |
849 | |
850 | static Msg lastBotMsg(L<Msg> l) { |
851 | ret lastThat(l, msg -> !msg.fromUser); |
852 | } |
853 | |
854 | sS dbStats() { |
855 | Cl<Conversation> all = list(Conversation); |
856 | int nTestConvos = countPred(all, c -> startsWith(c.cookie, "test_")); |
857 | ret n2(l(all)-nTestConvos, "real conversation") + ", " + n2(nTestConvos, "test conversation"); |
858 | } |
859 | |
860 | svoid proposeForm(Conversation conversation default conv!, FormInFlight form) { |
861 | if (out! != null && out->proposeMode) |
862 | out->proposedForm = form; |
863 | else if (conversation != null) |
864 | conversation.setForm(form); |
865 | } |
Began life as a copy of #1028300
download show line numbers debug dex old transpilations
Travelled to 5 computer(s): bhatertpkbcr, mqqgnosmbjvj, pyentgdyhuwx, tvejysmllsmz, xrpafgyirdlv
No comments. add comment
Snippet ID: | #1028422 |
Snippet name: | BookBetter Chat Bot |
Eternal ID of this version: | #1028422/98 |
Text MD5: | 042e2d540b0f9b0a13adb25032b5a816 |
Transpilation MD5: | d915ba7ea4136bba63ad6e6d449671d6 |
Author: | stefan |
Category: | javax / web chat bots |
Type: | JavaX source code (desktop) |
Public (visible to everyone): | Yes |
Archived (hidden from active list): | No |
Created/modified: | 2022-05-21 17:43:45 |
Source code size: | 26273 bytes / 865 lines |
Pitched / IR pitched: | No / No |
Views / Downloads: | 652 / 2530 |
Version history: | 97 change(s) |
Referenced in: | [show references] |