Show operator presence in admin chats

This commit is contained in:
rajchales-monito
2026-07-30 12:10:53 +02:00
parent ce560dcf4f
commit 9a0f7dd224
3 changed files with 151 additions and 3 deletions
+59 -1
View File
@@ -43,6 +43,7 @@ initDb();
seedDefaults();
const streams = new Map();
const operatorPresence = new Map();
const server = http.createServer(async (req, res) => {
try {
@@ -112,6 +113,7 @@ async function adminApi(req, res, url) {
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/messages$/) && req.method === "POST") return createAdminMessage(req, res, url);
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/status$/) && req.method === "PATCH") return updateConversationStatus(req, res, url);
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/typing$/) && req.method === "POST") return adminTyping(req, res, url);
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/presence$/) && req.method === "POST") return adminPresence(req, res, url);
return json(res, 404, { error: "not_found" });
}
@@ -967,10 +969,65 @@ async function visitorTyping(req, res, url) {
async function adminTyping(req, res, url) {
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
publishConversation(conversation, { type: "typing", actor: "admin", conversationId: conversation.public_id });
touchOperatorPresence(conversation.public_id, req.adminUser);
publishConversation(conversation, {
type: "typing",
actor: "admin",
conversationId: conversation.public_id,
username: req.adminUser.username,
name: operatorDisplayName(req.adminUser)
});
publish("admin", {
type: "operator:presence",
conversationId: conversation.public_id,
operators: currentOperatorPresence(conversation.public_id)
});
return json(res, 200, { ok: true });
}
async function adminPresence(req, res, url) {
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
touchOperatorPresence(conversation.public_id, req.adminUser);
const operators = currentOperatorPresence(conversation.public_id);
publish("admin", { type: "operator:presence", conversationId: conversation.public_id, operators });
return json(res, 200, { ok: true, operators });
}
function touchOperatorPresence(conversationId, adminUser) {
cleanupOperatorPresence();
const username = adminUser.username || "operator";
operatorPresence.set(`${conversationId}:${username}`, {
conversationId,
username,
name: operatorDisplayName(adminUser),
lastSeenAt: Date.now()
});
}
function operatorDisplayName(adminUser = {}) {
return adminUser.chatNick || adminUser.displayName || adminUser.username || "Operator";
}
function currentOperatorPresence(conversationId) {
cleanupOperatorPresence();
return [...operatorPresence.values()]
.filter((item) => item.conversationId === conversationId)
.sort((a, b) => a.name.localeCompare(b.name))
.map((item) => ({
username: item.username,
name: item.name,
lastSeenAt: new Date(item.lastSeenAt).toISOString()
}));
}
function cleanupOperatorPresence() {
const now = Date.now();
for (const [key, item] of operatorPresence.entries()) {
if (now - item.lastSeenAt > 45_000) operatorPresence.delete(key);
}
}
function eventStream(req, res, url, channel) {
const key = channel === "admin" ? "admin" : `conversation:${publicIdFrom(url)}`;
res.writeHead(200, {
@@ -1731,6 +1788,7 @@ function conversationDetails(conversation) {
const attachments = db.prepare("SELECT * FROM attachments WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);
return {
conversation: formatConversationSummary(conversation),
operators: currentOperatorPresence(conversation.public_id),
messages: messages.map((message) => ({
id: message.id,
senderType: message.sender_type,