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