diff --git a/dDashboardServer/static/js/chat-filter.js b/dDashboardServer/static/js/chat-filter.js index b97ec721a..b85226f2c 100644 --- a/dDashboardServer/static/js/chat-filter.js +++ b/dDashboardServer/static/js/chat-filter.js @@ -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 '
  • ' + - '
    ' + esc(w.word) + '
    ' + esc(w.added_by) + ', ' + esc(fmt.unix(w.added_at)) + '
    ' + - '
    ' + - '
  • '; + // ---- 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 ''; + if (w.reason === 'not_allowed') return ''; + return ''; } - 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 '' + esc(w.text || '(empty)') + ''; + }).join(''); + document.getElementById('testRows').innerHTML = d.words.map(function (w) { + return '' + esc(w.word || '(empty: two spaces in a row)') + '' + (w.stopped ? fmt.badge('Stopped', 'danger') : fmt.badge('OK', 'success')) + '' + + '' + esc(REASONS[w.reason] || w.reason) + '' + wordAction(w) + ''; + }).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 '' + esc(w.word) + '' + (w.allowed ? fmt.badge('Allowed', 'success') : fmt.badge('Blocked', 'danger')) + '' + + '' + esc(w.added_by) + '' + esc(fmt.unix(w.added_at)) + '' + + ' ' + + ' ' + + ''; + }).join('') || '' + (words.length ? 'No words match.' : 'No words added yet.') + ''; + 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('') || '
  • None added.
  • '; - document.getElementById('allowedList').innerHTML = allowed.map(item).join('') || '
  • None added.
  • '; + 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 '' + esc(d.word) + ' 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 = 'Seeing recent chat needs the chat_view permission.'; 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 '' + esc(fmt.unix(m.time)) + '' + (m.zone_id ? fmt.zone(m.zone_id, m.zone_name) : esc(m.channel)) + '' + + '' + fmt.character(m.sender_id, m.sender_name) + '' + esc(m.message) + ''; + }).join('') || 'None in the newest ' + esc(d.limit) + ' messages with this text.'; + }).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 = + '
  • ' + esc(d.allowFile) + ': ' + (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 client_location.') + '
  • ' + + '
  • ' + esc(d.blockFile) + ': ' + (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.') + '
  • '; + 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 ''; + return ''; }).join('') || 'No words match.'; - 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 = '' + esc(d.word) + ': ' + 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 '' + esc(fmt.unix(m.time)) + '' + (m.zone_id ? fmt.zone(m.zone_id, m.zone_name) : esc(m.channel)) + '' + - '' + fmt.character(m.sender_id, m.sender_name) + '' + esc(m.message) + '' + - '' + (m.account_id ? 'Account' : '') + ''; - }).join('') || 'None.'; - }).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(); })(); diff --git a/dDashboardServer/templates/chat_filter.jinja2 b/dDashboardServer/templates/chat_filter.jinja2 index 68d36be0b..c281b27ea 100644 --- a/dDashboardServer/templates/chat_filter.jinja2 +++ b/dDashboardServer/templates/chat_filter.jinja2 @@ -2,67 +2,125 @@ {% block title %}Chat Filter - DarkflameServer{% endblock %} +{% block css %} + +{% endblock %} + {% block content %} -
    +

    Chat Filter

    -

    Words added here go on top of the filter's files. Blocked words are stopped in every kind of chat, - even when the files allow them. Allowed words can be used in normal (whitelisted) chat, like the words in chatplus_en_us.txt. - Running worlds pick up changes straight away. Before blocking a word, check which recent messages it would have stopped.

    +

    Which words players may use in chat. Staff lists here override the word files. GM 2 and up skip the filter.

    - +
    +
    Test a message
    -
    -
    -
    -
    - - - - -
    + +
    +
    +
    +
    +
    -
    -
    -
    -

    - - - +
    +
    +
    +
    TimeWhereFromMessage
    + +
    WordResultWhy
    -
    -
    -
    -
    Blocked
    -
      -
      +
      +
      +
      Staff lists
      + 0 blocked + 0 allowed
      -
      -
      -
      Allowed
      -
        +
        +
        +
        Blocked
        Stopped in all chat, even if a word file allows it.
        +
        Allowed
        Usable in normal chat, like the words in chatplus_en_us.txt.
        +
        +
        +
        +
        +
        + + +
        +
        +
        +
        + + + +
        + +
        + + + +
        WordListAdded byAdded
        +
        + +
        -
        +
        -
        From the files
        - - +
        Word files
        +
        -

        -
        - +
        • Loading…
        +
        + + Click a word to test it. + file only also allowed here blocked here +
        +
        +
        + +
        +
        +
        +
        +
        + + diff --git a/docs/Dashboard.md b/docs/Dashboard.md index fc908f8af..34eb840ca 100644 --- a/docs/Dashboard.md +++ b/docs/Dashboard.md @@ -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