!7 sS dbBotID = #1028421; sbool newDesign = true; // use new chat bot design sS templateID = newDesign ? #1028282 : #1028283; sS cssID = newDesign ? #1028274 : #1026220; sS thoughtBotID = null; // thought bot is this program sS botName = "BookBetter"; sS heading = "BookBetter"; sS botImageID = #1102802; sS userImageID = #1102803; sS chatHeaderImageID = #1102802; sS baseLink; sbool botOnRight = true; p { standardTimeZone_name = californiaTimeZone_string(); thoughtBot = mc(); baseLink = "https://botcompany.de/" + psI(programID()) + "/raw"; pWebChatBot(); } svoid processParams(SS map) {} static Env env; static new ThreadLocal session; static new ThreadLocal out; sbool testFunctions = false; static interface Env { void sayAsync(S session, S msg); } concept Session { S cookie; S language; S lastQuestion; FormInFlight form, lastForm; void cancelForm { cset(this, lastForm := form, form := null); } S language() { ret or2(language, 'en); } } sclass FormStep { S key; S displayText, desc; S defaultValue; S placeholder; // null for same as displayText, "" for none LS buttons; bool allowFreeText; // only matters when there are buttons S value; // called before data entry void update(Runnable onChange) {} // called after data entry // return error message or null // call session->cancelForm(); to cancel the form (and make sure to return a text) S verifyData(S s) { null; } } sclass FormInFlight { S hashTag; new L steps; int stepIndex; // in steps list FormStep currentStep() { ret get(steps, stepIndex); } void update(Runnable onChange) { if (currentStep() != null) currentStep().update(onChange); } S complete() { ret "Form complete"; } S cancel() { ret "Request cancelled"; } } sbool debug; svoid setSession(S cookie, SS params) { session.set(uniq_sync(Session, +cookie)); S lang = mapGet(params, 'language); if (nempty(lang)) cset(session!, language := lang); S lang_default = mapGet(params, 'language_default); if (nempty(lang_default)) { print("lang_default=" + lang_default + ", have: " + session->language); if (empty(session->language)) { print("Setting language."); cset(session!, language := lang_default); } } } svoid clearSession(S cookie) { deleteConcepts(Session, +cookie); } sS returnQuestion(S q) { cset(session!, lastQuestion := q); ret q; } sS answer(S s) { if (session! == null) setSession("default", new Map); out.set(new Out); if (creator() == null) if "debug" set debug; LongRange range = parseEnglishDateRange(s, new DateInterpretationConfig); if (range != null) ret "Found date range: " + formatDateRangeWithSeconds(range, localTimeZone()); S lq = session->lastQuestion; cset(session!, lastQuestion := null); FormInFlight form = session->form; if (form != null && form.currentStep() != null) { if (eqicOneOf(s, "cancel", "Abbrechen", unicode_crossProduct())) { S answer = form.cancel(); session->cancelForm(); ret answer; } else if (eqicOneOf(s, "back", "zurück", unicode_undoArrow()) && form.stepIndex > 0) { --form.stepIndex; session->change(); ret deliverAnswerAndFormStep(""); } else { FormStep step = form.currentStep(); if (!step.allowFreeText && nempty(step.buttons) && !cic(step.buttons, s)) ret deliverAnswerAndFormStep(de() ? "Bitte wählen Sie eine Option!" : "Please choose an option."); print("Verifying data " + quote(s) + " in step " + step); S error = step.verifyData(s); if (error != null) ret deliverAnswerAndFormStep(error); step.value = s; ++form.stepIndex; session->change(); if (form.currentStep() == null) { S answer = form.complete(); session->cancelForm(); ret answer; } ret deliverAnswerAndFormStep(""); } } if (testFunctions) { if (eq(lq, "Would you like some tea?")) { if "yes" ret "Here's your tea."; if "no" ret "Very well then."; } if "test buttons" { out->buttons = ll("Yes", "No"); ret returnQuestion("Would you like some tea?"); } if "say something later" { if (env == null) ret("No env"); final S mySession = session->cookie; thread { sleepSeconds(5); env.sayAsync(mySession, "Here it is."); } ret "OK, will do it in 5 seconds"; } } // get answer from bot, switch language O bot = dbBot(); S a = (S) call(bot, 'answer, s, session->language()); S lang = cast getThreadLocal(((ThreadLocal) get(bot, 'language_out))); if (nempty(lang)) cset(session!, language := lang); S a2 = replaceAll(a, "\\s*#mainForm\\b", ""); if (neq(a, a2)) { form = new MainForm; print("Made form for cookie " + session->cookie + ": " + sfu(form)); cset(session!, +form); } ret deliverAnswerAndFormStep(a2); } sS deliverAnswerAndFormStep(S answer) { if (session->form != null) session->form.update(r { session->change() }); /*if (form.shouldCancel()) { session->cancelForm(); ret answer; } */ FormInFlight form = session->form; // form may have cancelled itself in update if (form == null || form.currentStep() == null) ret answer; FormStep step = form.currentStep(); if (empty(answer)) answer = step.displayText; else out->buttonsIntro = step.displayText; out->placeholder = or(step.placeholder, step.displayText); print("Step " + form.stepIndex + ": " + sfu(step)); out->defaultInput = or2(step.value, step.defaultValue); out->buttons = cloneList(step.buttons); if (form.stepIndex > 0) out->buttons.add(de() ? "Zurück" : "Back"); out->buttons.add(de() ? "Abbrechen" : "Cancel"); ret answer; } sS initialMessage() { print("initialMessage, lang=" + session->language()); ret or2(answer("#greeting"), "Hello"); } sO dbBot() { ret getBot(dbBotID); } sbool de() { ret eqic(session->language(), "de"); } // Web Chat Bot Include sO thoughtBot; static int longPollTick = 200; static int longPollMaxWait = 1000*30; // lowered to 30 seconds static int activeConversationSafetyMargin = 15000; // allow client 15 seconds to reload static Set specialButtons = litciset("Cancel", "Back", "Abbrechen", "Zurück"); sclass Out extends DynamicObject { S buttonsIntro; LS buttons; S placeholder; S defaultInput; } sclass Msg extends DynamicObject { long time; bool fromUser; Worker fromWorker; S text; Out out; *() {} *(bool *fromUser, S *text) { time = now(); } } // a human who answers to clients concept Worker { S loginName, displayName; //S password; //long available; // recent timestamp if available or 0 sS _fieldOrder = "loginName displayName"; S renderAsHTML() { ret htmlEncode2(loginName + " (display name: " + displayName + ")"); } } concept Conversation { S cookie, ip, country; new LL oldDialogs; new L msgs; long lastPing; bool botOn = true; Worker worker; // who are we talking to? void add(Msg m) { syncAdd(msgs, m); change(); vmBus_send chatBot_messageAdded(mc(), this, m); } int allCount() { ret lengthLevel2(oldDialogs) + l(msgs); } int archiveSize() { ret lengthLevel2(oldDialogs); } long lastMsgTime() { Msg m = last(msgs); ret m == null ? 0 : m.time; } } svoid pWebChatBot { dbIndexing(Conversation, 'cookie, Conversation, 'worker, Conversation, 'lastPing, Worker, 'loginName, AuthedDialogID, 'dialogID); if (thoughtBotID != null) thoughtBot = runDependent(thoughtBotID); Class envType = fieldType(thoughtBot, "env"); if (envType != null) setOpt(thoughtBot, "env", proxy(envType, (O) mc())); } static void sayAsync(S session, S text) { lock dbLock(); Conversation conv = getConv(session); conv.add(new Msg(false, text)); print("sayAsync " + session + ", new size=" + conv.allCount()); } html { temp tempRegisterThread(); S cookie = params.get('cookie); if (empty(cookie)) { registerVisitor(); cookie = cookieSent(); } bool workerMode = nempty(params.get("workerMode")) || startsWith(uri, "/worker"); Conversation conv = nempty(cookie) ? getConv(cookie) : null; if (conv != null && !workerMode) cset(conv, ip := subBot_clientIP()); print("URI: " + uri + ", cookie: " + cookie + ", msgs: " + l(conv.msgs)); S dialogID = getDialogID(); // for authing S pw = trim(params.get('pw)); if (nempty(pw)) { S realPW = loadSecretTextFileOrCreateWithRandomID("password.txt"); if (neq(pw, realPW)) ret errorMsg("Bad password, please try again"); uniq AuthedDialogID(+dialogID); if (nempty(params.get('redirect))) ret hrefresh(params.get('redirect)); } AuthedDialogID auth = authObject(); bool requestAuthed = auth != null; if (eq(uri, "/stats")) { if (!requestAuthed) ret serveAuthForm(rawLink(uri)); ret "Threads: " + ul_htmlEncode(getThreadNames(registeredThreads())); } if (eq(uri, "/logs")) { if (!requestAuthed) ret serveAuthForm(rawLink(uri)); ret webChatBotLogsHTML2(rawLink(uri), params); } if (eq(uri, "/auth-only")) { if (eq(params.get('logout), "1")) cdelete(AuthedDialogID, dialogID := getDialogID()); if (!requestAuthed) ret serveAuthForm(params.get('uri)); ret ""; } S uri2 = appendSlash(uri); if (startsWith(uri2, "/workers-admin/")) { if (!requestAuthed) ret serveAuthForm(params.get('uri)); ret serveWorkersAdmin(uri, params); } if (startsWith(uri2, "/worker/")) { if (!requestAuthed) ret serveAuthForm(params.get('uri)); if (eq(uri2, "/worker/botOnOff/")) { cset(conv, botOn := eq("true", params.get("on"))); ret "OK"; } ret serveWorkerPage(auth, conv, uri, params); } { lock dbLock(); S message = trim(params.get("btn")); if (empty(message)) message = trim(params.get("message")); if (match("new dialog", message)) { conv.oldDialogs.add(conv.msgs); cset(conv, msgs := new L); conv.change(); callOpt(thoughtBot, "clearSession", conv.cookie); vmBus_send chatBot_clearedSession(mc(), conv); message = null; } call(thoughtBot, "setSession", cookie, params); if (!workerMode && empty(conv.msgs)) addReplyToConvo(conv, lambda0 initialMessage); if (nempty(message) && !lastUserMessageWas(conv, message)) { print("Adding message: " + message); if (workerMode) { Msg msg = new(false, message); msg.fromWorker = auth.loggedIn; conv.add(msg); } else conv.add(new Msg(true, message)); } if (!workerMode && conv.botOn && nempty(conv.msgs) && last(conv.msgs).fromUser) addReplyToConvo(conv, () -> makeReply(last(conv.msgs).text)); } // locked if (eq(uri, "/msg")) ret withHeader("OK"); if (eq(uri, "/incremental")) { vmBus_send chatBot_userPolling(mc(), conv); cset(conv, lastPing := now()); int a = parseInt(params.get("a")); long start = sysNow(); L msgs; bool first = true; while (licensed() && sysNow() < start+longPollMaxWait) { int as = conv.archiveSize(); msgs = cloneSubList(conv.msgs, a-as); bool newDialog = a <= as; if (empty(msgs)) { if (first) { print("Long poll starting on " + cookie + ", " + a + "/" + a); first = false; } sleep(longPollTick); } else { if (first) print("Long poll ended."); new StringBuilder buf; renderMessages(buf, msgs); if (a != 0 && anyInterestingMessages(msgs, workerMode)) buf.append(hscript( "window.playChatNotification();\n" + "window.setTitleStatus(" + jsQuote((workerMode ? "User" : botName) + " says…") + ");" )); ret withHeader("\n" + buf); } } ret withHeader(""); } { lock dbLock(); processParams(params); S html = loadSnippet(templateID); // TODO: cache S workerModeParam = workerMode ? "workerMode=1&" : ""; S langlinks = ""; if (html.contains(langlinks)) html = html.replace(langlinks, ahref(rawLink("eng"), "English") + " | " + ahref(rawLink("deu"), "German")); html = html.replace("#BOTIMG#", imageSnippetURLOrEmptyGIF(chatHeaderImageID)); html = html.replace("#N#", "0"); html = html.replace("#INCREMENTALURL#", baseLink + "/incremental?" + workerModeParam + "a="); html = html.replace("#MSGURL#", baseLink + "/msg?" + workerModeParam + "message="); html = html.replace("#CSS_ID#", psI_str(cssID)); if (nempty(params.get("debug"))) html = html.replace("var showActions = false;", "var showActions = true;"); html = html.replace("#AUTOOPEN#", jsBool(workerMode || botAutoOpen()); html = html.replace("#BOT_ON#", jsBool(botOn()); html = html.replace("$HEADING", heading); html = html.replace("", ""); html = hreplaceTitle(html, heading); if (eqGet(params, "_botDemo", "1")) ret hhtml(hhead( htitle(heading) + loadJQuery() ) + hbody(hjavascript(html))); else ret withHeader(subBot_serveJavaScript(html)); } } svoid addReplyToConvo(Conversation conv, IF0 think) { S reply = ""; Out out = null; pcall { reply = think!; out = (Out) quickImport(getThreadLocal(thoughtBot, "out")); } Msg msg = new Msg(false, reply); msg.out = out; conv.add(msg); } sO withHeader(S html) { ret withHeader(subBot_noCacheHeaders(subBot_serveHTML(html))); } sO withHeader(O response) { call(response, 'addHeader, "Access-Control-Allow-Origin", "*"); ret response; } sS renderMessageText(S text, bool htmlEncode) { text = trim(text); if (htmlEncode) text = htmlEncode2(text); text = nlToBr(text); ret replace(text, ":wave:", html_wavingHand()); } svoid renderMessages(StringBuilder buf, L msgs) { new Set buttonsToSkip; new LS buttonsHtml; for (Msg m : msgs) { if (!m.fromUser && eq(m.text, "-")) continue; S html = renderMessageText(m.text, shouldHtmlEncodeMsg(m)); // pull back & cancel buttons to beginning of msg if (m == last(msgs) && m.out != null) { fOr (S btn : m.out.buttons) if (specialButtons.contains(btn)) { buttonsToSkip.add(btn); buttonsHtml.add(renderButtons(ll(btn))); } } if (nempty(buttonsHtml) && l(m.out.buttons) == l(buttonsToSkip)) html += " " + hspan("  ", class := "chat-button-span") + lines(buttonsHtml); else buttonsToSkip.clear(); appendMsg(buf, m.fromUser ? defaultUserName() : botName, formatTime(m.time), html, !m.fromUser); } appendButtons(buf, last(msgs).out, buttonsToSkip); } svoid appendMsg(StringBuilder buf, S name, S time, S text, bool bot) { S imgID = bot ? botImageID : userImageID; if (newDesign) { if (bot) { buf.append([[]]); if (nempty(imgID)) buf.append([[
]] .replace("$IMG", snippetImgLink(imgID))); buf.append([[$BOT says:]].replace("$BOT", botName)); buf.append(text); buf.append([[
]]); } else buf.append([[ You say: $TEXT ]].replace("$TEXT", text); } else { // old design buf.append(([[
]]); if (nempty(imgID)) buf.append([[message user image]] .replace("$IMG", snippetImgLink(imgID))); buf.append([[
$TIME
$NAME
$TEXT
]].replace("$LR", bot != botOnRight ? "left" : "right") .replace("$NAME", name) .replace("$TIME", time) .replace("$TEXT", text)); } } sS replaceButtonText(S s) { if (eqicOneOf(s, "back", "zurück")) ret unicode_undoArrow(); if (eqicOneOf(s, "cancel", "Abbrechen")) ret unicode_crossProduct(); ret s; } sS renderButtons(LS buttons) { new LS out; for i over buttons: { S code = buttons.get(i); S text = replaceButtonText(code); out.add(hbuttonOnClick_returnFalse(text, "submitAMsg(" + jsQuote(text) + ")", class := "chatbot-choice-button", title := eq(code, text) ? null : code)); if (!specialButtons.contains(code) && i+1 < l(buttons) && specialButtons.contains(buttons.get(i+1))) out.add("  "); } ret lines(out); } svoid appendButtons(StringBuilder buf, Out out, Set buttonsToSkip) { S placeholder = out == null ? "" : unnull(out.placeholder); S defaultInput = out == null ? "" : unnull(out.defaultInput); buf.append(hscript("chatBot_setInput(" + jsQuote(defaultInput) + ", " + jsQuote(placeholder) + ");")); if (out == null) ret; LS buttons = listMinusSet(out.buttons, buttonsToSkip); if (empty(buttons)) ret; S buttonsHtml = renderButtons(buttons); buf.append([[
$BUTTONS
]].replace("$BUTTONS", htmlEncode2_nlToBr(appendNewLineIfNempty(trim(out.buttonsIntro))) + buttonsHtml)); } svoid appendDate(StringBuilder buf, S date) { buf.append([[
DATE
]].replace("DATE", date)); } static bool lastUserMessageWas(Conversation conv, S message) { Msg m = last(conv.msgs); ret m != null && m.fromUser && eq(m.text, message); } sS makeReply(S message) { ret answer(message); } sS formatTime(long time) { ret timeInTimeZoneWithOptionalDate_24(timeZone, time); } sS formatDialog(S id, L msgs) { new L lc; for (Msg m : msgs) lc.add(htmlencode((m.fromUser ? "> " : "< ") + m.text)); ret id + ul(lc); } static Conversation getConv(fS cookie) { ret withDBLock(func -> Conversation { uniq(Conversation, +cookie) }); } sS serveAuthForm(S redirect) { ret hhtml(hhead(htitle("Authorization required")) + hbody(hfullcenter( h3_htmlEncode(heading + " Admin") + hpostform( hhidden(+redirect) + "Password: " + hpassword('pw) + "

" + hsubmit())))); } sS errorMsg(S msg) { ret hhtml(hhead_title("Error") + hbody(hfullcenter(msg + "

" + ahref(jsBackLink(), "Back")))); } sS defaultUserName() { ret de() ? "Sie" : "You"; } sbool botOn() { ret isTrue(call(dbBot(), "botOn")); } sbool botAutoOpen() { ret isTrue(call(dbBot(), "botAutoOpen")); } // TODO: handle deletion properly sS serveWorkersAdmin(S uri, SS params) { HCRUD_Concepts data = new HCRUD_Concepts(Worker); HCRUD crud = new(rawLink("workers-admin"), data) { S frame(S title, S contents) { ret hhtml(hhead_title(title) + hbody( p(ahref(rawBotLink(dbBotID), "<< back to admin")) + h1(title) + contents)); } }; ret crud.renderPage(params); } sS serveWorkerPage(AuthedDialogID auth, Conversation conv, S uri, SS params) { S cookie = conv.cookie; if (nempty(params.get('workerLogOut))) cset(auth, loggedIn := null); S loginID = params.get('workerLogIn); if (nempty(loginID)) cset(auth, loggedIn := getConcept Worker(parseLong(loginID))); Map map = prependEmptyOptionForHSelect(mapToOrderedMap(conceptsSortedByFieldCI(Worker, 'loginName), w -> pair(w.id, w.loginName))); if (auth.loggedIn == null) ret hsansserif() + p("You are not logged in as a worker") + hpostform( "Log in as: " + hselect("workerLogIn", map, conceptID(auth.loggedIn)) + " " + hsubmit("OK"), action := rawLink("worker")); // We are logged in if (eq(afterLastSlash(uri), "conversation")) { if (conv == null) ret "Conversation not found"; // Serve the checkbox & the JavaScript S onOffURL = rawLink("worker/botOnOff" + hquery(cookie := conv.cookie) + "&on="); ret hsansserif() + loadJQuery() + hhidden(cookie := conv.cookie) // for other frame + p(hcheckbox("botOn", conv.botOn, onclick := "$.get(" + jsQuote(onOffURL) + "+ this.checked)") + " Bot on") + hscriptsrc(rawLink(hquery(workerMode := 1, cookie := conv.cookie))); } if (eq(afterLastSlash(uri), "conversations")) { long pingThreshold = now()-activeConversationTimeout(); L convos = sortByCalculatedFieldDesc(c -> c.lastMsgTime(), conceptsWithFieldGreaterThan Conversation(lastPing := pingThreshold)); ret hsansserif() + hrefresh(10.0) + h3(botName) + hpostform( "Logged in as " + htmlEncode2(auth.loggedIn.loginName) + " (display name: " + htmlEncode2(auth.loggedIn.displayName) + ")" + hhidden('workerLogOut := 1) + " " + hsubmit("Log out"), target := "_top", action := rawLink("worker")) + h3("Active conversations") /*+ ul(map(convos, c -> { Msg last = last(c.msgs); S status; Worker worker = c.worker; if (worker != null) status = "claimed by " + htmlEncode2(worker.loginName); else if (!c.botOn) status = "bot off"; else status = "bot on"; ret ahref(rawLink("worker/conversation" + hquery(cookie := c.cookie)), "IP: " + c.ip, target := "conversation") + + ", " + status + ", last message: " + (last == null ? "-" : htmlEncode2(quote(shorten(last.text, 40)))); }))*/ + hcss_responstable() + tag table( hsimpletableheader("IP", "Country", "Bot status", "Last change", "Last messages") + mapToLines(convos, c -> { L lastMsgs = lastTwo(c.msgs); S style = c == conv ? "background: #90EE90" : null; ret tag tr( //td(ahref(rawLink("worker/conversation" + hquery(cookie := c.cookie)), c.ip, target := "conversation")) + td(ahref(rawLink("worker" + hquery(cookie := c.cookie)), c.ip, target := "_top")) + td(getCountry(c)) + td(c.botOn ? "Bot on" : "Bot off") + td(renderHowLongAgo(c.lastMsgTime())) + td(joinWithBR(lambdaMap renderMsgForWorkerChat(lastMsgs))), +style); }), class := "responstable"); } ret hhtml(hhead_title("Worker Chat [" + auth.loggedIn.loginName + "]") + hframeset_cols("*,*", tag frame("", name := "conversations", src := rawLink("worker/conversations" + hquery(+cookie))) + tag frame("", name := "conversation", src := conv == null ? null : rawLink("worker/conversation" + hquery(+cookie))))); } static long activeConversationTimeout() { ret longPollMaxWait+activeConversationSafetyMargin; } concept AuthedDialogID { S dialogID; Worker loggedIn; // who is logged in with this cookie } static AuthedDialogID authObject() { ret conceptWhere AuthedDialogID(dialogID := getDialogID()); } sbool anyInterestingMessages(L msgs, bool workerMode) { ret any(msgs, m -> m.fromUser == workerMode); } sS renderMsgForWorkerChat(Msg msg) { ret (msg.fromWorker != null ? htmlEncode2(msg.fromWorker.displayName) : msg.fromUser ? "User" : "Bot") + ": " + b(htmlEncode2If(shouldHtmlEncodeMsg(msg), msg.text)); } sbool shouldHtmlEncodeMsg(Msg msg) { ret msg.fromUser; } sS getCountry(Conversation c) { if (empty(c.country) && nempty(c.ip)) cset(c, country := ipToCountry2020_safe(c.ip)); ret or2(c.country, "?"); }