☰

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.3.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=yelive.tv
// @run-at       document-idle
// @author       XHXIAIEIN
// @license      CC-BY-NC-4.0
// ==/UserScript==

const sgMain = () => {
	'use strict';

	// 只运行一份,以免主体注入页面后和当前环境各跑一次
	const root = document.documentElement;
	if (root.dataset.sgGuard) return;
	root.dataset.sgGuard = '1';

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

	const CONFIG = {
		// 举报时附带的详情,快捷举报和举报弹窗共用;网站要求至少 5 个字符
		reportText: 'NO SAY',

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

		// 广告规则,任一条命中就算广告:
		//   关键词           逐字匹配谐音,字间可夹空格、零宽字符和符号
		//   A...B            A 之后出现 B
		//   A...[BCD]        A 之后出现 B、C、D 或它们的谐音之一
		//   QQ<5+>           QQ 后跟至少 5 位数字;<5+>QQ 是数字在前
		//   <cn3+>           连续 3 个中文数字
		//   <url> <www>      网址开头
		//   <domain>         裸域名,常见网站除外
		//   <spaced-domain>  字母间插空格、拿怪符号当点的域名,如 k g 7 8 9 . t o p
		adKeywords: [
			'QQ<5+>',
			'Q<5+>',
			'<5+>QQ',
			'Q群',
			'扣群',
			'<cn3+>',
			'<url>',
			'<www>',
			'<domain>',
			'<spaced-domain>',
			'主播往期开票合集',
			'往期开票合集在线爽看',
			'主播和榜一大哥趴趴流出',
			'录播...合集',
			'破解...[网址付费主播]',
			'私聊...价',
			'低价...代币',
			'开票...回放',
			'开票...回看',
			'错过开票',
			'逃票',
			'规避...票',
			'看我相册',
			'相册...[票费片]',
			'录播全集',
			'浏览器...搜',
			'浏览器...网址',
			'福利...群',
			'付费...网',
		],
		// 包含这些词的消息不算广告
		adWhitelist: ['已关注', '已加入', '粉丝团'],

		// 聊天设置里的开关;key 同时是存储键和 body 类名,改名会丢掉已存的设置
		filters: {
			hideWelcomeBot: { key: 'sg-hide-welcome-bot', label: '隐藏欢迎机器人', default: false },
			hideInteraction: { key: 'sg-hide-interaction', label: '隐藏礼物选单', default: false },
			hideGifts: { key: 'sg-hide-gifts', label: '隐藏互动信息', default: false },
			mergeRepeats: { key: 'sg-merge-repeats', label: '合并重复消息', default: true },
			dimAds: { key: 'sg-dim-ads', label: '弱化广告显示', default: true },
			autoGuard: { key: 'sg-auto-guard', label: '自动举报广告', default: true },
		},
		// 各类消息在网站上的类名片段
		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 STROKE = 'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
	// 静音图标
	const ICON_MUTE = `<svg viewBox="0 0 24 24" ${STROKE}><path d="M3.5 9.5h3.2L11 6v12l-4.3-3.5H3.5z"/><path d="M15.5 9.5l5 5m0-5l-5 5"/></svg>`;
	// 警告图标(举报)
	const ICON_REPORT =
		'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10.846 18.346V16.037H13.154V18.346H10.846ZM12 9.113C11.363 9.113 10.846 9.63 10.846 10.267V12.575C10.846 13.213 11.363 13.729 12 13.729 12.637 13.729 13.154 13.213 13.154 12.575V10.267C13.154 9.63 12.637 9.113 12 9.113Z"/><path fill-rule="evenodd" d="M1.31319 18.3458C0.424541 19.8808 1.53246 21.8081 3.30975 21.8081H20.6903C22.4675 21.8081 23.5755 19.8808 22.6868 18.3458L13.9966 3.33119C13.1079 1.79626 10.8921 1.79626 10.0034 3.33119L1.31319 18.3458ZM12 4.48527L20.6903 19.4999H3.30975L12 4.48527Z"/></svg>';
	const ICON_LOADING = `<svg class="sg-spinner" viewBox="0 0 24 24" ${STROKE}><circle class="sg-track" cx="12" cy="12" r="8"/><circle class="sg-arc" cx="12" cy="12" r="8"/></svg>`;
	const ICON_DONE = `<svg class="sg-ok" viewBox="0 0 24 24" ${STROKE}><circle cx="12" cy="12" r="9"/><path class="sg-check" pathLength="1" d="M8 12.3l2.7 2.7L16 9.7"/></svg>`;

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

	const warn = (...args) => console.warn('[Guard]', ...args);

	// 字间可能夹的空白、零宽字符、标点和符号
	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 = `
/* 隐藏聊天区滚动条 */
.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; }
/* 聊天区的滚动轨道不占位:轨道按 scrollTop 定位,切到消息少的房间时会撑住旧高度,消息全在可视区上方 */
.model-chat-content .ps__rail-x, .model-chat-content .ps__rail-y { display: none !important; }

/* 页面滚动条改成贴合深色页面的细条,去掉白色轨道和箭头 */
html::-webkit-scrollbar { width: 8px; height: 8px; background: transparent; }
html::-webkit-scrollbar-button { display: none; }
html::-webkit-scrollbar-track, html::-webkit-scrollbar-corner { background: transparent; }
html::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, .18); border-radius: 4px;
  border: 2px solid transparent; background-clip: padding-box;
}
html::-webkit-scrollbar-thumb:hover { background-color: rgba(255, 255, 255, .35); }
/* 只给 Firefox 设 scrollbar-color,因为 Chromium 设了它会让上面的规则失效 */
@supports not selector(::-webkit-scrollbar) {
  html { scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, .25) transparent; }
}

/* 加宽「新消息」按钮的点击区域 */
.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;
}

/* 快捷按钮在三点菜单左侧,悬停消息时才出现 */
.sg-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 .sg-ad-actions,
.fullscreen-message-wrapper:hover .sg-ad-actions { opacity: 1 !important; pointer-events: auto !important; }
/* 广告消息里由快捷按钮顶到右侧,三点菜单紧挨着它 */
.sg-ad-flagged .message-more-menu { margin-left: 4px !important; }
/* 全屏模式下快捷按钮和三点菜单同级 */
.fullscreen-message-wrapper > .sg-ad-actions { margin-left: 4px !important; padding-left: 0 !important; }
/* 快捷按钮照三点菜单的样子:22px 圆形热区、透明底、16px 图标,悬停才出底色 */
.sg-ad-actions button {
  border: none !important; border-radius: 50% !important; background: transparent !important;
  width: 22px !important; height: 22px !important; padding: 0 !important;
  display: flex !important; align-items: center !important; justify-content: center !important;
  color: rgba(255,255,255,.7) !important;
  cursor: pointer !important; transition: background .15s, color .15s !important;
}
.sg-ad-actions button svg { width: 16px; height: 16px; flex-shrink: 0; 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; }
/* 确认本房间能静音后才显示静音按钮 */
body:not(.sg-can-mute) .sg-quick-mute { display: none !important; }
/* 未登录或举报暂停期间隐藏举报按钮 */
body.sg-logged-out .sg-quick-report,
body.sg-report-paused .sg-quick-report { display: none !important; }
/* 静音悬停用网站主色红,举报悬停和三点菜单一样泛白 */
.sg-quick-mute:hover { background: rgba(250,83,101,.16) !important; color: #fa5365 !important; }
.sg-quick-report:hover { background: rgba(255,255,255,.12) !important; color: #f8f8f8 !important; }
/* 已完成的按钮只剩绿色对勾,不能再点,以免和待处理的按钮混淆 */
.sg-ad-actions button.sg-done { background: transparent !important; pointer-events: none !important; }

/* 转圈和对勾,快捷按钮和原生菜单共用,绿色取自网站的在线状态 */
.sg-spinner { animation: sg-spin .9s linear infinite; }
.sg-spinner .sg-track { opacity: .25; }
.sg-spinner .sg-arc { stroke-dasharray: 14 51; }
.sg-ok { color: #9fe20d; }
.sg-ok .sg-check { stroke-dasharray: 1; stroke-dashoffset: 1; animation: sg-draw .3s .08s ease-out forwards; }
@keyframes sg-spin { to { transform: rotate(360deg); } }
/* 原生菜单的举报按钮换成「举报中」「举报完成」,期间不能再点;图标和间距照原按钮 */
.sg-native-state { pointer-events: none !important; }
.sg-native-state > :not(.sg-status) { display: none !important; }
.sg-status { display: inline-flex !important; align-items: center !important; gap: 4px !important; animation: sg-fade-in .16s ease-out; }
.sg-status svg { width: 20px; height: 20px; flex-shrink: 0; }
@keyframes sg-fade-in { from { opacity: 0; } }
@keyframes sg-draw { to { stroke-dashoffset: 0; } }
@media (prefers-reduced-motion: reduce) {
  .sg-status { animation: none !important; }
  .sg-ok .sg-check { animation: none !important; stroke-dashoffset: 0; }
}

/* 广告消息 */
.sg-ad-flagged {
  background: rgba(255,60,60,.15) !important; border-left: 2px solid rgba(255,60,60,.6) !important;
  border-radius: 2px !important;
}
.sg-ad-flagged .user-levels-username {
  background: rgba(220,50,50,.85) !important; color: #fff !important;
  border-radius: 3px !important; padding: 0 5px !important;
}
.sg-ad-flagged .user-levels-username-text { color: #fff !important; }

/* 已举报 */
.sg-ad-reported {
  background: rgba(120,120,120,.15) !important; border-left-color: rgba(120,120,120,.4) !important;
  opacity: .6 !important;
}
.sg-ad-reported .user-levels-username { background: rgba(100,100,100,.7) !important; }
/* 在用户名后加「[已举报]」,好让用户名那一列照旧左对齐;字号行高跟用户名一致 */
.sg-ad-reported .message-username::after {
  content: '[已举报]'; margin-left: 6px;
  font-size: 12px; font-weight: 400; line-height: 18px; color: rgba(255,255,255,.5);
}

/* 已静音 */
.sg-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;
}
.sg-ad-muted .user-levels-username {
  background: rgba(140,120,30,.7) !important; color: rgba(255,220,120,.9) !important;
}
.sg-ad-muted .user-levels-username-text { color: rgba(255,220,120,.9) !important; }

/* 设置面板里的开关:原生过渡 .36s 偏慢,只在本脚本的分组里加快 */
[data-sg-section] [class*="Switcher"] { transition-duration: .12s !important; transition-timing-function: ease-out !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; }

/* 重复的礼物和互动消息只显示最新一条,次数跟在第一行后面 */
body.sg-merge-repeats [data-sg-dup] { display: none !important; }
.sg-count { display: none; }
body.sg-merge-repeats .sg-count {
  display: inline; margin-left: 6px; font-weight: 700; color: rgba(255,255,255,.7);
  white-space: nowrap; font-variant-numeric: tabular-nums;
}
/* 「×」的中心比数字和中文低约 0.1em,提上去和正文对齐 */
.sg-count-x { position: relative; top: -.1em; }
/* 次数变化时旧数字向上滚出,新数字从下面滚进来;两个数字叠在同一格,好让数字仍按基线和正文对齐 */
.sg-count-roll { display: inline-grid; clip-path: inset(0); text-align: right; }
.sg-count-roll > span { grid-area: 1 / 1; animation: .35s cubic-bezier(.2,.8,.2,1); }
.sg-count-roll > :first-child { transform: translateY(-100%); animation-name: sg-roll-out; }
.sg-count-roll > :last-child { animation-name: sg-roll-in; }
@keyframes sg-roll-out { from { transform: none; } }
@keyframes sg-roll-in { from { transform: translateY(100%); } }
@media (prefers-reduced-motion: reduce) {
  .sg-count-roll > span { animation: none !important; }
}

/* 弱化广告,给没有静音和举报权限的用户用 */
body.sg-dim-ads .sg-ad-flagged { background: none !important; border-left: none !important; opacity: .3 !important; transition: opacity .15s !important; }
body.sg-dim-ads .sg-ad-flagged:hover { opacity: .8 !important; }
/* 去掉底色后也去掉给底色留的内边距,好让用户名和普通消息左对齐 */
body.sg-dim-ads .sg-ad-flagged .user-levels-username { background: none !important; padding: 0 !important; }
/* 正文单行铺到最右,超出部分悬停时用滚轮横向滚动 */
body.sg-dim-ads .sg-ad-flagged [class*="RegularMessage__contentWithControls"] { position: relative !important; }
body.sg-dim-ads .sg-ad-flagged [class*="RegularMessage__contentWithControls"] > div:first-child {
  flex: 1 1 auto !important; min-width: 0 !important;
  flex-wrap: nowrap !important; white-space: nowrap !important;
  overflow-x: auto !important; overflow-y: hidden !important; scrollbar-width: none !important;
}
body.sg-dim-ads .sg-ad-flagged [class*="RegularMessage__contentWithControls"] > div:first-child::-webkit-scrollbar { display: none !important; }
/* 用户名不收缩,以免被正文挤成 0 宽,连「[已举报]」一起看不见 */
body.sg-dim-ads .sg-ad-flagged .message-username { flex-shrink: 0 !important; }
/* 快捷按钮和三点菜单浮在右侧,不占正文宽度,悬停时才出现 */
body.sg-dim-ads .sg-ad-flagged [class*="RegularMessage__contentWithControls"] > .sg-ad-actions,
body.sg-dim-ads .sg-ad-flagged [class*="RegularMessage__contentWithControls"] > .message-more-menu {
  position: absolute !important; top: 50% !important; transform: translateY(-50%) !important;
}
body.sg-dim-ads .sg-ad-flagged [class*="RegularMessage__contentWithControls"] > .message-more-menu { right: 0 !important; margin: 0 !important; z-index: 1 !important; }
/* 按钮区左侧渐变到聊天背景色,好让正文在图标下淡出,不叠在一起 */
body.sg-dim-ads .sg-ad-flagged [class*="RegularMessage__contentWithControls"] > .sg-ad-actions {
  right: 0 !important; margin: 0 !important; padding: 0 26px 0 20px !important;
  background: linear-gradient(to right, transparent, rgb(38,38,38) 20px) !important;
}
body.sg-dim-ads .sg-ad-flagged:not(:hover) .message-more-menu { opacity: 0 !important; pointer-events: none !important; }
`;

	const styleEl = document.createElement('style');
	styleEl.id = 'sg-style';
	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',
		'douyin',
		'baidu',
		'reddit',
		'wikipedia',
		'github',
		'discord',
		'twitch',
		'whatsapp',
		'telegram',
		't\\.me',
		'line\\.me',
		'imgur',
		'spotify',
		'netflix',
		'amazon',
		'amzn',
		'yfsp',
		'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 === '<spaced-domain>') return `(?<![a-z0-9])${SAFE_DOMAIN_RE}(?:[a-z0-9]\\s+){2,}[a-z0-9]?\\s*[.‧·•∙・。\\u2800-\\u28ff]\\s*(?:t\\s*o\\s*p|c\\s*c|c\\s*o\\s*m|x\\s*y\\s*z|v\\s*i\\s*p|c\\s*n)(?![a-z])`;
		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('[Guard] 广告规则编译失败,广告识别已停用', 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 SWITCH_ON_KEY = 'sg-switch-on-class';
	const getSwitchOnClass = () => {
		for (const el of document.querySelectorAll('[class*="Switcher_checked"]')) {
			const cls = [...el.classList].find(c => c.startsWith('Switcher_checked'));
			if (cls) {
				localStorage.setItem(SWITCH_ON_KEY, cls);
				return cls;
			}
		}
		return localStorage.getItem(SWITCH_ON_KEY) || 'Switcher_checked#Da';
	};

	// 在「聊天外观」后面加一组开关,照抄原生分组的结构和类名,好让样式跟网站一致
	const injectSettings = () => {
		if (document.querySelector('[data-sg-section]')) return;
		const tplBtn = document.querySelector('[data-testid="timestamp-chat-settings-button"]');
		const tplSection = tplBtn?.closest('[data-testid="model-chat-appearance-section"]');
		const tplHeader = tplSection?.querySelector('[class*="ChatSection__header"]');
		if (!tplHeader) return;
		const onCls = getSwitchOnClass();

		const section = tplSection.cloneNode(false);
		section.removeAttribute('data-testid');
		section.dataset.sgSection = '1';
		const header = tplHeader.cloneNode(false);
		header.textContent = '骑士助手';
		const ul = document.createElement('ul');
		section.append(header, ul);

		for (const f of Object.values(CONFIG.filters)) {
			const li = tplBtn.closest('li').cloneNode(true);
			li.dataset.sgFilter = f.key;
			const btn = li.querySelector('button');
			btn.removeAttribute('data-testid');
			btn.querySelector('[class*="ChatSettingsItem__titleWrapper"] span').textContent = f.label;
			const sw = btn.querySelector('[class*="Switcher_size"]');
			const input = sw.querySelector('input');
			const render = on => {
				sw.classList.toggle(onCls, on);
				input.checked = on;
				input.setAttribute('aria-checked', String(on));
			};
			render(getFilter(f));
			// 拦下点击,以免切换开关时设置面板收起
			btn.onclick = e => {
				e.stopPropagation();
				const next = !getFilter(f);
				setFilter(f, next);
				document.body.classList.toggle(f.key, next);
				render(next);
			};
			ul.appendChild(li);
		}
		tplSection.after(section);
	};

	// ===================== 平台 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;
		}
	};

	// 每次从 store 读当前房间 ID,因为切换房间不会刷新页面;
	// 页面上的 [data-model-id] 属于相关主播列表,不是当前房间
	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();

	// 发送带 CSRF 的请求,返回 { ok, status, data };status 为 0 表示没拿到响应,原因在 error 里
	const apiPost = async (url, method, body) => {
		const csrf = await fetchCsrf();
		if (!csrf) return { ok: false, status: 0, data: null, error: '没拿到 CSRF' };
		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 (e) {
			return { ok: false, status: 0, data: null, error: String(e) };
		}
	};

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

	// 查当前账号能否在本房间静音这些用户,网站的静音按钮也按它显示;请求失败时返回 null
	const fetchCanMute = async (hostId, userIds) => {
		try {
			const qs = userIds.map(id => `userIds[]=${id}`).join('&');
			const res = await fetch(`/api/front/models/${hostId}/relations?${qs}`);
			if (!res.ok) return null;
			const { data } = await res.json();
			return data.some(r => r.canToggleMute || r.canMute);
		} catch {
			return null;
		}
	};

	// 举报一条消息,和网站一样先过 checking,被限流时这一步就会拒绝并给出 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' });
	};

	// ===================== 操作状态 =====================

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

	// 静音和举报是否可用,手动和自动操作共用,也决定快捷按钮显不显示
	const REPORT_PAUSE = 10 * 60 * 1000; // 举报被限流或拒绝后暂停 10 分钟
	const isAuthError = status => status === 401 || status === 403;
	const isReportBlocked = r => !!r.limitType || r.status === 429 || isAuthError(r.status);

	let canMute = false; // 本房间是否能静音,进房后查一次,被拒时关掉
	const setCanMute = v => {
		canMute = v;
		document.body.classList.toggle('sg-can-mute', v);
	};

	let reportPausedUntil = 0; // 举报暂停到的时刻,期间不自动举报,也隐藏举报按钮
	let reportPauseTimer = 0;
	const pauseReport = ms => {
		clearTimeout(reportPauseTimer);
		reportPausedUntil = ms ? Date.now() + ms : 0;
		document.body.classList.toggle('sg-report-paused', ms > 0);
		if (ms) reportPauseTimer = setTimeout(() => pauseReport(0), ms);
	};

	// 把按钮换成绿色对勾
	const markBtn = btn => {
		if (!btn) return;
		btn.innerHTML = ICON_DONE;
		btn.classList.add('sg-done');
	};

	const setBusy = btn => {
		if (!btn) return;
		if (btn.dataset.sgOrig === undefined) btn.dataset.sgOrig = btn.innerHTML;
		btn.innerHTML = ICON_LOADING;
		btn.style.pointerEvents = 'none';
	};

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

	// 隐藏失败的快捷按钮并记下原因,因为它没有网站弹窗可以兜底
	const hideBtn = (btn, reason) => {
		warn(reason);
		if (!btn) return;
		resetBtn(btn);
		btn.style.setProperty('display', 'none', 'important');
	};

	const failReason = (action, r) => {
		if (r.limitType || r.status === 429) return `${action}被限流(${r.limitType || 'HTTP 429'})`;
		if (isAuthError(r.status)) return `${action}被拒绝:未登录或没有权限(HTTP ${r.status})`;
		if (r.status === 0) return `${action}失败:${r.error || '网络错误'}`;
		return `${action}失败(HTTP ${r.status})`;
	};

	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('sg-ad-muted');
		markBtn(msg.querySelector('.sg-quick-mute'));
		markSameUser(msg, username, 'sg-ad-muted', '.sg-quick-mute');
		scrollToBottom();
	};

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

	// ===================== 手动静音和举报 =====================

	const NATIVE_DONE_MS = 800; // 「举报完成」停留多久再收起菜单

	// 在原生按钮里叠一层状态,不改 innerHTML,因为它的子节点归 React 管
	const setNativeState = (btn, icon, text) => {
		let el = btn.querySelector(':scope > .sg-status');
		if (!el) {
			// 锁住按钮宽度,以免换成较短的「举报中」时菜单跟着缩
			btn.style.minWidth = `${btn.offsetWidth}px`;
			el = document.createElement('span');
			btn.appendChild(el);
		}
		el.className = 'sg-status';
		el.innerHTML = `${icon}<span>${text}</span>`;
		btn.classList.add('sg-native-state');
	};

	const clearNativeState = btn => {
		btn.querySelector(':scope > .sg-status')?.remove();
		btn.classList.remove('sg-native-state');
		btn.style.minWidth = '';
	};

	// 带标记再点一次原生按钮,交回网站自己的确认框或举报弹窗
	const clickNative = btn => {
		if (!btn || !btn.isConnected) return;
		btn.dataset.sgNative = '1';
		btn.click();
	};

	// 操作失败时,原生按钮交回网站处理,快捷按钮就隐藏
	const failAction = (btn, nativeBtn, reason) => {
		if (!nativeBtn) return hideBtn(btn, reason);
		warn(reason);
		resetBtn(btn);
		clearNativeState(nativeBtn);
		clickNative(nativeBtn);
	};

	const doMute = (msg, btn, nativeBtn) => {
		setBusy(btn);
		const targetId = getUserId(msg);
		if (!targetId) return failAction(btn, nativeBtn, '静音失败:消息里找不到用户 ID');
		muteUser(targetId).then(r => {
			if (r.ok) return markMuted(msg, getUsername(msg));
			if (isAuthError(r.status)) setCanMute(false);
			failAction(btn, nativeBtn, failReason('静音', r));
		});
	};

	// 举报一条消息;从原生菜单发起时,成功后先显示「举报完成」,再用 closeMenu 收起菜单
	const doReport = (msg, btn, nativeBtn, closeMenu) => {
		setBusy(btn);
		if (nativeBtn) setNativeState(nativeBtn, ICON_LOADING, '举报中');
		const messageId = Number(msg.dataset.messageId);
		if (!messageId) return failAction(btn, nativeBtn, '举报失败:消息上没有 data-message-id');
		reportMsg(messageId).then(r => {
			if (r.ok) {
				if (nativeBtn) {
					setNativeState(nativeBtn, ICON_DONE, '举报完成');
					setTimeout(() => {
						// 如果用户已经关掉菜单,就不再收起,以免反而把菜单重新打开
						if (!nativeBtn.isConnected) return;
						clearNativeState(nativeBtn);
						closeMenu?.();
					}, NATIVE_DONE_MS);
				}
				return markReported(msg, getUsername(msg));
			}
			if (isReportBlocked(r)) pauseReport(REPORT_PAUSE);
			failAction(btn, nativeBtn, failReason('举报', r));
		});
	};

	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('.sg-ad-actions')) return;
		const wrapper = msg.closest('.fullscreen-message-wrapper');
		if (wrapper?.querySelector('.sg-ad-actions')) return;

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

		// 放在三点菜单前面;全屏模式放到外层,和三点菜单同级
		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);
	};

	// 找按钮所属的消息,全屏模式下从外层 wrapper 找
	const msgOf = el => el.closest('.message-base') || el.closest('.fullscreen-message-wrapper')?.querySelector('.message-base');
	// 最近点开的三点菜单和它的消息,因为菜单弹层渲染在消息外面,没法从 DOM 反查
	let menuMsg = null;
	let menuBtn = null;
	// 消息菜单里的举报按钮,不含同样叫 ReportButton 的顶栏「举报模特」
	const MENU_REPORT = '.message-more-menu__popover [class*="ReportButton"]';

	// 原生静音和举报先走 API;找不到所属消息时(如用户卡片里的静音按钮)交给网站处理
	document.body.addEventListener(
		'click',
		e => {
			const btn = e.target.closest(`.message-more-menu, .mute-button, ${MENU_REPORT}`);
			if (!btn) return;
			// 放行 clickNative 交回网站的点击
			if (btn.dataset.sgNative) {
				delete btn.dataset.sgNative;
				return;
			}
			// 点开三点菜单时记下它属于哪条消息
			if (btn.classList.contains('message-more-menu')) {
				menuMsg = msgOf(btn) || menuMsg;
				menuBtn = btn;
				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 = menuMsg?.isConnected ? menuMsg : null;
			if (!msg) return;
			e.stopPropagation();
			e.preventDefault();
			// 再点一次三点按钮就能收起菜单
			const trigger = menuBtn;
			doReport(msg, null, btn, () => trigger?.isConnected && trigger.click());
		},
		true
	);

	// ===================== 举报弹窗 =====================

	const REPORT_FORM = 'form[class*="ReportModal__form"]';
	const REPORT_REASONS = ['spam', 'spamming']; // 消息举报和用户举报里的「垃圾信息或发送广告」

	const reactFiber = el => el[Object.keys(el).find(k => k.startsWith('__reactFiber$'))];

	// 在表单的组件树里找原因下拉框,好直接选中选项,不必展开菜单
	const findReasonSelect = form => {
		const queue = [reactFiber(form)?.child];
		for (let i = 0; i < queue.length && i < 500; i++) {
			const fiber = queue[i];
			if (!fiber) continue;
			const p = fiber.memoizedProps;
			if (typeof p?.onSelect === 'function' && Array.isArray(p.options)) return p;
			queue.push(fiber.child, fiber.sibling);
		}
		return null;
	};

	// 填入预设举报详情,但只在用户还没写东西时
	const fillReportDetails = form => {
		const ta = form.querySelector('textarea[name="details"]') || form.querySelector('textarea');
		if (!ta || ta.value.trim()) return;
		// 用原生 setter 赋值,React 才能收到这次输入
		Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set.call(ta, CONFIG.reportText);
		ta.dispatchEvent(new Event('input', { bubbles: true }));
	};

	// 网站举报弹窗一出现就选好广告原因、填好详情,等用户自己点「上报」
	const autofillReportForm = form => {
		if (form.dataset.sgFilled) return;
		form.dataset.sgFilled = '1';
		try {
			const select = findReasonSelect(form);
			if (!select) return warn('举报弹窗没能自动选原因:找不到下拉框');
			const spam = select.options.find(o => REPORT_REASONS.includes(o.value));
			// 举报主播、举报内容的弹窗没有广告选项,保持原样
			if (!spam) return;
			if (!select.value) select.onSelect(spam);
			fillReportDetails(form);
		} catch (e) {
			warn('举报弹窗自动填写失败', e);
		}
	};

	// ===================== 自动举报和静音 =====================

	{
		let lastId = 0;
		let started = false;
		let roomId = null;
		let permChecked = false;
		let permPending = false;
		let permRetryAt = 0;
		let loginWarned = false;
		let pollErrorLogged = false;
		const handled = new Set(); // 本房间处理完、不再重试的用户
		const pending = new Set(); // 正在处理的用户

		const isTransient = status => status === 0 || status >= 500;

		// 进房后确认能否静音:主播本人和本房骑士直接放行,其他身份拿最近几位发言者问服务端
		const checkMutePermission = (state, msgs) => {
			if (permChecked || permPending || Date.now() < permRetryAt) return;
			const myId = state.userSession?.currentUser?.id;
			if (!state.userSession?.isLoggedIn || !myId) return;
			if (myId === roomId || state.knights?.knightsCollection?.[roomId]?.includes(myId)) {
				setCanMute(true);
				permChecked = true;
				return;
			}
			const targets = [...new Set(msgs.map(m => m.userData?.id).filter(id => id && id !== myId && id !== roomId))].slice(-5);
			if (!targets.length) return;

			permPending = true;
			const room = roomId;
			fetchCanMute(room, targets).then(ok => {
				if (room !== roomId) return;
				permPending = false;
				// 如果请求失败,就 30 秒后再查,期间按没有权限处理
				if (ok === null) {
					permRetryAt = Date.now() + 30 * 1000;
					return;
				}
				permChecked = true;
				setCanMute(ok);
				console.info(`[Guard] 本房间静音权限:${ok ? '有' : '无,已隐藏静音按钮'}`);
			});
		};

		// 自动举报并静音一名广告用户,暂停或没有权限的那项跳过
		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, 'sg-ad-reported', '.sg-quick-report');
							return;
						}
						if (isReportBlocked(r)) {
							pauseReport(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, 'sg-ad-muted', '.sg-quick-mute');
							return;
						}
						if (isAuthError(r.status)) {
							setCanMute(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);
		};

		// 每 500 毫秒从 store 取新消息,好在渲染前识别广告
		const poll = () => {
			try {
				const state = window.StripChat?.getState?.();
				if (state?.userSession) document.body.classList.toggle('sg-logged-out', !state.userSession.isLoggedIn);
				const msgs = state?.publicChat?.messages?.server;
				if (!msgs?.length) return;

				// 换了房间就重新对齐起点、清空上个房间的状态,因为换房间不会刷新页面
				const curRoom = getModelId();
				if (curRoom !== roomId) {
					roomId = curRoom;
					started = false;
					setCanMute(false);
					permChecked = permPending = false;
					permRetryAt = 0;
					pauseReport(0);
					handled.clear();
					pending.clear();
					mutedUsers.clear();
					reportedUsers.clear();
				}

				checkMutePermission(state, msgs);

				// 进房时已有的消息不处理
				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);
	}

	// ===================== 合并重复消息 =====================

	const MERGE_WINDOW = 20; // 往回找多少条消息,大约一屏
	const MERGEABLE = '.sg-msg-gift, .sg-msg-interaction';

	// 消息所在的一行,全屏模式下是外层 wrapper
	const rowOf = msg => msg.closest('.fullscreen-message-wrapper') || msg;
	// 比对用的文字,在标上次数前记下,并去掉时间,以免开着时间显示时认不出重复
	const repeatKeys = new WeakMap();
	const repeatKey = msg => {
		if (!repeatKeys.has(msg)) {
			const ts = msg.querySelector('[class*="timestamp"]');
			repeatKeys.set(msg, (ts ? msg.textContent.replace(ts.textContent, '') : msg.textContent).trim());
		}
		return repeatKeys.get(msg);
	};

	// 次数跟在消息第一行后面;图标、正文和按钮排成网格的消息,放进正文那一格,以免次数另占一格
	const countTarget = msg => {
		const first = msg.querySelector('.message-body')?.firstElementChild;
		if (!first) return msg;
		if (getComputedStyle(first).display !== 'grid') return first;
		return [...first.children].find(el => el.tagName !== 'BUTTON' && el.textContent.trim()) || first;
	};

	// 标上次数,数字从上一个值滚到这次的值
	const showCount = (msg, count) => {
		const badge = document.createElement('span');
		badge.className = 'sg-count';
		badge.innerHTML = `<span class="sg-count-x">×</span><span class="sg-count-roll"><span aria-hidden="true">${count - 1}</span><span>${count}</span></span>`;
		countTarget(msg).appendChild(badge);
	};

	// 一屏内出现相同的礼物或互动消息时,收起较早那条,次数累加到新消息上;
	// 收起和次数的显示交给 CSS,好让关掉开关就恢复原样
	const mergeRepeat = msg => {
		if (!msg.matches(MERGEABLE)) return;
		const key = repeatKey(msg);
		if (!key) return;
		let row = rowOf(msg).previousElementSibling;
		for (let seen = 0; row && seen < MERGE_WINDOW; row = row.previousElementSibling) {
			if (row.dataset.sgDup) continue;
			seen++;
			const prev = row.matches('.message-base') ? row : row.querySelector('.message-base');
			if (!prev?.matches(MERGEABLE) || repeatKey(prev) !== key) continue;
			const count = Number(prev.dataset.sgCount || 1) + 1;
			prev.querySelector('.sg-count')?.remove();
			row.dataset.sgDup = '1';
			msg.dataset.sgCount = count;
			showCount(msg, count);
			return;
		}
	};

	// ===================== 聊天消息 =====================

	// 给消息归类、标出广告,并同步已静音和已举报的状态
	const processMessage = msg => {
		if (msg.dataset.sgProcessed) return;
		msg.dataset.sgProcessed = '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');
		mergeRepeat(msg);

		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('sg-ad-flagged');
				injectAdActions(msg);
			}
		}

		// 同步已静音、已举报的状态,但跳过系统消息,以免标到「已被静音」提示上
		if (!msg.classList.contains('m-bg-system')) {
			const name = getUsername(msg);
			if (name) {
				if (mutedUsers.has(name)) {
					msg.classList.add('sg-ad-muted');
					markBtn(msg.querySelector('.sg-quick-mute'));
				}
				if (reportedUsers.has(name)) {
					msg.classList.add('sg-ad-reported');
					markBtn(msg.querySelector('.sg-quick-report'));
				}
			}
		}
	};

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

	// 弱化广告时,在单行正文上把竖向滚轮转成横向滚动
	document.addEventListener(
		'wheel',
		e => {
			if (!document.body.classList.contains(CONFIG.filters.dimAds.key) || e.shiftKey) return;
			const el = e.target.closest?.('.sg-ad-flagged [class*="RegularMessage__contentWithControls"] > div:first-child');
			if (!el || el.scrollWidth <= el.clientWidth) return;
			const delta = e.deltaY || e.deltaX;
			const max = el.scrollWidth - el.clientWidth;
			// 已经滚到头就交还给页面
			if ((delta < 0 && el.scrollLeft <= 0) || (delta > 0 && el.scrollLeft >= max - 1)) return;
			e.preventDefault();
			el.scrollLeft += delta;
		},
		{ passive: false }
	);

	// 处理新进来的消息,举报弹窗出现时自动填写,设置面板打开时补上开关
	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-sg-processed])').forEach(processMessage);
				// 举报弹窗随整棵子树一起挂载,先用 form 计数筛掉聊天消息
				if (node.tagName === 'FORM' || node.getElementsByTagName('form').length) {
					const form = node.matches(REPORT_FORM) ? node : node.querySelector(REPORT_FORM);
					if (form) autofillReportForm(form);
				}
			}
		}
		// 设置面板 300 毫秒内只查一次
		if (!settingsTimer)
			settingsTimer = setTimeout(() => {
				settingsTimer = 0;
				injectSettings();
			}, 300);
	}).observe(document.body, { childList: true, subtree: true });
};

// 把主体注入页面执行,因为脚本管理器可能把脚本放进读不到 StripChat 的隔离环境;
// 如果页面禁止内联脚本,注入不生效,就在当前环境运行
(() => {
	const script = document.createElement('script');
	script.textContent = `(${sgMain})();`;
	(document.head || document.documentElement).appendChild(script);
	script.remove();
	sgMain();
})();