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