diff --git a/dDashboardServer/routes/APIRoutes.cpp b/dDashboardServer/routes/APIRoutes.cpp index 508cc61ac..9501ea5fa 100644 --- a/dDashboardServer/routes/APIRoutes.cpp +++ b/dDashboardServer/routes/APIRoutes.cpp @@ -41,6 +41,7 @@ #include #include #include "Locale.h" +#include "LocaleText.h" #include #include #include @@ -128,6 +129,56 @@ namespace { }); } + std::string Localized(const std::string& text) { + return LocaleText::Expand(text, [](const std::string& key) -> const std::string& { return Locale::GetPhrase(key); }); + } + + // One mail row for the Mail page and mailboxes: the stored text, the text as the client shows it (locale keys + // translated), both ends with their accounts, the attachment and the state. The first fields are the ones the + // mailbox API always had. + nlohmann::json MailJson(const IMail::MailRecord& m) { + nlohmann::json attachment = nullptr; + if (m.attachmentCount > 0) { + attachment = { {"id", std::to_string(m.attachmentId)}, {"lot", m.attachmentLot}, {"count", m.attachmentCount}, + {"subkey", std::to_string(m.attachmentSubkey)}, {"name", m.attachmentLot > 0 ? ClientAssets::ObjectName(m.attachmentLot).value_or("") : std::string{}}, + {"claimed", m.attachmentLot <= 0}, {"config", m.attachmentConfig} }; + } + const bool waiting = m.attachmentLot > 0 && m.attachmentCount > 0; + return { + {"id", std::to_string(m.id)}, + {"sender", m.senderName}, + {"subject", m.subject}, + {"body", m.body}, + {"time_sent", m.timeSent}, + {"read", m.read}, + {"attachment_lot", waiting ? m.attachmentLot : 0}, + {"attachment_count", waiting ? m.attachmentCount : 0}, + {"attachment_name", waiting ? ClientAssets::ObjectName(m.attachmentLot).value_or("") : std::string{}}, + {"sender_id", std::to_string(m.senderId)}, + {"sender_name", m.senderName}, + {"sender_text", Localized(m.senderName)}, + {"sender_kind", m.senderId != LWOOBJID_EMPTY ? "player" : m.senderName.starts_with("[GM] ") ? "staff" : "game"}, + {"sender_account_id", m.senderAccountId}, + {"receiver_id", std::to_string(m.receiverId)}, + {"receiver_name", m.receiverName}, + {"receiver_account_id", m.receiverAccountId}, + {"subject_text", Localized(m.subject)}, + {"body_text", Localized(m.body)}, + {"attachment", attachment}, + {"deleted_at", m.deletedAt} + }; + } + + IMail::eMailState MailState(const std::string& name) { + using eMailState = IMail::eMailState; + if (name == "unread") return eMailState::UNREAD; + if (name == "read") return eMailState::READ; + if (name == "attachment") return eMailState::ATTACHMENT; + if (name == "claimed") return eMailState::CLAIMED; + if (name == "deleted") return eMailState::DELETED; + return eMailState::ANY; + } + // Parse the numeric ID at the given path segment, replying 400 if it is not a valid number template std::optional RequireId(const HTTPContext& context, size_t segment, HTTPReply& reply) { @@ -928,26 +979,30 @@ namespace { JsonSuccess(reply, { {"requestId", requestId}, {"message", "Uploading"}, {"warnings", check.warnings}, {"removed", removeIds.size()} }); }); - Route(eHTTPMethod::GET, "/api/characters/:id/mail", 0, "A character's mailbox (up to 100 most recent). With characters_mail, or the character's owner", + Route(eHTTPMethod::GET, "/api/characters/:id/mail", 0, + "A character's mailbox (up to 100 most recent), newest first: [{id, sender, subject, body, time_sent, read, attachment_lot, attachment_count, " + "attachment_name, sender_id, sender_name, sender_text, sender_kind, sender_account_id, receiver_id, receiver_name, receiver_account_id, " + "subject_text, body_text (locale keys translated), attachment: {id, lot, count, subkey, name, claimed, config} or null, deleted_at}]. " + "With characters_mail, including mail the player deleted; the character's owner sees only what is still in the mailbox", [](HTTPReply& reply, const HTTPContext& context) { const auto charId = RequireId(context, 2, reply); if (!charId) return; const auto info = Database::Get()->GetCharacterInfo(*charId); if (!info) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Character not found"); - if (!Can(context, "characters_mail") && info->accountId != context.accountId) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Insufficient permissions"); + const bool staff = Can(context, "characters_mail"); + if (!staff && info->accountId != context.accountId) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Insufficient permissions"); + // The mailbox: what this character received (the Mail page also has what it sent) + IMail::MailQuery query; + query.characterId = *charId; + query.includeDeleted = staff; + query.limit = 500; nlohmann::json mail = nlohmann::json::array(); - for (const auto& m : Database::Get()->GetMailForPlayer(*charId, 100)) { - mail.push_back({ - {"id", std::to_string(m.id)}, - {"sender", m.senderUsername}, - {"subject", m.subject}, - {"body", m.body}, - {"time_sent", m.timeSent}, - {"read", m.wasRead}, - {"attachment_lot", m.itemLOT}, - {"attachment_count", m.itemCount}, - {"attachment_name", m.itemLOT > 0 ? ItemName(m.itemLOT) : ""} - }); + for (const auto& m : Database::Get()->GetMailHistory(query)) { + if (m.receiverId != *charId || mail.size() >= 100) continue; + auto row = MailJson(m); + // Other players' accounts are for staff + if (!staff) row["sender_account_id"] = row["receiver_account_id"] = 0; + mail.push_back(std::move(row)); } JsonReply(reply, eHTTPStatusCode::OK, mail); }); @@ -1460,6 +1515,32 @@ namespace { } void RegisterMailRoutes() { + Route(eHTTPMethod::POST, "/api/tables/mail", Perm("characters_mail"), + "Every in-game mail for the Mail page (DataTables), newest first, deleted mail included; rows as /api/characters/:id/mail. " + "Body adds {state (unread, read, attachment, claimed, deleted), character (name or ID; sent or received), account (ID)}", + [](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 text = [&body](const char* name) { + const auto it = body->find(name); + return it != body->end() && it->is_string() ? it->get() : std::string{}; + }; + IMail::MailQuery query; + query.search = request->search; + query.state = MailState(text("state")); + // A name or ID that matches no character matches no mail + if (const auto character = text("character"); !character.empty()) query.characterId = ResolveCharacter(character).value_or(-1); + if (const auto account = text("account"); !account.empty()) query.accountId = GeneralUtils::TryParse(account).value_or(UINT32_MAX); + query.offset = request->start; + query.limit = std::clamp(request->length, 1, 500); + IMail::MailQuery all; + nlohmann::json rows = nlohmann::json::array(); + for (const auto& m : Database::Get()->GetMailHistory(query)) rows.push_back(MailJson(m)); + JsonReply(reply, eHTTPStatusCode::OK, { {"draw", request->draw}, {"recordsTotal", Database::Get()->CountMailHistory(all)}, + {"recordsFiltered", Database::Get()->CountMailHistory(query)}, {"data", rows} }); + }); + Route(eHTTPMethod::POST, "/api/mail/send", Perm("mail_send"), "Send in-game mail. Body: {recipient_id ('0' = everyone), subject, body, attachment_lot, attachment_count}", [](HTTPReply& reply, const HTTPContext& context) { const auto body = ParseBody(context); diff --git a/dDashboardServer/routes/DashboardRoutes.cpp b/dDashboardServer/routes/DashboardRoutes.cpp index 90e94c191..7e9c2597d 100644 --- a/dDashboardServer/routes/DashboardRoutes.cpp +++ b/dDashboardServer/routes/DashboardRoutes.cpp @@ -441,6 +441,7 @@ void RegisterDashboardRoutes() { }); SimplePage("/reports", Perm("reports_view"), "reports.jinja2", "reports", "Economy and world reports"); SimplePage("/pet_names", Perm("moderate_pet_names"), "pet_names.jinja2", "pet_names", "Pet names"); + SimplePage("/mail", Perm("characters_mail"), "mail.jinja2", "mail", "Every in-game mail, deleted mail included"); SimplePage("/send_mail", Perm("mail_send"), "send_mail.jinja2", "send_mail", "Send mail"); SimplePage("/api_docs", 0, "api_docs.jinja2", "api_docs", "API documentation"); SimplePage("/webhooks", Perm("webhooks"), "webhooks.jinja2", "webhooks", "Outgoing webhooks for alerts"); diff --git a/dDashboardServer/routes/LocaleText.h b/dDashboardServer/routes/LocaleText.h new file mode 100644 index 000000000..569f206c9 --- /dev/null +++ b/dDashboardServer/routes/LocaleText.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +// Text the game stores with locale keys in it, as the client shows it +namespace LocaleText { + /** + * Every %[KEY] in `text` replaced with the phrase `lookup` gives for KEY (the client's locale.xml); a key without a + * phrase stays as written. Mail from the game is stored this way ("%[MissionEmail_12_subjectText]"). Pure; unit tested. + */ + inline std::string Expand(const std::string& text, const std::function& lookup) { + std::string out; + size_t pos = 0; + while (pos < text.size()) { + const auto start = text.find("%[", pos); + const auto end = start == std::string::npos ? std::string::npos : text.find(']', start + 2); + if (end == std::string::npos) break; + out.append(text, pos, start - pos); + const auto& phrase = lookup(text.substr(start + 2, end - start - 2)); + if (phrase.empty()) out.append(text, start, end - start + 1); + else out += phrase; + pos = end + 1; + } + out.append(text, pos, std::string::npos); + return out; + } +} diff --git a/dDashboardServer/static/js/mail-view.js b/dDashboardServer/static/js/mail-view.js new file mode 100644 index 000000000..ff926494a --- /dev/null +++ b/dDashboardServer/static/js/mail-view.js @@ -0,0 +1,75 @@ +/** + * How one in-game mail row is shown on the Mail page and in a character's mailbox: who sent it and to whom (linked to + * their characters and accounts), its subject and body as the client shows them (locale keys translated), the + * attachment with its icon, and its state (unread, read, attachment waiting or claimed, deleted by the player). + * Rows come from /api/tables/mail and /api/characters/:id/mail. + */ +(function () { + 'use strict'; + + function account(id) { + return id ? ' (account)' : ''; + } + + var MailView = { + // Sender: a player's character, the game, or a staff member ("[GM] name") + sender: function (m, withAccount) { + if (m.sender_id && m.sender_id !== '0') return fmt.character(m.sender_id, m.sender_name) + (withAccount ? account(m.sender_account_id) : ''); + return '' + esc(m.sender_text) + ' ' + + fmt.badge(m.sender_kind === 'staff' ? 'Staff' : 'Game', m.sender_kind === 'staff' ? 'warning' : 'secondary'); + }, + + receiver: function (m, withAccount) { + return fmt.character(m.receiver_id, m.receiver_name) + (withAccount ? account(m.receiver_account_id) : ''); + }, + + attachment: function (m, large) { + var a = m.attachment; + if (!a) return '-'; + var size = large ? 40 : 28; + var icon = a.lot ? '' : ''; + var name = a.lot ? esc(a.count) + 'Ă— ' + esc(a.name || ('LOT ' + a.lot)) + ' (' + esc(a.lot) + ')' + : esc(a.count) + ' item(s)'; + var state = a.claimed ? ' ' + fmt.badge('Claimed', 'secondary') : ' ' + fmt.badge('Waiting', 'info'); + return '' + icon + name + '' + state; + }, + + state: function (m) { + if (m.deleted_at) return '' + fmt.badge('Deleted', 'danger') + ''; + return m.read ? fmt.badge('Read', 'secondary') : fmt.badge('Unread', 'primary'); + }, + + // The subject as shown, with the stored text on hover when it had locale keys + subject: function (m) { + var title = m.subject_text !== m.subject ? ' title="Stored as ' + esc(m.subject) + '"' : ''; + return '' + esc(m.subject_text || '(no subject)') + ''; + }, + + // The whole mail, for a dialog or a mailbox list + card: function (m) { + var stored = m.subject_text !== m.subject || m.body_text !== m.body || m.sender_text !== m.sender_name; + var a = m.attachment; + var rows = [ + ['From', MailView.sender(m, true)], + ['To', MailView.receiver(m, true)], + ['Sent', esc(fmt.unix(m.time_sent))], + ['State', m.deleted_at ? fmt.badge('Deleted by the player', 'danger') + ' ' + esc(fmt.unix(m.deleted_at)) + ' (' + (m.read ? 'read' : 'never read') + ' before; the player no longer sees it)' + : (m.read ? fmt.badge('Read', 'secondary') : fmt.badge('Unread', 'primary'))] + ]; + if (a) { + rows.push(['Attachment', MailView.attachment(m, true)]); + if (a.id && a.id !== '0') rows.push(['Item ID', '' + esc(a.id) + '' + (a.subkey && a.subkey !== '0' ? ' subkey ' + esc(a.subkey) + '' : '')]); + if (a.config) rows.push(['Item data', '
' + esc(a.config) + '
']); + } + return '
' + + '
' + MailView.subject(m) + '#' + esc(m.id) + '
' + + '
' + rows.map(function (r) { return '
' + r[0] + '
' + r[1] + '
'; }).join('') + '
' + + '
' + esc(m.body_text) + '
' + + (stored ? '
Stored text (locale keys)
' + + esc('From: ' + m.sender_name + '\nSubject: ' + m.subject + '\n\n' + m.body) + '
' : '') + + '
'; + } + }; + + window.MailView = MailView; +})(); diff --git a/dDashboardServer/static/js/mail.js b/dDashboardServer/static/js/mail.js new file mode 100644 index 000000000..47da5f30b --- /dev/null +++ b/dDashboardServer/static/js/mail.js @@ -0,0 +1,53 @@ +/** + * The Mail page: every in-game mail (DataTables on /api/tables/mail), filtered by state, character or account. + * Opens with filters from the address: /mail#character=, /mail#account=, /mail#state=deleted. + */ +(function () { + 'use strict'; + + var filters = { state: '', character: '', account: '' }; + (location.hash.replace(/^#/, '').split('&')).forEach(function (part) { + var kv = part.split('='); + if (kv.length === 2 && kv[0] in filters) filters[kv[0]] = decodeURIComponent(kv[1]); + }); + document.getElementById('mailState').value = filters.state; + document.getElementById('mailCharacter').value = filters.character; + document.getElementById('mailAccount').value = filters.account; + + var rows = {}; + var table = serverTable('#mailTable', '/api/tables/mail', [ + { data: 'time_sent', orderable: false, render: function (d) { return '' + esc(fmt.unix(d)) + ''; } }, + { data: 'sender_name', orderable: false, render: function (d, t, m) { return MailView.sender(m); } }, + { data: 'receiver_name', orderable: false, render: function (d, t, m) { return MailView.receiver(m); } }, + // The subject opens the whole mail + { data: 'subject', orderable: false, render: function (d, t, m) { + rows[m.id] = m; + return '' + esc(m.subject_text || '(no subject)') + ''; + } }, + { data: 'attachment', orderable: false, render: function (d, t, m) { return MailView.attachment(m); } }, + { data: 'deleted_at', orderable: false, render: function (d, t, m) { return MailView.state(m); } } + ], { + liveTable: 'mail', + dataTable: { language: { searchPlaceholder: 'Subject, text or name' } }, + extra: function () { return { state: filters.state, character: filters.character, account: filters.account }; } + }); + + function setFilter(name, value) { + filters[name] = value; + var hash = Object.keys(filters).filter(function (k) { return filters[k]; }).map(function (k) { return k + '=' + encodeURIComponent(filters[k]); }).join('&'); + try { history.replaceState(null, '', hash ? '#' + hash : location.pathname); } catch (e) {} + table.ajax.reload(); + } + + document.getElementById('mailState').addEventListener('change', function () { setFilter('state', this.value); }); + document.getElementById('mailCharacter').addEventListener('change', function () { setFilter('character', this.value.trim()); }); + document.getElementById('mailAccount').addEventListener('change', function () { setFilter('account', this.value.trim()); }); + + document.getElementById('mailTable').addEventListener('click', function (e) { + var open = e.target.closest('[data-mail]'); + if (!open || !rows[open.dataset.mail]) return; + e.preventDefault(); + document.getElementById('mailModalBody').innerHTML = MailView.card(rows[open.dataset.mail]); + bootstrap.Modal.getOrCreateInstance(document.getElementById('mailModal')).show(); + }); +})(); diff --git a/dDashboardServer/templates/character-view.jinja2 b/dDashboardServer/templates/character-view.jinja2 index ee030df86..50fe13be6 100644 --- a/dDashboardServer/templates/character-view.jinja2 +++ b/dDashboardServer/templates/character-view.jinja2 @@ -679,15 +679,9 @@ function openMailbox() { list.textContent = 'Loading…'; new bootstrap.Modal(document.getElementById('mailModal')).show(); api.get('/api/characters/' + characterId + '/mail').then(function (mail) { - if (!mail.length) { list.textContent = 'No mail'; return; } - list.innerHTML = mail.map(function (m) { - return '
' + - '
' + esc(m.subject) + '' + fmt.unix(m.time_sent) + '
' + - '
From ' + esc(m.sender) + (m.read ? '' : ' ' + fmt.badge('Unread', 'info')) + '
' + - '
' + esc(m.body) + '
' + - (m.attachment_lot > 0 ? '
Attachment: ' + esc(m.attachment_count) + 'x ' + esc(m.attachment_name || ('LOT ' + m.attachment_lot)) + '
' : '') + - '
'; - }).join(''); + // Staff also get mail the player deleted (marked) and a link to everything this character sent and received + var more = {% if can.characters_mail %}'

All mail sent and received on the Mail page

'{% else %}''{% endif %}; + list.innerHTML = more + (mail.length ? mail.map(MailView.card).join('') : '

No mail

'); }); } @@ -765,4 +759,5 @@ function rescueCharacter() { + {% endblock %} diff --git a/dDashboardServer/templates/header.jinja2 b/dDashboardServer/templates/header.jinja2 index e403032c0..d3c838828 100644 --- a/dDashboardServer/templates/header.jinja2 +++ b/dDashboardServer/templates/header.jinja2 @@ -31,8 +31,8 @@ {% endif %} - {% set moderationPages = ["players", "chat_log", "chat_filter", "pet_names", "send_mail"] %} - {% if can.players_view or can.chat_view or can.chat_filter_manage or can.moderate_pet_names or can.mail_send %} + {% set moderationPages = ["players", "chat_log", "chat_filter", "pet_names", "mail", "send_mail"] %} + {% if can.players_view or can.chat_view or can.chat_filter_manage or can.moderate_pet_names or can.characters_mail or can.mail_send %} diff --git a/dDashboardServer/templates/mail.jinja2 b/dDashboardServer/templates/mail.jinja2 new file mode 100644 index 000000000..c0b4a431d --- /dev/null +++ b/dDashboardServer/templates/mail.jinja2 @@ -0,0 +1,53 @@ +{% extends "base.jinja2" %} + +{% block title %}Mail - DarkflameServer{% endblock %} + +{% block content %} +
+
+

Mail

+

Every in-game mail, newest first. Mail a player deleted stays here, marked; they no longer see it.

+
+ {% if can.mail_send %}Send mail{% endif %} +
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + + +
SentFromToSubjectAttachmentState
+
+
+ + +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/docs/Dashboard.md b/docs/Dashboard.md index f0cda8fad..fc908f8af 100644 --- a/docs/Dashboard.md +++ b/docs/Dashboard.md @@ -892,6 +892,23 @@ accounts share one, and an address an account hasn't used for `log_login_address log pruning. A shared address can also be a family, a school or a public network, so treat it as a hint. Turn `log_login_addresses` off if you don't want addresses kept (and mention it in your privacy notice if you do). +### Mail + +The **Mail** page (GM 3+, `characters_mail`, under Moderation) lists every in-game mail, newest first and live: when it +was sent, the sender (a character, the game, or staff from the dashboard) and receiver linked to their character +pages, the subject, the attachment (icon, name, count; waiting or claimed) and the state (unread, read, deleted). +Click a subject for the whole mail: body, accounts, attachment item ID, subkey and item data. Filter by state, by a character (sent or +received) or an account ID; the search box matches the stored subject, body and names. `/mail#character=`, +`#account=` and `#state=deleted` open it filtered. + +Mail from the game stores locale keys (`%[MissionEmail_12_subjectText]`); the dashboard shows them as the client does, +from the client's `locale/locale.xml` (needs `client_location`), with the stored text under **Stored text**. + +When a player deletes a mail in game, the row is kept and marked deleted with the time (`mail.deleted_at`). The game never +shows it again (mailbox, unread count, claiming); staff see it on the Mail page and in the character's **Mailbox**. The +character's owner sees only what is still in their mailbox. Deleting a character still removes its mail. API: +`POST /api/tables/mail`, `GET /api/characters/:id/mail`. + ### Chat filter The **Chat Filter** page (GM 5+, `chat_filter_manage`, under Moderation) adds words on top of the chat filter's files diff --git a/tests/dWebTests/CMakeLists.txt b/tests/dWebTests/CMakeLists.txt index 676b808a9..d1be1a2ac 100644 --- a/tests/dWebTests/CMakeLists.txt +++ b/tests/dWebTests/CMakeLists.txt @@ -31,6 +31,7 @@ set(DWEBTESTS_SOURCES "ServerOperationsTests.cpp" "PublicPagesTests.cpp" "ModerationToolsTests.cpp" + "LocaleTextTests.cpp" "ModeratorHelperTests.cpp" "${PROJECT_SOURCE_DIR}/dDashboardServer/ai/ClaudeClient.cpp" "${PROJECT_SOURCE_DIR}/dDashboardServer/ai/ModeratorPrompt.cpp" diff --git a/tests/dWebTests/LocaleTextTests.cpp b/tests/dWebTests/LocaleTextTests.cpp new file mode 100644 index 000000000..ae58372ae --- /dev/null +++ b/tests/dWebTests/LocaleTextTests.cpp @@ -0,0 +1,30 @@ +#include + +#include + +#include "LocaleText.h" + +namespace { + const std::string EMPTY; + const std::map PHRASES{ { "MissionEmail_1_subjectText", "Welcome!" }, { "MAIL_SYSTEM_NOTIFICATION", "LEGO Universe" } }; + + std::string Expand(const std::string& text) { + return LocaleText::Expand(text, [](const std::string& key) -> const std::string& { + const auto it = PHRASES.find(key); + return it == PHRASES.end() ? EMPTY : it->second; + }); + } +} + +TEST(LocaleTextTest, ReplacesKeys) { + EXPECT_EQ(Expand("%[MissionEmail_1_subjectText]"), "Welcome!"); + EXPECT_EQ(Expand("From %[MAIL_SYSTEM_NOTIFICATION]: %[MissionEmail_1_subjectText] ok"), "From LEGO Universe: Welcome! ok"); +} + +TEST(LocaleTextTest, LeavesOtherTextAlone) { + EXPECT_EQ(Expand("Items returned to you"), "Items returned to you"); + EXPECT_EQ(Expand("%[Unknown_key] and 100%"), "%[Unknown_key] and 100%"); + EXPECT_EQ(Expand("%[not closed"), "%[not closed"); + EXPECT_EQ(Expand("[x] %[]"), "[x] %[]"); + EXPECT_EQ(Expand(""), ""); +}