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

@@ -108,6 +108,18 @@
</div>
</div>
{% if can.chat_view or can.chat_dms or can.chat_private or can.chat_flag %}
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0">Chat</h5></div>
<div class="card-body d-flex flex-wrap gap-2">
{% if can.chat_view %}<a class="btn btn-sm btn-outline-secondary" href="/chat_log#character={{ character.id }}">Chat log</a>{% endif %}
{% if can.chat_dms %}<a class="btn btn-sm btn-outline-secondary" href="/characters/{{ character.id }}/whispers">Whispers</a>{% endif %}
{% if can.chat_private %}<a class="btn btn-sm btn-outline-secondary" href="/chat_log/teams#character={{ character.id }}">Team chat</a>{% endif %}
{% if can.chat_flag %}<a class="btn btn-sm btn-outline-secondary" href="/chat_flags#character={{ character.id }}">Chat flags</a>{% endif %}
</div>
</div>
{% endif %}
{% if existsIn(character, "stats") %}
<div class="card">
<div class="card-header"><h5 class="mb-0">Visited Zones</h5></div>

View File

@@ -0,0 +1,73 @@
{% extends "base.jinja2" %}
{% block title %}{{ title }} - DarkflameServer{% endblock %}
{% block css %}
<style>
.chathist-layout { display: grid; grid-template-columns: 19rem minmax(0, 1fr); gap: 1rem; align-items: start; }
.chathist-layout.single { grid-template-columns: minmax(0, 1fr); }
.chathist-list { position: sticky; top: 4rem; }
.chathist-list .list-group { max-height: 70vh; overflow-y: auto; }
.chathist-list .list-group-item { cursor: pointer; }
.chathist-list .meta { font-size: .8rem; color: var(--bs-secondary-color); }
.chathist-list .list-group-item.active .meta { color: inherit; opacity: .85; }
.chat-line td { vertical-align: top; }
.chat-line .when { white-space: nowrap; font-size: .8rem; color: var(--bs-secondary-color); }
.chat-line .who { white-space: nowrap; }
@media (max-width: 991.98px) { .chathist-layout { grid-template-columns: minmax(0, 1fr); } .chathist-list { position: static; } }
</style>
{% endblock %}
{% block content %}
<div id="chatHistoryPage" data-mode="{{ mode }}" data-id="{{ id }}">
<nav data-crumbs data-crumb-label="{{ title }}" aria-label="Breadcrumb"><ol class="breadcrumb mb-3">
{% if mode == "guild" %}<li class="breadcrumb-item"><a href="/guilds">Guilds</a></li>{% endif %}
{% if mode == "teams" %}<li class="breadcrumb-item"><a href="/chat_log">Chat Log</a></li>{% endif %}
{% if mode == "whispers" %}<li class="breadcrumb-item"><a href="/characters">Characters</a></li><li class="breadcrumb-item"><a href="/characters/{{ id }}">{{ character_name }}</a></li>{% endif %}
<li class="breadcrumb-item active" aria-current="page">{{ title }}</li>
</ol></nav>
<div class="mb-3">
<h2 class="mb-1">{{ title }}</h2>
<p class="text-body-secondary mb-0">
{% if mode == "guild" %}Everything said in this guild's chat, newest at the bottom. <strong>Older</strong> loads the page before.{% endif %}
{% if mode == "teams" %}Team chat, one team at a time: pick a team to read what its members said. Filter by a character to see only teams they talked in.{% endif %}
{% if mode == "whispers" %}Every character {{ character_name }} whispered with, most recent first; pick one to read the conversation. Opening a conversation is recorded in the audit log.{% endif %}
Tick messages to flag them for review (shift-click picks a range). Chat is kept for <code>log_chat_days</code>; flags keep their own copy.
</p>
</div>
<div class="chathist-layout{% if mode == "guild" %} single{% endif %}">
{% if mode != "guild" %}
<aside class="chathist-list" aria-label="{% if mode == "teams" %}Teams{% else %}Conversations{% endif %}">
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center"><h5 class="mb-0 fs-6">{% if mode == "teams" %}Teams{% else %}Conversations{% endif %}</h5><span class="small text-body-secondary" id="listCount"></span></div>
{% if mode == "teams" %}<input class="form-control form-control-sm mt-2" id="teamCharacter" placeholder="Character name or ID">{% endif %}
</div>
<div class="list-group list-group-flush" id="threadList"><div class="list-group-item text-body-secondary small">Loading&hellip;</div></div>
<div class="card-footer d-none" id="listMoreBox"><button type="button" class="btn btn-sm btn-outline-secondary w-100" id="listMore">More</button></div>
</div>
</aside>
{% endif %}
<div class="card">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<h5 class="mb-0" id="threadTitle">{% if mode == "guild" %}Guild chat{% else %}Pick a conversation{% endif %}</h5>
<div class="d-flex flex-wrap gap-2" id="threadLinks"></div>
</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-2 align-items-center mb-2" id="flagBar"></div>
<div class="text-center mb-2 d-none" id="olderBox"><button type="button" class="btn btn-sm btn-outline-secondary" id="older">Older</button></div>
<table class="table table-sm align-middle mb-0" id="threadTable"><tbody id="threadRows">
<tr><td class="text-body-secondary">{% if mode == "guild" %}Loading&hellip;{% else %}Nothing picked yet.{% endif %}</td></tr>
</tbody></table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/chat-flagging.js"></script>
<script src="/js/chat-history.js"></script>
{% endblock %}

View File

@@ -22,7 +22,8 @@
<div class="mb-3">
<h2 class="mb-1">Chat Log</h2>
<p class="text-body-secondary mb-0">What players say, newest first, updating live. Pick a world server to see just its chat, who's in it and how busy it is.
Messages the chat filter stopped are marked; nobody saw them. Bots can read and post through the <a href="/api_docs#/chat">API</a>.</p>
Messages the chat filter stopped are marked; nobody saw them. Tick messages to flag them for review{% if can.chat_flag %} (<a href="/chat_flags">Chat Flags</a>){% endif %}; <strong>Context</strong> shows the conversation around one.
{% if can.chat_private %}<a href="/chat_log/teams">Team chat</a> is also kept by team, and guild chat on each guild. {% endif %}Bots can read and post through the <a href="/api_docs#/chat">API</a>.</p>
</div>
<div class="chatlog-layout">
@@ -59,21 +60,29 @@
<div class="card">
<div class="card-header">
<div class="row g-2 align-items-end">
<div class="col-sm-4"><label class="form-label small mb-1" for="chatChannel">Channel</label>
<div class="col-sm-6 col-lg-3"><label class="form-label small mb-1" for="chatChannel">Channel</label>
<select class="form-select form-select-sm" id="chatChannel">
<option value="">All</option><option value="zone">Zone chat</option>
{% if can.chat_private %}<option value="whisper">Whispers</option><option value="team">Team chat</option><option value="guild">Guild chat</option>{% endif %}
{% if can.chat_dms %}<option value="whisper">Whispers</option>{% endif %}
{% if can.chat_private %}<option value="team">Team chat</option><option value="guild">Guild chat</option>{% endif %}
<option value="web">From the web and bots</option>
</select></div>
<div class="col-sm-4"><label class="form-label small mb-1" for="chatCharacter">Character</label>
<div class="col-sm-6 col-lg-3"><label class="form-label small mb-1" for="chatCharacter">Character</label>
<input class="form-control form-control-sm" id="chatCharacter" placeholder="Name or ID"></div>
<div class="col-sm-4"><div class="form-check form-switch mb-1">
<div class="col-sm-6 col-lg-2"><label class="form-label small mb-1" for="chatAccount">Account ID</label>
<input class="form-control form-control-sm" id="chatAccount" inputmode="numeric" placeholder="Any"></div>
<div class="col-sm-6 col-lg-2"><label class="form-label small mb-1" for="chatSince">From</label>
<input class="form-control form-control-sm" type="datetime-local" id="chatSince"></div>
<div class="col-sm-6 col-lg-2"><label class="form-label small mb-1" for="chatUntil">To</label>
<input class="form-control form-control-sm" type="datetime-local" id="chatUntil"></div>
<div class="col-sm-6 col-lg-3"><div class="form-check form-switch mb-1">
<input class="form-check-input" type="checkbox" role="switch" id="chatBlocked"><label class="form-check-label small" for="chatBlocked">Only what the filter stopped</label></div></div>
</div>
</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-2 align-items-center mb-2" id="flagBar"></div>
<table id="chatTable" class="table table-sm table-hover align-middle w-100 table-stack">
<thead><tr><th>Time</th><th>Channel</th><th>Where</th><th>From</th><th>To</th><th>Message</th><th></th></tr></thead>
<thead><tr><th></th><th>Time</th><th>Channel</th><th>Where</th><th>From</th><th>To</th><th>Message</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
@@ -84,5 +93,6 @@
{% endblock %}
{% block scripts %}
<script src="/js/chat-flagging.js"></script>
<script src="/js/chat-log.js"></script>
{% endblock %}

View File

@@ -35,6 +35,7 @@
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0" id="guildTitle"></h5>
<div>
{% if can.chat_private %}<a class="btn btn-sm btn-outline-secondary me-1" id="guildChat" href="#">Guild chat</a>{% endif %}
<button class="btn btn-sm btn-outline-primary me-1" id="guildRename">Rename</button>
<button class="btn btn-sm btn-outline-danger" id="guildDisband">Disband</button>
</div>
@@ -92,6 +93,8 @@ $(function () {
shown = guild;
document.getElementById('guildDetail').classList.remove('d-none');
document.getElementById('guildTitle').textContent = guild.name + ' (' + guild.id + ')';
var chatLink = document.getElementById('guildChat');
if (chatLink) chatLink.href = '/chat_log/guild/' + encodeURIComponent(guild.id);
document.getElementById('guildInfo').innerHTML = 'Name ' + esc(guild.name_status) + '. Founded ' + esc(fmt.unix(guild.created_at)) +
(guild.founder_name ? ' by ' + fmt.character(guild.founder_id, guild.founder_name) : '') + '.';
document.getElementById('guildMembers').innerHTML = guild.members.map(function (m) {
@@ -120,6 +123,10 @@ $(function () {
api.action('/api/guilds/' + id + '/reject', { reason: reason }, 'Rejected').then(refresh).catch(function () {});
});
// #guild=<id> opens that guild (links from its chat history)
var fromHash = (location.hash.match(/guild=(\d+)/) || [])[1];
if (fromHash) show(fromHash);
document.getElementById('guildRename').addEventListener('click', function () {
if (!shown) return;
var name = prompt('New name for ' + shown.name + ' (3 to 30 letters, digits, spaces, \' - and .):', shown.name);