dooris/app/src/assets/chat.ts
kritzl 68423c4c49
All checks were successful
Build Container / Build Container (push) Successful in 4m25s
add special to activate meowris
2026-09-29 16:33:45 +02:00

458 lines
No EOL
14 KiB
TypeScript

import {Bot, createIcons, UserRound, Send, Stars} from "lucide"
import {useTranslations} from "../i18n/utils.ts"
import removeDiacritics from "./removeDiacritics.ts"
export type ResponseDictType = {
trigger?: Array<string>;
startsWith?: Array<string>;
endsWith?: Array<string>;
response: string;
errors?: Record<string, string>
priority: number;
special?: string;
}
const lang = window.lang
const doors = window.doors
const auth = window.auth
const doorAction = window.doorAction
const t = useTranslations(lang)
const responses =
lang === "de"
? (await import("./chatDict_de")).default
: lang === "uwu"
? (await import("./chatDict_uwu")).default
: lang === "meow"
? (await import("./chatDict_meow")).default
: (await import("./chatDict_en")).default
const sortedResponses = responses.sort(
(a, b) => b.priority - a.priority)
const chatAvatar = `
<div class="chat-image avatar">
<div class="w-10 rounded-full ${lang === 'uwu' ? 'bg-transparent' : 'bg-primary'} flex items-center justify-center">
${lang === 'uwu' ? '<span class="text-4xl">🥺</span>' : '<i data-lucide="bot"></i>'}
</div>
</div>
`
const chatHeader = `
<div class="chat-header">
${t("chat.dooris")}
</div>
`
const chatAvatarUser = `
<div class="chat-image avatar">
<div class="w-10 rounded-full ${lang === 'uwu' ? 'bg-transparent' : 'bg-secondary'} flex items-center justify-center">
${lang === 'uwu' ? '<span class="text-4xl">🥺</span>' : '<i data-lucide="user-round"></i>'}
</div>
</div>
`
const chatHeaderUser = `
<div class="chat-header">
${t("chat.user")}
</div>
`
const chatWait = `
<div class="chat chat-start w-full prose" data-waiting>
${chatAvatar}
${chatHeader}
<div class="chat-bubble chat-bubble-primary whitespace-normal min-h-10">
<span class="loading loading-dots loading-md"></span>
</div>
</div>
`
const templateInjectChat = `
<details class="dropdown dropdown-top dropdown-end absolute right-2 bottom-2" id="chat-root">
<summary class="btn btn-lg btn-circle btn-primary me-2 mb-2">
<i data-lucide="stars"></i>
</summary>
<div class="dropdown-content card h-140 w-90 bg-base-300 p-4 mb-4" style="max-height: min(calc(100vh - 10rem), 50rem); max-width: calc(100vw - 1rem)">
<div class="w-full overflow-y-auto h-full prose leading-5 grow pb-4" id="chat"></div>
<div class="join mt-auto w-full">
<div class="w-full">
<label class="input join-item">
<input type="text" id="chat-text" />
</label>
</div>
<button class="btn btn-secondary join-item" id="send-chat">
<i data-lucide="send"></i>
</button>
</div>
</div>
</details>
`
type ChatMessage = {
type: "bot" | "user",
message: string,
special?: boolean;
id?: string;
fresh?: boolean;
}
const chat: Array<ChatMessage> = []
const chatWaiting = {
value: false,
}
const timeouts: {
wait: number | null;
message: number | null;
} = {
wait: null,
message: null,
}
let chatId: number = 0
function refreshChat() {
const templateBot = (id: string, message: string, special: boolean) => {
const chatContainer = document.createElement("div")
const chatBubble = document.createElement("div")
chatBubble.classList.add("chat-bubble", "chat-bubble-primary", "whitespace-normal", "min-h-10", "break-[break-word]")
chatBubble.style.wordBreak = "break-word"
chatBubble.classList.toggle("shimmer", special)
chatBubble.dataset.content = message
chatContainer.classList.add("chat", "chat-start", "w-full", "overflow-visible", "hidden", "pe-10")
chatContainer.id = id
chatContainer.insertAdjacentHTML("afterbegin", chatAvatar)
chatContainer.insertAdjacentHTML("afterbegin", chatHeader)
chatContainer.append(chatBubble)
return chatContainer
}
const templateUser = (message: string) => `
<div class="chat chat-end w-full ps-10">
${chatAvatarUser}
${chatHeaderUser}
<div class="chat-bubble chat-bubble-secondary whitespace-normal" style="word-break: break-word;">${message}</div>
</div>`
const chatBubbleIds: Array<string> = []
console.log(chat)
chat.forEach(chatItem => {
switch (chatItem.type) {
case "bot":
if (!chatItem.id) {
chatItem.id = `chat-${chatId}`
chatId++
chatElement?.appendChild(templateBot(chatItem.id, chatItem.message, chatItem.special ?? false))
chatBubbleIds.push(chatItem.id)
}
break
case "user":
if (!chatItem.fresh) {
chatItem.fresh = true
chatElement?.insertAdjacentHTML("beforeend", templateUser(chatItem.message))
}
break
}
})
typeBubble(chatBubbleIds)
chatElement.querySelector("[data-waiting]")?.remove()
if (chatWaiting.value) {
chatElement.insertAdjacentHTML("beforeend", chatWait)
}
chatElement.scrollTo({
top: chatElement.scrollHeight,
})
refreshIcons()
}
function typeBubble(ids: Array<string>) {
if (!ids.length) return
console.log(ids)
const firstId = ids.slice(0, 1)[0]
const restIds = ids.slice(1)!
const chatContainer = document.getElementById(firstId)!
chatContainer.classList.remove('hidden')
const chatBubble: HTMLDivElement = chatContainer.querySelector('.chat-bubble')!
const tmpDiv = document.createElement("div")
tmpDiv.innerHTML = chatBubble.getAttribute("data-content") || ""
const typingArray: Array<string> = []
tmpDiv.childNodes.forEach(child => {
// check if textNode
if (child.nodeType === 3) {
typingArray.push(...(child.textContent?.split("")) ?? [])
} else {
typingArray.push((child as Element).outerHTML)
}
})
let text = ""
let charIndex = 0
const typingSpeed = 25
function typeChar() {
if (charIndex < typingArray.length) {
text += typingArray.at(charIndex)
chatBubble.innerHTML = text
charIndex++
setTimeout(typeChar, typingSpeed)
chatElement.scrollTo({
top: chatElement.scrollHeight,
})
} else if (restIds.length > 0) {
typeBubble(restIds)
}
}
typeChar()
}
function sendMessage() {
const chatTextElement: HTMLTextAreaElement = document.querySelector("#chat-text")!
const text = chatTextElement.value
chatTextElement.value = ""
if (text.trim() === "")
return
chat.push({
type: "user",
message: text,
})
refreshChat()
timeouts.wait !== null && clearTimeout(timeouts.wait)
timeouts.message !== null && clearTimeout(timeouts.message)
timeouts.wait = setTimeout(() => {
chatWaiting.value = true
refreshChat()
}, 500)
timeouts.message = setTimeout(() => {
chatWaiting.value = false
getResponse(text)
refreshChat()
}, 2000)
}
function getResponse(message: string) {
const msg = removeDiacritics(message.trim().normalize()).toLowerCase()
const matchingResponses: Array<{
message: string;
priority: number;
special?: boolean;
}> = []
let highestMatchingPriority = 0
for (const response of sortedResponses) {
let match = false
for (const trigger of response.trigger ?? []) {
if (msg.includes(trigger)) match = true
}
for (const start of response.startsWith ?? []) {
if (msg.startsWith(start)) match = true
}
for (const end of response.endsWith ?? []) {
if (msg.endsWith(end)) match = true
}
if (match) {
if (response.priority > highestMatchingPriority) highestMatchingPriority = response.priority
if (response.special) {
// clear previous matches
while (matchingResponses.length) {
matchingResponses.pop()
}
if (response.special === "unlock" || response.special === "lock") {
let matchedDoors = []
const action = response.special
let doorListElement = `<ul>`
for (let door of doors) {
if (msg.includes(door.label.toLowerCase())) {
matchedDoors.push(door)
}
doorListElement += `<li>${door.label}</li>`
}
doorListElement += `</ul>`
let message = ""
if (matchedDoors.length == 0 || !auth.authorized || !auth.authenticated) {
if (matchedDoors.length == 0) {
message = response.errors ? response.errors["unknownDoor"].replace("{doors}", doorListElement) : ""
}
if (doors.length == 0) {
message = response.errors ? response.errors["noDoors"] : ""
}
if (!auth.authorized) {
message = response.errors ? response.errors["unauthorized"] : ""
}
if (!auth.authenticated) {
message = response.errors ? response.errors["unauthenticated"] : ""
}
} else {
matchedDoors.forEach(door => {
doorAction(action, door.id)
})
message = response.response.replace("{door}", matchedDoors
.map(door => `<span class="badge">${door.label}</span>`)
.join())
}
matchingResponses.push({
message: message,
priority: response.priority,
special: true,
})
}
if (response.special === "uwu") {
setTimeout(() => {
window.location.href = "/uwu"
}, 1000)
matchingResponses.push({
message: response.response,
priority: response.priority,
special: true,
})
}
if (response.special === "meow") {
setTimeout(() => {
window.location.href = "/meow"
}, 1000)
matchingResponses.push({
message: response.response,
priority: response.priority,
special: true,
})
}
if (response.special === "logout") {
if (auth.authenticated) {
setTimeout(() => {
window.location.href = "/auth/logout?next=/"
}, 1000)
matchingResponses.push({
message: response.response,
priority: response.priority,
special: true,
})
} else {
matchingResponses.push({
message: response.errors ? response.errors["unauthenticated"] : "",
priority: response.priority,
special: true,
})
}
}
// stop searching for more matches
break
} else {
matchingResponses.push({
message: response.response,
priority: response.priority,
})
}
}
}
if (matchingResponses.length > 0) {
const matchingMessages = matchingResponses
.filter(response => response.priority == highestMatchingPriority)
const chatMessages = matchingMessages.map(response => {
return {
type: "bot" as ChatMessage["type"],
message: response.message,
special: response.special,
}
})
chat.push(...chatMessages)
} else {
chat.push({
type: "bot",
message: sortedResponses.at(-1)?.response ?? "",
})
}
}
document.querySelector("body")?.insertAdjacentHTML("beforeend", templateInjectChat)
const chatElement: HTMLDivElement = document.querySelector("#chat")!
function initChat() {
chat.push({
type: "bot",
message: sortedResponses.at(-2)?.response ?? "",
})
refreshChat()
}
let firstOpen = true
function refreshIcons() {
createIcons({
nameAttr: "data-lucide",
root: document.querySelector("#chat-root")!,
icons: {
Stars,
Send,
UserRound,
Bot,
},
})
}
refreshIcons()
document.querySelector("#send-chat")!.addEventListener("click", sendMessage)
document.querySelector("#chat-text")!.addEventListener("keydown", (e: Event) => {
if ((e as KeyboardEvent).key === "Enter") {
sendMessage()
}
})
document.querySelector("#chat-root")!.addEventListener("click", () => {
if (firstOpen) {
firstOpen = false
chatWaiting.value = true
refreshChat()
timeouts.message = setTimeout(() => {
chatWaiting.value = false
initChat()
}, 1000)
}
})