Stripchat Guard

广告自动检测/静音/举报 + 消息过滤 + 界面优化

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Stripchat Guard
// @name:zh-CN   骑士助手
// @version      2.1.0
// @description  广告自动检测/静音/举报 + 消息过滤 + 界面优化
// @namespace    https://greasyfork.org/zh-CN/scripts/573083-stripchat-guard
// @match        *://*.stripchat.com/*
// @match        *://*.xhamsterlive.com/*
// @match        *://*.yelive.tv/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=yelive.tv
// @run-at       document-idle
// @author       XHXIAIEIN
// @license      CC-BY-NC-4.0
// ==/UserScript==

(() => {
	'use strict';

	// ===================== 配置 =====================

	const CONFIG = {
		reportText: 'NO SAY',

		homophone: {
			Q: '[QɊɋꝖꝗ]',
			群: '[群裙郡]',
			免: '[免冕勉]',
			费: '[费废飞]',
			网: '[网往忘旺]',
			址: '[址扯]',
			加: '[加架嫁]',
			微: '[微薇威]',
			信: '[信芯新]',
			看: '[看坎砍]',
			片: '[片偏骗篇]',
			视: '[视市是式]',
			频: '[频拼品]',
			付: '[付副负富傅]',
			录: '[录路鹿陆绿]',
			播: '[播拨]',
			破: '[破迫泼]',
			解: '[解借姐]',
			票: '[票漂飘]',
			充: '[充冲虫]',
			价: '[价驾架嫁]',
			扣: '[扣抠口叩]',
			分: '[分粉纷芬汾纷氛]',
		},

		/**
		 * 广告规则 DSL 语法:
		 *   "关键词"    → 谐音 + 间隔    "A...B" → A.*B    "A...[B]" → A.*[B字符组]
		 *   "QQ<5+>"    → QQ + 5+位数字   "<5+>QQ" → 数字 + QQ
		 *   "<cn3+>"    → 3+连续中文数字   "<url>/<www>/<domain>" → URL 匹配
		 */
		adKeywords: [
			'QQ<5+>',
			'Q<5+>',
			'<5+>QQ',
			'Q群',
			'扣群',
			'<cn3+>',
			'<url>',
			'<www>',
			'<domain>',
			'主播往期开票合集',
			'往期开票合集在线爽看',
			'主播和榜一大哥趴趴流出',
			'录播...合集',
			'破解...[网址付费主播]',
			'私聊...价',
			'低价...代币',
			'开票...回放',
			'福利...群',
			'付费...网',
		],
		adWhitelist: ['已关注', '已加入', '粉丝团'],

		filters: {
			autoGuard: { key: 'sg-auto-guard', label: '自动举报广告', default: true },
			hideGifts: { key: 'sg-hide-gifts', label: '隐藏礼物信息', default: false },
			hideInteraction: { key: 'sg-hide-interaction', label: '隐藏互动信息', default: false },
			hideWelcomeBot: { key: 'sg-hide-welcome-bot', label: '隐藏欢迎机器人', default: false },
		},
		giftClasses: ['m-bg-tip-v2', 'm-bg-default-v2', 'm-bg-public-tip'],
		interactionClasses: ['m-bg-goal', 'm-bg-action', 'm-bg-system'],
		welcomeBotClasses: ['WelcomeBotMessage', 'ConsoleAnnouncementMessage'],
	};

	// ===================== 常量 =====================

	const ICON_MUTE =
		'<svg viewBox="0 0 24 24"><path d="M16.5 12A4.5 4.5 0 0 0 14 7.97v2.21l2.45 2.45c.03-.2.05-.41.05-.63zM19 12c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.8 8.8 0 0 0 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06a8.99 8.99 0 0 0 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z"/></svg>';
	const ICON_REPORT = '<svg viewBox="0 0 24 24"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></svg>';
	const ICON_DONE = '<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>';
	const ICON_LOADING = '<svg viewBox="0 0 24 24"><path d="M12 4V1L8 5l4 4V6a6 6 0 0 1 0 12 6 6 0 0 1-6-6H4a8 8 0 1 0 8-8z"/></svg>';

	// ===================== 工具 =====================

	const S = '[\\s\\u200b\\u200c\\u200d\\ufeff\\p{P}\\p{S}]*';
	const CN_DIGITS = '零一二三四五六七八九〇壹贰叁肆伍陆柒捌玖';

	const zh = w =>
		w
			.split('')
			.map(c => CONFIG.homophone[c] || c)
			.join(S);
	const chars = c => (CONFIG.homophone[c] ? CONFIG.homophone[c].slice(1, -1) : c);
	const uniq = () => Math.random().toString(36).slice(2);

	const scrollToBottom = () => {
		const el = document.querySelector('.model-chat-content');
		if (el) el.scrollTop = el.scrollHeight;
	};

	// ===================== 样式 =====================

	const CSS = `
/* 隐藏滚动条(原生 + PerfectScrollbar) */
.model-chat-content { scrollbar-width: none; }
.model-chat-content::-webkit-scrollbar { display: none; }
.ps__thumb-x, .ps__thumb-y { opacity: 0 !important; }
.ps__rail-x, .ps__rail-y { background: transparent !important; }

.model-chat-new-messages-btn { padding: 6px 35% !important; }

[class*="RegularMessage__contentWithControls"] > div:first-child {
  display: flex !important; flex-wrap: wrap !important; align-items: center !important;
}
.mute-button {
  order: -1 !important; flex-shrink: 0 !important;
  margin: 0 !important; padding-right: 4px !important;
}

.knight-ad-actions {
  position: absolute !important; right: 4px !important;
  top: 50% !important; transform: translateY(-50%) !important;
  display: flex !important; gap: 4px !important; z-index: 10 !important;
  opacity: 0 !important; transition: opacity .15s !important; pointer-events: none !important;
}
.message-base:hover .knight-ad-actions { opacity: 1 !important; pointer-events: auto !important; }
/* 全屏模式:按钮在 wrapper 层级,和三点菜单同级,常显 */
.fullscreen-message-wrapper > .knight-ad-actions {
  position: static !important; transform: none !important;
  opacity: 1 !important; pointer-events: auto !important;
}
.knight-ad-actions button {
  border: none !important; border-radius: 50% !important;
  width: 28px !important; height: 28px !important; padding: 0 !important;
  display: flex !important; align-items: center !important; justify-content: center !important;
  cursor: pointer !important; transition: background .15s !important;
}
.knight-ad-actions button svg { width: 16px; height: 16px; fill: #f8f8f8; pointer-events: none; }
.knight-quick-mute { background: rgba(180,60,60,.85) !important; }
.knight-quick-mute:hover { background: rgb(210,50,50) !important; }
.knight-quick-report { background: rgba(180,110,30,.85) !important; }
.knight-quick-report:hover { background: rgb(210,120,20) !important; }
.knight-ad-actions button.busy svg { animation: sg-spin .8s linear infinite; }
@keyframes sg-spin { to { transform: rotate(360deg); } }

.knight-ad-flagged {
  background: rgba(255,60,60,.15) !important; border-left: 2px solid rgba(255,60,60,.6) !important;
  border-radius: 2px !important; position: relative !important;
}
.knight-ad-flagged .user-levels-username {
  background: rgba(220,50,50,.85) !important; color: #fff !important;
  border-radius: 3px !important; padding: 0 5px !important;
}
.knight-ad-flagged .user-levels-username-text { color: #fff !important; }

/* 已举报(独立生效,不依赖 knight-ad-flagged) */
.knight-ad-reported {
  background: rgba(120,120,120,.15) !important; border-left-color: rgba(120,120,120,.4) !important;
  opacity: .6 !important;
}
.knight-ad-reported .user-levels-username { background: rgba(100,100,100,.7) !important; }

/* 已静音(独立生效,不依赖 knight-ad-flagged) */
.knight-ad-muted {
  background: rgba(120,100,30,.2) !important; border-left-color: rgba(180,150,40,.5) !important;
  color: rgba(255,220,120,.75) !important;
}
.knight-ad-muted .user-levels-username {
  background: rgba(140,120,30,.7) !important; color: rgba(255,220,120,.9) !important;
}
.knight-ad-muted .user-levels-username-text { color: rgba(255,220,120,.9) !important; }

.sg-filter-btn {
  display: flex !important; align-items: center !important; justify-content: space-between !important;
  width: 100% !important; padding: 10px 16px !important;
  background: none !important; border: none !important;
  color: rgba(255,255,255,.85) !important; font-size: 14px !important; cursor: pointer !important;
}
.sg-filter-btn:hover { background: rgba(255,255,255,.05) !important; }
.sg-filter-label { flex: 1 !important; text-align: left !important; }
.sg-switch {
  position: relative !important; width: 40px !important; height: 22px !important;
  background: rgba(255,255,255,.2) !important; border-radius: 11px !important;
  transition: background .2s !important; flex-shrink: 0 !important;
}
.sg-switch.on { background: #4caf50 !important; }
.sg-switch-thumb {
  position: absolute !important; top: 2px !important; left: 2px !important;
  width: 18px !important; height: 18px !important; background: #fff !important;
  border-radius: 50% !important; transition: transform .2s !important;
}
.sg-switch.on .sg-switch-thumb { transform: translateX(18px) !important; }

body.sg-hide-gifts .sg-msg-gift,
body.sg-hide-interaction .sg-msg-interaction,
body.sg-hide-welcome-bot .sg-msg-welcome-bot { display: none !important; }
`;

	const oldStyle = document.getElementById('knight-yelive-tweaks');
	if (oldStyle) oldStyle.remove();
	const styleEl = document.createElement('style');
	styleEl.id = 'knight-yelive-tweaks';
	styleEl.textContent = CSS;
	document.head.appendChild(styleEl);

	// ===================== 广告检测 =====================

	const NUM_GAP = '\\d[\\s.\\-]*';

	const SAFE_DOMAINS = [
		'youtube',
		'youtu\\.be',
		'google',
		'gmail',
		'goo\\.gl',
		'instagram',
		'facebook',
		'fb\\.me',
		'twitter',
		'x\\.com',
		'tiktok',
		'reddit',
		'wikipedia',
		'github',
		'discord',
		'twitch',
		'whatsapp',
		'telegram',
		't\\.me',
		'line\\.me',
		'imgur',
		'spotify',
		'netflix',
		'amazon',
		'amzn',
		'stripchat',
		'xhamsterlive',
		'yelive',
	];
	const SAFE_DOMAIN_RE = `(?!(?:${SAFE_DOMAINS.join('|')})\\.\\w)`;

	const compileRule = rule => {
		if (rule === '<url>') return `h${S}t${S}t${S}p${S}s?${S}:${S}/${S}/`;
		if (rule === '<www>') return `w${S}w${S}w${S}\\.`;
		if (rule === '<domain>') return `(?!\\d+\\.\\d)(?!no\\.\\d)${SAFE_DOMAIN_RE}[a-zA-Z]\\w*\\.[a-zA-Z]{1,4}(?=\\s|$)`;
		if (rule === '<cn3+>') return `[${CN_DIGITS}]${S}[${CN_DIGITS}]${S}[${CN_DIGITS}]`;
		let m;
		if ((m = rule.match(/^(.+)<(\d)\+>$/))) return zh(m[1]) + `[\\s::]?${NUM_GAP.repeat(+m[2] - 1)}\\d`;
		if ((m = rule.match(/^<(\d)\+>(.+)$/))) return `\\d{${m[1]},}\\s*` + zh(m[2]);
		if ((m = rule.match(/^(.+)\.\.\.\[(.+)\]$/))) return zh(m[1]) + '.*[' + m[2].split('').map(chars).join('') + ']';
		if (rule.includes('...')) {
			const [a, b] = rule.split('...');
			return zh(a) + '.*' + zh(b);
		}
		return zh(rule);
	};

	let adPattern, whitelistPattern;
	try {
		adPattern = new RegExp(CONFIG.adKeywords.map(compileRule).join('|'), 'iu');
		whitelistPattern = new RegExp(CONFIG.adWhitelist.map(w => w.replace(/\*/g, '.*')).join('|'));
	} catch (e) {
		console.error('[Stripchat Guard] pattern compile failed:', e);
		adPattern = whitelistPattern = /(?!)/;
	}

	const isAdText = text => {
		if (!text) return false;
		const trimmed = text.trim();
		if (trimmed.length < 6) return false;
		if (trimmed.length < 12 && !/[\u4e00-\u9fff]/.test(trimmed)) return false;
		if (whitelistPattern.test(text)) return false;
		return adPattern.test(text);
	};

	const getMsgText = msg => {
		const content = msg.querySelector('[class*="RegularMessage__contentWithControls"] > div:first-child');
		if (!content) return msg.textContent;
		let text = '';
		for (const n of content.childNodes) {
			if (n.nodeType === Node.TEXT_NODE) text += n.textContent;
			else if (n.nodeType === Node.ELEMENT_NODE) {
				const cls = n.className?.toString() || '';
				if (!cls.includes('username') && !cls.includes('timestamp')) text += n.textContent;
			}
		}
		return text || msg.textContent;
	};

	// ===================== 消息过滤开关 =====================

	const getFilter = f => (localStorage.getItem(f.key) === null ? f.default : localStorage.getItem(f.key) === '1');
	const setFilter = (f, v) => localStorage.setItem(f.key, v ? '1' : '0');

	for (const f of Object.values(CONFIG.filters)) document.body.classList.toggle(f.key, getFilter(f));

	const injectSettings = () => {
		if (document.querySelector('[data-sg-filter]')) return;
		const anchor = document.querySelector('[data-testid="timestamp-chat-settings-button"]');
		const ul = anchor?.closest('li')?.parentElement;
		if (!ul) return;
		for (const f of Object.values(CONFIG.filters)) {
			const li = document.createElement('li');
			li.dataset.sgFilter = f.key;
			li.style.listStyle = 'none';
			const active = getFilter(f);
			li.innerHTML = `<div class="sg-filter-btn"><span class="sg-filter-label">${f.label}</span><div class="sg-switch ${active ? 'on' : ''}"><div class="sg-switch-thumb"></div></div></div>`;
			const sw = li.querySelector('.sg-switch');
			li.querySelector('.sg-filter-btn').onclick = () => {
				const next = !getFilter(f);
				setFilter(f, next);
				document.body.classList.toggle(f.key, next);
				sw.classList.toggle('on', next);
			};
			ul.appendChild(li);
		}
	};

	// ===================== 平台 API =====================

	let csrfCache = null;
	let modelIdCache = null;

	const fetchCsrf = async () => {
		if (csrfCache && Date.now() - csrfCache._ts < 30 * 60 * 1000) return csrfCache;
		try {
			const res = await fetch(`/api/front/v3/config/initial-dynamic?requestPath=${encodeURIComponent(location.pathname)}`);
			const { initialDynamic: d } = await res.json();
			csrfCache = { csrfToken: d.csrfToken, csrfTimestamp: d.csrfTimestamp, csrfNotifyTimestamp: d.csrfNotifyTimestamp, _ts: Date.now() };
			return csrfCache;
		} catch {
			return null;
		}
	};

	const getModelId = async () => {
		if (modelIdCache) return modelIdCache;
		const el = document.querySelector('[data-model-id]');
		if (el) return (modelIdCache = el.dataset.modelId);
		const name = location.pathname.match(/^\/([^/]+)/)?.[1];
		if (!name) return null;
		try {
			const res = await fetch(`/api/front/models/username/${name}`);
			const json = await res.json();
			return (modelIdCache = json.user?.id || json.id);
		} catch {
			return null;
		}
	};

	const getUserId = msg => msg.querySelector('[id^="user-levels-name-"]')?.id?.match(/user-levels-name-(\d+)-/)?.[1];
	const getUsername = msg => msg.querySelector('.user-levels-username-text')?.textContent?.trim();

	const apiPost = async (url, method, body) => {
		const csrf = await fetchCsrf();
		if (!csrf) return false;
		try {
			const res = await fetch(url, {
				method,
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify({ ...body, ...csrf, uniq: uniq() }),
			});
			return res.ok || res.status === 204;
		} catch {
			return false;
		}
	};

	const muteUser = async targetId => {
		const modelId = await getModelId();
		if (!targetId || !modelId) return false;
		return apiPost(`/api/front/users/${modelId}/bans/users/${targetId}`, 'PUT', { type: 'mute' });
	};

	const reportMsg = async messageId => {
		const modelId = await getModelId();
		if (!messageId || !modelId) return false;
		const base = { messageId, modelId: Number(modelId) };
		await apiPost('/api/front/message-reports/checking', 'POST', base);
		return apiPost('/api/front/message-reports', 'POST', { ...base, reasonText: CONFIG.reportText, type: 'spam' });
	};

	// ===================== UI 标记 =====================

	const mutedUsers = new Set();
	const reportedUsers = new Set();

	const markBtn = btn => {
		if (!btn) return;
		btn.innerHTML = ICON_DONE;
		btn.classList.remove('busy');
		btn.style.pointerEvents = 'none';
		btn.style.background = 'rgba(100,100,100,.5)';
	};

	const setBusy = btn => {
		if (!btn) return;
		btn.classList.add('busy');
		btn.innerHTML = ICON_LOADING;
		btn.style.pointerEvents = 'none';
	};

	const resetBtn = (btn, icon) => {
		if (!btn) return;
		btn.classList.remove('busy');
		btn.innerHTML = icon;
		btn.style.pointerEvents = '';
	};

	const applyMark = (el, cls, btnSelector) => {
		el.classList.add(cls);
		markBtn(el.querySelector(btnSelector));
	};

	const markSameUser = (msg, username, cls, btnSelector) => {
		const msgId = msg.dataset.messageId;
		if (msgId) {
			document.querySelectorAll(`[data-message-id="${msgId}"]`).forEach(el => {
				if (el !== msg) applyMark(el, cls, btnSelector);
			});
		}
		if (!username) return;
		document.querySelectorAll('.user-levels-username-text').forEach(el => {
			if (el.textContent.trim() !== username) return;
			const other = el.closest('.message-base');
			if (!other || other === msg || other.dataset.messageId === msgId) return;
			processMessage(other);
			applyMark(other, cls, btnSelector);
			const otherId = other.dataset.messageId;
			if (otherId) {
				document.querySelectorAll(`[data-message-id="${otherId}"]`).forEach(dup => {
					if (dup !== other) applyMark(dup, cls, btnSelector);
				});
			}
		});
	};

	const markMuted = (msg, username) => {
		if (username) mutedUsers.add(username);
		msg.classList.add('knight-ad-muted');
		markBtn(msg.querySelector('.knight-quick-mute'));
		markSameUser(msg, username, 'knight-ad-muted', '.knight-quick-mute');
		scrollToBottom();
	};

	const markReported = (msg, username) => {
		if (username) reportedUsers.add(username);
		msg.classList.add('knight-ad-reported');
		markBtn(msg.querySelector('.knight-quick-report'));
		markSameUser(msg, username, 'knight-ad-reported', '.knight-quick-report');
		scrollToBottom();
	};

	// ===================== 操作入口 =====================

	const doMute = (msg, btn) => {
		setBusy(btn);
		const targetId = getUserId(msg);
		if (!targetId) {
			resetBtn(btn, ICON_MUTE);
			return;
		}
		muteUser(targetId).then(ok => {
			if (ok) markMuted(msg, getUsername(msg));
			else resetBtn(btn, ICON_MUTE);
		});
	};

	const doReport = (msg, btn) => {
		setBusy(btn);
		const messageId = Number(msg.dataset.messageId);
		if (!messageId) {
			resetBtn(btn, ICON_REPORT);
			return;
		}
		reportMsg(messageId).then(ok => {
			if (ok) markReported(msg, getUsername(msg));
			else resetBtn(btn, ICON_REPORT);
		});
	};

	const createBtn = (cls, icon, handler) => {
		const btn = document.createElement('button');
		btn.className = cls;
		btn.innerHTML = icon;
		btn.onmousedown = e => e.preventDefault();
		btn.onclick = e => {
			e.stopPropagation();
			handler(btn);
		};
		return btn;
	};

	const injectAdActions = msg => {
		if (msg.querySelector('.knight-ad-actions')) return;
		const wrapper = msg.closest('.fullscreen-message-wrapper');
		if (wrapper?.querySelector('.knight-ad-actions')) return;

		const actions = document.createElement('div');
		actions.className = 'knight-ad-actions';
		actions.appendChild(createBtn('knight-quick-mute', ICON_MUTE, btn => doMute(msg, btn)));
		actions.appendChild(createBtn('knight-quick-report', ICON_REPORT, btn => doReport(msg, btn)));

		if (wrapper) {
			const moreMenu = wrapper.querySelector('.message-more-menu');
			if (moreMenu) wrapper.insertBefore(actions, moreMenu);
			else wrapper.appendChild(actions);
		} else {
			msg.appendChild(actions);
		}
	};

	document.body.addEventListener(
		'click',
		e => {
			const muteBtn = e.target.closest('.mute-button');
			if (muteBtn && !muteBtn.classList.contains('muted')) {
				e.stopPropagation();
				e.preventDefault();
				const msg = muteBtn.closest('.message-base') || muteBtn.closest('.fullscreen-message-wrapper')?.querySelector('.message-base');
				if (msg) doMute(msg);
				return;
			}
			const reportBtn = e.target.closest('[class*="ReportButton"]');
			if (reportBtn) {
				e.stopPropagation();
				e.preventDefault();
				const msg = document.querySelector('.message-base:hover') || document.querySelector('.fullscreen-message-wrapper:hover .message-base');
				if (msg) doReport(msg, reportBtn);
				return;
			}
		},
		true
	);

	// ===================== Store 预检 + 自动操作 =====================

	{
		let lastId = 0;
		let started = false;
		let canMute = true;

		const poll = () => {
			try {
				const msgs = window.StripChat?.getState()?.publicChat?.messages?.server;
				if (!msgs?.length) return;

				if (!started) {
					lastId = msgs[msgs.length - 1]?.id || 0;
					started = true;
					return;
				}

				const curLastId = msgs[msgs.length - 1]?.id;
				if (curLastId === lastId) return;

				if (!getFilter(CONFIG.filters.autoGuard)) {
					lastId = curLastId;
					return;
				}

				for (const m of msgs) {
					if (m.id <= lastId) continue;
					if (m.type !== 'text') continue;

					const body = m.details?.body;
					const username = m.userData?.username;
					const userId = m.userData?.id;

					if (!body || !username || !userId) continue;
					if (mutedUsers.has(username) || reportedUsers.has(username)) continue;

					if (isAdText(body)) {
						console.info(`[Guard] 广告预检: %c${username}%c\n → ${body.substring(0, 60)}`, 'color:#f66;font-weight:bold', 'color:inherit;font-weight:normal');
						reportedUsers.add(username);
						reportMsg(m.id);
						if (canMute) {
							muteUser(String(userId)).then(ok => {
								if (ok) mutedUsers.add(username);
								else canMute = false;
							});
						}
					}
				}
				lastId = curLastId;
			} catch {}
		};

		setInterval(poll, 500);
	}

	// ===================== 消息处理 + Observer =====================

	const processMessage = msg => {
		if (msg.dataset.processed) return;
		msg.dataset.processed = '1';

		const hasClass = cls => msg.matches(`[class*="${cls}"]`);
		if (CONFIG.giftClasses.some(hasClass)) msg.classList.add('sg-msg-gift');
		if (CONFIG.interactionClasses.some(hasClass) && !msg.classList.contains('user-muted-message')) msg.classList.add('sg-msg-interaction');
		if (CONFIG.welcomeBotClasses.some(hasClass)) msg.classList.add('sg-msg-welcome-bot');

		if (msg.matches('[class*="RegularMessage"]')) {
			const isOwner = !!msg.querySelector('.user-levels-username-chat-owner, [class*="chat-owner"]');
			if (!isOwner && isAdText(getMsgText(msg))) {
				msg.classList.add('knight-ad-flagged');
				injectAdActions(msg);
			}
		}

		// 同步已操作状态(跳过系统消息,避免误标"已被静音"提示)
		if (!msg.classList.contains('m-bg-system')) {
			const name = getUsername(msg);
			if (name) {
				if (mutedUsers.has(name)) {
					msg.classList.add('knight-ad-muted');
					markBtn(msg.querySelector('.knight-quick-mute'));
				}
				if (reportedUsers.has(name)) {
					msg.classList.add('knight-ad-reported');
					markBtn(msg.querySelector('.knight-quick-report'));
				}
			}
		}
	};

	document.querySelectorAll('.message-base').forEach(processMessage);

	let settingsTimer = 0;
	new MutationObserver(mutations => {
		for (const { addedNodes } of mutations) {
			for (const node of addedNodes) {
				if (node.nodeType !== Node.ELEMENT_NODE) continue;
				if (node.classList?.contains('message-base')) processMessage(node);
				else node.querySelectorAll?.('.message-base:not([data-processed])').forEach(processMessage);
			}
		}
		if (!settingsTimer)
			settingsTimer = setTimeout(() => {
				settingsTimer = 0;
				injectSettings();
			}, 300);
	}).observe(document.body, { childList: true, subtree: true });
})();