// ==UserScript==
// @name CB Quick Silence
// @namespace aravvn.tools
// @version 2.0.5
// @description Pulls auto-silence usernames + trigger words from Supabase periodically. CSP-safe via GM_xmlhttpRequest. Adds Silence button (if mod), auto-silences or highlights. SPA-safe.
// @author aravvn
// @license CC-BY-NC-SA-4.0
// @match https://*.chaturbate.com/*
// @run-at document-idle
// @noframes
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @connect *.supabase.co
// @connect supabase.co
// ==/UserScript==
(() => {
'use strict';
// ----------- CONFIG -----------
const SUPABASE_URL = 'https://gbscowfidfdiaywktqal.supabase.co';
const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imdic2Nvd2ZpZGZkaWF5d2t0cWFsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTYxNDU0MzUsImV4cCI6MjA3MTcyMTQzNX0.BITW_rG0-goeo8VCgl-ZTSWfa8BDYsmT2xhIg-9055g';
const SUPABASE_TABLE_USERS = 'auto_silence_users';
const SUPABASE_TABLE_WORDS = 'trigger_words';
const DEFAULT_POLL_MS = 300_000;
const FALLBACK_USERS = [];
const FALLBACK_WORDS = [];
const CONFIG_DEFAULTS = {
enableAutoSilence: true,
enableHighlighting: true,
showSilenceButtons: true,
pollMs: DEFAULT_POLL_MS,
};
// ----------- LOG/UTIL -----------
const DEBUG = true;
const log = (...a) => DEBUG && console.log('[QS]', ...a);
const getCfg = (k) => GM_getValue(k, CONFIG_DEFAULTS[k]);
const setCfg = (k, v) => GM_setValue(k, v);
function toast(msg) {
const t = document.createElement('div');
t.className = 'qs_toast';
t.textContent = msg;
document.body.appendChild(t);
requestAnimationFrame(() => t.classList.add('show'));
setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 180); }, 1600);
}
function stripTs(s) { return String(s || '').replace(/^[\[]\d{2}:\d{2}[]]\s*/, '').trim(); }
function getCookie(name) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, '');
}
function getRoomFromURL() {
const first = location.pathname.replace(/^\/+|\/+$/g, '').split('/')[0] || '';
return (!first || ['b','p','tags','api','auth','proxy'].includes(first)) ? '' : first;
}
function waitForSelector(selector, timeout = 5000) {
return new Promise((resolve) => {
const found = document.querySelector(selector);
if (found) return resolve(found);
const obs = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) { obs.disconnect(); resolve(el); }
});
obs.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(() => { obs.disconnect(); resolve(null); }, timeout);
});
}
// ----------- DOM/CSS -----------
const SEL = {
rootMessage: 'div[data-testid="chat-message"]',
usernameContainer: 'div[data-testid="chat-message-username"]',
username: 'span[data-testid="username"]',
viewerUsername: '.user_information_header_username, [data-testid="user-information-username"]',
};
const style = document.createElement('style');
style.textContent = `
.qs_sil_btn{display:inline-block;margin-left:6px;padding:0 6px;font:11px/18px system-ui,sans-serif;border-radius:4px;cursor:pointer;user-select:none;background:#d13b3b;color:#fff;border:0}
.qs_sil_btn:hover{filter:brightness(1.06)}
.qs_toast{position:fixed;right:12px;bottom:12px;z-index:2147483647;background:#151515;color:#e9e9ea;border:1px solid #3a3a3d;border-radius:8px;padding:8px 10px;font:12px system-ui,sans-serif;box-shadow:0 6px 18px rgba(0,0,0,.45);opacity:0;transform:translateY(8px);transition:all .2s ease}
.qs_toast.show{opacity:1;transform:translateY(0)}
.qs_flagged_user { border-left: 4px solid #f40 !important; background: rgba(255,80,80,0.08) !important; }
.qs_flagged_word { border-left: 4px solid #f93 !important; background: rgba(255,160,60,0.08) !important; }
`;
document.head.appendChild(style);
// ----------- GM_xmlhttpRequest wrapper (CSP-safe) -----------
function gmRequestJSON(method, url, headers = {}, data = null) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method,
url,
headers,
data,
responseType: 'json',
onload: (res) => {
const status = res.status;
const text = res.responseText;
if (status >= 200 && status < 300) {
try {
resolve(JSON.parse(text || 'null'));
} catch {
resolve(res.response ?? null);
}
} else {
reject(new Error(`HTTP ${status}: ${text?.slice(0, 200)}`));
}
},
onerror: (e) => reject(new Error('Network error')),
ontimeout: () => reject(new Error('Timeout')),
});
});
}
const hasSupabase = () =>
SUPABASE_URL.startsWith('https://') &&
SUPABASE_ANON_KEY &&
SUPABASE_ANON_KEY.length > 20;
function buildUrl(path, params = {}) {
const url = new URL(`${SUPABASE_URL}/rest/v1/${path}`);
const defaultParams = { select: '*', order: 'updated_at.desc', limit: '10000' };
const q = new URLSearchParams({ ...defaultParams, ...params });
url.search = q.toString();
return url.toString();
}
async function sbGet(path, params = {}) {
const url = buildUrl(path, params);
return gmRequestJSON('GET', url, {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
Accept: 'application/json',
});
}
async function fetchUsers() {
const rows = await sbGet(SUPABASE_TABLE_USERS, { select: 'username,is_active,updated_at', 'is_active': 'eq.true' });
return rows
.filter(r => r && r.username)
.map(r => String(r.username).toLowerCase().trim())
.filter(Boolean);
}
async function fetchWords() {
const rows = await sbGet(SUPABASE_TABLE_WORDS, { select: 'word,is_active,updated_at', 'is_active': 'eq.true' });
return rows
.filter(r => r && r.word)
.map(r => String(r.word).toLowerCase().trim())
.filter(Boolean);
}
// ----------- State -----------
let AUTO_SILENCE_USERS = [...FALLBACK_USERS.map(s => s.toLowerCase())];
let TRIGGER_WORDS = [...FALLBACK_WORDS.map(s => s.toLowerCase())];
let lastUsersSig = JSON.stringify(AUTO_SILENCE_USERS);
let lastWordsSig = JSON.stringify(TRIGGER_WORDS);
let pollTimer = null;
function applyNewLists(users, words) {
let changed = false;
if (users) {
const mergedUsers = Array.from(new Set([...FALLBACK_USERS.map(s => s.toLowerCase()), ...users]));
AUTO_SILENCE_USERS = mergedUsers;
changed = true;
}
if (words) {
const mergedWords = Array.from(new Set([...FALLBACK_WORDS.map(s => s.toLowerCase()), ...words]));
TRIGGER_WORDS = mergedWords;
changed = true;
}
if (changed) {
rescanExistingForHighlights();
}
}
async function syncFromSupabaseOnce() {
if (!hasSupabase()) return;
try {
const [users, words] = await Promise.all([fetchUsers(), fetchWords()]);
applyNewLists(users, words);
setCfg('sb_last_ok', Date.now());
} catch (e) {
log('Supabase sync failed:', e.message || e);
const lastOk = GM_getValue('sb_last_ok', 0);
if (!lastOk) toast('⚠️ Supabase unreachable, using fallback lists');
}
}
function startPolling() {
if (pollTimer) clearInterval(pollTimer);
const ms = Math.max(10_000, Number(getCfg('pollMs')) || DEFAULT_POLL_MS);
pollTimer = setInterval(syncFromSupabaseOnce, ms);
}
// ----------- CB actions / logic -----------
async function fetchIsPrivileged(viewer, room) {
const url = `${location.origin}/api/ts/chatmessages/user_info/${encodeURIComponent(viewer)}/?room=${encodeURIComponent(room)}`;
try {
const res = await fetch(url, { credentials: 'include', headers: { 'Accept': 'application/json' } });
const data = await res.json();
return !!(data?.user?.is_mod || data?.user?.is_broadcaster);
} catch {
return false;
}
}
async function postSilence(username) {
const url = `${location.origin}/roomsilence/${encodeURIComponent(username)}${location.pathname.endsWith('/') ? location.pathname : location.pathname + '/'}`;
const csrf = getCookie('csrftoken') || getCookie('CSRF-TOKEN') || getCookie('XSRF-TOKEN');
const headers = {
'X-Requested-With': 'XMLHttpRequest',
'Accept': '*/*',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
...(csrf ? { 'X-CSRFToken': csrf, 'X-CSRF-Token': csrf } : {}),
'Referer': location.href
};
const body = 'source=chat';
const res = await fetch(url, { method: 'POST', credentials: 'include', headers, body, redirect: 'manual' });
return res.ok;
}
function getUsernameFromRoot(root) {
const span = root.querySelector(SEL.username) || root.querySelector(`${SEL.usernameContainer} ${SEL.username}`);
if (span && span.textContent) return stripTs(span.textContent);
const cont = root.querySelector(SEL.usernameContainer);
const raw = cont?.textContent || '';
const idx = raw.lastIndexOf(']');
return stripTs(idx >= 0 ? raw.slice(idx + 1) : raw);
}
let running = false;
let currentRoom = '';
let isPrivileged = false;
function mountButtonForMessage(root) {
if (!(root instanceof HTMLElement) || root.dataset.qsBtn === '1') return;
const nameWrap = root.querySelector(SEL.usernameContainer);
if (!nameWrap) return;
const username = getUsernameFromRoot(root);
if (!username) return;
const usernameLc = username.toLowerCase();
const msgText = root.textContent.toLowerCase();
const triggeredByWord = TRIGGER_WORDS.find(word => word && msgText.includes(word));
const triggeredByUser = AUTO_SILENCE_USERS.includes(usernameLc);
if (isPrivileged && getCfg('showSilenceButtons')) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'qs_sil_btn';
btn.textContent = 'Silence';
btn.addEventListener('click', async (ev) => {
ev.preventDefault(); ev.stopPropagation();
const ok = await postSilence(username);
toast(ok ? `🛑 Silenced @${username}` : `⚠️ Forbidden @${username}`);
root.setAttribute('data-qs-silenced', ok ? '1' : '0');
});
nameWrap.appendChild(btn);
root.dataset.qsBtn = '1';
}
if ((triggeredByUser || triggeredByWord) && root.getAttribute('data-qs-silenced') !== '1') {
if (isPrivileged && getCfg('enableAutoSilence')) {
postSilence(username).then(ok => {
toast(ok
? `🤫 Auto-silenced @${username}${triggeredByWord ? ` for "${triggeredByWord}"` : ''}`
: `⚠️ Auto-silence failed @${username}`);
root.setAttribute('data-qs-silenced', ok ? '1' : '0');
});
} else if (!isPrivileged && getCfg('enableHighlighting')) {
if (triggeredByUser) root.classList.add('qs_flagged_user');
if (triggeredByWord) root.classList.add('qs_flagged_word');
root.setAttribute('data-qs-silenced', '0');
}
}
}
function scanExisting() {
document.querySelectorAll(SEL.rootMessage).forEach(mountButtonForMessage);
}
function rescanExistingForHighlights() {
if (!getCfg('enableHighlighting')) return;
document.querySelectorAll(SEL.rootMessage).forEach(root => {
root.classList.remove('qs_flagged_user','qs_flagged_word');
root.removeAttribute('data-qs-silenced');
mountButtonForMessage(root);
});
}
const msgObserver = new MutationObserver((muts) => {
for (const m of muts) for (const node of m.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
if (node.matches?.(SEL.rootMessage)) {
mountButtonForMessage(node);
} else {
node.querySelectorAll?.(SEL.rootMessage).forEach(mountButtonForMessage);
}
}
});
function stopAll() {
if (!running) return;
msgObserver.disconnect();
document.querySelectorAll('.qs_sil_btn').forEach(btn => btn.remove());
running = false;
isPrivileged = false;
}
async function startForRoom(room) {
stopAll();
const viewerEl = await waitForSelector(SEL.viewerUsername, 5000);
const viewer = viewerEl?.textContent?.trim();
if (!viewer) return;
isPrivileged = await fetchIsPrivileged(viewer, room);
log('Viewer:', viewer, 'isPrivileged:', isPrivileged);
running = true;
if (hasSupabase()) {
await syncFromSupabaseOnce();
startPolling();
} else {
toast('ℹ️ Using local fallback lists (no Supabase config)');
}
scanExisting();
msgObserver.observe(document.body, { childList: true, subtree: true });
}
function handleRoomEnter() {
const room = getRoomFromURL();
if (!room || room === currentRoom) return;
currentRoom = room;
startForRoom(room);
}
// SPA locationchange hook
(() => {
const _ps = history.pushState, _rs = history.replaceState;
history.pushState = function (...a) { const r = _ps.apply(this, a); window.dispatchEvent(new Event('locationchange')); return r; };
history.replaceState = function (...a) { const r = _rs.apply(this, a); window.dispatchEvent(new Event('locationchange')); return r; };
window.addEventListener('popstate', () => window.dispatchEvent(new Event('locationchange')));
window.addEventListener('locationchange', handleRoomEnter);
})();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', handleRoomEnter, { once: true });
} else {
handleRoomEnter();
}
// Menüs
GM_registerMenuCommand(`[Toggle] Auto Silence: ${getCfg('enableAutoSilence') ? '✅ ON' : '❌ OFF'}`, () => {
setCfg('enableAutoSilence', !getCfg('enableAutoSilence'));
toast(`Auto Silence is now ${getCfg('enableAutoSilence') ? '✅ ON' : '❌ OFF'}`);
});
GM_registerMenuCommand(`[Toggle] Highlight Matches: ${getCfg('enableHighlighting') ? '✅ ON' : '❌ OFF'}`, () => {
setCfg('enableHighlighting', !getCfg('enableHighlighting'));
toast(`Highlighting is now ${getCfg('enableHighlighting') ? '✅ ON' : '❌ OFF'}`);
rescanExistingForHighlights();
});
GM_registerMenuCommand(`[Toggle] Show Buttons (mods only): ${getCfg('showSilenceButtons') ? '✅ ON' : '❌ OFF'}`, () => {
setCfg('showSilenceButtons', !getCfg('showSilenceButtons'));
toast(`Silence buttons are now ${getCfg('showSilenceButtons') ? '✅ ON' : '❌ OFF'}`);
});
GM_registerMenuCommand(`Sync now (Supabase)`, async () => {
await syncFromSupabaseOnce();
});
GM_registerMenuCommand(`Polling interval: ${(getCfg('pollMs')/1000)|0}s (click to change)`, () => {
const cur = Number(getCfg('pollMs')) || DEFAULT_POLL_MS;
const s = prompt('Polling interval in seconds (min 10s):', String((cur/1000)|0));
if (s === null) return;
const sec = Math.max(10, Number(s) || 60);
setCfg('pollMs', sec * 1000);
startPolling();
toast(`⏱️ Polling every ${sec}s`);
});
})();