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:
Aaron Kimbrell
2026-09-29 18:22:42 -05:00
parent 6fd0dbd723
commit f3a49991d4
13 changed files with 1013 additions and 35 deletions

View File

@@ -37,6 +37,7 @@ set(DASHBOARDROUTES_SOURCES
"LeaderboardRoutes.cpp"
"VanityRoutes.cpp"
"ChatRoutes.cpp"
"ChatHistory.cpp"
"Strikes.cpp"
"Inspector.cpp"
"CDClientBrowser.cpp"

View 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;
}
}

View 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);
}

View File

@@ -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}",