diff --git a/dDashboardServer/routes/CMakeLists.txt b/dDashboardServer/routes/CMakeLists.txt index c347e04e1..ca2c66682 100644 --- a/dDashboardServer/routes/CMakeLists.txt +++ b/dDashboardServer/routes/CMakeLists.txt @@ -37,6 +37,7 @@ set(DASHBOARDROUTES_SOURCES "LeaderboardRoutes.cpp" "VanityRoutes.cpp" "ChatRoutes.cpp" + "ChatHistory.cpp" "Strikes.cpp" "Inspector.cpp" "CDClientBrowser.cpp" diff --git a/dDashboardServer/routes/ChatHistory.cpp b/dDashboardServer/routes/ChatHistory.cpp new file mode 100644 index 000000000..bc912c68c --- /dev/null +++ b/dDashboardServer/routes/ChatHistory.cpp @@ -0,0 +1,121 @@ +#include "ChatHistory.h" + +#include + +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 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& 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(text[cut]) & 0xC0) == 0x80) cut--; + return text.substr(0, cut) + ellipsis; + } + + nlohmann::json Snapshot(const std::vector& before, const std::vector& flagged, + const std::vector& after) { + const Access all{ true, true }; + nlohmann::json out = nlohmann::json::array(); + const auto add = [&](const std::vector& 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 Subject(const std::vector& 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; + } +} diff --git a/dDashboardServer/routes/ChatHistory.h b/dDashboardServer/routes/ChatHistory.h new file mode 100644 index 000000000..b937c6c01 --- /dev/null +++ b/dDashboardServer/routes/ChatHistory.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include + +#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 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& 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& before, const std::vector& flagged, + const std::vector& 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 Subject(const std::vector& flagged, LWOOBJID chosen); +} diff --git a/dDashboardServer/routes/ChatRoutes.cpp b/dDashboardServer/routes/ChatRoutes.cpp index 59b24deb5..c735dbb67 100644 --- a/dDashboardServer/routes/ChatRoutes.cpp +++ b/dDashboardServer/routes/ChatRoutes.cpp @@ -1,8 +1,10 @@ #include "ChatRoutes.h" #include +#include #include +#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& messages, const ChatHistory::Access& access) { + std::vector ids; + for (const auto& m : messages) ids.push_back(m.id); + std::map 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& 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(get("account")).value_or(0); q.zoneId = GeneralUtils::TryParse(get("zone")).value_or(0); q.instanceId = GeneralUtils::TryParse(get("instance")).value_or(-1); + q.since = std::max(0, GeneralUtils::TryParse(get("since")).value_or(0)); + q.until = std::max(0, GeneralUtils::TryParse(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(GeneralUtils::TryParse(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(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 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([] { 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(QueryValue(context.queryString, "after")).value_or(0); - q.limit = std::clamp(GeneralUtils::TryParse(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(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(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(GeneralUtils::TryParse(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 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(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(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(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(context.path, 2); + if (!characterId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid character ID"); + const auto offset = GeneralUtils::TryParse(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(context.path, 2); + const auto otherId = PathId(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(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(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}", diff --git a/dDashboardServer/static/js/chat-flagging.js b/dDashboardServer/static/js/chat-flagging.js new file mode 100644 index 000000000..ed013a647 --- /dev/null +++ b/dDashboardServer/static/js/chat-flagging.js @@ -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 ' Flagged'; + } + + // The message text, or why it can't be shown + function text(row) { + if (row.redacted) return 'Hidden: reading ' + esc(row.channel) + ' chat needs ' + esc(CHANNEL_NEEDS[row.channel] || 'another permission') + ''; + 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 + '' + esc(row.message) + ''; + } + + function checkbox(row) { + if (!DASH.can('chat_flag') || row.redacted || !row.id) return ''; + return ''; + } + + /** + * 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 = '' + (n ? n + ' message' + (n === 1 ? '' : 's') + ' picked' : + 'Tick messages to flag them for review (shift-click picks a range).') + '' + + (n ? ' ' : ''); + } + + 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 = ''; + 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 }; +})(); diff --git a/dDashboardServer/static/js/chat-history.js b/dDashboardServer/static/js/chat-history.js new file mode 100644 index 000000000..6ec14c437 --- /dev/null +++ b/dDashboardServer/static/js/chat-history.js @@ -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= and #with= 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 ? '
' + esc(m.zone_name || ('Zone ' + m.zone_id)) + '
' : ''; + return '' + ChatFlagging.checkbox(m) + '' + + '' + esc(fmt.unix(m.time)) + '' + + '' + (m.sender_id !== '0' ? fmt.character(m.sender_id, m.sender_name) : esc(m.sender_name)) + where + '' + + '' + ChatFlagging.text(m) + ChatFlagging.flagLink(m) + '' + + '' + (m.account_id ? 'Account' : '') + ''; + } + + function render() { + if (!thread) return; + rows.innerHTML = thread.messages.length ? thread.messages.map(line).join('') + : 'No messages (chat is kept for log_chat_days).'; + 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 = '' + esc(d.error || 'Could not load the chat') + ''; 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') ? 'Members and history' : ''); + }).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 '' + + '
' + esc(item.title) + '' + + '' + nf.format(item.messages) + '
' + + '
' + esc(item.meta) + '
'; + } + + function renderList(total) { + listEl.innerHTML = list.items.length ? list.items.map(itemHtml).join('') : '
None found.
'; + 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 = '
' + esc(d.error || 'Could not load') + '
'; 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('' + esc(name) + '' + + 'Their whispers'); + 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 () {}); +})(); diff --git a/dDashboardServer/static/js/chat-log.js b/dDashboardServer/static/js/chat-log.js index 82960dff3..a4e1e1f5b 100644 --- a/dDashboardServer/static/js/chat-log.js +++ b/dDashboardServer/static/js/chat-log.js @@ -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') + ' ' : '') + '' + esc(d) + ''; - } }, - // 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 '
' + (d ? 'Account' : '') + suggest + '
'; + var thread = conversationLink(row); + return '
' + + (row.redacted ? '' : '') + + (thread ? 'Conversation' : '') + + (d ? 'Account' : '') + suggest + '
'; } } ], { 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 = ''; + 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(); }); diff --git a/dDashboardServer/templates/character-view.jinja2 b/dDashboardServer/templates/character-view.jinja2 index a90f5d456..d7879cbfd 100644 --- a/dDashboardServer/templates/character-view.jinja2 +++ b/dDashboardServer/templates/character-view.jinja2 @@ -108,6 +108,18 @@ + {% if can.chat_view or can.chat_dms or can.chat_private or can.chat_flag %} +
+
Chat
+
+ {% if can.chat_view %}Chat log{% endif %} + {% if can.chat_dms %}Whispers{% endif %} + {% if can.chat_private %}Team chat{% endif %} + {% if can.chat_flag %}Chat flags{% endif %} +
+
+ {% endif %} + {% if existsIn(character, "stats") %}
Visited Zones
diff --git a/dDashboardServer/templates/chat_history.jinja2 b/dDashboardServer/templates/chat_history.jinja2 new file mode 100644 index 000000000..f69aee118 --- /dev/null +++ b/dDashboardServer/templates/chat_history.jinja2 @@ -0,0 +1,73 @@ +{% extends "base.jinja2" %} + +{% block title %}{{ title }} - DarkflameServer{% endblock %} + +{% block css %} + +{% endblock %} + +{% block content %} +
+ +
+

{{ title }}

+

+ {% if mode == "guild" %}Everything said in this guild's chat, newest at the bottom. Older 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 log_chat_days; flags keep their own copy. +

+
+ +
+ {% if mode != "guild" %} + + {% endif %} + +
+
+
{% if mode == "guild" %}Guild chat{% else %}Pick a conversation{% endif %}
+ +
+
+
+
+ + +
{% if mode == "guild" %}Loading…{% else %}Nothing picked yet.{% endif %}
+
+
+
+
+{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/dDashboardServer/templates/chat_log.jinja2 b/dDashboardServer/templates/chat_log.jinja2 index 85c0b1197..deda8d561 100644 --- a/dDashboardServer/templates/chat_log.jinja2 +++ b/dDashboardServer/templates/chat_log.jinja2 @@ -22,7 +22,8 @@

Chat Log

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 API.

+ Messages the chat filter stopped are marked; nobody saw them. Tick messages to flag them for review{% if can.chat_flag %} (Chat Flags){% endif %}; Context shows the conversation around one. + {% if can.chat_private %}Team chat is also kept by team, and guild chat on each guild. {% endif %}Bots can read and post through the API.

@@ -59,21 +60,29 @@
-
+
-
+
-
+
+
+
+
+
+
+
+
- +
TimeChannelWhereFromToMessage
TimeChannelWhereFromToMessage
@@ -84,5 +93,6 @@ {% endblock %} {% block scripts %} + {% endblock %} diff --git a/dDashboardServer/templates/guilds.jinja2 b/dDashboardServer/templates/guilds.jinja2 index 031fa9ea2..706e3ccd7 100644 --- a/dDashboardServer/templates/guilds.jinja2 +++ b/dDashboardServer/templates/guilds.jinja2 @@ -35,6 +35,7 @@
+ {% if can.chat_private %}Guild chat{% endif %}
@@ -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= 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); diff --git a/tests/dWebTests/CMakeLists.txt b/tests/dWebTests/CMakeLists.txt index 496a42712..2a65763b3 100644 --- a/tests/dWebTests/CMakeLists.txt +++ b/tests/dWebTests/CMakeLists.txt @@ -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" diff --git a/tests/dWebTests/ChatHistoryTests.cpp b/tests/dWebTests/ChatHistoryTests.cpp new file mode 100644 index 000000000..79bac6993 --- /dev/null +++ b/tests/dWebTests/ChatHistoryTests.cpp @@ -0,0 +1,167 @@ +#include + +#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(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()); + 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()); + EXPECT_TRUE(shown["filtered"].get()); +} + +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 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 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 order; + for (const auto& m : snapshot) order.push_back(m["id"].get()); + EXPECT_EQ(order, (std::vector{ 1, 3, 4, 5, 7 })); + EXPECT_FALSE(snapshot[0]["flagged"].get()); + EXPECT_TRUE(snapshot[1]["flagged"].get()); + EXPECT_FALSE(snapshot[2]["flagged"].get()); + 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()); + EXPECT_TRUE(hidden[0]["flagged"].get()); + 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 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()); +}