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 * The Chat Filter page: test a message against the filter, the staff Blocked and Allowed lists (search, paging, add,
* would have stopped (or which messages the filter stopped that contain it), and where it stands in the files. The files' * move and remove, each confirmed with what it changes in recent chat), and the filter's word files.
* allowed words can be browsed and copied into the Allowed list. Every change is applied in running worlds.
*/ */
(function () { (function () {
'use strict'; 'use strict';
var input = document.getElementById('wordInput'); var page = document.getElementById('chatFilterPage');
var lastSubmitter = null; 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) { // ---- test a message ----
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>' + var testForm = document.getElementById('testForm');
'<div class="d-flex gap-1"><button type="button" class="btn btn-sm btn-outline-secondary" data-pick="' + esc(w.word) + '">Check</button>' + var testMessage = document.getElementById('testMessage');
'<button type="button" class="btn btn-sm btn-outline-danger" data-remove="' + esc(w.word) + '">Remove</button></div></li>';
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) { api.get('/api/chat_filter').then(function (d) {
if (!d.success) return; if (!d.success) return;
var blocked = d.words.filter(function (w) { return !w.allowed; }), allowed = d.words.filter(function (w) { return w.allowed; }); words = d.words.sort(function (a, b) { return a.word < b.word ? -1 : a.word > b.word ? 1 : 0; });
document.getElementById('blockedCount').textContent = blocked.length; var blocked = words.filter(function (w) { return !w.allowed; }).length;
document.getElementById('allowedCount').textContent = allowed.length; document.getElementById('blockedCount').textContent = blocked + ' blocked';
document.getElementById('blockedList').innerHTML = blocked.map(item).join('') || '<li class="list-group-item text-body-secondary">None added.</li>'; document.getElementById('allowedCount').textContent = (words.length - blocked) + ' allowed';
document.getElementById('allowedList').innerHTML = allowed.map(item).join('') || '<li class="list-group-item text-body-secondary">None added.</li>'; renderList();
}).catch(function () {}); }).catch(function () {});
} }
// The words of the filter's files (chatplus_en_us.txt; blocklist.dcf holds only hashes, so only its size is known) document.querySelectorAll('input[name="listFilter"]').forEach(function (r) { r.addEventListener('change', function () { listStart = 0; renderList(); }); });
var fileSearch = document.getElementById('fileSearch'), fileWords = [], fileStart = 0, fileTimer = null; document.getElementById('listSearch').addEventListener('input', function () { listStart = 0; renderList(); });
function loadFiles(more) { document.getElementById('listPrev').addEventListener('click', function () { listStart = Math.max(0, listStart - PAGE); renderList(); });
fileStart = more ? fileStart + fileWords.length : 0; 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) { api.get('/api/chat_filter/files?search=' + encodeURIComponent(fileSearch.value.trim()) + '&start=' + fileStart).then(function (d) {
if (!d.success) return; if (!d.success) return;
fileWords = more ? fileWords.concat(d.words) : d.words; filePage = d.pageSize || filePage;
fileStart = more ? fileStart : 0; document.getElementById('fileSummary').innerHTML =
document.getElementById('fileCount').textContent = d.allowFileFound ? d.allowTotal : ''; '<li><code>' + esc(d.allowFile) + '</code>: ' + (d.allowFileFound
document.getElementById('fileHelp').textContent = (d.allowFileFound ? esc(d.allowTotal) + ' words normal chat may use (from the client); ' + esc(d.onDashboard) + ' are on a staff list too, which wins.'
? 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 be read. Set <code>client_location</code>.') + '</li>' +
: 'Could not read ' + d.allowFile + ' from the client: set client_location. ') + '<li><code>' + esc(d.blockFile) + '</code>: ' + (d.blockFileFound
(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.' ? 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.'
: 'There is no ' + d.blockFile + ' next to the servers.'); : 'not found next to the servers, so best friends\' free chat stops everything.') + '</li>';
document.getElementById('fileWords').innerHTML = fileWords.map(function (w) { 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 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-test="' + esc(w.word) + '">' + esc(w.word) + '</button>';
return '<button type="button" class="badge border-0 ' + cls + '" data-pick="' + esc(w.word) + '" title="' + esc(title) + '">' + esc(w.word) + '</button>';
}).join('') || '<span class="text-body-secondary small">No words match.</span>'; }).join('') || '<span class="text-body-secondary small">No words match.</span>';
var more = document.getElementById('fileMore'); document.getElementById('fileRange').textContent = d.matched ? (fileStart + 1) + '–' + (fileStart + d.words.length) + ' of ' + d.matched : '';
more.classList.toggle('d-none', fileWords.length >= d.matched); document.getElementById('filePrev').disabled = fileStart === 0;
more.textContent = 'Show more (' + (d.matched - fileWords.length) + ' left)'; document.getElementById('fileNext').disabled = fileStart + d.words.length >= d.matched;
}).catch(function () {}); }).catch(function () {});
} }
// Where a word stands: the files and the lists here fileSearch.addEventListener('input', function () { clearTimeout(fileTimer); fileTimer = setTimeout(function () { fileStart = 0; loadFiles(); }, 250); });
function lookup(word) { document.getElementById('filePrev').addEventListener('click', function () { fileStart = Math.max(0, fileStart - filePage); loadFiles(); });
var el = document.getElementById('lookupResult'); document.getElementById('fileNext').addEventListener('click', function () { fileStart += filePage; loadFiles(); });
api.get('/api/chat_filter/lookup?word=' + encodeURIComponent(word)).then(function (d) { document.getElementById('importFile').addEventListener('click', function () {
if (!d.success) return; openConfirm({
var parts = []; title: 'Copy the file\'s words into Allowed?',
parts.push(d.inAllowFile ? 'allowed by the file' : 'not in the allowed words file'); 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.',
if (d.inBlockFile) parts.push('blocked by the blocked words file'); tone: 'primary',
if (d.dashboard) parts.push((d.dashboard === 'blocked' ? 'blocked' : 'allowed') + ' on the list here (this wins)'); button: 'Copy',
el.innerHTML = '<strong>' + esc(d.word) + '</strong>: ' + esc(parts.join('; ')) + '.'; run: function () { return api.action('/api/chat_filter/import', {}).then(function (d) { toast(d.message, 'success'); }); }
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 () {});
}); });
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 () {}); }); 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); }); if (window.Live) Live.on('chat_filter', function () {
document.getElementById('fileMore').addEventListener('click', function () { loadFiles(true); }); loadList();
document.getElementById('importFile').addEventListener('click', function () { loadFiles();
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; if (!document.getElementById('testResult').classList.contains('d-none')) runTest();
api.action('/api/chat_filter/import', {}).then(function (d) { toast(d.message, 'success'); }).catch(function () {});
}); });
loadList();
if (window.Live) Live.on('chat_filter', function () { load(); loadFiles(false); }); loadFiles();
load();
loadFiles(false);
})(); })();

View File

@@ -2,67 +2,125 @@
{% block title %}Chat Filter - DarkflameServer{% endblock %} {% 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 %} {% 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 class="d-flex justify-content-between align-items-start mb-3 flex-wrap gap-2">
<div> <div>
<h2 class="mb-1">Chat Filter</h2> <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, <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>
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>
</div> </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>
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"><h5 class="mb-0">Test a message</h5></div>
<div class="card-body"> <div class="card-body">
<form class="row g-2 align-items-end" id="wordForm"> <form class="row g-2 align-items-end" id="testForm">
<div class="col-md-5"><label class="form-label small mb-1" for="wordInput">Word</label> <div class="col-md-7"><label class="form-label small mb-1" for="testMessage">Message</label>
<input class="form-control form-control-sm" id="wordInput" maxlength="64" required autocomplete="off" placeholder="One word, no spaces"></div> <input class="form-control" id="testMessage" maxlength="300" required autocomplete="off" placeholder="Type a message as a player would"></div>
<div class="col-md-7 d-flex flex-wrap gap-2"> <div class="col-md-3"><label class="form-label small mb-1" for="testChat">Chat</label>
<button type="button" class="btn btn-sm btn-outline-secondary" data-check="0">What would blocking it stop?</button> <select class="form-select" id="testChat">
<button type="button" class="btn btn-sm btn-outline-secondary" data-check="1">What did the filter stop with it?</button> <option value="normal">Normal chat</option>
<button type="submit" class="btn btn-sm btn-danger" data-allowed="0">Block</button> <option value="free">Best friends' free chat</option>
<button type="submit" class="btn btn-sm btn-success" data-allowed="1">Allow</button> </select></div>
</div> <div class="col-md-2"><button type="submit" class="btn btn-primary w-100">Test</button></div>
</form> </form>
<div class="small mt-2 d-none" id="lookupResult"></div> <div class="mt-3 d-none" id="testResult">
<div class="mt-3 d-none" id="checkResult"> <div class="alert mb-2 py-2" id="testVerdict"></div>
<h6 class="mb-1" id="checkTitle"></h6> <div class="cf-words d-flex flex-wrap gap-1 mb-2" id="testWords"></div>
<p class="small text-body-secondary mb-2" id="checkHelp"></p> <table class="table table-sm align-middle mb-0 table-stack">
<table class="table table-sm align-middle w-100 table-stack mb-0"> <thead><tr><th>Word</th><th>Result</th><th>Why</th><th></th></tr></thead>
<thead><tr><th>Time</th><th>Where</th><th>From</th><th>Message</th><th></th></tr></thead> <tbody id="testRows"></tbody>
<tbody id="checkRows"></tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<div class="row g-3"> <div class="card mb-3">
<div class="col-lg-6"> <div class="card-header d-flex flex-wrap align-items-center gap-2">
<div class="card h-100 panel-fill panel-cap"> <h5 class="mb-0 me-auto">Staff lists</h5>
<div class="card-header"><h5 class="mb-0">Blocked <span class="badge rounded-pill text-bg-danger align-middle" id="blockedCount"></span></h5></div> <span class="badge rounded-pill text-bg-danger" id="blockedCount">0 blocked</span>
<ul class="list-group list-group-flush" id="blockedList"></ul> <span class="badge rounded-pill text-bg-success" id="allowedCount">0 allowed</span>
</div>
</div> </div>
<div class="col-lg-6"> <div class="card-body">
<div class="card h-100 panel-fill panel-cap"> <dl class="small cf-explain mb-3 row">
<div class="card-header"><h5 class="mb-0">Allowed <span class="badge rounded-pill text-bg-success align-middle" id="allowedCount"></span></h5></div> <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>
<ul class="list-group list-group-flush" id="allowedList"></ul> <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>
</div> </div>
<div class="card mt-3"> <div class="card">
<div class="card-header d-flex flex-wrap align-items-center gap-2"> <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> <h5 class="mb-0 me-auto">Word files</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 file words into Allowed…</button>
<button type="button" class="btn btn-sm btn-outline-primary" id="importFile">Copy into the Allowed list</button>
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="small text-body-secondary mb-2" id="fileHelp"></p> <ul class="small mb-3 ps-3" id="fileSummary"><li class="text-body-secondary">Loading…</li></ul>
<div class="d-flex flex-wrap gap-1" id="fileWords"></div> <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<button type="button" class="btn btn-sm btn-link px-0 d-none" id="fileMore">Show more</button> <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> </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 ### Chat filter
The **Chat Filter** page (GM 5+, `chat_filter_manage`, under Moderation) adds words on top of the chat filter's files The **Chat Filter** page (GM 5+, `chat_filter_manage`, under Moderation) decides which words players below GM 2 may use
(`chatplus_en_us.txt` in the client's `res` folder lists the words normal chat may use, `blocklist.dcf` the words best in chat. The filter's files: `chatplus_en_us.txt` (client `res` folder) lists the words normal chat may use,
friends' free chat may not). **Block** stops a word in every kind of chat, even where the files allow it; **Allow** lets `blocklist.dcf` (next to the servers, hashes only) the words best friends' free chat may not. Approved character names
normal chat use a word the files don't list. Words are compared the way the filter compares them: lower case, without also count as allowed. Words are compared lower case, without `! ? ; . ,`. Changes apply at once in running worlds and in
`! ? ; . ,`. Every change is applied straight away in running worlds and in the chat server's web chat, without a the chat server's web chat; servers that start later read them. Changes are audited and go to the `moderation` webhook
restart, and servers that start later read the list. event.
Before blocking a word, **What would blocking it stop?** lists the recent chat players saw that contains it; **What The page has three sections:
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.
**From the files** lists the allowed words of `chatplus_en_us.txt` (needs `client_location`), searchable, marked when a - **Test a message**: type a message, pick normal or best friends' free chat, and see whether it would be sent and why,
list here also has them. Click one to check it; block it with **Block** as any other word. `blocklist.dcf` keeps only word by word (in the file, allowed or blocked here, a character name, not allowed, in the blocked words file). Each
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 word has a Block, Allow or Remove button.
Allowed list** copies every word of the file into the Allowed list once (words already on a list are left alone), for - **Staff lists**: **Blocked** words are stopped in all chat, even where a file allows them; **Allowed** words are usable
servers that want to edit the whole list here; the file stays as it is, and a word removed from the Allowed list is in normal chat. Search, filter by list, 50 per page. Block, Allow (or move to the other list) and Remove each open a
still allowed by the file until it is blocked. API: `GET /api/chat_filter/files?search=&start=`, confirmation that shows where the word stands now and, for Block and Allow, the recent chat it changes (players' chat
`GET /api/chat_filter/lookup?word=`, `POST /api/chat_filter/import`. 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 ### AI moderator helper