// a human who answers to clients concept Worker { S loginName, displayName; //S password; bool available; long lastOnline; // recent timestamp if online sS _fieldOrder = "loginName displayName"; S renderAsHTML() { ret htmlEncode2(loginName + " (display name: " + displayName + ")"); } } sclass WorkerChat { int workerLongPollTick = 200; int workerLongPollMaxWait = 1000*30; long lastWorkerRequested; // timestamp for notification sound O html(S uri, SS params, Conversation conv, AuthedDialogID auth) null { S uri2 = appendSlash(uri); bool requestAuthed = auth != null; if (startsWith(uri2, "/workers-admin/")) { if (!requestAuthed) ret serveAuthForm(params.get('uri)); ret serveWorkersAdmin(uri, params); } if (startsWith(uri2, "/worker/")) { if (!subBot_isHttps()) ret subBot_serveRedirect("https://" + domain() + fullSelfLink(params)); if (!requestAuthed) ret serveAuthForm(params.get('uri)); if (nempty(params.get("turnBotOn"))) conv.turnBotOn(); ret serveWorkerPage(auth, conv, uri, params); } } // TODO: handle deletion properly S serveWorkersAdmin(S uri, SS params) { S nav = p(ahref(rawBotLink(dbBotID), "Main admin") + " | " + ahref(baseLink + "/workers-admin", "Workers admin")); if (eq(uri, "/workers-admin/change-image")) { long id = parseLong(params.get("id")); Worker worker = getConcept Worker(id); File f = workerImageFile(id); S content; S b64 = params.get("base64"); if (nempty(b64)) saveFile(f, decodeBASE64(b64)); if (worker == null) content = "Worker not found"; else content = nav + hscript([[ function submitIt() { var file = $('#myUpload')[0].files[0]; var reader = new FileReader(); reader.onloadend = function () { var b64 = reader.result.replace(/^data:.+;base64,/, ''); $("#base64").val(b64); console.log("Got base64 data: " + b64.length); $("#submitForm").submit(); }; reader.readAsDataURL(file); return false; } ]]) + h2("Worker image for: " + worker.renderAsHTML()) + p(!fileExists(f) ? "No image set" : himgsrc(rawLink("worker-image/" + id))) + hpostform( hhiddenWithIDAndName("base64") + "Choose image: " + hfileupload("accept", "image/png,image/jpeg,image/gif", id := "myUpload") + "

" + "Note: Image should be square. Will be scaled to 40x40px in the chat" + hhidden(+id), action := rawLink("/workers-admin/change-image"), id := "submitForm") + hbuttonOnClick_returnFalse("Upload", "submitIt()"); ret hhtml(hhead_title("Change worker image") + hsansserif() + hbody(loadJQuery() + content)); } HCRUD_Concepts data = new HCRUD_Concepts(Worker); HCRUD crud = new(rawLink("workers-admin"), data) { S frame(S title, S contents) { ret hhtml(hhead_title_htmldecode(title) + hbody( nav + h1(title) + contents)); } }; crud.unshownFields = litset("available", "lastOnline", "away"); crud.postProcessTableRow = (item, rendered) -> { printStruct(+item); long id = parseLong(item.get("id")); File f = workerImageFile(id); ret mapPlus(rendered, "Image" := f == null ? "???" : !fileExists(f) ? "-" : himgsrc(rawLink("worker-image/" + id))); }; crud.renderCmds = item -> crud.renderCmds_base(item) + " | " + ahref(rawLink("workers-admin/change-image" + hquery(id := item.get("id"))), "Change image..."); crud.tableClass = "responstable"; ret hsansserif() + hcss_responstable() + crud.renderPage(params); } // auth is tested before we get here S serveWorkerPage(AuthedDialogID auth, Conversation conv, S uri, SS params) { S cookie = conv.cookie; S uri2 = afterLastSlash(uri); if (eq(uri2, "availableWorkers")) ret "Available workers: " + or2(joinWithComma(map(workersAvailable(), w -> w.renderAsHTML())), "-"); if (nempty(params.get('workerLogOut))) cset(auth, loggedIn := null); if (auth.loggedIn != null && nempty(params.get('workerAvailableBox))) if (cset_trueIfChanged(auth.loggedIn, available := nempty(params.get('workerAvailable)))) noteConversationChange(); // update list of available workers if (nempty(params.get("acceptConversation"))) { if (conv.worker == null) { // only if not accepted by anyone cset(conv, worker := auth.loggedIn); conv.turnBotOff(); } } 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(uri2, "conversation")) { if (conv == null) ret "Conversation not found"; // Serve the checkbox & the JavaScript S onOffURL = rawLink("worker/botOnOff" + hquery(+cookie) + "&on="); ret hsansserif() + loadJQuery() + hhidden(cookie := conv.cookie) // for other frame + hpostform( hhidden(+cookie) + p(renderBotStatus(conv)) + p(conv.botOn ? hsubmit("Accept conversation", name := "acceptConversation") : hsubmit("Turn bot back on", name := "turnBotOn")) , action := rawLink("worker/innerFrameSet"), target := "innerFrameSet") // include bot + hscriptsrc(rawLink(hquery(workerMode := 1, cookie := conv.cookie))); } if (eq(uri2, "conversations")) { cset(auth.loggedIn, lastOnline := now()); bool poll = eq("1", params.get("poll")); // poll mode? S content = ""; if (poll) { long seenChange = parseLong(params.get("lastChange")); vmBus_send chatBot_startingWorkerPoll(mc(), conv); long start = sysNow(); L msgs; bool first = true; while (licensed() && sysNow() < start+workerLongPollMaxWait && lastConversationChange == seenChange) sleep(workerLongPollTick); printVars_str(+lastWorkerRequested, +seenChange); if (lastWorkerRequested > seenChange) content = hscript([[ window.parent.parent.frames[0].sendDesktopNotification("A worker is requested!", { action: function() { window.focus(); } }); window.parent.parent.frames[0].playWorkerRequestedSound(); ]]); // if poll times out, send update anyway to update time calculations } long pingThreshold = now()-activeConversationTimeout(); L convos = sortByCalculatedFieldDesc(c -> c.lastMsgTime(), conceptsWithFieldGreaterThan Conversation(lastPing := pingThreshold)); content += hhiddenWithID(+lastConversationChange) + tag table( hsimpletableheader("IP", "Country", "Bot/worker status", "Last change", "Last messages") + mapToLines(convos, c -> { L lastMsgs = lastTwo(c.msgs); S style = c == conv ? "background: #90EE90" : null; S convLink = rawLink("worker/innerFrameSet" + hquery(cookie := c.cookie)); ret tag tr( td(ahref(convLink, c.ip, target := "innerFrameSet")) + td(getCountry(c)) + td(renderBotStatus(c)) + td(renderHowLongAgo(c.lastMsgTime())) + td(ahref(convLink, hparagraphs(lambdaMap renderMsgForWorkerChat(lastMsgs)), target := "innerFrameSet", style := "text-decoration: none")), +style, /*class := "clickable-row", "data-href" := convLink*/); }), class := "responstable"); if (poll) ret content; S incrementalURL = rawLink("worker/conversations?poll=1&lastChange="); ret hhtml( hhead(hsansserif() + loadJQuery() + hscript_clickableRows()) + hbody(h3(botName) + hpostform( "Logged in as " + htmlEncode2(auth.loggedIn.loginName) + " (display name: " + htmlEncode2(auth.loggedIn.displayName) + ")" + hhidden(workerAvailableBox := 1) + "   " + hcheckboxWithText("workerAvailable", "I am available", auth.loggedIn.available, onclick := "form.submit()") + "   " + hsubmit("Log out", name := "workerLogOut"), target := "innerFrameSet", action := rawLink("worker/innerFrameSet")) + p("Available workers: " + b(or2(joinWithComma( map(workersAvailable(), w -> w.displayName)), "none"))) + h3("Active conversations") + hcss_responstable() + hdivWithID("contentArea", content) + hscript([[ function poll_start() { var lastChange = $("#lastConversationChange").val(); if (!lastChange) setTimeout(poll_start, 1000); else { var url = "#INCREMENTALURL#" + lastChange; console.log("Loading " + url); $.get(url, function(src) { if (src.match(/^ERROR/)) console.log(src); else { console.log("Loaded " + src.length + " chars"); $("#contentArea").html(src); } setTimeout(poll_start, 1000); }, 'text') .fail(function() { console.log("Rescheduling after fail"); setTimeout(poll_start, 1000); }); } } poll_start(); ]].replace("#INCREMENTALURL#", incrementalURL) )); } // end of worker/conversations part if (eq(uri2, "notificationArea")) ret hhtml( hhead(hsansserif() + loadJQuery()) + hbody(hdesktopNotifications() + div(small( span(hbutton("CLICK HERE to enable notification sounds!"), id := "enableSoundsBtn") + " | " + span("", id := "notiStatus")), style := "float: right") + hscript([[ function enableSounds() { document.removeEventListener('click', enableSounds); $("#enableSoundsBtn").html("Notification sounds enabled"); } document.addEventListener('click', enableSounds); if (window.workerRequestedSound == null) { console.log("Loading worker requested sound"); window.workerRequestedSound = new Audio("https://botcompany.de/files/1400404/worker-requested.mp3"); } function playWorkerRequestedSound() { console.log("Playing worker requested sound"); window.workerRequestedSound.play(); } window.playWorkerRequestedSound = playWorkerRequestedSound; ]]))); if (eq(uri2, "innerFrameSet")) // serve frame set 2 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))))); // serve frame set 1 ret hhtml(hhead_title("Worker Chat [" + auth.loggedIn.loginName + "]") + hframeset_rows("50,*", tag frame("", name := "notificationArea", src := rawLink("worker/notificationArea")) + tag frame("", name := "innerFrameSet", src := conv == null ? null : rawLink("worker/innerFrameSet" + hquery(+cookie))))); } S renderMsgForWorkerChat(Msg msg) { ret (msg.fromWorker != null ? htmlEncode2(msg.fromWorker.displayName) : msg.fromUser ? "User" : "Bot") + ": " + b(htmlEncode2If(shouldHtmlEncodeMsg(msg), msg.text)); } Cl workersAvailable() { long timestamp = now()-workerLongPollMaxWait-10000; ret filter(list(Worker), w -> w.available && w.lastOnline >= timestamp); } bool anyWorkersAvailable() { ret nempty(workersAvailable()); } S renderBotStatus(Conversation conv) { ret "Bot is " + b(conv.botOn ? "on" : "off") + "
" + "Assigned worker: " + b(conv.worker == null ? "none" : conv.worker.displayName); } }