mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
feat(dashboard): guild, team and whisper chat histories
Guild chat per guild (/chat_log/guild/<id>, from the guild's card), team chat by team (/chat_log/teams) and a character's whispers by conversation (/characters/<id>/whispers, chat_dms; opening one is audited), paged on the server. The Chat Log filters by account and time range, shows the conversation around a message, links to its history and marks flagged messages; messages can be picked (shift-click for a range) to flag. The character page links to all of them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ set(DASHBOARDROUTES_SOURCES
|
||||
"LeaderboardRoutes.cpp"
|
||||
"VanityRoutes.cpp"
|
||||
"ChatRoutes.cpp"
|
||||
"ChatHistory.cpp"
|
||||
"Strikes.cpp"
|
||||
"Inspector.cpp"
|
||||
"CDClientBrowser.cpp"
|
||||
|
||||
121
dDashboardServer/routes/ChatHistory.cpp
Normal file
121
dDashboardServer/routes/ChatHistory.cpp
Normal file
@@ -0,0 +1,121 @@
|
||||
#include "ChatHistory.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace ChatHistory {
|
||||
bool CanRead(const std::string& channel, const Access& access) {
|
||||
if (channel == "zone" || channel == "web") return true;
|
||||
if (channel == "team" || channel == "guild") return access.group;
|
||||
if (channel == "whisper") return access.whispers;
|
||||
return false;
|
||||
}
|
||||
|
||||
nlohmann::json MessageJson(const IChatLog::ChatMessage& m, const Access& access) {
|
||||
const bool readable = CanRead(m.channel, access);
|
||||
return { {"id", m.id}, {"time", m.time}, {"channel", m.channel}, {"sender_id", std::to_string(m.senderId)}, {"sender_name", m.senderName},
|
||||
{"account_id", m.accountId}, {"recipient_id", std::to_string(m.recipientId)}, {"recipient_name", m.recipientName},
|
||||
{"zone_id", m.zoneId}, {"instance_id", m.instanceId}, {"clone_id", m.cloneId}, {"message", readable ? m.message : ""},
|
||||
{"blocked", m.blocked}, {"filtered", m.filtered}, {"guild_id", std::to_string(m.guildId)}, {"team_id", std::to_string(m.teamId)},
|
||||
{"redacted", !readable} };
|
||||
}
|
||||
|
||||
IChatLog::ChatQuery ConversationQuery(const IChatLog::ChatMessage& m, const Access& access) {
|
||||
IChatLog::ChatQuery q;
|
||||
q.includePrivate = access.group;
|
||||
q.includeWhispers = access.whispers;
|
||||
q.channel = m.channel;
|
||||
if (m.channel == "zone") {
|
||||
q.zoneId = m.zoneId;
|
||||
q.instanceId = m.instanceId;
|
||||
// Zone 0 would mean any zone
|
||||
if (m.zoneId == 0) q.channel = "none";
|
||||
} else if (m.channel == "whisper") {
|
||||
q.characterId = m.senderId;
|
||||
q.otherCharacterId = m.recipientId;
|
||||
if (m.senderId == 0 || m.recipientId == 0) q.channel = "none";
|
||||
} else if (m.channel == "team") {
|
||||
q.teamId = m.teamId;
|
||||
if (m.teamId == 0) q.channel = "none"; // logged before teams were recorded: no way to tell them apart
|
||||
} else if (m.channel == "guild") {
|
||||
q.guildId = m.guildId;
|
||||
if (m.guildId == 0) q.channel = "none";
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
bool SameConversation(const IChatLog::ChatMessage& a, const IChatLog::ChatMessage& b) {
|
||||
if (a.channel != b.channel) return false;
|
||||
if (a.channel == "zone") return a.zoneId == b.zoneId && a.instanceId == b.instanceId;
|
||||
if (a.channel == "whisper") {
|
||||
return (a.senderId == b.senderId && a.recipientId == b.recipientId) || (a.senderId == b.recipientId && a.recipientId == b.senderId);
|
||||
}
|
||||
if (a.channel == "team") return a.teamId == b.teamId;
|
||||
if (a.channel == "guild") return a.guildId == b.guildId;
|
||||
return true; // web
|
||||
}
|
||||
|
||||
std::optional<std::string> ParseStatus(const std::string& status) {
|
||||
if (status == "open" || status == "actioned" || status == "dismissed") return status;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string StatusAction(const std::string& from, const std::string& to) {
|
||||
if (from == to) return "";
|
||||
if (to == "open") return "reopened";
|
||||
return to;
|
||||
}
|
||||
|
||||
std::string Excerpt(const std::vector<IChatLog::ChatMessage>& messages, const size_t maxBytes) {
|
||||
std::string text;
|
||||
for (const auto& m : messages) {
|
||||
if (!text.empty()) text += " / ";
|
||||
text += m.senderName + ": " + m.message;
|
||||
}
|
||||
if (text.size() <= maxBytes) return text;
|
||||
const std::string ellipsis = "\xE2\x80\xA6"; // …
|
||||
size_t cut = maxBytes >= ellipsis.size() ? maxBytes - ellipsis.size() : 0;
|
||||
// Back up to the start of a UTF-8 character
|
||||
while (cut > 0 && (static_cast<unsigned char>(text[cut]) & 0xC0) == 0x80) cut--;
|
||||
return text.substr(0, cut) + ellipsis;
|
||||
}
|
||||
|
||||
nlohmann::json Snapshot(const std::vector<IChatLog::ChatMessage>& before, const std::vector<IChatLog::ChatMessage>& flagged,
|
||||
const std::vector<IChatLog::ChatMessage>& after) {
|
||||
const Access all{ true, true };
|
||||
nlohmann::json out = nlohmann::json::array();
|
||||
const auto add = [&](const std::vector<IChatLog::ChatMessage>& list, bool isFlagged) {
|
||||
for (const auto& m : list) {
|
||||
auto json = MessageJson(m, all);
|
||||
json.erase("redacted");
|
||||
json["flagged"] = isFlagged;
|
||||
out.push_back(std::move(json));
|
||||
}
|
||||
};
|
||||
add(before, false);
|
||||
add(flagged, true);
|
||||
add(after, false);
|
||||
std::stable_sort(out.begin(), out.end(), [](const nlohmann::json& a, const nlohmann::json& b) { return a.value("id", uint64_t{}) < b.value("id", uint64_t{}); });
|
||||
return out;
|
||||
}
|
||||
|
||||
nlohmann::json RedactSnapshot(const nlohmann::json& snapshot, const Access& access) {
|
||||
nlohmann::json out = nlohmann::json::array();
|
||||
if (!snapshot.is_array()) return out;
|
||||
for (auto message : snapshot) {
|
||||
if (!message.is_object()) continue;
|
||||
const bool readable = CanRead(message.value("channel", ""), access);
|
||||
if (!readable) message["message"] = "";
|
||||
message["redacted"] = !readable;
|
||||
out.push_back(std::move(message));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<IChatLog::ChatMessage> Subject(const std::vector<IChatLog::ChatMessage>& flagged, const LWOOBJID chosen) {
|
||||
if (chosen != 0) {
|
||||
for (const auto& m : flagged) if (m.senderId == chosen) return m;
|
||||
}
|
||||
for (const auto& m : flagged) if (m.senderId != 0) return m;
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
54
dDashboardServer/routes/ChatHistory.h
Normal file
54
dDashboardServer/routes/ChatHistory.h
Normal file
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "IChatLog.h"
|
||||
#include "json.hpp"
|
||||
|
||||
/**
|
||||
* The parts of the chat histories and chat flags that don't touch the database or the web server: who may read which
|
||||
* channel, which chat counts as one conversation, and what a flag keeps. The routes are in ChatRoutes.cpp and
|
||||
* ChatFlagRoutes.cpp.
|
||||
*/
|
||||
namespace ChatHistory {
|
||||
// What a viewer may read besides zone and web chat (chat_view): team and guild chat (chat_private), whispers (chat_dms)
|
||||
struct Access {
|
||||
bool group{};
|
||||
bool whispers{};
|
||||
};
|
||||
|
||||
bool CanRead(const std::string& channel, const Access& access);
|
||||
|
||||
// A message as the dashboard sends it. Text of a channel the viewer may not read is left out (redacted: true).
|
||||
nlohmann::json MessageJson(const IChatLog::ChatMessage& message, const Access& access);
|
||||
|
||||
// The query for the conversation a message belongs to: the same world for zone chat, the same two characters for
|
||||
// whispers, the same team or guild, or chat from the web
|
||||
IChatLog::ChatQuery ConversationQuery(const IChatLog::ChatMessage& message, const Access& access);
|
||||
|
||||
// Whether two messages are in the same conversation (a flag covers messages of one conversation)
|
||||
bool SameConversation(const IChatLog::ChatMessage& a, const IChatLog::ChatMessage& b);
|
||||
|
||||
// open, actioned or dismissed
|
||||
std::optional<std::string> ParseStatus(const std::string& status);
|
||||
|
||||
// The history entry for a status change: actioned, dismissed or reopened (empty when it didn't change)
|
||||
std::string StatusAction(const std::string& from, const std::string& to);
|
||||
|
||||
// "Name: text / Name: text", at most maxBytes (cut between UTF-8 characters, with an ellipsis)
|
||||
std::string Excerpt(const std::vector<IChatLog::ChatMessage>& messages, size_t maxBytes = 500);
|
||||
|
||||
// What a flag keeps: the chat before, the flagged messages (flagged: true) and the chat after, oldest first
|
||||
nlohmann::json Snapshot(const std::vector<IChatLog::ChatMessage>& before, const std::vector<IChatLog::ChatMessage>& flagged,
|
||||
const std::vector<IChatLog::ChatMessage>& after);
|
||||
|
||||
// A flag's copy of the chat for a viewer: text of channels they may not read is left out
|
||||
nlohmann::json RedactSnapshot(const nlohmann::json& snapshot, const Access& access);
|
||||
|
||||
// Who a flag is about: the character given if they sent one of the messages, else the sender of the first message
|
||||
// from a character (web messages have none)
|
||||
std::optional<IChatLog::ChatMessage> Subject(const std::vector<IChatLog::ChatMessage>& flagged, LWOOBJID chosen);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
#include "ChatRoutes.h"
|
||||
|
||||
#include <ctime>
|
||||
#include <map>
|
||||
#include <regex>
|
||||
|
||||
#include "ChatHistory.h"
|
||||
#include "RouteUtils.h"
|
||||
#include "DashboardRoutes.h"
|
||||
#include "PlayerActions.h"
|
||||
@@ -22,33 +24,75 @@ namespace {
|
||||
constexpr uint32_t MAX_PAGE = 500;
|
||||
uint64_t g_LastPushed = 0;
|
||||
|
||||
bool IsPrivate(const std::string& channel) { return channel == "whisper" || channel == "team" || channel == "guild"; }
|
||||
ChatHistory::Access AccessOf(const HTTPContext& context) {
|
||||
return { Can(context, "chat_private"), Can(context, "chat_dms") };
|
||||
}
|
||||
|
||||
nlohmann::json MessageJson(const IChatLog::ChatMessage& m) {
|
||||
nlohmann::json RowJson(const IChatLog::ChatMessage& m, const ChatHistory::Access& access) {
|
||||
const auto& zones = ZoneNames();
|
||||
const auto zone = std::to_string(m.zoneId);
|
||||
return { {"id", m.id}, {"time", m.time}, {"channel", m.channel}, {"sender_id", std::to_string(m.senderId)}, {"sender_name", m.senderName},
|
||||
{"account_id", m.accountId}, {"recipient_id", std::to_string(m.recipientId)}, {"recipient_name", m.recipientName},
|
||||
{"zone_id", m.zoneId}, {"zone_name", zones.contains(zone) ? zones[zone] : nlohmann::json("")}, {"instance_id", m.instanceId},
|
||||
{"clone_id", m.cloneId}, {"message", m.message}, {"blocked", m.blocked} };
|
||||
auto json = ChatHistory::MessageJson(m, access);
|
||||
json["zone_name"] = zones.contains(zone) ? zones[zone] : nlohmann::json("");
|
||||
return json;
|
||||
}
|
||||
|
||||
// The messages as JSON, each with the newest flag covering it (flag_id, 0 for none)
|
||||
nlohmann::json MessagesJson(const std::vector<IChatLog::ChatMessage>& messages, const ChatHistory::Access& access) {
|
||||
std::vector<uint64_t> ids;
|
||||
for (const auto& m : messages) ids.push_back(m.id);
|
||||
std::map<uint64_t, uint64_t> flags;
|
||||
for (const auto& [message, flag] : Database::Get()->GetFlaggedMessages(ids)) flags[message] = flag;
|
||||
nlohmann::json out = nlohmann::json::array();
|
||||
for (const auto& m : messages) {
|
||||
auto json = RowJson(m, access);
|
||||
json["flag_id"] = flags.contains(m.id) ? flags[m.id] : 0;
|
||||
out.push_back(std::move(json));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The filters both the API and the Chat Log page take, from a query string or a JSON body
|
||||
IChatLog::ChatQuery Query(const HTTPContext& context, const std::function<std::string(const char*)>& get) {
|
||||
IChatLog::ChatQuery q;
|
||||
q.channel = get("channel");
|
||||
// Whispers, team and guild chat need their own permission
|
||||
q.includePrivate = Can(context, "chat_private");
|
||||
if (IsPrivate(q.channel) && !q.includePrivate) q.channel = "none";
|
||||
// Team and guild chat, and whispers, need their own permissions
|
||||
const auto access = AccessOf(context);
|
||||
q.includePrivate = access.group;
|
||||
q.includeWhispers = access.whispers;
|
||||
if (!q.channel.empty() && !ChatHistory::CanRead(q.channel, access)) q.channel = "none";
|
||||
const auto character = get("character");
|
||||
if (!character.empty()) q.characterId = ResolveCharacter(character).value_or(-1);
|
||||
q.accountId = GeneralUtils::TryParse<uint32_t>(get("account")).value_or(0);
|
||||
q.zoneId = GeneralUtils::TryParse<uint32_t>(get("zone")).value_or(0);
|
||||
q.instanceId = GeneralUtils::TryParse<int64_t>(get("instance")).value_or(-1);
|
||||
q.since = std::max<int64_t>(0, GeneralUtils::TryParse<int64_t>(get("since")).value_or(0));
|
||||
q.until = std::max<int64_t>(0, GeneralUtils::TryParse<int64_t>(get("until")).value_or(0));
|
||||
q.search = get("search");
|
||||
q.blockedOnly = get("blocked") == "1" || get("blocked") == "true";
|
||||
return q;
|
||||
}
|
||||
|
||||
uint32_t Limit(const HTTPContext& context, uint32_t fallback) {
|
||||
return std::clamp<uint32_t>(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "limit")).value_or(fallback), 1, MAX_PAGE);
|
||||
}
|
||||
|
||||
// A page of one conversation, newest page first, going back with `before`: {messages (oldest first), has_more}
|
||||
nlohmann::json ConversationPage(IChatLog::ChatQuery q, const HTTPContext& context, const ChatHistory::Access& access) {
|
||||
q.beforeId = GeneralUtils::TryParse<uint64_t>(QueryValue(context.queryString, "before")).value_or(0);
|
||||
q.limit = Limit(context, 100) + 1; // one more, to know whether there is an older page
|
||||
q.newestFirst = true;
|
||||
auto messages = Database::Get()->GetChatMessages(q);
|
||||
const bool more = messages.size() == q.limit;
|
||||
if (more) messages.pop_back();
|
||||
std::reverse(messages.begin(), messages.end());
|
||||
return { {"messages", MessagesJson(messages, access)}, {"has_more", more} };
|
||||
}
|
||||
|
||||
std::optional<std::string> CharacterName(LWOOBJID id) {
|
||||
const auto info = Database::Get()->GetCharacterInfo(id);
|
||||
if (!info) return std::nullopt;
|
||||
return info->name;
|
||||
}
|
||||
}
|
||||
|
||||
namespace ChatRoutes {
|
||||
@@ -63,7 +107,7 @@ namespace ChatRoutes {
|
||||
for (int page = 0; page < MAX_PAGES; page++) {
|
||||
const auto messages = Database::Get()->GetChatMessages(q);
|
||||
for (const auto& message : messages) {
|
||||
auto json = MessageJson(message);
|
||||
auto json = RowJson(message, {});
|
||||
Game::web.SendWSMessage("chat_message", json);
|
||||
}
|
||||
if (messages.size() < q.limit) break;
|
||||
@@ -78,28 +122,32 @@ void RegisterChatRoutes() {
|
||||
Game::web.RegisterWSSubscription("chat_message", std::function<uint8_t()>([] { return Permissions::Level("chat_view"); }), "chat_view");
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/chat", Perm("chat_view"),
|
||||
"Chat messages, oldest first. Query: after (the last id you have; for bridges polling), limit (max 500), channel (zone, whisper, team, web), "
|
||||
"character (name or ID), account, zone, instance (one world server of that zone), search, blocked=1 (only what the chat filter stopped). Whispers and team chat need chat_private. "
|
||||
"Returns {messages, last_id}. Live: subscribe to the chat_message WebSocket topic",
|
||||
"Chat messages, oldest first. Query: after (the last id you have; for bridges polling), limit (max 500), channel (zone, whisper, team, guild, web), "
|
||||
"character (name or ID), account, zone, instance (one world server of that zone), since and until (unix times), search, blocked=1 (only what the chat filter stopped). "
|
||||
"Team and guild chat need chat_private, whispers chat_dms. Returns {messages, last_id}. Live: subscribe to the chat_message WebSocket topic",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
const auto access = AccessOf(context);
|
||||
auto q = Query(context, [&](const char* name) { return QueryValue(context.queryString, name); });
|
||||
q.afterId = GeneralUtils::TryParse<uint64_t>(QueryValue(context.queryString, "after")).value_or(0);
|
||||
q.limit = std::clamp<uint32_t>(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "limit")).value_or(100), 1, MAX_PAGE);
|
||||
q.limit = Limit(context, 100);
|
||||
// Without `after`, the newest page (still returned oldest first)
|
||||
q.newestFirst = q.afterId == 0;
|
||||
auto messages = Database::Get()->GetChatMessages(q);
|
||||
if (q.newestFirst) std::reverse(messages.begin(), messages.end());
|
||||
nlohmann::json out = nlohmann::json::array();
|
||||
uint64_t last = q.afterId;
|
||||
for (const auto& m : messages) { out.push_back(MessageJson(m)); last = std::max(last, m.id); }
|
||||
for (const auto& m : messages) { out.push_back(RowJson(m, access)); last = std::max(last, m.id); }
|
||||
JsonSuccess(reply, { {"messages", out}, {"last_id", last} });
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::POST, "/api/tables/chat_log", Perm("chat_view"), "The chat log for the Chat Log page (DataTables). Body adds {channel, character, zone, instance, blocked}",
|
||||
Route(eHTTPMethod::POST, "/api/tables/chat_log", Perm("chat_view"),
|
||||
"The chat log for the Chat Log page (DataTables), newest first. Body adds {channel, character, account, zone, instance, since, until, blocked}. "
|
||||
"Each row has flag_id: the newest chat flag covering it (0: none)",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
const auto request = ParseDataTablesRequest(context.body);
|
||||
const auto body = ParseBody(context);
|
||||
if (!request || !body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
|
||||
const auto access = AccessOf(context);
|
||||
auto q = Query(context, [&](const char* name) {
|
||||
// DataTables' own search is an object ({value, regex}); it's read from the request below
|
||||
if (!body->contains(name) || !(*body)[name].is_primitive() || (*body)[name].is_null()) return std::string{};
|
||||
@@ -112,10 +160,9 @@ void RegisterChatRoutes() {
|
||||
q.limit = std::clamp<uint32_t>(request->length, 1, MAX_PAGE);
|
||||
IChatLog::ChatQuery all;
|
||||
all.includePrivate = q.includePrivate;
|
||||
nlohmann::json rows = nlohmann::json::array();
|
||||
for (const auto& m : Database::Get()->GetChatMessages(q)) rows.push_back(MessageJson(m));
|
||||
all.includeWhispers = q.includeWhispers;
|
||||
JsonReply(reply, eHTTPStatusCode::OK, { {"draw", request->draw}, {"recordsTotal", Database::Get()->CountChatMessages(all)},
|
||||
{"recordsFiltered", Database::Get()->CountChatMessages(q)}, {"data", rows} });
|
||||
{"recordsFiltered", Database::Get()->CountChatMessages(q)}, {"data", MessagesJson(Database::Get()->GetChatMessages(q), access)} });
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/chat/stats", Perm("chat_view"),
|
||||
@@ -131,6 +178,148 @@ void RegisterChatRoutes() {
|
||||
JsonSuccess(reply, { {"lastHour", count(now - 3600, false)}, {"today", count(now - 86400, false)}, {"blockedToday", count(now - 86400, true)} });
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/chat/messages/:id/context", Perm("chat_view"),
|
||||
"A message and the conversation around it: the same world for zone chat, the same two characters for whispers, the same team or guild. "
|
||||
"Query: count (messages before and after, default 10, max 50). Needs the permission for the message's channel",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
const auto id = PathId<uint64_t>(context.path, 3);
|
||||
if (!id || *id == 0) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid message ID");
|
||||
const auto access = AccessOf(context);
|
||||
IChatLog::ChatQuery find;
|
||||
find.includePrivate = true;
|
||||
find.includeWhispers = true;
|
||||
find.afterId = *id - 1;
|
||||
find.limit = 1;
|
||||
const auto found = Database::Get()->GetChatMessages(find);
|
||||
if (found.empty() || found.front().id != *id) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such message (chat is kept for log_chat_days)");
|
||||
const auto& message = found.front();
|
||||
if (!ChatHistory::CanRead(message.channel, access)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Reading " + message.channel + " chat needs another permission");
|
||||
const auto count = std::clamp<uint32_t>(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "count")).value_or(10), 1, 50);
|
||||
auto before = ChatHistory::ConversationQuery(message, access);
|
||||
before.beforeId = message.id;
|
||||
before.newestFirst = true;
|
||||
before.limit = count;
|
||||
auto older = Database::Get()->GetChatMessages(before);
|
||||
std::reverse(older.begin(), older.end());
|
||||
auto after = ChatHistory::ConversationQuery(message, access);
|
||||
after.afterId = message.id;
|
||||
after.limit = count;
|
||||
std::vector<IChatLog::ChatMessage> all = older;
|
||||
all.push_back(message);
|
||||
for (const auto& m : Database::Get()->GetChatMessages(after)) all.push_back(m);
|
||||
if (message.channel == "whisper") {
|
||||
Audit(context, "chat_dms_view", "Whispers between " + message.senderName + " and " + message.recipientName + " around message " + std::to_string(message.id),
|
||||
AuditTarget::Character(message.senderId));
|
||||
}
|
||||
JsonSuccess(reply, { {"message", RowJson(message, access)}, {"messages", MessagesJson(all, access)} });
|
||||
});
|
||||
|
||||
// ---- Conversations: guild chat, team chat, whispers ----
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/chat/guild/:id", Perm("chat_private"),
|
||||
"A guild's chat, newest page first. Query: before (the oldest id you have, for the page before it), limit (max 500). Returns {guild, messages (oldest first), has_more}",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
const auto guildId = PathId<int64_t>(context.path, 3);
|
||||
if (!guildId || *guildId <= 0) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid guild ID");
|
||||
const auto access = AccessOf(context);
|
||||
IChatLog::ChatQuery q;
|
||||
q.includePrivate = true;
|
||||
q.channel = "guild";
|
||||
q.guildId = *guildId;
|
||||
auto page = ConversationPage(q, context, access);
|
||||
const auto guild = Database::Get()->GetGuild(*guildId);
|
||||
page["guild"] = { {"id", std::to_string(*guildId)}, {"name", guild ? guild->name : ""}, {"exists", guild.has_value()} };
|
||||
JsonSuccess(reply, page);
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/chat/teams", Perm("chat_private"),
|
||||
"Teams that talked in team chat, most recent first. Query: character (name or ID: only teams they talked in), offset, limit (max 500). "
|
||||
"Returns {teams: [{team_id, messages, first_time, last_time, senders}], total}",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
LWOOBJID character = 0;
|
||||
const auto text = QueryValue(context.queryString, "character");
|
||||
if (!text.empty()) {
|
||||
const auto resolved = ResolveCharacter(text);
|
||||
if (!resolved) return JsonSuccess(reply, { {"teams", nlohmann::json::array()}, {"total", 0} });
|
||||
character = *resolved;
|
||||
}
|
||||
const auto offset = GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "offset")).value_or(0);
|
||||
nlohmann::json teams = nlohmann::json::array();
|
||||
for (const auto& team : Database::Get()->GetChatTeams(character, offset, Limit(context, 50))) {
|
||||
teams.push_back({ {"team_id", std::to_string(team.teamId)}, {"messages", team.messages}, {"first_time", team.firstTime}, {"last_time", team.lastTime},
|
||||
{"senders", GeneralUtils::SplitString(team.senders, ',')} });
|
||||
}
|
||||
JsonSuccess(reply, { {"teams", teams}, {"total", Database::Get()->CountChatTeams(character)} });
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/chat/team/:id", Perm("chat_private"),
|
||||
"One team's chat, newest page first. Query: before, limit (max 500). Returns {messages (oldest first), has_more}",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
const auto teamId = PathId<LWOOBJID>(context.path, 3);
|
||||
if (!teamId || *teamId <= 0) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid team ID");
|
||||
IChatLog::ChatQuery q;
|
||||
q.includePrivate = true;
|
||||
q.channel = "team";
|
||||
q.teamId = *teamId;
|
||||
JsonSuccess(reply, ConversationPage(q, context, AccessOf(context)));
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/characters/:id/whispers", Perm("chat_dms"),
|
||||
"The characters a character whispered with, most recent conversation first. Query: offset, limit (max 500). "
|
||||
"Returns {character, partners: [{character_id, name, messages, first_time, last_time}], total}",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
const auto characterId = PathId<LWOOBJID>(context.path, 2);
|
||||
if (!characterId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid character ID");
|
||||
const auto offset = GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "offset")).value_or(0);
|
||||
nlohmann::json partners = nlohmann::json::array();
|
||||
for (const auto& p : Database::Get()->GetWhisperPartners(*characterId, offset, Limit(context, 50))) {
|
||||
partners.push_back({ {"character_id", std::to_string(p.characterId)}, {"name", p.name}, {"messages", p.messages}, {"first_time", p.firstTime}, {"last_time", p.lastTime} });
|
||||
}
|
||||
JsonSuccess(reply, { {"character", { {"id", std::to_string(*characterId)}, {"name", CharacterName(*characterId).value_or("")} }},
|
||||
{"partners", partners}, {"total", Database::Get()->CountWhisperPartners(*characterId)} });
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/characters/:id/whispers/:other", Perm("chat_dms"),
|
||||
"The whispers between two characters, newest page first. Query: before, limit (max 500). Returns {messages (oldest first), has_more}. "
|
||||
"Opening a conversation (the first page) is audited",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
const auto characterId = PathId<LWOOBJID>(context.path, 2);
|
||||
const auto otherId = PathId<LWOOBJID>(context.path, 4);
|
||||
if (!characterId || !otherId || *characterId == *otherId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid character IDs");
|
||||
IChatLog::ChatQuery q;
|
||||
q.includeWhispers = true;
|
||||
q.channel = "whisper";
|
||||
q.characterId = *characterId;
|
||||
q.otherCharacterId = *otherId;
|
||||
auto page = ConversationPage(q, context, AccessOf(context));
|
||||
if (QueryValue(context.queryString, "before").empty()) {
|
||||
Audit(context, "chat_dms_view", "Whispers between " + CharacterName(*characterId).value_or(std::to_string(*characterId)) + " and " +
|
||||
CharacterName(*otherId).value_or(std::to_string(*otherId)), AuditTarget::Character(*characterId));
|
||||
}
|
||||
JsonSuccess(reply, page);
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::GET, "/chat_log/guild/:id", Perm("chat_private"), "One guild's chat", [](HTTPReply& reply, const HTTPContext& context) {
|
||||
const auto guildId = PathId<int64_t>(context.path, 2);
|
||||
if (!guildId || *guildId <= 0) return RenderError(reply, context, eHTTPStatusCode::NOT_FOUND, "Invalid guild ID");
|
||||
const auto guild = Database::Get()->GetGuild(*guildId);
|
||||
RenderPage(reply, context, "chat_history.jinja2", "guilds", { {"mode", "guild"}, {"id", std::to_string(*guildId)},
|
||||
{"title", guild ? guild->name + " guild chat" : "Guild " + std::to_string(*guildId) + " chat"} });
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::GET, "/chat_log/teams", Perm("chat_private"), "Team chat, by team", [](HTTPReply& reply, const HTTPContext& context) {
|
||||
RenderPage(reply, context, "chat_history.jinja2", "chat_teams", { {"mode", "teams"}, {"id", ""}, {"title", "Team Chat"} });
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::GET, "/characters/:id/whispers", Perm("chat_dms"), "A character's whispers, by conversation", [](HTTPReply& reply, const HTTPContext& context) {
|
||||
const auto characterId = PathId<LWOOBJID>(context.path, 1);
|
||||
if (!characterId) return RenderError(reply, context, eHTTPStatusCode::NOT_FOUND, "Invalid character ID");
|
||||
const auto name = CharacterName(*characterId);
|
||||
if (!name) return RenderError(reply, context, eHTTPStatusCode::NOT_FOUND, "Character not found");
|
||||
RenderPage(reply, context, "chat_history.jinja2", "characters", { {"mode", "whispers"}, {"id", std::to_string(*characterId)},
|
||||
{"title", *name + "'s whispers"}, {"character_name", *name} });
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::POST, "/api/chat/send", Perm("chat_send"),
|
||||
"Post in players' chat. Body: {message (1-300 characters), name (who it's from, e.g. a Discord user; default your account), label (shown in brackets, "
|
||||
"default Web, e.g. Discord), zone (optional: only that zone), instance (optional, with zone: only that world server)}. Shown as \"[label] name\" so it can't pass for a player. Runs in the background: {requestId}",
|
||||
|
||||
136
dDashboardServer/static/js/chat-flagging.js
Normal file
136
dDashboardServer/static/js/chat-flagging.js
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Picking chat messages and flagging them for review, shared by the Chat Log and the chat histories (guild, team and
|
||||
* whisper conversations). Each message row has a checkbox (ChatFlagging.checkbox); shift-click picks every message
|
||||
* between the last one clicked and this one. The bar (ChatFlagging.bar) shows how many are picked and opens the flag
|
||||
* dialog: who the flag is about (one of the senders) and a note. The server keeps a copy of the messages and the chat
|
||||
* around them. Messages already flagged link to their flag (ChatFlagging.flagLink).
|
||||
*/
|
||||
window.ChatFlagging = (function () {
|
||||
'use strict';
|
||||
|
||||
var CHANNEL_NEEDS = { whisper: 'chat_dms', team: 'chat_private', guild: 'chat_private' };
|
||||
|
||||
function flagLink(row) {
|
||||
if (!row.flag_id) return '';
|
||||
return ' <a class="badge text-bg-warning text-decoration-none" href="/chat_flags#flag=' + esc(row.flag_id) + '" title="In chat flag ' + esc(row.flag_id) + '">Flagged</a>';
|
||||
}
|
||||
|
||||
// The message text, or why it can't be shown
|
||||
function text(row) {
|
||||
if (row.redacted) return '<span class="text-body-secondary fst-italic">Hidden: reading ' + esc(row.channel) + ' chat needs ' + esc(CHANNEL_NEEDS[row.channel] || 'another permission') + '</span>';
|
||||
var badges = '';
|
||||
if (row.blocked) badges += fmt.badge('Stopped by the filter', 'danger') + ' ';
|
||||
else if (row.filtered) badges += fmt.badge('Filter words', 'warning') + ' ';
|
||||
return badges + '<span class="' + (row.blocked ? 'text-body-secondary' : '') + '">' + esc(row.message) + '</span>';
|
||||
}
|
||||
|
||||
function checkbox(row) {
|
||||
if (!DASH.can('chat_flag') || row.redacted || !row.id) return '';
|
||||
return '<input class="form-check-input" type="checkbox" data-flag-pick="' + esc(row.id) + '" aria-label="Pick this message to flag">';
|
||||
}
|
||||
|
||||
/**
|
||||
* Picking in `container` (an element, or a selector), with the bar in `barEl`. onFlagged(id) runs after a flag
|
||||
* is made.
|
||||
*/
|
||||
function create(container, barEl, onFlagged) {
|
||||
var root = typeof container === 'string' ? document.querySelector(container) : container;
|
||||
var bar = typeof barEl === 'string' ? document.querySelector(barEl) : barEl;
|
||||
var rows = {}; // id -> message, for every message shown
|
||||
var picked = {}; // id -> true
|
||||
var lastClicked = null;
|
||||
|
||||
function count() { return Object.keys(picked).length; }
|
||||
|
||||
function renderBar() {
|
||||
if (!bar) return;
|
||||
var n = count();
|
||||
bar.classList.toggle('d-none', !DASH.can('chat_flag'));
|
||||
bar.innerHTML = '<span class="small text-body-secondary">' + (n ? n + ' message' + (n === 1 ? '' : 's') + ' picked' :
|
||||
'Tick messages to flag them for review (shift-click picks a range).') + '</span>' +
|
||||
(n ? ' <button type="button" class="btn btn-sm btn-warning" data-flag-open>Flag…</button> <button type="button" class="btn btn-sm btn-outline-secondary" data-flag-clear>Clear</button>' : '');
|
||||
}
|
||||
|
||||
function sync() {
|
||||
root.querySelectorAll('[data-flag-pick]').forEach(function (box) { box.checked = !!picked[box.getAttribute('data-flag-pick')]; });
|
||||
renderBar();
|
||||
}
|
||||
|
||||
function clear() { picked = {}; lastClicked = null; sync(); }
|
||||
|
||||
root.addEventListener('click', function (e) {
|
||||
var box = e.target.closest('[data-flag-pick]');
|
||||
if (!box) return;
|
||||
var id = box.getAttribute('data-flag-pick');
|
||||
var on = box.checked;
|
||||
if (e.shiftKey && lastClicked !== null) {
|
||||
var boxes = Array.prototype.slice.call(root.querySelectorAll('[data-flag-pick]'));
|
||||
var ids = boxes.map(function (b) { return b.getAttribute('data-flag-pick'); });
|
||||
var a = ids.indexOf(lastClicked), b = ids.indexOf(id);
|
||||
if (a !== -1 && b !== -1) {
|
||||
for (var i = Math.min(a, b); i <= Math.max(a, b); i++) {
|
||||
if (on) picked[ids[i]] = true; else delete picked[ids[i]];
|
||||
}
|
||||
}
|
||||
} else if (on) picked[id] = true;
|
||||
else delete picked[id];
|
||||
lastClicked = id;
|
||||
sync();
|
||||
});
|
||||
|
||||
if (bar) bar.addEventListener('click', function (e) {
|
||||
if (e.target.closest('[data-flag-clear]')) return clear();
|
||||
if (e.target.closest('[data-flag-open]')) openDialog();
|
||||
});
|
||||
|
||||
function openDialog() {
|
||||
var ids = Object.keys(picked);
|
||||
var messages = ids.map(function (id) { return rows[id]; }).filter(Boolean).sort(function (a, b) { return a.id - b.id; });
|
||||
var senders = [];
|
||||
messages.forEach(function (m) {
|
||||
if (m.sender_id !== '0' && !senders.some(function (s) { return s.id === m.sender_id; })) senders.push({ id: m.sender_id, name: m.sender_name });
|
||||
});
|
||||
var modalEl = document.createElement('div');
|
||||
modalEl.className = 'modal fade';
|
||||
modalEl.tabIndex = -1;
|
||||
modalEl.innerHTML = '<div class="modal-dialog modal-lg"><form class="modal-content">' +
|
||||
'<div class="modal-header"><h5 class="modal-title">Flag ' + ids.length + ' message' + (ids.length === 1 ? '' : 's') + '</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>' +
|
||||
'<div class="modal-body">' +
|
||||
'<div class="border rounded p-2 mb-3 small" style="max-height: 14rem; overflow-y: auto">' + messages.map(function (m) {
|
||||
return '<div><span class="text-body-secondary">' + esc(fmt.unix(m.time)) + '</span> <strong>' + esc(m.sender_name) + '</strong>' +
|
||||
(m.recipient_name ? ' → ' + esc(m.recipient_name) : '') + ': ' + esc(m.message) + '</div>';
|
||||
}).join('') + '</div>' +
|
||||
(senders.length ? '<div class="mb-3"><label class="form-label" for="flagAbout">About</label><select class="form-select" id="flagAbout">' +
|
||||
senders.map(function (s) { return '<option value="' + esc(s.id) + '">' + esc(s.name) + '</option>'; }).join('') + '</select></div>' : '') +
|
||||
'<div><label class="form-label" for="flagNote">Note</label><textarea class="form-control" id="flagNote" rows="3" maxlength="2000" placeholder="What is wrong here, for whoever reviews it"></textarea>' +
|
||||
'<div class="form-text">The flag keeps a copy of these messages and the chat around them, even after the chat log is pruned. The messages must be from one conversation.</div></div>' +
|
||||
'</div>' +
|
||||
'<div class="modal-footer"><button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button><button type="submit" class="btn btn-warning">Flag</button></div>' +
|
||||
'</form></div>';
|
||||
document.body.appendChild(modalEl);
|
||||
var modal = new bootstrap.Modal(modalEl);
|
||||
modalEl.addEventListener('hidden.bs.modal', function () { modal.dispose(); modalEl.remove(); });
|
||||
modalEl.querySelector('form').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var about = modalEl.querySelector('#flagAbout');
|
||||
api.action('/api/chat/flags', { message_ids: ids, character: about ? about.value : '', note: modalEl.querySelector('#flagNote').value }, 'Flagged for review').then(function (d) {
|
||||
modal.hide();
|
||||
clear();
|
||||
if (onFlagged) onFlagged(d.id);
|
||||
}).catch(function () {});
|
||||
});
|
||||
modal.show();
|
||||
}
|
||||
|
||||
renderBar();
|
||||
return {
|
||||
// Remember the messages shown (for the dialog); call before or after rendering them
|
||||
remember: function (messages) { messages.forEach(function (m) { rows[m.id] = m; }); },
|
||||
// After the rows were drawn again: tick what's picked
|
||||
sync: sync,
|
||||
clear: clear
|
||||
};
|
||||
}
|
||||
|
||||
return { create: create, checkbox: checkbox, flagLink: flagLink, text: text };
|
||||
})();
|
||||
141
dDashboardServer/static/js/chat-history.js
Normal file
141
dDashboardServer/static/js/chat-history.js
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* The chat histories (chat_history.jinja2): one guild's chat (mode guild), team chat by team (mode teams) and one
|
||||
* character's whispers by conversation (mode whispers). Conversations load newest page first; Older loads the page
|
||||
* before. #team=<id> and #with=<character id> open that team or conversation (links from the Chat Log).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var page = document.getElementById('chatHistoryPage');
|
||||
var mode = page.dataset.mode;
|
||||
var id = page.dataset.id;
|
||||
var PAGE = 100, LIST_PAGE = 50;
|
||||
var nf = new Intl.NumberFormat();
|
||||
|
||||
var rows = document.getElementById('threadRows');
|
||||
var flagging = ChatFlagging.create(document.getElementById('threadTable'), document.getElementById('flagBar'), function () { reloadThread(); });
|
||||
var thread = null; // {url, oldest, messages}
|
||||
var list = { offset: 0, items: [], selected: null };
|
||||
|
||||
function hashValue(name) { var m = location.hash.match(new RegExp('[#&]' + name + '=([^&]*)')); return m ? decodeURIComponent(m[1]) : ''; }
|
||||
|
||||
// ---- the conversation ----
|
||||
|
||||
function line(m) {
|
||||
var where = m.zone_id ? '<div class="small text-body-secondary">' + esc(m.zone_name || ('Zone ' + m.zone_id)) + '</div>' : '';
|
||||
return '<tr class="chat-line"><td style="width: 1.5rem">' + ChatFlagging.checkbox(m) + '</td>' +
|
||||
'<td class="when">' + esc(fmt.unix(m.time)) + '</td>' +
|
||||
'<td class="who">' + (m.sender_id !== '0' ? fmt.character(m.sender_id, m.sender_name) : esc(m.sender_name)) + where + '</td>' +
|
||||
'<td>' + ChatFlagging.text(m) + ChatFlagging.flagLink(m) + '</td>' +
|
||||
'<td class="text-end">' + (m.account_id ? '<a class="btn btn-sm btn-outline-secondary" href="/accounts/' + esc(m.account_id) + '">Account</a>' : '') + '</td></tr>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!thread) return;
|
||||
rows.innerHTML = thread.messages.length ? thread.messages.map(line).join('')
|
||||
: '<tr><td class="text-body-secondary">No messages (chat is kept for log_chat_days).</td></tr>';
|
||||
document.getElementById('olderBox').classList.toggle('d-none', !thread.more);
|
||||
flagging.sync();
|
||||
}
|
||||
|
||||
function load(url, older) {
|
||||
var query = '?limit=' + PAGE + (older && thread && thread.messages.length ? '&before=' + thread.messages[0].id : '');
|
||||
return api.get(url + query).then(function (d) {
|
||||
if (!d.success) { rows.innerHTML = '<tr><td class="text-danger">' + esc(d.error || 'Could not load the chat') + '</td></tr>'; return d; }
|
||||
flagging.remember(d.messages);
|
||||
if (older && thread && thread.url === url) thread.messages = d.messages.concat(thread.messages);
|
||||
else thread = { url: url, messages: d.messages };
|
||||
thread.more = d.has_more;
|
||||
render();
|
||||
if (!older) window.scrollTo({ top: document.getElementById('threadTable').getBoundingClientRect().bottom + window.scrollY - window.innerHeight + 80 });
|
||||
return d;
|
||||
});
|
||||
}
|
||||
|
||||
function reloadThread() { if (thread) load(thread.url, false); }
|
||||
|
||||
document.getElementById('older').addEventListener('click', function () { if (thread) load(thread.url, true); });
|
||||
|
||||
function setLinks(html) { document.getElementById('threadLinks').innerHTML = html; }
|
||||
|
||||
// ---- guild ----
|
||||
|
||||
if (mode === 'guild') {
|
||||
load('/api/chat/guild/' + encodeURIComponent(id), false).then(function (d) {
|
||||
if (!d || !d.success) return;
|
||||
document.getElementById('threadTitle').textContent = d.guild.exists ? d.guild.name : 'Guild ' + id + ' (disbanded)';
|
||||
setLinks(d.guild.exists && DASH.can('guilds_manage') ? '<a class="btn btn-sm btn-outline-secondary" href="/guilds#guild=' + esc(id) + '">Members and history</a>' : '');
|
||||
}).catch(function () {});
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- teams and whisper conversations: the list on the left ----
|
||||
|
||||
var listEl = document.getElementById('threadList');
|
||||
|
||||
function itemHtml(item) {
|
||||
var active = list.selected === item.key;
|
||||
return '<a class="list-group-item list-group-item-action' + (active ? ' active' : '') + '" data-key="' + esc(item.key) + '">' +
|
||||
'<div class="d-flex justify-content-between align-items-center gap-2"><span class="fw-semibold text-truncate">' + esc(item.title) + '</span>' +
|
||||
'<span class="badge rounded-pill text-bg-secondary" title="Messages">' + nf.format(item.messages) + '</span></div>' +
|
||||
'<div class="meta">' + esc(item.meta) + '</div></a>';
|
||||
}
|
||||
|
||||
function renderList(total) {
|
||||
listEl.innerHTML = list.items.length ? list.items.map(itemHtml).join('') : '<div class="list-group-item small text-body-secondary">None found.</div>';
|
||||
document.getElementById('listCount').textContent = nf.format(total);
|
||||
document.getElementById('listMoreBox').classList.toggle('d-none', list.items.length >= total);
|
||||
}
|
||||
|
||||
function span(first, last) { return fmt.unix(first) + (last !== first ? ' to ' + fmt.unix(last) : ''); }
|
||||
|
||||
function loadList(more) {
|
||||
if (!more) { list.offset = 0; list.items = []; }
|
||||
var url = mode === 'teams'
|
||||
? '/api/chat/teams?limit=' + LIST_PAGE + '&offset=' + list.offset + '&character=' + encodeURIComponent(document.getElementById('teamCharacter').value.trim())
|
||||
: '/api/characters/' + encodeURIComponent(id) + '/whispers?limit=' + LIST_PAGE + '&offset=' + list.offset;
|
||||
return api.get(url).then(function (d) {
|
||||
if (!d.success) { listEl.innerHTML = '<div class="list-group-item small text-danger">' + esc(d.error || 'Could not load') + '</div>'; return; }
|
||||
var items = mode === 'teams'
|
||||
? d.teams.map(function (t) { return { key: t.team_id, title: t.senders.join(', ') || 'Team', messages: t.messages, meta: span(t.first_time, t.last_time), team: t }; })
|
||||
: d.partners.map(function (p) { return { key: p.character_id, title: p.name || p.character_id, messages: p.messages, meta: span(p.first_time, p.last_time), partner: p }; });
|
||||
list.items = list.items.concat(items);
|
||||
list.offset += items.length;
|
||||
renderList(d.total);
|
||||
});
|
||||
}
|
||||
|
||||
function open(key) {
|
||||
list.selected = key;
|
||||
listEl.querySelectorAll('[data-key]').forEach(function (el) { el.classList.toggle('active', el.getAttribute('data-key') === key); });
|
||||
var item = list.items.filter(function (i) { return i.key === key; })[0];
|
||||
flagging.clear();
|
||||
try { history.replaceState(null, '', '#' + (mode === 'teams' ? 'team' : 'with') + '=' + key); } catch (e) {}
|
||||
if (mode === 'teams') {
|
||||
document.getElementById('threadTitle').textContent = item ? 'Team: ' + item.title : 'Team ' + key;
|
||||
setLinks('');
|
||||
return load('/api/chat/team/' + encodeURIComponent(key), false).catch(function () {});
|
||||
}
|
||||
var name = item ? item.title : key;
|
||||
document.getElementById('threadTitle').textContent = 'With ' + name;
|
||||
setLinks('<a class="btn btn-sm btn-outline-secondary" href="/characters/' + esc(key) + '">' + esc(name) + '</a>' +
|
||||
'<a class="btn btn-sm btn-outline-secondary" href="/characters/' + esc(key) + '/whispers">Their whispers</a>');
|
||||
return load('/api/characters/' + encodeURIComponent(id) + '/whispers/' + encodeURIComponent(key), false).catch(function () {});
|
||||
}
|
||||
|
||||
listEl.addEventListener('click', function (e) {
|
||||
var item = e.target.closest('[data-key]');
|
||||
if (item) open(item.getAttribute('data-key'));
|
||||
});
|
||||
document.getElementById('listMore').addEventListener('click', function () { loadList(true).catch(function () {}); });
|
||||
var teamCharacter = document.getElementById('teamCharacter');
|
||||
if (teamCharacter && hashValue('character')) teamCharacter.value = hashValue('character');
|
||||
if (teamCharacter) teamCharacter.addEventListener('change', function () { loadList(false).catch(function () {}); });
|
||||
|
||||
var start = hashValue(mode === 'teams' ? 'team' : 'with');
|
||||
loadList(false).then(function () {
|
||||
if (start) open(start);
|
||||
// Whispers are only opened on purpose: each one opened is audited
|
||||
else if (list.items.length && mode === 'teams') open(list.items[0].key);
|
||||
}).catch(function () {});
|
||||
})();
|
||||
@@ -1,21 +1,33 @@
|
||||
/**
|
||||
* The Chat Log page: the chat table, and a list of the world servers running now. Picking one narrows the chat to that
|
||||
* server and shows who's in it, how busy its chat is, and what can be done there (say something, shut it down).
|
||||
* The selection is kept in the address (#world=1200:2) so it can be linked to, e.g. from the home page.
|
||||
* The selection is kept in the address (#world=1200:2) so it can be linked to, e.g. from the home page; #character=,
|
||||
* #account= and #search= fill in those filters (links from character and account pages).
|
||||
* Messages can be picked and flagged for review (chat-flagging.js); Context shows the conversation around a message.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var page = document.getElementById('chatLogPage');
|
||||
var can = { send: !!page.dataset.canSend, players: !!page.dataset.canPlayers, worlds: !!page.dataset.canWorlds };
|
||||
var CHANNELS = { zone: ['Zone', 'secondary'], whisper: ['Whisper', 'info'], team: ['Team', 'primary'], web: ['Web', 'warning'] };
|
||||
var CHANNELS = { zone: ['Zone', 'secondary'], whisper: ['Whisper', 'info'], team: ['Team', 'primary'], guild: ['Guild', 'success'], web: ['Web', 'warning'] };
|
||||
var nf = new Intl.NumberFormat();
|
||||
|
||||
var state = {
|
||||
worlds: [],
|
||||
selected: null, // {zone, instance} or null for every world
|
||||
filters: { channel: '', character: '', blocked: false }
|
||||
filters: { channel: '', character: '', account: '', since: 0, until: 0, blocked: false }
|
||||
};
|
||||
var flagging = ChatFlagging.create('#chatTable', '#flagBar', function () { table.ajax.reload(null, false); });
|
||||
|
||||
// Where the rest of a message's conversation is: the whisper thread, the team or the guild
|
||||
function conversationLink(row) {
|
||||
if (row.redacted) return '';
|
||||
if (row.channel === 'whisper' && DASH.can('chat_dms') && row.sender_id !== '0' && row.recipient_id !== '0') return '/characters/' + row.sender_id + '/whispers#with=' + row.recipient_id;
|
||||
if (row.channel === 'team' && DASH.can('chat_private') && row.team_id !== '0') return '/chat_log/teams#team=' + row.team_id;
|
||||
if (row.channel === 'guild' && DASH.can('chat_private') && row.guild_id !== '0') return '/chat_log/guild/' + row.guild_id;
|
||||
return '';
|
||||
}
|
||||
|
||||
function worldKey(w) { return w.mapID + ':' + w.instanceID; }
|
||||
function selectedWorld() {
|
||||
@@ -26,6 +38,7 @@
|
||||
// ---- the chat table ----
|
||||
|
||||
var table = serverTable('#chatTable', '/api/tables/chat_log', [
|
||||
{ data: 'id', orderable: false, render: function (d, t, row) { return ChatFlagging.checkbox(row); } },
|
||||
{ data: 'time', orderable: false, render: function (d) { return esc(fmt.unix(d)); } },
|
||||
{ data: 'channel', orderable: false, render: function (d) { var c = CHANNELS[d] || [d, 'secondary']; return fmt.badge(c[0], c[1]); } },
|
||||
{ data: 'zone_id', orderable: false, render: function (d, t, row) {
|
||||
@@ -34,19 +47,26 @@
|
||||
} },
|
||||
{ data: 'sender_name', orderable: false, render: function (d, t, row) { return row.sender_id !== '0' ? fmt.character(row.sender_id, d) : esc(d); } },
|
||||
{ data: 'recipient_name', orderable: false, render: function (d, t, row) { return row.recipient_id !== '0' ? fmt.character(row.recipient_id, d) : ''; } },
|
||||
{ data: 'message', orderable: false, render: function (d, t, row) {
|
||||
return (row.blocked ? fmt.badge('Stopped by the filter', 'danger') + ' ' : '') + '<span class="' + (row.blocked ? 'text-body-secondary' : '') + '">' + esc(d) + '</span>';
|
||||
} },
|
||||
// The sender's account: mute, warn or ban from there
|
||||
{ data: 'message', orderable: false, render: function (d, t, row) { return ChatFlagging.text(row) + ChatFlagging.flagLink(row); } },
|
||||
// The conversation, and the sender's account: mute, warn or ban from there
|
||||
{ data: 'account_id', orderable: false, render: function (d, t, row) {
|
||||
var suggest = row.sender_id !== '0' && row.channel !== 'web' ? AiSuggest.button('chat_message', row.id) : '';
|
||||
return '<div class="d-flex justify-content-end gap-1">' + (d ? '<a class="btn btn-sm btn-outline-secondary text-nowrap" href="/accounts/' + esc(d) + '">Account</a>' : '') + suggest + '</div>';
|
||||
var thread = conversationLink(row);
|
||||
return '<div class="d-flex justify-content-end gap-1">' +
|
||||
(row.redacted ? '' : '<button type="button" class="btn btn-sm btn-outline-secondary text-nowrap" data-context="' + esc(row.id) + '">Context</button>') +
|
||||
(thread ? '<a class="btn btn-sm btn-outline-secondary text-nowrap" href="' + esc(thread) + '">Conversation</a>' : '') +
|
||||
(d ? '<a class="btn btn-sm btn-outline-secondary text-nowrap" href="/accounts/' + esc(d) + '">Account</a>' : '') + suggest + '</div>';
|
||||
} }
|
||||
], {
|
||||
liveTable: 'chat',
|
||||
dataTable: { language: { searchPlaceholder: 'Message or name' } },
|
||||
dataTable: {
|
||||
language: { searchPlaceholder: 'Message or name' },
|
||||
order: [],
|
||||
drawCallback: function () { flagging.sync(); }
|
||||
},
|
||||
extra: function () {
|
||||
var body = { channel: state.filters.channel, character: state.filters.character, blocked: state.filters.blocked };
|
||||
var body = { channel: state.filters.channel, character: state.filters.character, account: state.filters.account,
|
||||
since: state.filters.since, until: state.filters.until, blocked: state.filters.blocked };
|
||||
if (state.selected) { body.zone = state.selected.zone; body.instance = state.selected.instance; }
|
||||
return body;
|
||||
}
|
||||
@@ -152,6 +172,47 @@
|
||||
document.getElementById('chatChannel').addEventListener('change', function () { state.filters.channel = this.value; table.ajax.reload(); });
|
||||
document.getElementById('chatCharacter').addEventListener('change', function () { state.filters.character = this.value.trim(); table.ajax.reload(); });
|
||||
document.getElementById('chatBlocked').addEventListener('change', function () { state.filters.blocked = this.checked; table.ajax.reload(); });
|
||||
document.getElementById('chatAccount').addEventListener('change', function () { state.filters.account = this.value.trim(); table.ajax.reload(); });
|
||||
// datetime-local is the viewer's local time; the server takes unix seconds
|
||||
function unixOf(value) { var t = value ? new Date(value).getTime() : NaN; return isNaN(t) ? 0 : Math.floor(t / 1000); }
|
||||
document.getElementById('chatSince').addEventListener('change', function () { state.filters.since = unixOf(this.value); table.ajax.reload(); });
|
||||
document.getElementById('chatUntil').addEventListener('change', function () { state.filters.until = unixOf(this.value); table.ajax.reload(); });
|
||||
$('#chatTable').on('xhr.dt', function (e, settings, json) { if (json && json.data) flagging.remember(json.data); });
|
||||
|
||||
// ---- the conversation around a message ----
|
||||
|
||||
document.getElementById('chatTable').addEventListener('click', function (e) {
|
||||
var button = e.target.closest('[data-context]');
|
||||
if (button) showContext(button.getAttribute('data-context'));
|
||||
});
|
||||
|
||||
function showContext(id) {
|
||||
api.get('/api/chat/messages/' + encodeURIComponent(id) + '/context?count=15').then(function (d) {
|
||||
if (!d.success) { toast(d.error || 'Could not load the conversation', 'danger'); return; }
|
||||
var modalEl = document.createElement('div');
|
||||
modalEl.className = 'modal fade';
|
||||
modalEl.tabIndex = -1;
|
||||
var thread = conversationLink(d.message);
|
||||
modalEl.innerHTML = '<div class="modal-dialog modal-xl modal-dialog-scrollable"><div class="modal-content">' +
|
||||
'<div class="modal-header"><h5 class="modal-title">Conversation around a ' + esc((CHANNELS[d.message.channel] || [d.message.channel])[0].toLowerCase()) + ' message</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>' +
|
||||
'<div class="modal-body"><div class="d-flex flex-wrap gap-2 align-items-center mb-2" data-context-bar></div>' +
|
||||
'<table class="table table-sm align-middle mb-0" data-context-table><tbody>' + d.messages.map(function (m) {
|
||||
return '<tr class="' + (String(m.id) === String(id) ? 'table-warning' : '') + '"><td style="width: 1.5rem">' + ChatFlagging.checkbox(m) + '</td>' +
|
||||
'<td class="text-nowrap small text-body-secondary">' + esc(fmt.unix(m.time)) + '</td>' +
|
||||
'<td class="text-nowrap">' + (m.sender_id !== '0' ? fmt.character(m.sender_id, m.sender_name) : esc(m.sender_name)) + (m.recipient_id !== '0' ? ' → ' + fmt.character(m.recipient_id, m.recipient_name) : '') + '</td>' +
|
||||
'<td>' + ChatFlagging.text(m) + ChatFlagging.flagLink(m) + '</td></tr>';
|
||||
}).join('') + '</tbody></table></div>' +
|
||||
'<div class="modal-footer">' + (thread ? '<a class="btn btn-outline-secondary" href="' + esc(thread) + '">Whole conversation</a>' : '') +
|
||||
'<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button></div></div></div>';
|
||||
document.body.appendChild(modalEl);
|
||||
var modal = new bootstrap.Modal(modalEl);
|
||||
var picker = ChatFlagging.create(modalEl.querySelector('[data-context-table]'), modalEl.querySelector('[data-context-bar]'), function () { modal.hide(); table.ajax.reload(null, false); });
|
||||
picker.remember(d.messages);
|
||||
modalEl.addEventListener('hidden.bs.modal', function () { modal.dispose(); modalEl.remove(); });
|
||||
modalEl.addEventListener('click', function (e) { if (e.target.closest('a[href]')) modal.hide(); });
|
||||
modal.show();
|
||||
}).catch(function () {});
|
||||
}
|
||||
document.getElementById('worldActions').addEventListener('click', function (e) {
|
||||
if (!e.target.closest('#worldShutdown') || !state.selected) return;
|
||||
var world = selectedWorld();
|
||||
@@ -173,6 +234,10 @@
|
||||
}
|
||||
setInterval(loadWorlds, 15000);
|
||||
|
||||
var hashValue = function (name) { var m = location.hash.match(new RegExp('[#&]' + name + '=([^&]*)')); return m ? decodeURIComponent(m[1]) : ''; };
|
||||
if (hashValue('character')) { state.filters.character = hashValue('character'); document.getElementById('chatCharacter').value = state.filters.character; }
|
||||
if (hashValue('account')) { state.filters.account = hashValue('account'); document.getElementById('chatAccount').value = state.filters.account; }
|
||||
if (state.filters.character || state.filters.account) table.ajax.reload();
|
||||
var fromHash = (location.hash.match(/world=(\d+:\d+)/) || [])[1];
|
||||
if (fromHash) { var p = fromHash.split(':'); state.selected = { zone: Number(p[0]), instance: Number(p[1]) }; }
|
||||
loadWorlds().then(function () { if (state.selected) table.ajax.reload(); });
|
||||
|
||||
@@ -108,6 +108,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can.chat_view or can.chat_dms or can.chat_private or can.chat_flag %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="mb-0">Chat</h5></div>
|
||||
<div class="card-body d-flex flex-wrap gap-2">
|
||||
{% if can.chat_view %}<a class="btn btn-sm btn-outline-secondary" href="/chat_log#character={{ character.id }}">Chat log</a>{% endif %}
|
||||
{% if can.chat_dms %}<a class="btn btn-sm btn-outline-secondary" href="/characters/{{ character.id }}/whispers">Whispers</a>{% endif %}
|
||||
{% if can.chat_private %}<a class="btn btn-sm btn-outline-secondary" href="/chat_log/teams#character={{ character.id }}">Team chat</a>{% endif %}
|
||||
{% if can.chat_flag %}<a class="btn btn-sm btn-outline-secondary" href="/chat_flags#character={{ character.id }}">Chat flags</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if existsIn(character, "stats") %}
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0">Visited Zones</h5></div>
|
||||
|
||||
73
dDashboardServer/templates/chat_history.jinja2
Normal file
73
dDashboardServer/templates/chat_history.jinja2
Normal file
@@ -0,0 +1,73 @@
|
||||
{% extends "base.jinja2" %}
|
||||
|
||||
{% block title %}{{ title }} - DarkflameServer{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.chathist-layout { display: grid; grid-template-columns: 19rem minmax(0, 1fr); gap: 1rem; align-items: start; }
|
||||
.chathist-layout.single { grid-template-columns: minmax(0, 1fr); }
|
||||
.chathist-list { position: sticky; top: 4rem; }
|
||||
.chathist-list .list-group { max-height: 70vh; overflow-y: auto; }
|
||||
.chathist-list .list-group-item { cursor: pointer; }
|
||||
.chathist-list .meta { font-size: .8rem; color: var(--bs-secondary-color); }
|
||||
.chathist-list .list-group-item.active .meta { color: inherit; opacity: .85; }
|
||||
.chat-line td { vertical-align: top; }
|
||||
.chat-line .when { white-space: nowrap; font-size: .8rem; color: var(--bs-secondary-color); }
|
||||
.chat-line .who { white-space: nowrap; }
|
||||
@media (max-width: 991.98px) { .chathist-layout { grid-template-columns: minmax(0, 1fr); } .chathist-list { position: static; } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="chatHistoryPage" data-mode="{{ mode }}" data-id="{{ id }}">
|
||||
<nav data-crumbs data-crumb-label="{{ title }}" aria-label="Breadcrumb"><ol class="breadcrumb mb-3">
|
||||
{% if mode == "guild" %}<li class="breadcrumb-item"><a href="/guilds">Guilds</a></li>{% endif %}
|
||||
{% if mode == "teams" %}<li class="breadcrumb-item"><a href="/chat_log">Chat Log</a></li>{% endif %}
|
||||
{% if mode == "whispers" %}<li class="breadcrumb-item"><a href="/characters">Characters</a></li><li class="breadcrumb-item"><a href="/characters/{{ id }}">{{ character_name }}</a></li>{% endif %}
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ title }}</li>
|
||||
</ol></nav>
|
||||
<div class="mb-3">
|
||||
<h2 class="mb-1">{{ title }}</h2>
|
||||
<p class="text-body-secondary mb-0">
|
||||
{% if mode == "guild" %}Everything said in this guild's chat, newest at the bottom. <strong>Older</strong> loads the page before.{% endif %}
|
||||
{% if mode == "teams" %}Team chat, one team at a time: pick a team to read what its members said. Filter by a character to see only teams they talked in.{% endif %}
|
||||
{% if mode == "whispers" %}Every character {{ character_name }} whispered with, most recent first; pick one to read the conversation. Opening a conversation is recorded in the audit log.{% endif %}
|
||||
Tick messages to flag them for review (shift-click picks a range). Chat is kept for <code>log_chat_days</code>; flags keep their own copy.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="chathist-layout{% if mode == "guild" %} single{% endif %}">
|
||||
{% if mode != "guild" %}
|
||||
<aside class="chathist-list" aria-label="{% if mode == "teams" %}Teams{% else %}Conversations{% endif %}">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center"><h5 class="mb-0 fs-6">{% if mode == "teams" %}Teams{% else %}Conversations{% endif %}</h5><span class="small text-body-secondary" id="listCount"></span></div>
|
||||
{% if mode == "teams" %}<input class="form-control form-control-sm mt-2" id="teamCharacter" placeholder="Character name or ID">{% endif %}
|
||||
</div>
|
||||
<div class="list-group list-group-flush" id="threadList"><div class="list-group-item text-body-secondary small">Loading…</div></div>
|
||||
<div class="card-footer d-none" id="listMoreBox"><button type="button" class="btn btn-sm btn-outline-secondary w-100" id="listMore">More</button></div>
|
||||
</div>
|
||||
</aside>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<h5 class="mb-0" id="threadTitle">{% if mode == "guild" %}Guild chat{% else %}Pick a conversation{% endif %}</h5>
|
||||
<div class="d-flex flex-wrap gap-2" id="threadLinks"></div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center mb-2" id="flagBar"></div>
|
||||
<div class="text-center mb-2 d-none" id="olderBox"><button type="button" class="btn btn-sm btn-outline-secondary" id="older">Older</button></div>
|
||||
<table class="table table-sm align-middle mb-0" id="threadTable"><tbody id="threadRows">
|
||||
<tr><td class="text-body-secondary">{% if mode == "guild" %}Loading…{% else %}Nothing picked yet.{% endif %}</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/js/chat-flagging.js"></script>
|
||||
<script src="/js/chat-history.js"></script>
|
||||
{% endblock %}
|
||||
@@ -22,7 +22,8 @@
|
||||
<div class="mb-3">
|
||||
<h2 class="mb-1">Chat Log</h2>
|
||||
<p class="text-body-secondary mb-0">What players say, newest first, updating live. Pick a world server to see just its chat, who's in it and how busy it is.
|
||||
Messages the chat filter stopped are marked; nobody saw them. Bots can read and post through the <a href="/api_docs#/chat">API</a>.</p>
|
||||
Messages the chat filter stopped are marked; nobody saw them. Tick messages to flag them for review{% if can.chat_flag %} (<a href="/chat_flags">Chat Flags</a>){% endif %}; <strong>Context</strong> shows the conversation around one.
|
||||
{% if can.chat_private %}<a href="/chat_log/teams">Team chat</a> is also kept by team, and guild chat on each guild. {% endif %}Bots can read and post through the <a href="/api_docs#/chat">API</a>.</p>
|
||||
</div>
|
||||
|
||||
<div class="chatlog-layout">
|
||||
@@ -59,21 +60,29 @@
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-sm-4"><label class="form-label small mb-1" for="chatChannel">Channel</label>
|
||||
<div class="col-sm-6 col-lg-3"><label class="form-label small mb-1" for="chatChannel">Channel</label>
|
||||
<select class="form-select form-select-sm" id="chatChannel">
|
||||
<option value="">All</option><option value="zone">Zone chat</option>
|
||||
{% if can.chat_private %}<option value="whisper">Whispers</option><option value="team">Team chat</option><option value="guild">Guild chat</option>{% endif %}
|
||||
{% if can.chat_dms %}<option value="whisper">Whispers</option>{% endif %}
|
||||
{% if can.chat_private %}<option value="team">Team chat</option><option value="guild">Guild chat</option>{% endif %}
|
||||
<option value="web">From the web and bots</option>
|
||||
</select></div>
|
||||
<div class="col-sm-4"><label class="form-label small mb-1" for="chatCharacter">Character</label>
|
||||
<div class="col-sm-6 col-lg-3"><label class="form-label small mb-1" for="chatCharacter">Character</label>
|
||||
<input class="form-control form-control-sm" id="chatCharacter" placeholder="Name or ID"></div>
|
||||
<div class="col-sm-4"><div class="form-check form-switch mb-1">
|
||||
<div class="col-sm-6 col-lg-2"><label class="form-label small mb-1" for="chatAccount">Account ID</label>
|
||||
<input class="form-control form-control-sm" id="chatAccount" inputmode="numeric" placeholder="Any"></div>
|
||||
<div class="col-sm-6 col-lg-2"><label class="form-label small mb-1" for="chatSince">From</label>
|
||||
<input class="form-control form-control-sm" type="datetime-local" id="chatSince"></div>
|
||||
<div class="col-sm-6 col-lg-2"><label class="form-label small mb-1" for="chatUntil">To</label>
|
||||
<input class="form-control form-control-sm" type="datetime-local" id="chatUntil"></div>
|
||||
<div class="col-sm-6 col-lg-3"><div class="form-check form-switch mb-1">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="chatBlocked"><label class="form-check-label small" for="chatBlocked">Only what the filter stopped</label></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center mb-2" id="flagBar"></div>
|
||||
<table id="chatTable" class="table table-sm table-hover align-middle w-100 table-stack">
|
||||
<thead><tr><th>Time</th><th>Channel</th><th>Where</th><th>From</th><th>To</th><th>Message</th><th></th></tr></thead>
|
||||
<thead><tr><th></th><th>Time</th><th>Channel</th><th>Where</th><th>From</th><th>To</th><th>Message</th><th></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -84,5 +93,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/js/chat-flagging.js"></script>
|
||||
<script src="/js/chat-log.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0" id="guildTitle"></h5>
|
||||
<div>
|
||||
{% if can.chat_private %}<a class="btn btn-sm btn-outline-secondary me-1" id="guildChat" href="#">Guild chat</a>{% endif %}
|
||||
<button class="btn btn-sm btn-outline-primary me-1" id="guildRename">Rename</button>
|
||||
<button class="btn btn-sm btn-outline-danger" id="guildDisband">Disband</button>
|
||||
</div>
|
||||
@@ -92,6 +93,8 @@ $(function () {
|
||||
shown = guild;
|
||||
document.getElementById('guildDetail').classList.remove('d-none');
|
||||
document.getElementById('guildTitle').textContent = guild.name + ' (' + guild.id + ')';
|
||||
var chatLink = document.getElementById('guildChat');
|
||||
if (chatLink) chatLink.href = '/chat_log/guild/' + encodeURIComponent(guild.id);
|
||||
document.getElementById('guildInfo').innerHTML = 'Name ' + esc(guild.name_status) + '. Founded ' + esc(fmt.unix(guild.created_at)) +
|
||||
(guild.founder_name ? ' by ' + fmt.character(guild.founder_id, guild.founder_name) : '') + '.';
|
||||
document.getElementById('guildMembers').innerHTML = guild.members.map(function (m) {
|
||||
@@ -120,6 +123,10 @@ $(function () {
|
||||
api.action('/api/guilds/' + id + '/reject', { reason: reason }, 'Rejected').then(refresh).catch(function () {});
|
||||
});
|
||||
|
||||
// #guild=<id> opens that guild (links from its chat history)
|
||||
var fromHash = (location.hash.match(/guild=(\d+)/) || [])[1];
|
||||
if (fromHash) show(fromHash);
|
||||
|
||||
document.getElementById('guildRename').addEventListener('click', function () {
|
||||
if (!shown) return;
|
||||
var name = prompt('New name for ' + shown.name + ' (3 to 30 letters, digits, spaces, \' - and .):', shown.name);
|
||||
|
||||
@@ -34,6 +34,8 @@ set(DWEBTESTS_SOURCES
|
||||
"ModerationToolsTests.cpp"
|
||||
"LocaleTextTests.cpp"
|
||||
"ModeratorHelperTests.cpp"
|
||||
"ChatHistoryTests.cpp"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes/ChatHistory.cpp"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/ai/ClaudeClient.cpp"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/ai/ModeratorPrompt.cpp"
|
||||
"WorldViewTests.cpp"
|
||||
|
||||
167
tests/dWebTests/ChatHistoryTests.cpp
Normal file
167
tests/dWebTests/ChatHistoryTests.cpp
Normal file
@@ -0,0 +1,167 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "ChatHistory.h"
|
||||
#include "Permissions.h"
|
||||
|
||||
using ChatHistory::Access;
|
||||
|
||||
namespace {
|
||||
constexpr LWOOBJID ALICE = 1152921510000000001;
|
||||
constexpr LWOOBJID BOB = 1152921510000000002;
|
||||
|
||||
IChatLog::ChatMessage Message(uint64_t id, const std::string& channel, LWOOBJID sender, const std::string& name, const std::string& text) {
|
||||
IChatLog::ChatMessage m;
|
||||
m.id = id;
|
||||
m.time = 1700000000 + static_cast<int64_t>(id);
|
||||
m.channel = channel;
|
||||
m.senderId = sender;
|
||||
m.senderName = name;
|
||||
m.accountId = sender == ALICE ? 1 : 2;
|
||||
m.zoneId = 1100;
|
||||
m.instanceId = 3;
|
||||
m.message = text;
|
||||
return m;
|
||||
}
|
||||
|
||||
IChatLog::ChatMessage Whisper(uint64_t id, LWOOBJID from, LWOOBJID to, const std::string& text) {
|
||||
auto m = Message(id, "whisper", from, from == ALICE ? "Alice" : "Bob", text);
|
||||
m.recipientId = to;
|
||||
m.recipientName = to == ALICE ? "Alice" : "Bob";
|
||||
return m;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ChatHistoryTests, EachPrivateChannelNeedsItsPermission) {
|
||||
const Access none{}, group{ true, false }, dms{ false, true };
|
||||
for (const auto* open : { "zone", "web" }) {
|
||||
EXPECT_TRUE(ChatHistory::CanRead(open, none)) << open;
|
||||
}
|
||||
EXPECT_FALSE(ChatHistory::CanRead("team", none));
|
||||
EXPECT_FALSE(ChatHistory::CanRead("guild", dms));
|
||||
EXPECT_TRUE(ChatHistory::CanRead("guild", group));
|
||||
EXPECT_FALSE(ChatHistory::CanRead("whisper", group));
|
||||
EXPECT_TRUE(ChatHistory::CanRead("whisper", dms));
|
||||
EXPECT_FALSE(ChatHistory::CanRead("something new", Access{ true, true }));
|
||||
}
|
||||
|
||||
TEST(ChatHistoryTests, WhispersAreTheirOwnPermission) {
|
||||
const auto* dms = Permissions::Find("chat_dms");
|
||||
const auto* group = Permissions::Find("chat_private");
|
||||
ASSERT_NE(dms, nullptr);
|
||||
ASSERT_NE(group, nullptr);
|
||||
EXPECT_EQ(dms->category, group->category);
|
||||
ASSERT_NE(Permissions::Find("chat_flag"), nullptr);
|
||||
ASSERT_NE(Permissions::Find("chat_flag_review"), nullptr);
|
||||
// Reading whispers stays with the highest staff levels by default, like team and guild chat
|
||||
EXPECT_GE(dms->defaultLevel, group->defaultLevel);
|
||||
}
|
||||
|
||||
TEST(ChatHistoryTests, MessageTextIsLeftOutWithoutThePermission) {
|
||||
auto m = Whisper(5, ALICE, BOB, "secret");
|
||||
m.filtered = true;
|
||||
const auto hidden = ChatHistory::MessageJson(m, {});
|
||||
EXPECT_EQ(hidden["message"], "");
|
||||
EXPECT_TRUE(hidden["redacted"].get<bool>());
|
||||
EXPECT_EQ(hidden["recipient_id"], std::to_string(BOB)); // IDs are strings: 64-bit numbers don't survive JavaScript
|
||||
const auto shown = ChatHistory::MessageJson(m, { false, true });
|
||||
EXPECT_EQ(shown["message"], "secret");
|
||||
EXPECT_FALSE(shown["redacted"].get<bool>());
|
||||
EXPECT_TRUE(shown["filtered"].get<bool>());
|
||||
}
|
||||
|
||||
TEST(ChatHistoryTests, ConversationQueryFollowsTheChannel) {
|
||||
const Access all{ true, true };
|
||||
const auto zone = ChatHistory::ConversationQuery(Message(1, "zone", ALICE, "Alice", "hi"), all);
|
||||
EXPECT_EQ(zone.channel, "zone");
|
||||
EXPECT_EQ(zone.zoneId, 1100u);
|
||||
EXPECT_EQ(zone.instanceId, 3);
|
||||
|
||||
const auto whisper = ChatHistory::ConversationQuery(Whisper(2, ALICE, BOB, "hi"), all);
|
||||
EXPECT_EQ(whisper.channel, "whisper");
|
||||
EXPECT_EQ(whisper.characterId, ALICE);
|
||||
EXPECT_EQ(whisper.otherCharacterId, BOB);
|
||||
EXPECT_TRUE(whisper.includeWhispers);
|
||||
|
||||
auto team = Message(3, "team", BOB, "Bob", "go");
|
||||
team.teamId = 42;
|
||||
EXPECT_EQ(ChatHistory::ConversationQuery(team, all).teamId, 42);
|
||||
// Team chat logged before teams were recorded can't be told apart: no conversation rather than every team's
|
||||
team.teamId = 0;
|
||||
EXPECT_EQ(ChatHistory::ConversationQuery(team, all).channel, "none");
|
||||
|
||||
auto guild = Message(4, "guild", BOB, "Bob", "gg");
|
||||
guild.guildId = 9;
|
||||
const auto guildQuery = ChatHistory::ConversationQuery(guild, { false, false });
|
||||
EXPECT_EQ(guildQuery.guildId, 9);
|
||||
EXPECT_FALSE(guildQuery.includePrivate); // what the viewer may read still decides
|
||||
}
|
||||
|
||||
TEST(ChatHistoryTests, SameConversation) {
|
||||
EXPECT_TRUE(ChatHistory::SameConversation(Whisper(1, ALICE, BOB, "a"), Whisper(2, BOB, ALICE, "b")));
|
||||
EXPECT_FALSE(ChatHistory::SameConversation(Whisper(1, ALICE, BOB, "a"), Whisper(2, ALICE, 7, "b")));
|
||||
auto zone1 = Message(1, "zone", ALICE, "Alice", "a");
|
||||
auto zone2 = Message(2, "zone", BOB, "Bob", "b");
|
||||
EXPECT_TRUE(ChatHistory::SameConversation(zone1, zone2));
|
||||
zone2.instanceId = 4;
|
||||
EXPECT_FALSE(ChatHistory::SameConversation(zone1, zone2));
|
||||
EXPECT_FALSE(ChatHistory::SameConversation(zone1, Whisper(3, ALICE, BOB, "c")));
|
||||
auto guildA = Message(1, "guild", ALICE, "Alice", "a"), guildB = Message(2, "guild", BOB, "Bob", "b");
|
||||
guildA.guildId = 1; guildB.guildId = 2;
|
||||
EXPECT_FALSE(ChatHistory::SameConversation(guildA, guildB));
|
||||
}
|
||||
|
||||
TEST(ChatHistoryTests, FlagStatuses) {
|
||||
EXPECT_EQ(ChatHistory::ParseStatus("open"), "open");
|
||||
EXPECT_EQ(ChatHistory::ParseStatus("actioned"), "actioned");
|
||||
EXPECT_EQ(ChatHistory::ParseStatus("dismissed"), "dismissed");
|
||||
EXPECT_FALSE(ChatHistory::ParseStatus("closed").has_value());
|
||||
EXPECT_FALSE(ChatHistory::ParseStatus("").has_value());
|
||||
EXPECT_EQ(ChatHistory::StatusAction("open", "actioned"), "actioned");
|
||||
EXPECT_EQ(ChatHistory::StatusAction("actioned", "open"), "reopened");
|
||||
EXPECT_EQ(ChatHistory::StatusAction("dismissed", "dismissed"), "");
|
||||
}
|
||||
|
||||
TEST(ChatHistoryTests, ExcerptCutsBetweenCharacters) {
|
||||
const std::vector<IChatLog::ChatMessage> two{ Message(1, "zone", ALICE, "Alice", "hi"), Message(2, "zone", BOB, "Bob", "yo") };
|
||||
EXPECT_EQ(ChatHistory::Excerpt(two), "Alice: hi / Bob: yo");
|
||||
// "é" is two bytes; a cut in its middle backs up to before it
|
||||
const std::vector<IChatLog::ChatMessage> accent{ Message(1, "zone", ALICE, "A", "\xC3\xA9\xC3\xA9\xC3\xA9\xC3\xA9") };
|
||||
const auto cut = ChatHistory::Excerpt(accent, 9); // "A: " + 6 bytes fit before the ellipsis, 3 of them whole characters
|
||||
EXPECT_EQ(cut, "A: \xC3\xA9\xE2\x80\xA6");
|
||||
EXPECT_LE(cut.size(), 9u);
|
||||
}
|
||||
|
||||
TEST(ChatHistoryTests, SnapshotMarksTheFlaggedMessagesInOrder) {
|
||||
const auto snapshot = ChatHistory::Snapshot({ Message(1, "zone", BOB, "Bob", "before"), Message(4, "zone", BOB, "Bob", "between") },
|
||||
{ Message(3, "zone", ALICE, "Alice", "bad"), Message(5, "zone", ALICE, "Alice", "worse") }, { Message(7, "zone", BOB, "Bob", "after") });
|
||||
ASSERT_EQ(snapshot.size(), 5u);
|
||||
std::vector<uint64_t> order;
|
||||
for (const auto& m : snapshot) order.push_back(m["id"].get<uint64_t>());
|
||||
EXPECT_EQ(order, (std::vector<uint64_t>{ 1, 3, 4, 5, 7 }));
|
||||
EXPECT_FALSE(snapshot[0]["flagged"].get<bool>());
|
||||
EXPECT_TRUE(snapshot[1]["flagged"].get<bool>());
|
||||
EXPECT_FALSE(snapshot[2]["flagged"].get<bool>());
|
||||
EXPECT_EQ(snapshot[3]["message"], "worse");
|
||||
EXPECT_FALSE(snapshot[0].contains("redacted")); // kept whole; hidden when shown (RedactSnapshot)
|
||||
}
|
||||
|
||||
TEST(ChatHistoryTests, RedactSnapshotHidesChannelsTheViewerMayNotRead) {
|
||||
const auto snapshot = ChatHistory::Snapshot({}, { Whisper(1, ALICE, BOB, "secret") }, {});
|
||||
const auto hidden = ChatHistory::RedactSnapshot(snapshot, { true, false });
|
||||
ASSERT_EQ(hidden.size(), 1u);
|
||||
EXPECT_EQ(hidden[0]["message"], "");
|
||||
EXPECT_TRUE(hidden[0]["redacted"].get<bool>());
|
||||
EXPECT_TRUE(hidden[0]["flagged"].get<bool>());
|
||||
EXPECT_EQ(ChatHistory::RedactSnapshot(snapshot, { false, true })[0]["message"], "secret");
|
||||
// A damaged copy shows as nothing, not an error
|
||||
EXPECT_TRUE(ChatHistory::RedactSnapshot(nlohmann::json("not a list"), {}).empty());
|
||||
}
|
||||
|
||||
TEST(ChatHistoryTests, SubjectIsAChosenSenderOrTheFirst) {
|
||||
auto web = Message(1, "web", 0, "[Web] gm", "announcement");
|
||||
const std::vector<IChatLog::ChatMessage> flagged{ web, Message(2, "zone", BOB, "Bob", "a"), Message(3, "zone", ALICE, "Alice", "b") };
|
||||
EXPECT_EQ(ChatHistory::Subject(flagged, 0)->senderId, BOB);
|
||||
EXPECT_EQ(ChatHistory::Subject(flagged, ALICE)->senderId, ALICE);
|
||||
EXPECT_EQ(ChatHistory::Subject(flagged, 12345)->senderId, BOB); // not one of the senders
|
||||
EXPECT_FALSE(ChatHistory::Subject({ web }, 0).has_value());
|
||||
}
|
||||
Reference in New Issue
Block a user