Stripchat Guard

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Stripchat Guard
// @name:zh-CN   骑士助手
// @version      2.2.0
// @description  广告自动检测/静音/举报 + 消息过滤 + 界面优化
// @namespace    https://greasyfork.org/zh-CN/scripts/573083
// @match        *://*.stripchat.com/*
// @match        *://*.yelive.tv/*
// @match        *://*.xhamsterlive.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=stripchat.com
// @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;
}

/* 快捷按钮是 contentWithControls 行内的 flex 子项,放在原生三点菜单左侧,不覆盖它 */
.knight-ad-actions {
  display: flex !important; align-items: center !important; gap: 4px !important;
  flex-shrink: 0 !important; margin-left: auto !important; padding-left: 6px !important;
  opacity: 0 !important; transition: opacity .15s !important; pointer-events: none !important;
}
.message-base:hover .knight-ad-actions,
.fullscreen-message-wrapper:hover .knight-ad-actions { opacity: 1 !important; pointer-events: auto !important; }
/* 三点菜单原本靠 margin-left:auto 贴右;有快捷按钮时由按钮占住这段空隙 */
.knight-ad-flagged .message-more-menu { margin-left: 4px !important; }
/* 全屏模式:按钮在 wrapper 层级,和三点菜单同级 */
.fullscreen-message-wrapper > .knight-ad-actions { margin-left: 4px !important; padding-left: 0 !important; }
.knight-ad-actions button {
  border: none !important; border-radius: 50% !important;
  width: 22px !important; height: 22px !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: 14px; height: 14px; fill: #f8f8f8; pointer-events: none; }
/* 原生三点菜单:与快捷按钮同尺寸的圆形热区 */
.model-chat-content [class*="RegularMessage"] .message-more-menu {
  width: 22px !important; height: 22px !important; border-radius: 50% !important; transition: background .15s !important;
}
.model-chat-content [class*="RegularMessage"] .message-more-menu:hover { background: rgba(255,255,255,.12) !important; color: #f8f8f8 !important; }
.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;
}
.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;

	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;
		}
	};

	// 房间 ID 只从 store 读取,且不缓存:切换房间不会整页刷新。
	// 页面上的 [data-model-id] 属于相关主播列表链接,取到的是别的房间;
	// 旧的 /api/front/models/username/ 接口已下线,返回 418。
	const getModelId = () => {
		const sc = window.StripChat;
		if (!sc) return null;
		try {
			const id = sc.getCurrentViewCamModel?.()?.id;
			if (id) return id;
			const state = sc.getState?.();
			return state?.viewCam?.model?.id || state?.viewCamBase?.model?.id || state?.publicChat?.messages?.server?.[0]?.modelId || null;
		} 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();

	// 返回 { ok, status, data };status 0 表示没拿到 CSRF 或网络错误
	const apiPost = async (url, method, body) => {
		const csrf = await fetchCsrf();
		if (!csrf) return { ok: false, status: 0, data: null };
		try {
			const headers = { 'Content-Type': 'application/json' };
			const version = window.DEPLOY_CONFIG?.releaseVersion;
			if (version) headers['Front-Version'] = version;
			const res = await fetch(url, { method, headers, body: JSON.stringify({ ...body, ...csrf, uniq: uniq() }) });
			let data = null;
			try {
				data = await res.json();
			} catch {}
			return { ok: res.ok || res.status === 204, status: res.status, data };
		} catch {
			return { ok: false, status: 0, data: null };
		}
	};

	const muteUser = async (targetId, hostId = getModelId()) => {
		if (!targetId || !hostId) return { ok: false, status: 0, data: null };
		return apiPost(`/api/front/users/${hostId}/bans/users/${targetId}`, 'PUT', { type: 'mute' });
	};

	// 与网站一致:先 checking,被限流时服务端在这一步拒绝并返回 details.limitType
	const reportMsg = async (messageId, hostId = getModelId()) => {
		if (!messageId || !hostId) return { ok: false, status: 0, data: null };
		const base = { messageId, modelId: Number(hostId) };
		const check = await apiPost('/api/front/message-reports/checking', 'POST', base);
		if (!check.ok) return { ...check, limitType: check.data?.details?.limitType };
		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;
		if (btn.dataset.sgOrig === undefined) btn.dataset.sgOrig = btn.innerHTML;
		btn.classList.add('busy');
		btn.innerHTML = ICON_LOADING;
		btn.style.pointerEvents = 'none';
	};

	const resetBtn = btn => {
		if (!btn) return;
		btn.classList.remove('busy');
		if (btn.dataset.sgOrig !== undefined) btn.innerHTML = btn.dataset.sgOrig;
		delete btn.dataset.sgOrig;
		btn.style.pointerEvents = '';
	};

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

	// 标记页面上某个用户的全部消息,含全屏副本
	const markUser = (username, 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) return;
			processMessage(other);
			applyMark(other, cls, btnSelector);
			const otherId = other.dataset.messageId;
			if (otherId) document.querySelectorAll(`[data-message-id="${otherId}"]`).forEach(dup => applyMark(dup, cls, btnSelector));
		});
	};

	const markSameUser = (msg, username, cls, btnSelector) => {
		const msgId = msg.dataset.messageId;
		if (msgId) document.querySelectorAll(`[data-message-id="${msgId}"]`).forEach(el => applyMark(el, cls, btnSelector));
		markUser(username, 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();
	};

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

	// 原生按钮被拦截后 API 失败时,带标记重新点击,交回网站自己的确认框 / 举报弹窗
	const clickNative = btn => {
		if (!btn || !btn.isConnected) return;
		btn.dataset.sgNative = '1';
		btn.click();
	};

	const doMute = (msg, btn, nativeBtn) => {
		setBusy(btn);
		const targetId = getUserId(msg);
		const fail = () => {
			resetBtn(btn);
			clickNative(nativeBtn);
		};
		if (!targetId) return fail();
		muteUser(targetId).then(r => (r.ok ? markMuted(msg, getUsername(msg)) : fail()));
	};

	const doReport = (msg, btn, nativeBtn) => {
		setBusy(btn);
		const messageId = Number(msg.dataset.messageId);
		const fail = () => {
			resetBtn(btn);
			clickNative(nativeBtn);
		};
		if (!messageId) return fail();
		reportMsg(messageId).then(r => (r.ok ? markReported(msg, getUsername(msg)) : fail()));
	};

	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)));

		// 普通模式放进 contentWithControls 行内、三点菜单之前;全屏模式放在 wrapper 层级
		const row = wrapper || msg.querySelector('[class*="RegularMessage__contentWithControls"]') || msg;
		const moreMenu = row.querySelector('.message-more-menu');
		if (moreMenu) row.insertBefore(actions, moreMenu);
		else row.appendChild(actions);
	};

	// 原生按钮先走 API。找不到对应消息(如用户卡片里的静音按钮)时不拦截,交给网站处理。
	const msgOf = el => el.closest('.message-base') || el.closest('.fullscreen-message-wrapper')?.querySelector('.message-base');
	let menuMsg = null; // 最近一次点开三点菜单的消息;举报按钮渲染在 portal 里,无法从 DOM 反查

	document.body.addEventListener(
		'click',
		e => {
			const btn = e.target.closest('.message-more-menu, .mute-button, [class*="ReportButton"]');
			if (!btn) return;
			if (btn.dataset.sgNative) {
				delete btn.dataset.sgNative;
				return;
			}
			if (btn.classList.contains('message-more-menu')) {
				menuMsg = msgOf(btn) || menuMsg;
				return;
			}
			if (btn.classList.contains('mute-button')) {
				const msg = msgOf(btn);
				if (!msg || btn.classList.contains('muted')) return;
				e.stopPropagation();
				e.preventDefault();
				doMute(msg, null, btn);
				return;
			}
			const msg = msgOf(btn) || (menuMsg?.isConnected ? menuMsg : null);
			if (!msg) return;
			e.stopPropagation();
			e.preventDefault();
			doReport(msg, btn, btn);
		},
		true
	);

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

	{
		const REPORT_PAUSE = 10 * 60 * 1000;
		let lastId = 0;
		let started = false;
		let roomId = null;
		let canMute = true; // 静音要求骑士或主播身份,收到 401/403 后本房间不再尝试
		let reportPausedUntil = 0; // 举报被限流或未授权后暂停到这个时间
		let loginWarned = false;
		let pollErrorLogged = false;
		const handled = new Set(); // 本房间已处理完毕、不再重试的用户
		const pending = new Set(); // API 调用进行中的用户

		const warn = (text, extra) => console.warn(`[Guard] ${text}`, extra ?? '');
		const isTransient = status => status === 0 || status >= 500;
		const isAuthError = status => status === 401 || status === 403;

		const autoHandle = async m => {
			const { username, id: userId } = m.userData;
			const tasks = [];
			let retry = false;

			if (Date.now() >= reportPausedUntil) {
				tasks.push(
					reportMsg(m.id, m.modelId).then(r => {
						if (r.ok) {
							reportedUsers.add(username);
							markUser(username, 'knight-ad-reported', '.knight-quick-report');
							return;
						}
						if (r.limitType || r.status === 429 || isAuthError(r.status)) {
							reportPausedUntil = Date.now() + REPORT_PAUSE;
							warn(`举报被拒绝(${r.limitType || `HTTP ${r.status}`}),自动举报暂停 10 分钟`);
						} else {
							warn(`举报 ${username} 失败(HTTP ${r.status})`, r.data);
						}
						if (isTransient(r.status)) retry = true;
					})
				);
			}

			if (canMute) {
				tasks.push(
					muteUser(String(userId), m.modelId).then(r => {
						if (r.ok) {
							mutedUsers.add(username);
							markUser(username, 'knight-ad-muted', '.knight-quick-mute');
							return;
						}
						if (isAuthError(r.status)) {
							canMute = false;
							warn('当前账号在本房间没有静音权限(需要骑士或主播身份),自动静音已关闭');
						} else {
							warn(`静音 ${username} 失败(HTTP ${r.status})`, r.data);
						}
						if (isTransient(r.status)) retry = true;
					})
				);
			}

			if (!tasks.length) return; // 两条路都暂停时不算处理过,恢复后该用户的下一条消息会再进来
			pending.add(username);
			await Promise.allSettled(tasks);
			pending.delete(username);
			if (!retry) handled.add(username);
		};

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

				// 换房间不会整页刷新:重新对齐起点,清空本房间状态
				const curRoom = getModelId();
				if (curRoom !== roomId) {
					roomId = curRoom;
					started = false;
					canMute = true;
					reportPausedUntil = 0;
					handled.clear();
					pending.clear();
					mutedUsers.clear();
					reportedUsers.clear();
				}

				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;
				}

				// 未登录时举报要过验证码,静音也没有权限
				if (!state.userSession?.isLoggedIn) {
					if (!loginWarned) {
						loginWarned = true;
						warn('未登录,自动举报和静音不可用');
					}
					lastId = curLastId;
					return;
				}
				const myId = state.userSession.currentUser?.id;

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

					const u = m.userData;
					const body = m.details?.body;
					if (!body || !u?.username || !u?.id) continue;
					if (u.id === myId || u.id === m.modelId || u.isAdmin || u.isSupport) continue;
					if (handled.has(u.username) || pending.has(u.username) || mutedUsers.has(u.username) || reportedUsers.has(u.username)) continue;
					if (!isAdText(body)) continue;

					console.info(`[Guard] 广告预检: %c${u.username}%c\n → ${body.substring(0, 60)}`, 'color:#f66;font-weight:bold', 'color:inherit;font-weight:normal');
					autoHandle(m);
				}
				lastId = curLastId;
			} catch (e) {
				if (!pollErrorLogged) {
					pollErrorLogged = true;
					warn('轮询出错', e);
				}
			}
		};

		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 });
})();