feat(dashboard): redesign the Chat Filter page

Three sections with one-line explanations instead of paragraphs:
- Test a message: normal or best friends' free chat, the verdict, each
  word with why, and Block/Allow/Remove per word.
- Staff lists: one table for Blocked and Allowed with search, a list
  filter and 50 per page. Block, Allow, move and Remove open a
  confirmation showing where the word stands and the recent chat the
  change affects (the old "What would blocking it stop?" checks).
- Word files: each file's size in one line; chatplus_en_us.txt words
  searchable, 200 per page with Previous/Next; click a word to test it.
All existing APIs are unchanged and still used. docs/Dashboard.md
rewritten for the page.

Check: /chat_filter: test a message in both chats, add, move and remove
a word (confirm and cancel), search and page both lists, copy file
words into Allowed, Re-apply in running worlds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 00:58:06 -05:00
parent 29edb18298
commit dc1c5fb36b
3 changed files with 359 additions and 152 deletions

View File

@@ -1,123 +1,265 @@
/**
* The Chat Filter page: words staff block or allow on top of the filter's files, and for a word, which recent chat it
* would have stopped (or which messages the filter stopped that contain it), and where it stands in the files. The files'
* allowed words can be browsed and copied into the Allowed list. Every change is applied in running worlds.
* The Chat Filter page: test a message against the filter, the staff Blocked and Allowed lists (search, paging, add,
* move and remove, each confirmed with what it changes in recent chat), and the filter's word files.
*/
(function () {
'use strict';
var input = document.getElementById('wordInput');
var lastSubmitter = null;
var page = document.getElementById('chatFilterPage');
var canChat = !!page.dataset.canChat;
var PAGE = 50;
var REASONS = {
blocked_here: 'Blocked on the staff list',
allowed_here: 'Allowed on the staff list',
allow_file: 'In chatplus_en_us.txt',
character_name: 'An approved character name',
not_allowed: 'Not in chatplus_en_us.txt or the Allowed list',
block_file: 'In blocklist.dcf',
not_in_block_file: 'Not a blocked word',
no_block_file: 'No blocklist.dcf: free chat stops every word'
};
function item(w) {
return '<li class="list-group-item d-flex justify-content-between align-items-center gap-2">' +
'<div><strong>' + esc(w.word) + '</strong><div class="small text-body-secondary">' + esc(w.added_by) + ', ' + esc(fmt.unix(w.added_at)) + '</div></div>' +
'<div class="d-flex gap-1"><button type="button" class="btn btn-sm btn-outline-secondary" data-pick="' + esc(w.word) + '">Check</button>' +
'<button type="button" class="btn btn-sm btn-outline-danger" data-remove="' + esc(w.word) + '">Remove</button></div></li>';
// ---- test a message ----
var testForm = document.getElementById('testForm');
var testMessage = document.getElementById('testMessage');
function wordAction(w) {
if (!w.word) return '';
if (w.reason === 'blocked_here' || w.reason === 'allowed_here') return '<button type="button" class="btn btn-sm btn-outline-secondary" data-remove="' + esc(w.word) + '">Remove…</button>';
if (w.reason === 'not_allowed') return '<button type="button" class="btn btn-sm btn-outline-success" data-add="allowed" data-word="' + esc(w.word) + '">Allow…</button>';
return '<button type="button" class="btn btn-sm btn-outline-danger" data-add="blocked" data-word="' + esc(w.word) + '">Block…</button>';
}
function load() {
function runTest() {
var message = testMessage.value;
if (!message.trim()) return;
var chat = document.getElementById('testChat').value;
api.get('/api/chat_filter/test?chat=' + chat + '&message=' + encodeURIComponent(message)).then(function (d) {
if (!d.success) { toast(d.error || 'Test failed', 'danger'); return; }
var stopped = d.words.filter(function (w) { return w.stopped; });
var verdict = document.getElementById('testVerdict');
verdict.className = 'alert mb-2 py-2 alert-' + (stopped.length ? 'danger' : 'success');
verdict.textContent = stopped.length
? 'Stopped: ' + stopped.length + ' of ' + d.words.length + ' word(s) not allowed. Nobody would see it.'
: 'Sent: every word is allowed.';
document.getElementById('testWords').innerHTML = d.words.map(function (w) {
return '<span class="badge ' + (w.stopped ? 'text-bg-danger' : 'text-bg-success') + '" title="' + esc(REASONS[w.reason] || w.reason) + '">' + esc(w.text || '(empty)') + '</span>';
}).join('');
document.getElementById('testRows').innerHTML = d.words.map(function (w) {
return '<tr><td>' + esc(w.word || '(empty: two spaces in a row)') + '</td><td>' + (w.stopped ? fmt.badge('Stopped', 'danger') : fmt.badge('OK', 'success')) + '</td>' +
'<td class="small">' + esc(REASONS[w.reason] || w.reason) + '</td><td class="text-end">' + wordAction(w) + '</td></tr>';
}).join('');
document.getElementById('testResult').classList.remove('d-none');
}).catch(function () {});
}
function testWord(word) {
testMessage.value = word;
runTest();
testForm.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
testForm.addEventListener('submit', function (e) { e.preventDefault(); runTest(); });
document.getElementById('testChat').addEventListener('change', runTest);
// ---- staff lists ----
var words = [], listStart = 0;
function listed() {
var which = (document.querySelector('input[name="listFilter"]:checked') || {}).value || '';
var search = document.getElementById('listSearch').value.trim().toLowerCase();
return words.filter(function (w) {
if (which === 'blocked' && w.allowed) return false;
if (which === 'allowed' && !w.allowed) return false;
return !search || w.word.indexOf(search) !== -1 || String(w.added_by).toLowerCase().indexOf(search) !== -1;
});
}
function renderList() {
var rows = listed();
if (listStart >= rows.length) listStart = Math.max(0, Math.floor((rows.length - 1) / PAGE) * PAGE);
var shown = rows.slice(listStart, listStart + PAGE);
document.getElementById('listRows').innerHTML = shown.map(function (w) {
var other = w.allowed ? 'blocked' : 'allowed';
return '<tr><td>' + esc(w.word) + '</td><td>' + (w.allowed ? fmt.badge('Allowed', 'success') : fmt.badge('Blocked', 'danger')) + '</td>' +
'<td class="small">' + esc(w.added_by) + '</td><td class="small text-nowrap">' + esc(fmt.unix(w.added_at)) + '</td>' +
'<td class="text-end text-nowrap"><button type="button" class="btn btn-sm btn-outline-secondary" data-test="' + esc(w.word) + '">Test</button> ' +
'<button type="button" class="btn btn-sm btn-outline-' + (w.allowed ? 'danger' : 'success') + '" data-add="' + other + '" data-word="' + esc(w.word) + '">' + (w.allowed ? 'Block…' : 'Allow…') + '</button> ' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-remove="' + esc(w.word) + '">Remove…</button></td></tr>';
}).join('') || '<tr><td colspan="5" class="text-body-secondary">' + (words.length ? 'No words match.' : 'No words added yet.') + '</td></tr>';
document.getElementById('listRange').textContent = rows.length ? (listStart + 1) + '–' + (listStart + shown.length) + ' of ' + rows.length : '';
document.getElementById('listPrev').disabled = listStart === 0;
document.getElementById('listNext').disabled = listStart + PAGE >= rows.length;
}
function loadList() {
api.get('/api/chat_filter').then(function (d) {
if (!d.success) return;
var blocked = d.words.filter(function (w) { return !w.allowed; }), allowed = d.words.filter(function (w) { return w.allowed; });
document.getElementById('blockedCount').textContent = blocked.length;
document.getElementById('allowedCount').textContent = allowed.length;
document.getElementById('blockedList').innerHTML = blocked.map(item).join('') || '<li class="list-group-item text-body-secondary">None added.</li>';
document.getElementById('allowedList').innerHTML = allowed.map(item).join('') || '<li class="list-group-item text-body-secondary">None added.</li>';
words = d.words.sort(function (a, b) { return a.word < b.word ? -1 : a.word > b.word ? 1 : 0; });
var blocked = words.filter(function (w) { return !w.allowed; }).length;
document.getElementById('blockedCount').textContent = blocked + ' blocked';
document.getElementById('allowedCount').textContent = (words.length - blocked) + ' allowed';
renderList();
}).catch(function () {});
}
// The words of the filter's files (chatplus_en_us.txt; blocklist.dcf holds only hashes, so only its size is known)
var fileSearch = document.getElementById('fileSearch'), fileWords = [], fileStart = 0, fileTimer = null;
function loadFiles(more) {
fileStart = more ? fileStart + fileWords.length : 0;
document.querySelectorAll('input[name="listFilter"]').forEach(function (r) { r.addEventListener('change', function () { listStart = 0; renderList(); }); });
document.getElementById('listSearch').addEventListener('input', function () { listStart = 0; renderList(); });
document.getElementById('listPrev').addEventListener('click', function () { listStart = Math.max(0, listStart - PAGE); renderList(); });
document.getElementById('listNext').addEventListener('click', function () { listStart += PAGE; renderList(); });
var addForm = document.getElementById('addForm'), addWord = document.getElementById('addWord'), addList = 'blocked';
addForm.addEventListener('click', function (e) { var b = e.target.closest('button[data-list]'); if (b) addList = b.dataset.list; });
addForm.addEventListener('submit', function (e) {
e.preventDefault();
var word = addWord.value.trim();
if (!word) return;
confirmAdd(word, addList === 'allowed').then(function (done) { if (done) addWord.value = ''; });
});
// ---- confirmation: what the change does, from the word's standing and recent chat ----
var modalEl = document.getElementById('cfConfirm');
var modal = new bootstrap.Modal(modalEl);
var onGo = null;
function standing(d) {
var parts = [d.inAllowFile ? 'in chatplus_en_us.txt' : 'not in chatplus_en_us.txt'];
if (d.inBlockFile) parts.push('in blocklist.dcf');
parts.push(d.dashboard ? (d.dashboard === 'blocked' ? 'on the Blocked list' : 'on the Allowed list') : 'on neither staff list');
return '<strong>' + esc(d.word) + '</strong> now: ' + esc(parts.join(', ')) + '.';
}
function openConfirm(options) {
document.getElementById('cfConfirmTitle').textContent = options.title;
document.getElementById('cfConfirmText').textContent = options.text;
document.getElementById('cfConfirmStanding').innerHTML = '';
document.getElementById('cfConfirmImpact').classList.add('d-none');
var go = document.getElementById('cfConfirmGo');
go.className = 'btn btn-' + options.tone;
go.textContent = options.button;
go.disabled = false;
return new Promise(function (resolve) {
var settled = false;
onGo = function () {
go.disabled = true;
options.run().then(function () { settled = true; resolve(true); modal.hide(); }).catch(function () { go.disabled = false; });
};
modalEl.addEventListener('hidden.bs.modal', function once() { modalEl.removeEventListener('hidden.bs.modal', once); if (!settled) resolve(false); });
modal.show();
});
}
document.getElementById('cfConfirmGo').addEventListener('click', function () { if (onGo) onGo(); });
function showStanding(word) {
api.get('/api/chat_filter/lookup?word=' + encodeURIComponent(word)).then(function (d) {
var el = document.getElementById('cfConfirmStanding');
if (!d.success) { el.textContent = d.error || ''; return; }
el.innerHTML = standing(d);
}).catch(function () {});
}
// allowed=false: recent messages players saw that blocking would stop; true: recent stopped messages with the word
function showImpact(word, allowed) {
var box = document.getElementById('cfConfirmImpact');
var title = document.getElementById('cfImpactTitle');
var rows = document.getElementById('cfImpactRows');
box.classList.remove('d-none');
if (!canChat) { title.textContent = 'Recent chat'; rows.innerHTML = '<tr><td colspan="4" class="text-body-secondary">Seeing recent chat needs the chat_view permission.</td></tr>'; return; }
title.textContent = 'Checking recent chat…';
rows.innerHTML = '';
api.get('/api/chat_filter/check?word=' + encodeURIComponent(word) + '&allowed=' + (allowed ? 1 : 0)).then(function (d) {
if (!d.success) { title.textContent = d.error || 'Check failed'; return; }
title.textContent = allowed
? d.messages.length + ' recent stopped message(s) contain it (they still need their other words allowed)'
: d.messages.length + ' recent message(s) players saw would have been stopped';
rows.innerHTML = d.messages.map(function (m) {
return '<tr><td class="small text-nowrap">' + esc(fmt.unix(m.time)) + '</td><td class="small">' + (m.zone_id ? fmt.zone(m.zone_id, m.zone_name) : esc(m.channel)) + '</td>' +
'<td>' + fmt.character(m.sender_id, m.sender_name) + '</td><td>' + esc(m.message) + '</td></tr>';
}).join('') || '<tr><td colspan="4" class="text-body-secondary">None in the newest ' + esc(d.limit) + ' messages with this text.</td></tr>';
}).catch(function () {});
}
function confirmAdd(word, allowed) {
var p = openConfirm({
title: (allowed ? 'Allow "' : 'Block "') + word + '"?',
text: allowed ? 'Players may use it in normal chat. Running worlds apply it at once.' : 'It is stopped in all chat, even where a word file allows it. Running worlds apply it at once.',
tone: allowed ? 'success' : 'danger',
button: allowed ? 'Allow' : 'Block',
run: function () { return api.action('/api/chat_filter/words', { word: word, allowed: allowed }).then(function (d) { toast(d.message, 'success'); }); }
});
showStanding(word);
showImpact(word, allowed);
return p;
}
function confirmRemove(word) {
var p = openConfirm({
title: 'Remove "' + word + '"?',
text: 'It comes off the staff lists and the word files decide about it again.',
tone: 'secondary',
button: 'Remove',
run: function () { return api.action('/api/chat_filter/words/delete', { word: word }).then(function (d) { toast(d.message, 'success'); }); }
});
showStanding(word);
return p;
}
page.addEventListener('click', function (e) {
var add = e.target.closest('[data-add]'), remove = e.target.closest('[data-remove]'), test = e.target.closest('[data-test]');
if (add) confirmAdd(add.dataset.word, add.dataset.add === 'allowed');
else if (remove) confirmRemove(remove.dataset.remove);
else if (test) testWord(test.dataset.test);
});
// ---- word files ----
var fileSearch = document.getElementById('fileSearch'), fileStart = 0, fileTimer = null, filePage = 200;
function loadFiles() {
api.get('/api/chat_filter/files?search=' + encodeURIComponent(fileSearch.value.trim()) + '&start=' + fileStart).then(function (d) {
if (!d.success) return;
fileWords = more ? fileWords.concat(d.words) : d.words;
fileStart = more ? fileStart : 0;
document.getElementById('fileCount').textContent = d.allowFileFound ? d.allowTotal : '';
document.getElementById('fileHelp').textContent = (d.allowFileFound
? d.allowTotal + ' words are allowed by ' + d.allowFile + ' (from the client); ' + d.onDashboard + ' of them are on the lists above too, which win. '
: 'Could not read ' + d.allowFile + ' from the client: set client_location. ') +
(d.blockFileFound ? d.blockFile + ' blocks ' + d.blockTotal + ' more words, stored only as hashes, so they can\'t be listed: check a word above to see if it is one.'
: 'There is no ' + d.blockFile + ' next to the servers.');
document.getElementById('fileWords').innerHTML = fileWords.map(function (w) {
filePage = d.pageSize || filePage;
document.getElementById('fileSummary').innerHTML =
'<li><code>' + esc(d.allowFile) + '</code>: ' + (d.allowFileFound
? esc(d.allowTotal) + ' words normal chat may use (from the client); ' + esc(d.onDashboard) + ' are on a staff list too, which wins.'
: 'could not be read. Set <code>client_location</code>.') + '</li>' +
'<li><code>' + esc(d.blockFile) + '</code>: ' + (d.blockFileFound
? esc(d.blockTotal) + ' words best friends\' free chat may not use. Stored as hashes, so they can\'t be listed; test a word to see if it is one.'
: 'not found next to the servers, so best friends\' free chat stops everything.') + '</li>';
document.getElementById('fileWords').innerHTML = d.words.map(function (w) {
var cls = w.dashboard === 'blocked' ? 'text-bg-danger' : w.dashboard === 'allowed' ? 'text-bg-success' : 'text-bg-secondary';
var title = w.dashboard ? (w.dashboard === 'blocked' ? 'Blocked on the list above, whatever the file says' : 'Also on the Allowed list above') : 'Only in the file';
return '<button type="button" class="badge border-0 ' + cls + '" data-pick="' + esc(w.word) + '" title="' + esc(title) + '">' + esc(w.word) + '</button>';
return '<button type="button" class="badge border-0 ' + cls + '" data-test="' + esc(w.word) + '">' + esc(w.word) + '</button>';
}).join('') || '<span class="text-body-secondary small">No words match.</span>';
var more = document.getElementById('fileMore');
more.classList.toggle('d-none', fileWords.length >= d.matched);
more.textContent = 'Show more (' + (d.matched - fileWords.length) + ' left)';
document.getElementById('fileRange').textContent = d.matched ? (fileStart + 1) + '–' + (fileStart + d.words.length) + ' of ' + d.matched : '';
document.getElementById('filePrev').disabled = fileStart === 0;
document.getElementById('fileNext').disabled = fileStart + d.words.length >= d.matched;
}).catch(function () {});
}
// Where a word stands: the files and the lists here
function lookup(word) {
var el = document.getElementById('lookupResult');
api.get('/api/chat_filter/lookup?word=' + encodeURIComponent(word)).then(function (d) {
if (!d.success) return;
var parts = [];
parts.push(d.inAllowFile ? 'allowed by the file' : 'not in the allowed words file');
if (d.inBlockFile) parts.push('blocked by the blocked words file');
if (d.dashboard) parts.push((d.dashboard === 'blocked' ? 'blocked' : 'allowed') + ' on the list here (this wins)');
el.innerHTML = '<strong>' + esc(d.word) + '</strong>: ' + esc(parts.join('; ')) + '.';
el.classList.remove('d-none');
}).catch(function () {});
}
function check(word, allowed) {
lookup(word);
api.get('/api/chat_filter/check?word=' + encodeURIComponent(word) + '&allowed=' + (allowed ? 1 : 0)).then(function (d) {
if (!d.success) { toast(d.error || 'Check failed', 'danger'); return; }
document.getElementById('checkResult').classList.remove('d-none');
document.getElementById('checkTitle').textContent = d.allowed
? d.messages.length + ' stopped message(s) with "' + d.word + '"'
: d.messages.length + ' message(s) blocking "' + d.word + '" would have stopped';
document.getElementById('checkHelp').textContent = 'From the newest ' + d.limit + ' messages containing the text' +
(d.allowed ? '. Allowing the word lets these through only if their other words are allowed too.' : ' that players saw.');
document.getElementById('checkRows').innerHTML = d.messages.map(function (m) {
return '<tr><td class="small text-nowrap">' + esc(fmt.unix(m.time)) + '</td><td class="small">' + (m.zone_id ? fmt.zone(m.zone_id, m.zone_name) : esc(m.channel)) + '</td>' +
'<td>' + fmt.character(m.sender_id, m.sender_name) + '</td><td>' + esc(m.message) + '</td>' +
'<td>' + (m.account_id ? '<a class="btn btn-sm btn-outline-secondary" href="/accounts/' + esc(m.account_id) + '">Account</a>' : '') + '</td></tr>';
}).join('') || '<tr><td colspan="5" class="text-body-secondary">None.</td></tr>';
}).catch(function () {});
}
document.getElementById('wordForm').addEventListener('click', function (e) {
var button = e.target.closest('button');
if (!button) return;
if (button.type === 'submit') { lastSubmitter = button; return; }
if (button.dataset.check !== undefined && input.value.trim()) check(input.value.trim(), button.dataset.check === '1');
});
document.getElementById('wordForm').addEventListener('submit', function (e) {
e.preventDefault();
var allowed = !!(lastSubmitter && lastSubmitter.dataset.allowed === '1');
api.action('/api/chat_filter/words', { word: input.value.trim(), allowed: allowed }).then(function (d) {
toast(d.message, 'success');
input.value = '';
}).catch(function () {});
fileSearch.addEventListener('input', function () { clearTimeout(fileTimer); fileTimer = setTimeout(function () { fileStart = 0; loadFiles(); }, 250); });
document.getElementById('filePrev').addEventListener('click', function () { fileStart = Math.max(0, fileStart - filePage); loadFiles(); });
document.getElementById('fileNext').addEventListener('click', function () { fileStart += filePage; loadFiles(); });
document.getElementById('importFile').addEventListener('click', function () {
openConfirm({
title: 'Copy the file\'s words into Allowed?',
text: 'Every word of chatplus_en_us.txt goes on the Allowed list once, so each can be removed or blocked here. Words already on a list stay as they are; the file is not changed.',
tone: 'primary',
button: 'Copy',
run: function () { return api.action('/api/chat_filter/import', {}).then(function (d) { toast(d.message, 'success'); }); }
});
});
document.getElementById('chatFilterPage').addEventListener('click', function (e) {
var pick = e.target.closest('[data-pick]'), remove = e.target.closest('[data-remove]');
if (pick) {
input.value = pick.dataset.pick;
check(pick.dataset.pick, false);
} else if (remove && confirm('Remove "' + remove.dataset.remove + '"? The filter\'s files decide about it again.')) {
api.action('/api/chat_filter/words/delete', { word: remove.dataset.remove }).then(function (d) { toast(d.message, 'success'); }).catch(function () {});
}
});
document.getElementById('reloadWorlds').addEventListener('click', function () { api.action('/api/chat_filter/reload', {}).catch(function () {}); });
fileSearch.addEventListener('input', function () { clearTimeout(fileTimer); fileTimer = setTimeout(function () { loadFiles(false); }, 250); });
document.getElementById('fileMore').addEventListener('click', function () { loadFiles(true); });
document.getElementById('importFile').addEventListener('click', function () {
if (!confirm('Copy every word of the allowed words file into the Allowed list, so each can be removed or blocked here? Words already on a list stay as they are.')) return;
api.action('/api/chat_filter/import', {}).then(function (d) { toast(d.message, 'success'); }).catch(function () {});
if (window.Live) Live.on('chat_filter', function () {
loadList();
loadFiles();
if (!document.getElementById('testResult').classList.contains('d-none')) runTest();
});
if (window.Live) Live.on('chat_filter', function () { load(); loadFiles(false); });
load();
loadFiles(false);
loadList();
loadFiles();
})();

View File

@@ -2,67 +2,125 @@
{% block title %}Chat Filter - DarkflameServer{% endblock %}
{% block css %}
<style>
.cf-words .badge { font-size: .85rem; font-weight: 500; }
.cf-words button.badge { cursor: pointer; }
.cf-explain dt { font-weight: 600; }
.cf-explain dd { margin-bottom: .25rem; }
#cfWordTable td:first-child { font-weight: 600; word-break: break-all; }
</style>
{% endblock %}
{% block content %}
<div id="chatFilterPage">
<div id="chatFilterPage" data-can-chat="{% if can.chat_view %}1{% endif %}">
<div class="d-flex justify-content-between align-items-start mb-3 flex-wrap gap-2">
<div>
<h2 class="mb-1">Chat Filter</h2>
<p class="text-body-secondary mb-0">Words added here go on top of the filter's files. <strong>Blocked</strong> words are stopped in every kind of chat,
even when the files allow them. <strong>Allowed</strong> words can be used in normal (whitelisted) chat, like the words in <code>chatplus_en_us.txt</code>.
Running worlds pick up changes straight away. Before blocking a word, check which recent messages it would have stopped.</p>
<p class="text-body-secondary mb-0">Which words players may use in chat. Staff lists here override the word files. GM 2 and up skip the filter.</p>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" id="reloadWorlds">Apply in running worlds again</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="reloadWorlds" title="Changes apply at once; use this only if a world seems out of date">Re-apply in running worlds</button>
</div>
<div class="card mb-3">
<div class="card-header"><h5 class="mb-0">Test a message</h5></div>
<div class="card-body">
<form class="row g-2 align-items-end" id="wordForm">
<div class="col-md-5"><label class="form-label small mb-1" for="wordInput">Word</label>
<input class="form-control form-control-sm" id="wordInput" maxlength="64" required autocomplete="off" placeholder="One word, no spaces"></div>
<div class="col-md-7 d-flex flex-wrap gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" data-check="0">What would blocking it stop?</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-check="1">What did the filter stop with it?</button>
<button type="submit" class="btn btn-sm btn-danger" data-allowed="0">Block</button>
<button type="submit" class="btn btn-sm btn-success" data-allowed="1">Allow</button>
</div>
<form class="row g-2 align-items-end" id="testForm">
<div class="col-md-7"><label class="form-label small mb-1" for="testMessage">Message</label>
<input class="form-control" id="testMessage" maxlength="300" required autocomplete="off" placeholder="Type a message as a player would"></div>
<div class="col-md-3"><label class="form-label small mb-1" for="testChat">Chat</label>
<select class="form-select" id="testChat">
<option value="normal">Normal chat</option>
<option value="free">Best friends' free chat</option>
</select></div>
<div class="col-md-2"><button type="submit" class="btn btn-primary w-100">Test</button></div>
</form>
<div class="small mt-2 d-none" id="lookupResult"></div>
<div class="mt-3 d-none" id="checkResult">
<h6 class="mb-1" id="checkTitle"></h6>
<p class="small text-body-secondary mb-2" id="checkHelp"></p>
<table class="table table-sm align-middle w-100 table-stack mb-0">
<thead><tr><th>Time</th><th>Where</th><th>From</th><th>Message</th><th></th></tr></thead>
<tbody id="checkRows"></tbody>
<div class="mt-3 d-none" id="testResult">
<div class="alert mb-2 py-2" id="testVerdict"></div>
<div class="cf-words d-flex flex-wrap gap-1 mb-2" id="testWords"></div>
<table class="table table-sm align-middle mb-0 table-stack">
<thead><tr><th>Word</th><th>Result</th><th>Why</th><th></th></tr></thead>
<tbody id="testRows"></tbody>
</table>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div class="card h-100 panel-fill panel-cap">
<div class="card-header"><h5 class="mb-0">Blocked <span class="badge rounded-pill text-bg-danger align-middle" id="blockedCount"></span></h5></div>
<ul class="list-group list-group-flush" id="blockedList"></ul>
</div>
<div class="card mb-3">
<div class="card-header d-flex flex-wrap align-items-center gap-2">
<h5 class="mb-0 me-auto">Staff lists</h5>
<span class="badge rounded-pill text-bg-danger" id="blockedCount">0 blocked</span>
<span class="badge rounded-pill text-bg-success" id="allowedCount">0 allowed</span>
</div>
<div class="col-lg-6">
<div class="card h-100 panel-fill panel-cap">
<div class="card-header"><h5 class="mb-0">Allowed <span class="badge rounded-pill text-bg-success align-middle" id="allowedCount"></span></h5></div>
<ul class="list-group list-group-flush" id="allowedList"></ul>
<div class="card-body">
<dl class="small cf-explain mb-3 row">
<dt class="col-sm-2 text-danger">Blocked</dt><dd class="col-sm-10">Stopped in all chat, even if a word file allows it.</dd>
<dt class="col-sm-2 text-success">Allowed</dt><dd class="col-sm-10">Usable in normal chat, like the words in <code>chatplus_en_us.txt</code>.</dd>
</dl>
<form class="row g-2 align-items-end mb-3" id="addForm">
<div class="col-md-6"><label class="form-label small mb-1" for="addWord">Add a word</label>
<input class="form-control form-control-sm" id="addWord" maxlength="64" required autocomplete="off" placeholder="One word, no spaces"></div>
<div class="col-md-6 d-flex gap-2">
<button type="submit" class="btn btn-sm btn-danger" data-list="blocked">Block…</button>
<button type="submit" class="btn btn-sm btn-success" data-list="allowed">Allow…</button>
</div>
</form>
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<div class="btn-group btn-group-sm" role="group" aria-label="Which list">
<input type="radio" class="btn-check" name="listFilter" id="listAll" value="" checked><label class="btn btn-outline-secondary" for="listAll">All</label>
<input type="radio" class="btn-check" name="listFilter" id="listBlocked" value="blocked"><label class="btn btn-outline-secondary" for="listBlocked">Blocked</label>
<input type="radio" class="btn-check" name="listFilter" id="listAllowed" value="allowed"><label class="btn btn-outline-secondary" for="listAllowed">Allowed</label>
</div>
<input type="search" class="form-control form-control-sm w-auto ms-auto" id="listSearch" placeholder="Search the lists" aria-label="Search the lists">
</div>
<table class="table table-sm table-hover align-middle w-100 table-stack mb-2" id="cfWordTable">
<thead><tr><th>Word</th><th>List</th><th>Added by</th><th>Added</th><th></th></tr></thead>
<tbody id="listRows"></tbody>
</table>
<div class="d-flex justify-content-between align-items-center small">
<span class="text-body-secondary" id="listRange"></span>
<div class="btn-group btn-group-sm"><button type="button" class="btn btn-outline-secondary" id="listPrev">Previous</button><button type="button" class="btn btn-outline-secondary" id="listNext">Next</button></div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card">
<div class="card-header d-flex flex-wrap align-items-center gap-2">
<h5 class="mb-0 me-auto">From the files <span class="badge rounded-pill text-bg-secondary align-middle" id="fileCount"></span></h5>
<input type="search" class="form-control form-control-sm w-auto" id="fileSearch" placeholder="Search the allowed words" aria-label="Search the files' words">
<button type="button" class="btn btn-sm btn-outline-primary" id="importFile">Copy into the Allowed list</button>
<h5 class="mb-0 me-auto">Word files</h5>
<button type="button" class="btn btn-sm btn-outline-primary" id="importFile">Copy file words into Allowed…</button>
</div>
<div class="card-body">
<p class="small text-body-secondary mb-2" id="fileHelp"></p>
<div class="d-flex flex-wrap gap-1" id="fileWords"></div>
<button type="button" class="btn btn-sm btn-link px-0 d-none" id="fileMore">Show more</button>
<ul class="small mb-3 ps-3" id="fileSummary"><li class="text-body-secondary">Loading…</li></ul>
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<input type="search" class="form-control form-control-sm w-auto" id="fileSearch" placeholder="Search chatplus_en_us.txt" aria-label="Search the allowed words file">
<span class="small text-body-secondary">Click a word to test it.
<span class="badge text-bg-secondary">file only</span> <span class="badge text-bg-success">also allowed here</span> <span class="badge text-bg-danger">blocked here</span></span>
</div>
<div class="cf-words d-flex flex-wrap gap-1 mb-2" id="fileWords"></div>
<div class="d-flex justify-content-between align-items-center small">
<span class="text-body-secondary" id="fileRange"></span>
<div class="btn-group btn-group-sm"><button type="button" class="btn btn-outline-secondary" id="filePrev">Previous</button><button type="button" class="btn btn-outline-secondary" id="fileNext">Next</button></div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="cfConfirm" tabindex="-1" aria-labelledby="cfConfirmTitle">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title" id="cfConfirmTitle"></h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>
<div class="modal-body">
<p class="mb-2" id="cfConfirmText"></p>
<p class="small mb-2" id="cfConfirmStanding"></p>
<div class="d-none" id="cfConfirmImpact">
<h6 class="small text-uppercase text-body-secondary mb-1" id="cfImpactTitle"></h6>
<table class="table table-sm align-middle mb-0 table-stack">
<thead><tr><th>Time</th><th>Where</th><th>From</th><th>Message</th></tr></thead>
<tbody id="cfImpactRows"></tbody>
</table>
</div>
</div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button><button type="button" class="btn" id="cfConfirmGo"></button></div>
</div>
</div>
</div>

View File

@@ -911,25 +911,32 @@ character's owner sees only what is still in their mailbox. Deleting a character
### Chat filter
The **Chat Filter** page (GM 5+, `chat_filter_manage`, under Moderation) adds words on top of the chat filter's files
(`chatplus_en_us.txt` in the client's `res` folder lists the words normal chat may use, `blocklist.dcf` the words best
friends' free chat may not). **Block** stops a word in every kind of chat, even where the files allow it; **Allow** lets
normal chat use a word the files don't list. Words are compared the way the filter compares them: lower case, without
`! ? ; . ,`. Every change is applied straight away in running worlds and in the chat server's web chat, without a
restart, and servers that start later read the list.
The **Chat Filter** page (GM 5+, `chat_filter_manage`, under Moderation) decides which words players below GM 2 may use
in chat. The filter's files: `chatplus_en_us.txt` (client `res` folder) lists the words normal chat may use,
`blocklist.dcf` (next to the servers, hashes only) the words best friends' free chat may not. Approved character names
also count as allowed. Words are compared lower case, without `! ? ; . ,`. Changes apply at once in running worlds and in
the chat server's web chat; servers that start later read them. Changes are audited and go to the `moderation` webhook
event.
Before blocking a word, **What would blocking it stop?** lists the recent chat players saw that contains it; **What
did the filter stop with it?** lists recent stopped messages that contain it. Checking a word also says where it stands:
in the allowed words file, in the blocked words file, and on the lists here. Changes are audited and go to the
`moderation` webhook event.
The page has three sections:
**From the files** lists the allowed words of `chatplus_en_us.txt` (needs `client_location`), searchable, marked when a
list here also has them. Click one to check it; block it with **Block** as any other word. `blocklist.dcf` keeps only
hashes of its words, so only how many it has is shown; checking a word tells whether it is one of them. **Copy into the
Allowed list** copies every word of the file into the Allowed list once (words already on a list are left alone), for
servers that want to edit the whole list here; the file stays as it is, and a word removed from the Allowed list is
still allowed by the file until it is blocked. API: `GET /api/chat_filter/files?search=&start=`,
`GET /api/chat_filter/lookup?word=`, `POST /api/chat_filter/import`.
- **Test a message**: type a message, pick normal or best friends' free chat, and see whether it would be sent and why,
word by word (in the file, allowed or blocked here, a character name, not allowed, in the blocked words file). Each
word has a Block, Allow or Remove button.
- **Staff lists**: **Blocked** words are stopped in all chat, even where a file allows them; **Allowed** words are usable
in normal chat. Search, filter by list, 50 per page. Block, Allow (or move to the other list) and Remove each open a
confirmation that shows where the word stands now and, for Block and Allow, the recent chat it changes (players' chat
containing it that would have been stopped, or stopped messages containing it; the newest 1000 messages with the
text; needs `chat_view`).
- **Word files**: how many words each file has, and the words of `chatplus_en_us.txt` (needs `client_location`),
searchable, 200 per page, coloured by whether a staff list also has them. Click one to test it. **Copy file words into
Allowed** copies every word of the file into the Allowed list once (words already on a list stay as they are); the file
is not changed, and a word removed from Allowed is still allowed by the file until it is blocked.
**Re-apply in running worlds** loads the lists again in every world; changes already do this. API:
`GET /api/chat_filter`, `GET /api/chat_filter/test?message=&chat=normal|free`, `GET /api/chat_filter/lookup?word=`,
`GET /api/chat_filter/check?word=&allowed=`, `GET /api/chat_filter/files?search=&start=`, `POST /api/chat_filter/words`,
`POST /api/chat_filter/words/delete`, `POST /api/chat_filter/import`, `POST /api/chat_filter/reload`.
### AI moderator helper