mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
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:
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user