mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
feat(dashboard): chat flags queue
Staff flag chat for review (chat_flag): one message or several from one conversation, with who it is about and a note. The flag keeps a copy of the messages and 10 before and after. The Chat Flags page (Inbox, badge for open flags) filters by status, character and account; a flag links to the character, the account (mute, warn, ban), their player reports, their other flags and the conversation, and shows its history. Reviewers (chat_flag_review) mark it actioned or dismissed, change the note, link a player report or comment; all of it is in the flag's history and the audit log. The sidebar adds Team Chat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,7 @@
|
||||
#include "Permissions.h"
|
||||
#include "VanityRoutes.h"
|
||||
#include "ChatRoutes.h"
|
||||
#include "ChatFlagRoutes.h"
|
||||
#include "Strikes.h"
|
||||
#include "ModerationTools.h"
|
||||
#include "ModeratorHelper.h"
|
||||
@@ -547,6 +548,7 @@ int main(int argc, char** argv) {
|
||||
Game::web.SetWSApiAccessCallback([](const WSAuth& auth) { return Permissions::Allowed(auth.level, "api_access", nullptr, auth.grants.get()); });
|
||||
RegisterVanityRoutes();
|
||||
RegisterChatRoutes();
|
||||
ChatFlagRoutes::RegisterRoutes();
|
||||
RegisterStrikeRoutes();
|
||||
RegisterModerationToolRoutes();
|
||||
ModeratorHelper::RegisterRoutes();
|
||||
|
||||
@@ -474,7 +474,8 @@ namespace {
|
||||
{"pendingGuildNames", snapshot.pendingGuildNames},
|
||||
{"pendingProperties", snapshot.pendingProperties},
|
||||
{"unresolvedBugReports", snapshot.unresolvedBugReports},
|
||||
{"openEconomyFlags", snapshot.openEconomyFlags}
|
||||
{"openEconomyFlags", snapshot.openEconomyFlags},
|
||||
{"openChatFlags", snapshot.openChatFlags}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ set(DASHBOARDROUTES_SOURCES
|
||||
"VanityRoutes.cpp"
|
||||
"ChatRoutes.cpp"
|
||||
"ChatHistory.cpp"
|
||||
"ChatFlagRoutes.cpp"
|
||||
"Strikes.cpp"
|
||||
"Inspector.cpp"
|
||||
"CDClientBrowser.cpp"
|
||||
|
||||
230
dDashboardServer/routes/ChatFlagRoutes.cpp
Normal file
230
dDashboardServer/routes/ChatFlagRoutes.cpp
Normal file
@@ -0,0 +1,230 @@
|
||||
#include "ChatFlagRoutes.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <ctime>
|
||||
#include <set>
|
||||
|
||||
#include "ChatHistory.h"
|
||||
#include "RouteUtils.h"
|
||||
#include "WSRoutes.h"
|
||||
#include "Database.h"
|
||||
#include "GeneralUtils.h"
|
||||
#include "eHTTPMethod.h"
|
||||
|
||||
using namespace RouteUtils;
|
||||
|
||||
namespace {
|
||||
constexpr size_t MAX_MESSAGES = 100; // flagged at once
|
||||
constexpr uint32_t CONTEXT = 10; // messages kept before and after
|
||||
constexpr size_t MAX_NOTE = 2000;
|
||||
|
||||
int64_t Now() { return static_cast<int64_t>(std::time(nullptr)); }
|
||||
|
||||
ChatHistory::Access AccessOf(const HTTPContext& context) {
|
||||
return { Can(context, "chat_private"), Can(context, "chat_dms") };
|
||||
}
|
||||
|
||||
std::optional<IChatLog::ChatMessage> FindMessage(uint64_t id) {
|
||||
if (id == 0) return std::nullopt;
|
||||
IChatLog::ChatQuery q;
|
||||
q.includePrivate = true;
|
||||
q.includeWhispers = true;
|
||||
q.afterId = id - 1;
|
||||
q.limit = 1;
|
||||
const auto found = Database::Get()->GetChatMessages(q);
|
||||
if (found.empty() || found.front().id != id) return std::nullopt;
|
||||
return found.front();
|
||||
}
|
||||
|
||||
// The list's view of a flag: what it's about, without the copy of the chat
|
||||
nlohmann::json FlagJson(const IChatFlags::ChatFlag& f, const ChatHistory::Access& access) {
|
||||
const bool readable = ChatHistory::CanRead(f.channel, access);
|
||||
return { {"id", f.id}, {"created_at", f.createdAt}, {"created_by", f.createdBy}, {"status", f.status}, {"channel", f.channel},
|
||||
{"character_id", std::to_string(f.characterId)}, {"character_name", f.characterName}, {"account_id", f.accountId},
|
||||
{"first_message_id", f.firstMessageId}, {"last_message_id", f.lastMessageId}, {"first_time", f.firstTime}, {"last_time", f.lastTime},
|
||||
{"excerpt", readable ? f.excerpt : ""}, {"redacted", !readable}, {"note", f.note}, {"player_report_id", f.playerReportId},
|
||||
{"updated_at", f.updatedAt}, {"updated_by", f.updatedBy} };
|
||||
}
|
||||
|
||||
void Event(const HTTPContext& context, uint64_t flagId, const std::string& action, const std::string& detail) {
|
||||
Database::Get()->InsertChatFlagEvent({ 0, flagId, Now(), context.accountId, context.authenticatedUser, action, detail });
|
||||
}
|
||||
|
||||
std::string Trimmed(std::string text, size_t max) {
|
||||
std::erase_if(text, [](char c) { return c == '\r'; });
|
||||
if (text.size() > max) text.resize(max);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
void ChatFlagRoutes::RegisterRoutes() {
|
||||
Route(eHTTPMethod::GET, "/chat_flags", Perm("chat_flag"), "Chat flags: chat staff flagged for review", [](HTTPReply& reply, const HTTPContext& context) {
|
||||
RenderPage(reply, context, "chat_flags.jinja2", "chat_flags");
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::POST, "/api/chat/flags", Perm("chat_flag"),
|
||||
"Flag chat for review. Body: {message_ids (1-100, from one conversation), character (optional: which sender it is about; default the first), note}. "
|
||||
"The flag keeps a copy of the messages and the 10 before and after them in the conversation. Returns {id}",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
const auto body = ParseBody(context);
|
||||
if (!body || !body->contains("message_ids") || !(*body)["message_ids"].is_array()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "message_ids is a list of message IDs");
|
||||
std::set<uint64_t> ids;
|
||||
for (const auto& value : (*body)["message_ids"]) {
|
||||
const auto id = value.is_number_unsigned() ? std::optional(value.get<uint64_t>()) : value.is_string() ? GeneralUtils::TryParse<uint64_t>(value.get<std::string>()) : std::nullopt;
|
||||
if (!id || *id == 0) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid message ID");
|
||||
ids.insert(*id);
|
||||
}
|
||||
if (ids.empty() || ids.size() > MAX_MESSAGES) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Flag 1 to 100 messages at once");
|
||||
const auto access = AccessOf(context);
|
||||
std::vector<IChatLog::ChatMessage> flagged;
|
||||
for (const auto id : ids) {
|
||||
const auto message = FindMessage(id);
|
||||
if (!message) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Message " + std::to_string(id) + " is not in the chat log (chat is kept for log_chat_days)");
|
||||
if (!ChatHistory::CanRead(message->channel, access)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Reading " + message->channel + " chat needs another permission");
|
||||
if (!flagged.empty() && !ChatHistory::SameConversation(flagged.front(), *message)) {
|
||||
return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "The messages are from different conversations; flag each conversation on its own");
|
||||
}
|
||||
flagged.push_back(*message);
|
||||
}
|
||||
const auto chosen = GeneralUtils::TryParse<LWOOBJID>(body->value("character", std::string{})).value_or(0);
|
||||
const auto subject = ChatHistory::Subject(flagged, chosen);
|
||||
|
||||
// The chat around them, from the same conversation
|
||||
auto beforeQuery = ChatHistory::ConversationQuery(flagged.front(), access);
|
||||
beforeQuery.beforeId = flagged.front().id;
|
||||
beforeQuery.newestFirst = true;
|
||||
beforeQuery.limit = CONTEXT;
|
||||
auto before = Database::Get()->GetChatMessages(beforeQuery);
|
||||
std::reverse(before.begin(), before.end());
|
||||
auto afterQuery = ChatHistory::ConversationQuery(flagged.front(), access);
|
||||
afterQuery.afterId = flagged.back().id;
|
||||
afterQuery.limit = CONTEXT;
|
||||
const auto after = Database::Get()->GetChatMessages(afterQuery);
|
||||
// Messages between the first and last flagged one that weren't picked stay in the copy too
|
||||
auto betweenQuery = ChatHistory::ConversationQuery(flagged.front(), access);
|
||||
betweenQuery.afterId = flagged.front().id;
|
||||
betweenQuery.beforeId = flagged.back().id;
|
||||
betweenQuery.limit = 500;
|
||||
for (const auto& m : Database::Get()->GetChatMessages(betweenQuery)) {
|
||||
if (!ids.contains(m.id)) before.push_back(m);
|
||||
}
|
||||
|
||||
IChatFlags::ChatFlag flag;
|
||||
flag.createdAt = Now();
|
||||
flag.createdById = context.accountId;
|
||||
flag.createdBy = context.authenticatedUser;
|
||||
flag.channel = flagged.front().channel;
|
||||
if (subject) {
|
||||
flag.characterId = subject->senderId;
|
||||
flag.characterName = subject->senderName;
|
||||
flag.accountId = subject->accountId;
|
||||
}
|
||||
flag.firstMessageId = flagged.front().id;
|
||||
flag.lastMessageId = flagged.back().id;
|
||||
flag.firstTime = flagged.front().time;
|
||||
flag.lastTime = flagged.back().time;
|
||||
flag.excerpt = ChatHistory::Excerpt(flagged);
|
||||
flag.note = Trimmed(body->value("note", std::string{}), MAX_NOTE);
|
||||
flag.messages = ChatHistory::Snapshot(before, flagged, after).dump();
|
||||
const auto id = Database::Get()->InsertChatFlag(flag, { ids.begin(), ids.end() });
|
||||
Event(context, id, "created", std::to_string(ids.size()) + " message(s)" + (flag.note.empty() ? "" : ": " + flag.note));
|
||||
Audit(context, "chat_flag", "Flagged " + std::to_string(ids.size()) + " " + flag.channel + " message(s) as chat flag " + std::to_string(id) +
|
||||
(flag.characterName.empty() ? "" : " about " + flag.characterName), subject ? AuditTarget::Account(subject->accountId) : AuditTarget{});
|
||||
BroadcastTableChanged("chat_flags", std::to_string(id));
|
||||
JsonSuccess(reply, { {"id", id} });
|
||||
});
|
||||
|
||||
ReadRoute(eHTTPMethod::POST, "/api/tables/chat_flags", Perm("chat_flag"),
|
||||
"Chat flags (DataTables), newest first. Body adds {status (open, actioned, dismissed; empty: any), character (name or ID), account}. "
|
||||
"Flags on team and guild chat need chat_private, on whispers chat_dms",
|
||||
[](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);
|
||||
IChatFlags::ChatFlagQuery q;
|
||||
q.includePrivate = access.group;
|
||||
q.includeWhispers = access.whispers;
|
||||
q.status = ChatHistory::ParseStatus(body->value("status", std::string{})).value_or("");
|
||||
const auto character = body->value("character", std::string{});
|
||||
if (!character.empty()) q.characterId = ResolveCharacter(character).value_or(-1);
|
||||
q.accountId = body->contains("account") && (*body)["account"].is_number_unsigned() ? (*body)["account"].get<uint32_t>()
|
||||
: GeneralUtils::TryParse<uint32_t>(body->value("account", std::string{})).value_or(0);
|
||||
q.offset = request->start;
|
||||
q.limit = std::clamp<uint32_t>(request->length, 1, 200);
|
||||
IChatFlags::ChatFlagQuery all;
|
||||
all.includePrivate = q.includePrivate;
|
||||
all.includeWhispers = q.includeWhispers;
|
||||
nlohmann::json rows = nlohmann::json::array();
|
||||
for (const auto& flag : Database::Get()->GetChatFlags(q)) rows.push_back(FlagJson(flag, access));
|
||||
JsonReply(reply, eHTTPStatusCode::OK, { {"draw", request->draw}, {"recordsTotal", Database::Get()->CountChatFlags(all)},
|
||||
{"recordsFiltered", Database::Get()->CountChatFlags(q)}, {"data", rows} });
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/chat/flags/:id", Perm("chat_flag"),
|
||||
"One chat flag: {flag, messages (the copy of the chat, flagged ones marked; text of channels you may not read left out), events (its history, oldest first)}",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
const auto id = PathId<uint64_t>(context.path, 3);
|
||||
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid flag ID");
|
||||
const auto flag = Database::Get()->GetChatFlag(*id);
|
||||
if (!flag) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such chat flag");
|
||||
const auto access = AccessOf(context);
|
||||
auto snapshot = nlohmann::json::parse(flag->messages, nullptr, false);
|
||||
nlohmann::json events = nlohmann::json::array();
|
||||
for (const auto& e : Database::Get()->GetChatFlagEvents(*id)) {
|
||||
events.push_back({ {"id", e.id}, {"time", e.time}, {"actor", e.actor}, {"action", e.action}, {"detail", e.detail} });
|
||||
}
|
||||
if (flag->channel == "whisper" && access.whispers) {
|
||||
Audit(context, "chat_dms_view", "Whispers in chat flag " + std::to_string(*id), AuditTarget::Character(flag->characterId));
|
||||
}
|
||||
JsonSuccess(reply, { {"flag", FlagJson(*flag, access)}, {"messages", ChatHistory::RedactSnapshot(snapshot, access)}, {"events", events},
|
||||
{"canReview", Can(context, "chat_flag_review")} });
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::POST, "/api/chat/flags/:id", Perm("chat_flag_review"),
|
||||
"Review a chat flag. Body (each optional): {status (open, actioned, dismissed), note (replaces it), player_report_id (0 to unlink), comment (added to its history)}",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
const auto id = PathId<uint64_t>(context.path, 3);
|
||||
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid flag ID");
|
||||
const auto body = ParseBody(context);
|
||||
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
|
||||
const auto flag = Database::Get()->GetChatFlag(*id);
|
||||
if (!flag) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such chat flag");
|
||||
// Only chat the reviewer may read
|
||||
if (!ChatHistory::CanRead(flag->channel, AccessOf(context))) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Reviewing " + flag->channel + " chat needs another permission");
|
||||
|
||||
std::string status = flag->status;
|
||||
if (body->contains("status")) {
|
||||
const auto parsed = ChatHistory::ParseStatus(body->value("status", std::string{}));
|
||||
if (!parsed) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "status is open, actioned or dismissed");
|
||||
status = *parsed;
|
||||
}
|
||||
const std::string note = body->contains("note") ? Trimmed(body->value("note", std::string{}), MAX_NOTE) : flag->note;
|
||||
uint64_t report = flag->playerReportId;
|
||||
if (body->contains("player_report_id")) {
|
||||
const auto& value = (*body)["player_report_id"];
|
||||
const auto parsed = value.is_number_unsigned() ? std::optional(value.get<uint64_t>()) : value.is_string() ? GeneralUtils::TryParse<uint64_t>(value.get<std::string>()) : std::nullopt;
|
||||
if (!parsed) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "player_report_id is a number");
|
||||
report = *parsed;
|
||||
}
|
||||
const auto comment = Trimmed(body->value("comment", std::string{}), MAX_NOTE);
|
||||
|
||||
const auto action = ChatHistory::StatusAction(flag->status, status);
|
||||
const bool changed = !action.empty() || note != flag->note || report != flag->playerReportId;
|
||||
if (!changed && comment.empty()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Nothing to change");
|
||||
if (changed) Database::Get()->UpdateChatFlag(*id, status, note, report, Now(), context.authenticatedUser);
|
||||
if (!action.empty()) Event(context, *id, action, comment);
|
||||
if (note != flag->note) Event(context, *id, "note", note);
|
||||
if (report != flag->playerReportId) Event(context, *id, "report", report ? "Player report " + std::to_string(report) : "No player report");
|
||||
if (action.empty() && !comment.empty()) Event(context, *id, "comment", comment);
|
||||
|
||||
std::string what = "Chat flag " + std::to_string(*id) + ":";
|
||||
if (!action.empty()) what += " " + action;
|
||||
if (note != flag->note) what += " note changed";
|
||||
if (report != flag->playerReportId) what += " report " + std::to_string(report);
|
||||
if (!comment.empty()) what += " (" + comment + ")";
|
||||
Audit(context, "chat_flag_review", what, AuditTarget::Account(flag->accountId));
|
||||
BroadcastTableChanged("chat_flags", std::to_string(*id));
|
||||
JsonSuccess(reply);
|
||||
});
|
||||
}
|
||||
11
dDashboardServer/routes/ChatFlagRoutes.h
Normal file
11
dDashboardServer/routes/ChatFlagRoutes.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
/**
|
||||
* Chat flags: staff flag a chat message, or several from one conversation, for review. Each flag keeps a copy of the
|
||||
* flagged messages and the chat around them, has a status (open, actioned, dismissed), a note, a link to the character
|
||||
* and account it is about, and a history of who did what (also in the audit log). The Chat Flags page is the queue;
|
||||
* open flags count on the sidebar badge.
|
||||
*/
|
||||
namespace ChatFlagRoutes {
|
||||
void RegisterRoutes();
|
||||
}
|
||||
@@ -25,7 +25,8 @@ namespace {
|
||||
{"pendingGuildNames", snapshot.pendingGuildNames},
|
||||
{"pendingProperties", snapshot.pendingProperties},
|
||||
{"unresolvedBugReports", snapshot.unresolvedBugReports},
|
||||
{"openEconomyFlags", snapshot.openEconomyFlags}
|
||||
{"openEconomyFlags", snapshot.openEconomyFlags},
|
||||
{"openChatFlags", snapshot.openChatFlags}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,6 +48,7 @@ namespace {
|
||||
changed("command_log", previous.commandLogMaxId != current.commandLogMaxId);
|
||||
changed("audit_log", previous.auditLogMaxId != current.auditLogMaxId);
|
||||
changed("economy_flags", previous.openEconomyFlags != current.openEconomyFlags || previous.economyFlagsMaxId != current.economyFlagsMaxId);
|
||||
changed("chat_flags", previous.openChatFlags != current.openChatFlags || previous.chatFlagsMaxId != current.chatFlagsMaxId || previous.chatFlagsUpdatedAt != current.chatFlagsUpdatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
138
dDashboardServer/static/js/chat-flags.js
Normal file
138
dDashboardServer/static/js/chat-flags.js
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* The Chat Flags page: the queue of flagged chat (open first by default), and one flag in detail: its copy of the chat,
|
||||
* links to the character, account (mute, warn, ban), their player reports and the conversation, its history, and
|
||||
* (with chat_flag_review) the review form. #flag=<id> opens a flag, #character= and #account= filter the queue.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var CHANNELS = { zone: ['Zone', 'secondary'], whisper: ['Whisper', 'info'], team: ['Team', 'primary'], guild: ['Guild', 'success'], web: ['Web', 'warning'] };
|
||||
var STATUS = { open: ['Open', 'warning'], actioned: ['Actioned', 'success'], dismissed: ['Dismissed', 'secondary'] };
|
||||
var ACTIONS = { created: 'Flagged', actioned: 'Marked actioned', dismissed: 'Dismissed', reopened: 'Reopened', note: 'Changed the note', report: 'Linked a report', comment: 'Commented' };
|
||||
var NEEDS = { whisper: 'chat_dms', team: 'chat_private', guild: 'chat_private' };
|
||||
var state = { status: 'open', character: '', account: '' };
|
||||
var shown = null;
|
||||
|
||||
function hashValue(name) { var m = location.hash.match(new RegExp('[#&]' + name + '=([^&]*)')); return m ? decodeURIComponent(m[1]) : ''; }
|
||||
if (hashValue('character')) { state.character = hashValue('character'); document.getElementById('flagCharacter').value = state.character; }
|
||||
if (hashValue('account')) { state.account = hashValue('account'); document.getElementById('flagAccount').value = state.account; }
|
||||
if (state.character || state.account || hashValue('flag')) {
|
||||
state.status = '';
|
||||
document.getElementById('flagStatusAll').checked = true;
|
||||
}
|
||||
|
||||
function channelBadge(c) { var b = CHANNELS[c] || [c, 'secondary']; return fmt.badge(b[0], b[1]); }
|
||||
function statusBadge(s) { var b = STATUS[s] || [s, 'secondary']; return fmt.badge(b[0], b[1]); }
|
||||
function about(f) {
|
||||
return (f.character_id !== '0' ? fmt.character(f.character_id, f.character_name) : '<span class="text-body-secondary">-</span>') +
|
||||
(f.account_id ? '<div class="small"><a href="/accounts/' + esc(f.account_id) + '">Account ' + esc(f.account_id) + '</a></div>' : '');
|
||||
}
|
||||
function excerpt(f) {
|
||||
return f.redacted ? '<span class="text-body-secondary fst-italic">Hidden: needs ' + esc(NEEDS[f.channel] || 'another permission') + '</span>' : esc(f.excerpt);
|
||||
}
|
||||
|
||||
var table = serverTable('#flagsTable', '/api/tables/chat_flags', [
|
||||
{ data: 'id', orderable: false },
|
||||
{ data: 'created_at', orderable: false, render: function (d, t, row) { return esc(fmt.unix(d)) + '<div class="small text-body-secondary">by ' + esc(row.created_by) + '</div>'; } },
|
||||
{ data: 'channel', orderable: false, render: channelBadge },
|
||||
{ data: 'character_id', orderable: false, render: function (d, t, row) { return about(row); } },
|
||||
{ data: 'excerpt', orderable: false, render: function (d, t, row) { return excerpt(row); } },
|
||||
{ data: 'note', orderable: false, render: function (d) { return esc(d); } },
|
||||
{ data: 'status', orderable: false, render: function (d, t, row) {
|
||||
return statusBadge(d) + (row.updated_by ? '<div class="small text-body-secondary">' + esc(row.updated_by) + ', ' + esc(fmt.unix(row.updated_at)) + '</div>' : '');
|
||||
} },
|
||||
{ data: null, orderable: false, render: function (d) { return '<button type="button" class="btn btn-sm btn-outline-primary" data-flag="' + esc(d.id) + '">Review</button>'; } }
|
||||
], {
|
||||
liveTable: 'chat_flags',
|
||||
dataTable: { order: [], searching: false },
|
||||
extra: function () { return { status: state.status, character: state.character, account: state.account }; }
|
||||
});
|
||||
|
||||
function conversationLink(f, messages) {
|
||||
var first = messages.filter(function (m) { return m.flagged; })[0];
|
||||
if (!first || f.redacted) return '';
|
||||
if (f.channel === 'whisper' && first.sender_id !== '0' && first.recipient_id !== '0') return '/characters/' + first.sender_id + '/whispers#with=' + first.recipient_id;
|
||||
if (f.channel === 'team' && first.team_id !== '0') return '/chat_log/teams#team=' + first.team_id;
|
||||
if (f.channel === 'guild' && first.guild_id !== '0') return '/chat_log/guild/' + first.guild_id;
|
||||
if (f.channel === 'zone' && first.zone_id) return '/chat_log#world=' + first.zone_id + ':' + first.instance_id;
|
||||
return '';
|
||||
}
|
||||
|
||||
function show(id) {
|
||||
return api.get('/api/chat/flags/' + encodeURIComponent(id)).then(function (d) {
|
||||
if (!d.success) { toast(d.error || 'Could not load the flag', 'danger'); return; }
|
||||
var f = d.flag;
|
||||
shown = f;
|
||||
try { history.replaceState(null, '', '#flag=' + f.id); } catch (e) {}
|
||||
document.getElementById('flagDetail').classList.remove('d-none');
|
||||
document.getElementById('flagTitle').innerHTML = 'Chat flag ' + esc(f.id) + ' ' + statusBadge(f.status) + ' ' + channelBadge(f.channel);
|
||||
document.getElementById('flagSubtitle').innerHTML = 'Flagged by ' + esc(f.created_by) + ', ' + esc(fmt.unix(f.created_at)) +
|
||||
'. Messages from ' + esc(fmt.unix(f.first_time)) + (f.last_time !== f.first_time ? ' to ' + esc(fmt.unix(f.last_time)) : '') + '.';
|
||||
var thread = conversationLink(f, d.messages);
|
||||
var links = '';
|
||||
if (f.character_id !== '0') links += '<a class="btn btn-sm btn-outline-secondary" href="/characters/' + esc(f.character_id) + '">' + esc(f.character_name || 'Character') + '</a>';
|
||||
if (f.account_id) {
|
||||
links += '<a class="btn btn-sm btn-outline-danger" href="/accounts/' + esc(f.account_id) + '" title="Mute, warn, strike or ban from the account page">Account: mute, warn, ban</a>';
|
||||
if (DASH.can('player_reports_view')) links += '<a class="btn btn-sm btn-outline-secondary" href="/player_reports?status=&account=' + esc(f.account_id) + '">Reports about them</a>';
|
||||
links += '<a class="btn btn-sm btn-outline-secondary" href="/chat_flags#account=' + esc(f.account_id) + '">Their other flags</a>';
|
||||
}
|
||||
if (f.player_report_id && DASH.can('player_reports_view')) links += '<a class="btn btn-sm btn-outline-secondary" href="/player_reports?status=">Report ' + esc(f.player_report_id) + '</a>';
|
||||
if (thread) links += '<a class="btn btn-sm btn-outline-secondary" href="' + esc(thread) + '">Conversation</a>';
|
||||
document.getElementById('flagLinks').innerHTML = links;
|
||||
|
||||
document.getElementById('flagMessages').innerHTML = d.messages.length ? d.messages.map(function (m) {
|
||||
return '<tr class="' + (m.flagged ? 'table-warning' : '') + '"><td class="text-nowrap small text-body-secondary">' + esc(fmt.unix(m.time)) + '</td>' +
|
||||
'<td class="text-nowrap">' + (m.sender_id !== '0' ? fmt.character(m.sender_id, m.sender_name) : esc(m.sender_name)) +
|
||||
(m.recipient_id !== '0' ? ' → ' + fmt.character(m.recipient_id, m.recipient_name) : '') + '</td>' +
|
||||
'<td>' + ChatText(m) + '</td></tr>';
|
||||
}).join('') : '<tr><td class="text-body-secondary">No copy of the chat.</td></tr>';
|
||||
|
||||
document.getElementById('flagEvents').innerHTML = d.events.map(function (e) {
|
||||
return '<li class="mb-2"><span class="text-body-secondary">' + esc(fmt.unix(e.time)) + '</span> <strong>' + esc(e.actor) + '</strong> ' +
|
||||
esc((ACTIONS[e.action] || e.action).toLowerCase()) + (e.detail ? '<div>' + esc(e.detail) + '</div>' : '') + '</li>';
|
||||
}).join('') || '<li class="text-body-secondary">Nothing yet.</li>';
|
||||
|
||||
var form = document.getElementById('flagReview');
|
||||
form.classList.toggle('d-none', !d.canReview || f.redacted);
|
||||
var radio = document.querySelector('input[name="reviewStatus"][value="' + f.status + '"]');
|
||||
if (radio) radio.checked = true;
|
||||
document.getElementById('reviewNote').value = f.note;
|
||||
document.getElementById('reviewReport').value = f.player_report_id || '';
|
||||
document.getElementById('reviewComment').value = '';
|
||||
document.getElementById('flagDetail').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
function ChatText(m) {
|
||||
if (m.redacted) return '<span class="text-body-secondary fst-italic">Hidden: reading ' + esc(m.channel) + ' chat needs ' + esc(NEEDS[m.channel] || 'another permission') + '</span>';
|
||||
return (m.blocked ? fmt.badge('Stopped by the filter', 'danger') + ' ' : m.filtered ? fmt.badge('Filter words', 'warning') + ' ' : '') + esc(m.message);
|
||||
}
|
||||
|
||||
document.getElementById('flagReview').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
if (!shown) return;
|
||||
var status = document.querySelector('input[name="reviewStatus"]:checked');
|
||||
var body = {
|
||||
status: status ? status.value : shown.status,
|
||||
note: document.getElementById('reviewNote').value,
|
||||
player_report_id: document.getElementById('reviewReport').value.trim() || '0',
|
||||
comment: document.getElementById('reviewComment').value
|
||||
};
|
||||
var id = shown.id;
|
||||
api.action('/api/chat/flags/' + id, body, 'Saved').then(function () {
|
||||
table.ajax.reload(null, false);
|
||||
show(id);
|
||||
}).catch(function () {});
|
||||
});
|
||||
|
||||
$('#flagsTable').on('click', '[data-flag]', function () { show(this.getAttribute('data-flag')); });
|
||||
document.getElementById('statusFilter').addEventListener('change', function (e) {
|
||||
if (e.target.name !== 'flagStatus') return;
|
||||
state.status = e.target.value;
|
||||
table.ajax.reload();
|
||||
});
|
||||
document.getElementById('flagCharacter').addEventListener('change', function () { state.character = this.value.trim(); table.ajax.reload(); });
|
||||
document.getElementById('flagAccount').addEventListener('change', function () { state.account = this.value.trim(); table.ajax.reload(); });
|
||||
|
||||
if (hashValue('flag')) show(hashValue('flag'));
|
||||
})();
|
||||
@@ -130,7 +130,8 @@
|
||||
'pet_names': counts.pendingPetNames || 0,
|
||||
'guilds': counts.pendingGuildNames || 0,
|
||||
'bug_reports': counts.unresolvedBugReports || 0,
|
||||
'reports': counts.openEconomyFlags || 0
|
||||
'reports': counts.openEconomyFlags || 0,
|
||||
'chat_flags': counts.openChatFlags || 0
|
||||
};
|
||||
Object.keys(map).forEach(function (key) {
|
||||
var el = document.querySelector('[data-badge="' + key + '"]');
|
||||
|
||||
76
dDashboardServer/templates/chat_flags.jinja2
Normal file
76
dDashboardServer/templates/chat_flags.jinja2
Normal file
@@ -0,0 +1,76 @@
|
||||
{% extends "base.jinja2" %}
|
||||
|
||||
{% block title %}Chat Flags - DarkflameServer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="chatFlagsPage">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3 flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 class="mb-1">Chat Flags</h2>
|
||||
<p class="text-body-secondary mb-0">Chat staff flagged for review, newest first. Flag messages from the <a href="/chat_log">Chat Log</a> or a guild, team or whisper history.
|
||||
Each flag keeps a copy of the flagged messages and the chat around them. Mute, warn or ban from the account page, then mark the flag actioned; dismiss it when nothing needs doing.</p>
|
||||
</div>
|
||||
<div class="btn-group flex-wrap" role="group" id="statusFilter" aria-label="Status">
|
||||
<input type="radio" class="btn-check" name="flagStatus" id="flagStatusOpen" value="open" checked><label class="btn btn-outline-primary" for="flagStatusOpen">Open</label>
|
||||
<input type="radio" class="btn-check" name="flagStatus" id="flagStatusActioned" value="actioned"><label class="btn btn-outline-primary" for="flagStatusActioned">Actioned</label>
|
||||
<input type="radio" class="btn-check" name="flagStatus" id="flagStatusDismissed" value="dismissed"><label class="btn btn-outline-primary" for="flagStatusDismissed">Dismissed</label>
|
||||
<input type="radio" class="btn-check" name="flagStatus" id="flagStatusAll" value=""><label class="btn btn-outline-primary" for="flagStatusAll">All</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-sm-6 col-lg-4"><label class="form-label small mb-1" for="flagCharacter">About character</label>
|
||||
<input class="form-control form-control-sm" id="flagCharacter" placeholder="Name or ID"></div>
|
||||
<div class="col-sm-6 col-lg-3"><label class="form-label small mb-1" for="flagAccount">About account ID</label>
|
||||
<input class="form-control form-control-sm" id="flagAccount" inputmode="numeric" placeholder="Any"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table id="flagsTable" class="table table-sm table-hover align-middle w-100 table-stack">
|
||||
<thead><tr><th>#</th><th>Flagged</th><th>Channel</th><th>About</th><th>Messages</th><th>Note</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card d-none" id="flagDetail">
|
||||
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<div><h5 class="mb-0" id="flagTitle"></h5><div class="small text-body-secondary" id="flagSubtitle"></div></div>
|
||||
<div class="d-flex flex-wrap gap-2" id="flagLinks"></div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
<h6>Messages <span class="small text-body-secondary fw-normal">(flagged ones highlighted; the chat around them as it was)</span></h6>
|
||||
<table class="table table-sm align-middle"><tbody id="flagMessages"></tbody></table>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<form id="flagReview" class="mb-4 d-none">
|
||||
<h6>Review</h6>
|
||||
<div class="btn-group w-100 mb-2" role="group" aria-label="Status">
|
||||
<input type="radio" class="btn-check" name="reviewStatus" id="reviewOpen" value="open"><label class="btn btn-sm btn-outline-warning" for="reviewOpen">Open</label>
|
||||
<input type="radio" class="btn-check" name="reviewStatus" id="reviewActioned" value="actioned"><label class="btn btn-sm btn-outline-success" for="reviewActioned">Actioned</label>
|
||||
<input type="radio" class="btn-check" name="reviewStatus" id="reviewDismissed" value="dismissed"><label class="btn btn-sm btn-outline-secondary" for="reviewDismissed">Dismissed</label>
|
||||
</div>
|
||||
<label class="form-label small mb-1" for="reviewNote">Note</label>
|
||||
<textarea class="form-control form-control-sm mb-2" id="reviewNote" rows="3" maxlength="2000"></textarea>
|
||||
<label class="form-label small mb-1" for="reviewReport">Player report ID <span class="text-body-secondary">(optional)</span></label>
|
||||
<input class="form-control form-control-sm mb-2" id="reviewReport" inputmode="numeric" placeholder="None">
|
||||
<label class="form-label small mb-1" for="reviewComment">Comment for the history <span class="text-body-secondary">(optional, e.g. "muted 3 days")</span></label>
|
||||
<input class="form-control form-control-sm mb-2" id="reviewComment" maxlength="2000">
|
||||
<button class="btn btn-sm btn-primary">Save</button>
|
||||
</form>
|
||||
<h6>History</h6>
|
||||
<ul class="list-unstyled small mb-0" id="flagEvents"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/js/chat-flags.js"></script>
|
||||
{% endblock %}
|
||||
@@ -16,8 +16,8 @@
|
||||
{% if can.properties_view %}<a href="/ugc" class="list-group-item list-group-item-action{% if current_page == "ugc" %} active{% endif %}">UGC</a>{% endif %}
|
||||
</div>
|
||||
|
||||
{% set inboxPages = ["moderation", "player_reports", "bug_reports"] %}
|
||||
{% if can.moderate_names or can.moderate_pet_names or can.guilds_manage or can.moderate_properties or can.player_reports_view or can.bug_reports_view %}
|
||||
{% set inboxPages = ["moderation", "player_reports", "bug_reports", "chat_flags"] %}
|
||||
{% if can.moderate_names or can.moderate_pet_names or can.guilds_manage or can.moderate_properties or can.player_reports_view or can.bug_reports_view or can.chat_flag %}
|
||||
<div class="nav-group">
|
||||
<button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-inbox" aria-expanded="{% if current_page in inboxPages %}true{% else %}false{% endif %}" aria-controls="nav-inbox">
|
||||
Inbox<span class="nav-group-dot" title="Something in here needs attention"></span>
|
||||
@@ -26,13 +26,14 @@
|
||||
<div class="list-group list-group-flush">
|
||||
{% if can.moderate_names or can.moderate_pet_names or can.guilds_manage or can.moderate_properties %}<a href="/moderation" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "moderation" %} active{% endif %}">Review Queue <span class="badge rounded-pill text-bg-warning d-none" data-badge="moderation"></span></a>{% endif %}
|
||||
{% if can.player_reports_view %}<a href="/player_reports" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "player_reports" %} active{% endif %}">Player Reports <span class="badge rounded-pill text-bg-danger d-none" data-badge="player_reports"></span></a>{% endif %}
|
||||
{% if can.chat_flag %}<a href="/chat_flags" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "chat_flags" %} active{% endif %}">Chat Flags <span class="badge rounded-pill text-bg-warning d-none" data-badge="chat_flags" title="Open chat flags"></span></a>{% endif %}
|
||||
{% if can.bug_reports_view %}<a href="/bug_reports" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "bug_reports" %} active{% endif %}">Bug Reports <span class="badge rounded-pill text-bg-danger d-none" data-badge="bug_reports"></span></a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% set moderationPages = ["players", "chat_log", "chat_filter", "pet_names", "guilds", "mail", "send_mail"] %}
|
||||
{% set moderationPages = ["players", "chat_log", "chat_teams", "chat_filter", "pet_names", "guilds", "mail", "send_mail"] %}
|
||||
{% if can.players_view or can.chat_view or can.chat_filter_manage or can.moderate_pet_names or can.guilds_manage or can.characters_mail or can.mail_send %}
|
||||
<div class="nav-group">
|
||||
<button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-moderation" aria-expanded="{% if current_page in moderationPages %}true{% else %}false{% endif %}" aria-controls="nav-moderation">
|
||||
@@ -42,6 +43,7 @@
|
||||
<div class="list-group list-group-flush">
|
||||
{% if can.players_view %}<a href="/players" class="list-group-item list-group-item-action{% if current_page == "players" %} active{% endif %}">Online Players</a>{% endif %}
|
||||
{% if can.chat_view %}<a href="/chat_log" class="list-group-item list-group-item-action{% if current_page == "chat_log" %} active{% endif %}">Chat Log</a>{% endif %}
|
||||
{% if can.chat_private %}<a href="/chat_log/teams" class="list-group-item list-group-item-action{% if current_page == "chat_teams" %} active{% endif %}">Team Chat</a>{% endif %}
|
||||
{% if can.chat_filter_manage %}<a href="/chat_filter" class="list-group-item list-group-item-action{% if current_page == "chat_filter" %} active{% endif %}">Chat Filter</a>{% endif %}
|
||||
{% if can.moderate_pet_names %}<a href="/pet_names" class="list-group-item list-group-item-action{% if current_page == "pet_names" %} active{% endif %}">Pet Names</a>{% endif %}
|
||||
{% if can.guilds_manage %}<a href="/guilds" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "guilds" %} active{% endif %}">Guilds <span class="badge rounded-pill text-bg-warning d-none" data-badge="guilds" title="Guild names waiting for review"></span></a>{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user