☰

Stripchat Guard

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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