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: | 511 / 1774 |
| Version history: | 13 change(s) |
| Referenced in: | [show references] |