Automated PM system for Chaturbate broadcasters - Manual & Auto modes with token user filtering and exclusion list. NB! The scripts loads the user menu to detect gender and if is streaming, so there is a tiny flash when it's loading it.
// ==UserScript==
// @name Chaturbate PM Assistant
// @name:es Chaturbate PM Assistant
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Automated PM system for Chaturbate broadcasters - Manual & Auto modes with token user filtering and exclusion list. NB! The scripts loads the user menu to detect gender and if is streaming, so there is a tiny flash when it's loading it.
// @description:es Sistema PM automatizado para emisoras de Chaturbate - Modos manual y automático con filtrado de usuarios de token y lista de exclusión. ¡NB! Los scripts cargan el menú de usuario para detectar el género y si está transmitiendo, por lo que hay un pequeño flash cuando lo está cargando.
// @author brsrkr
// @match https://chaturbate.com/b/*
// @match https://www.chaturbate.com/b/*
// @license MIT
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
// ==================== CONFIGURATION ====================
const CONFIG = {
// Your custom PM messages
welcomeMessage: "Hey! 👋 Thanks for visiting! How are you today?",
// Automation settings
autoMode: GM_getValue('autoMode', false),
// Gender filters
genderFilters: {
male: GM_getValue('genderFilter_male', true),
female: GM_getValue('genderFilter_female', true),
couple: GM_getValue('genderFilter_couple', true),
trans: GM_getValue('genderFilter_trans', true)
},
// Per-gender token requirements
tokenFilters: {
male: GM_getValue('tokenFilter_male', false),
female: GM_getValue('tokenFilter_female', false),
couple: GM_getValue('tokenFilter_couple', false),
trans: GM_getValue('tokenFilter_trans', false),
broadcaster: GM_getValue('tokenFilter_broadcaster', false)
},
// Sound notifications
soundEnabled: GM_getValue('soundEnabled', true),
// Broadcaster/Model filter
sendToBroadcasters: GM_getValue('sendToBroadcasters', true),
// Rate limiting (safety feature)
minDelayBetweenPMs: 1000, // 1 second minimum between auto-PMs
maxPMsPerHour: 300,
// Cooldown for same user
userCooldown: 7200000, // 2 hour - won't PM same user twice within this time
// PM button display time (in milliseconds)
buttonDisplayTime: 30000, // 30 seconds
// DEPRECATED: Gender detection delay (no longer used with popup-based detection)
// Kept for backwards compatibility but has no effect
// Detection now happens via popup which is faster and more reliable
genderDetectionDelay: 2500, // Not used
// Language setting
language: GM_getValue('language', 'en'), // 'en' or 'es' (or any key in LANG)
blacklist: GM_getValue('blacklist', []),
// Exclusion list (users you don't want popup buttons for - can be managed)
exclusionList: GM_getValue('exclusionList', [])
};
// ==================== STATE TRACKING ====================
let pmHistory = GM_getValue('pmHistory', {});
let pmCount = 0;
let hourlyResetTime = Date.now() + 3600000;
let lastPMTime = 0;
// ==================== LOCALIZATION ====================
// HOW TO ADD A NEW LANGUAGE:
// 1. Copy the entire 'en: { ... }' block below
// 2. Paste it after the Spanish block and change 'en' to your language code (e.g. 'fr', 'de', 'pt')
// 3. Translate all the string values (right-hand side of each line)
// 4. Keep the function values like: tokenTip: (g) => `...${g}...` - just translate the text around ${g}
// 5. The language toggle button will automatically cycle through all languages you add
const LANG = {
en: {
title: 'PM Assistant',
autoPMOn: '🟢 Auto PM',
autoPMOff: '⚪ Auto PM',
sound: '🔊 Sound',
genderSection: 'Auto PM Genders & Tokens:',
male: '♂️ Male',
female: '♀️ Female',
couple: '👥 Couple',
trans: '⚧️ Trans',
models: '📷 Models',
tokenTip: (g) => `Click to toggle: Require tokens for ${g}`,
genderTip: (g) => `Enable PMs to ${g} users`,
clearLog: '🗑️ Clear',
clearLogTip: 'Clear the activity log',
message: '✏️ Message',
messageTip: 'Edit your welcome message',
list: (n) => `📋 List (${n})`,
listTip: "Manage list of users who won't receive PM popups",
modeLabel: 'Mode:',
sentLabel: 'Sent:',
remainingLabel: 'Remaining:',
cooldownLabel: 'Cooldown:',
manual: 'MANUAL',
auto: 'AUTO',
logPlaceholder: 'Waiting for users...',
excludeTitle: 'Exclusion List',
addExclude: 'Add User to Exclusion:',
excludePlaceholder: 'Enter username...',
addBtn: '🚫 Add',
addBtnTip: 'Add this user to exclusion list',
inputTip: 'Type a username to add to exclusion list',
noExcluded: 'No users in exclusion list',
removeBtn: '× Remove',
pmBtn: 'PM',
pmBtnTip: 'Send PM to this user',
excludeBtn: '🚫',
excludeBtnTip: 'Add to exclusion list (no PM popups)',
excludeBtnPopupTip: 'Exclude user (no more popups)',
confirmClearLog: 'Clear the activity log?',
msgTitle: 'Welcome Message',
msgLabel: 'Message sent to new users:',
msgSave: '💾 Save',
msgCancel: '✕ Cancel',
langLabel: '🌐',
// Log messages
logModerator: 'moderator (auto-excluded)',
logNoTokens: 'no tokens (required for',
logGenderFiltered: 'gender filtered',
logBroadcasterFiltered: 'broadcaster filtered (models disabled)',
logExclusionList: 'in exclusion list',
logAddedExclusion: 'added to exclusion list',
logAlreadyExcluded: 'already in exclusion list',
logRemovedExclusion: 'removed from exclusion list',
logNotInExclusion: 'not found in exclusion list',
logAutoEnabled: 'Auto mode ENABLED',
logAutoDisabled: 'Auto mode DISABLED',
logDetected: 'detected',
logPMSent: 'PM sent to',
logPMScheduled: 'Auto PM scheduled',
},
es: {
title: 'Asistente PM',
autoPMOn: '🟢 PM Auto',
autoPMOff: '⚪ PM Auto',
sound: '🔊 Sonido',
genderSection: 'Géneros y Tokens para PM:',
male: '♂️ Hombre',
female: '♀️ Mujer',
couple: '👥 Pareja',
trans: '⚧️ Trans',
models: '📷 Modelos',
tokenTip: (g) => `Clic para cambiar: Requerir tokens para ${g}`,
genderTip: (g) => `Activar PMs para ${g}`,
clearLog: '🗑️ Borrar',
clearLogTip: 'Borrar el registro de actividad',
message: '✏️ Mensaje',
messageTip: 'Editar tu mensaje de bienvenida',
list: (n) => `📋 Lista (${n})`,
listTip: 'Gestionar la lista de usuarios que no recibirán popups de PM',
modeLabel: 'Modo:',
sentLabel: 'Enviados:',
remainingLabel: 'Restantes:',
cooldownLabel: 'Espera:',
manual: 'MANUAL',
auto: 'AUTO',
logPlaceholder: 'Esperando usuarios...',
excludeTitle: 'Lista de Exclusión',
addExclude: 'Añadir usuario a la exclusión:',
excludePlaceholder: 'Nombre de usuario...',
addBtn: '🚫 Añadir',
addBtnTip: 'Añadir usuario a la lista de exclusión',
inputTip: 'Escribe un nombre de usuario para añadir a la exclusión',
noExcluded: 'No hay usuarios en la lista de exclusión',
removeBtn: '× Quitar',
pmBtn: 'PM',
pmBtnTip: 'Enviar PM a este usuario',
excludeBtn: '🚫',
excludeBtnTip: 'Añadir a exclusión (sin popups de PM)',
excludeBtnPopupTip: 'Excluir usuario (sin más popups)',
confirmClearLog: '¿Borrar el registro de actividad?',
msgTitle: 'Mensaje de Bienvenida',
msgLabel: 'Mensaje enviado a nuevos usuarios:',
msgSave: '💾 Guardar',
msgCancel: '✕ Cancelar',
langLabel: '🌐',
// Log messages
logModerator: 'moderador (auto-excluido)',
logNoTokens: 'sin tokens (requerido para',
logGenderFiltered: 'género filtrado',
logBroadcasterFiltered: 'modelo filtrado (modelos deshabilitados)',
logExclusionList: 'en lista de exclusión',
logAddedExclusion: 'añadido a lista de exclusión',
logAlreadyExcluded: 'ya está en lista de exclusión',
logRemovedExclusion: 'eliminado de lista de exclusión',
logNotInExclusion: 'no encontrado en lista de exclusión',
logAutoEnabled: 'Modo Auto ACTIVADO',
logAutoDisabled: 'Modo Auto DESACTIVADO',
logDetected: 'detectado',
logPMSent: 'PM enviado a',
logPMScheduled: 'PM Auto programado',
}
};
// Helper to get current language strings
function t() {
return LANG[CONFIG.language] || LANG['en'];
}
// ==================== STATE ====================
let processedUsers = new Set();
let genderCache = {}; // Store detected genders by username
let broadcasterCache = {}; // Store broadcaster status by username
let pendingUsers = new Set(); // Track users waiting for gender detection
// ==================== UI ELEMENTS ====================
let controlPanel;
let statusDisplay;
let logDisplay;
// ==================== HELPER: FIND CHAT FUNCTIONS ====================
function getRoomName() {
// Extract room name from URL: chaturbate.com/b/ROOMNAME
const match = window.location.pathname.match(/\/b\/([^\/]+)/);
return match ? match[1] : null;
}
function getBroadcasterUsername() {
// Same as room name for broadcaster
return getRoomName();
}
function getCSRFToken() {
// Extract CSRF token from cookie
const match = document.cookie.match(/csrftoken=([^;]+)/);
if (match) return match[1];
// Fallback: try meta tag
const metaTag = document.querySelector('[name="csrf-token"]');
if (metaTag) return metaTag.content;
// Fallback: try hidden input
const hiddenInput = document.querySelector('input[name="csrfmiddlewaretoken"]');
if (hiddenInput) return hiddenInput.value;
console.error('[PM Assistant] CSRF token not found!');
return null;
}
async function sendPrivateMessage(username, message) {
const room = getRoomName();
const fromUser = getBroadcasterUsername();
const csrfToken = getCSRFToken();
if (!room || !fromUser || !csrfToken) {
console.error('[PM Assistant] Missing required data:', { room, fromUser, csrfToken });
return false;
}
try {
// Format message as JSON like Chaturbate expects
const messageData = JSON.stringify({
m: message,
media_id: []
});
// Prepare form data
const formData = new URLSearchParams();
formData.append('room', room);
formData.append('to_user', username);
formData.append('from_user', fromUser);
formData.append('message', messageData);
formData.append('csrfmiddlewaretoken', csrfToken);
// Send PM via Chaturbate API
const response = await fetch('https://chaturbate.com/api/ts/chatmessages/pm_publish/', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken,
'X-Requested-With': 'XMLHttpRequest'
},
body: formData.toString(),
credentials: 'include'
});
if (response.ok) {
console.log(`[PM Assistant] PM sent to ${username}`);
return true;
} else {
const errorText = await response.text();
console.error(`[PM Assistant] PM failed: ${response.status}`, errorText);
return false;
}
} catch (error) {
console.error(`[PM Assistant] Error sending PM:`, error);
return false;
}
}
function isTokenUser(username, userElement) {
// Token detection based on class name
// Grey users (no tokens) have class "defaultUser" on their username-label element
// Token users (blue names) do NOT have this class
if (!username) {
return false;
}
// Search the entire chat for username-label elements with this username
const usernameLabels = document.querySelectorAll('[data-testid="username-label"]');
// Find the one that matches our username
let matchingLabel = null;
for (const label of usernameLabels) {
const labelText = label.textContent?.trim();
if (labelText === username) {
matchingLabel = label;
break;
}
}
if (!matchingLabel) {
// If we can't find it, assume they have tokens (safer default)
return true;
}
// Check if this element has the "defaultUser" class
const hasDefaultUser = matchingLabel.classList.contains('defaultUser');
if (hasDefaultUser) {
console.log(`[PM Assistant] ${username} - NO TOKENS`);
return false;
} else {
console.log(`[PM Assistant] ${username} - HAS TOKENS`);
return true;
}
}
// NEW: Check if user is a moderator
function isModerator(username, userElement) {
if (userElement) {
const color = window.getComputedStyle(userElement).color;
// Moderators typically have red usernames in Chaturbate
const modColors = [
'rgb(255, 0, 0)', // Red
'rgb(220, 38, 38)', // Dark red
'rgb(239, 68, 68)', // Light red
];
if (modColors.includes(color)) {
return true;
}
// Check for moderator classes
const classes = userElement.className || '';
if (classes.includes('moderator') ||
classes.includes('mod') ||
classes.includes('is-mod')) {
return true;
}
// Check data attributes
if (userElement.dataset?.isMod ||
userElement.dataset?.moderator) {
return true;
}
}
return false;
}
// ==================== POPUP-BASED DETECTION ====================
// Queue for users waiting to be detected via popup
const detectionQueue = [];
let isDetecting = false;
async function detectUserViaPopup(username) {
return new Promise((resolve) => {
// SAFETY: Check if popup is already open (user may have opened one manually)
const existingPopup = document.getElementById('user-context-menu');
if (existingPopup) {
console.log(`[PM Assistant] Popup already open, skipping detection for ${username}`);
resolve({ gender: 'unknown', isBroadcaster: false });
return;
}
// Find username element to click
const usernameLabels = document.querySelectorAll('[data-testid="username-label"]');
let targetLabel = null;
for (const label of usernameLabels) {
const usernameSpan = label.querySelector('[data-testid="username"]');
if (usernameSpan && usernameSpan.textContent.trim() === username) {
targetLabel = label;
break;
}
}
if (!targetLabel) {
console.log(`[PM Assistant] Could not find username element for ${username}`);
resolve({ gender: 'unknown', isBroadcaster: false });
return;
}
// Click the username to open popup
targetLabel.click();
// Wait for popup to appear and read it
const checkPopup = setInterval(() => {
const popup = document.getElementById('user-context-menu');
if (!popup) return;
clearInterval(checkPopup);
// Move popup OFF-SCREEN immediately (no flash, no conflicts)
popup.style.position = 'fixed';
popup.style.left = '-9999px';
popup.style.top = '-9999px';
// Extract gender from icon
const genderIcon = popup.querySelector('[data-testid="gender-icon"]');
let gender = 'unknown';
if (genderIcon && genderIcon.src) {
if (genderIcon.src.includes('female.svg')) gender = 'female';
else if (genderIcon.src.includes('male.svg')) gender = 'male';
else if (genderIcon.src.includes('couple.svg')) gender = 'couple';
else if (genderIcon.src.includes('trans.svg')) gender = 'trans';
}
// Check for broadcaster (preview image exists)
const previewImage = popup.querySelector('img[src*="thumb.live.mmcdn.com"]');
const isBroadcaster = !!previewImage;
// Wait a bit for notes section to load before closing (300ms)
setTimeout(() => {
// Close popup PROPERLY with ESC key (Chaturbate's intended close method)
const escEvent = new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
which: 27,
bubbles: true,
cancelable: true
});
document.dispatchEvent(escEvent);
console.log(`[PM Assistant] Detected ${username}: ${gender}, broadcaster: ${isBroadcaster}`);
resolve({ gender, isBroadcaster });
}, 300); // Wait 300ms for notes to load
}, 50); // Check every 50ms
// Timeout after 2 seconds
setTimeout(() => {
clearInterval(checkPopup);
// Try ESC if still open
const escEvent = new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
which: 27,
bubbles: true,
cancelable: true
});
document.dispatchEvent(escEvent);
console.log(`[PM Assistant] Popup detection timeout for ${username}`);
resolve({ gender: 'unknown', isBroadcaster: false });
}, 2000);
});
}
async function processDetectionQueue() {
if (isDetecting || detectionQueue.length === 0) return;
isDetecting = true;
while (detectionQueue.length > 0) {
// SAFETY: If user has a popup open, pause processing until it's closed
const existingPopup = document.getElementById('user-context-menu');
if (existingPopup) {
console.log(`[PM Assistant] User has popup open, pausing detection queue...`);
// Wait 1 second and check again (don't remove from queue yet!)
await new Promise(resolve => setTimeout(resolve, 1000));
continue; // Re-check at start of loop
}
// Now safe to remove from queue and process
const { username, userElement } = detectionQueue.shift();
// Detect via popup
const { gender, isBroadcaster } = await detectUserViaPopup(username);
// Remove from pending after detection
pendingUsers.delete(username);
// Process the user
const genderIcon = {
'male': '♂️',
'female': '♀️',
'couple': '👥',
'trans': '⚧️',
'unknown': '❓'
}[gender] || '❓';
const broadcasterIcon = isBroadcaster ? '📷' : '';
logMessage(`👤 ${genderIcon}${broadcasterIcon} ${username} detected`, 'info', username, gender, isBroadcaster);
// Handle the user
handleNewUser(username, userElement, gender, isBroadcaster);
// Longer delay between detections to let Chaturbate's popup system reset
await new Promise(resolve => setTimeout(resolve, 500));
}
isDetecting = false;
}
// ==================== OLD NOTICE-BASED DETECTION (FALLBACK) ====================
function detectGender(element) {
// Get both text content and HTML
const text = element.textContent || '';
const html = element.outerHTML || element.innerHTML || '';
const combined = text + ' ' + html;
// Chaturbate uses emoticons with gender indicators in the Notice messages
// Check for avatar emoticons first (most reliable)
if (combined.includes('avatar_cb_female') || combined.includes('_female')) {
return 'female';
}
if (combined.includes('avatar_cb_male') || combined.includes('_male')) {
return 'male';
}
if (combined.includes('avatar_cb_couple') || combined.includes('_couple')) {
return 'couple';
}
if (combined.includes('avatar_cb_trans') || combined.includes('_trans')) {
return 'trans';
}
// Check element classes
const classes = element.className || '';
if (classes.includes('female')) {
return 'female';
}
if (classes.includes('male')) {
return 'male';
}
if (classes.includes('couple')) {
return 'couple';
}
if (classes.includes('trans')) {
return 'trans';
}
return 'unknown';
}
// NEW: Detect if user is a broadcaster/model
function detectBroadcaster(element) {
const text = element.textContent || '';
const html = element.outerHTML || element.innerHTML || '';
const combined = text + ' ' + html;
// Check for broadcaster badge indicator
if (combined.includes(':broadcaster_badge') || combined.includes('broadcaster_badge')) {
return true;
}
// Check element classes
const classes = element.className || '';
if (classes.includes('broadcaster') || classes.includes('model')) {
return true;
}
return false;
}
// ==================== CORE FUNCTIONALITY ====================
function shouldPMUser(username) {
// Check blacklist
if (CONFIG.blacklist.includes(username.toLowerCase())) {
return { should: false, reason: 'blacklisted' };
}
// Check if already processed in this session
if (processedUsers.has(username)) {
return { should: false, reason: 'already processed this session' };
}
// Check cooldown
const lastPM = pmHistory[username];
if (lastPM && (Date.now() - lastPM) < CONFIG.userCooldown) {
const minutesLeft = Math.ceil((CONFIG.userCooldown - (Date.now() - lastPM)) / 60000);
return { should: false, reason: `cooldown active (${minutesLeft}min remaining)` };
}
// Check rate limits
if (Date.now() > hourlyResetTime) {
pmCount = 0;
hourlyResetTime = Date.now() + 3600000;
}
if (pmCount >= CONFIG.maxPMsPerHour) {
toggleAutoMode(false);
return { should: false, reason: 'hourly limit reached' };
}
const timeSinceLastPM = Date.now() - lastPMTime;
if (timeSinceLastPM < CONFIG.minDelayBetweenPMs) {
const secondsLeft = Math.ceil((CONFIG.minDelayBetweenPMs - timeSinceLastPM) / 1000);
return { should: false, reason: `rate limit (${secondsLeft}s)` };
}
return { should: true, reason: null };
}
async function sendPM(username, message, isAuto = false) {
try {
console.log(`Attempting to send PM to ${username}`);
const success = await sendPrivateMessage(username, message);
if (success) {
// Update tracking
processedUsers.add(username);
pmHistory[username] = Date.now();
GM_setValue('pmHistory', pmHistory);
lastPMTime = Date.now();
pmCount++;
logMessage(`✓ PM sent to ${username}`, 'success');
// NEW: Show notification popup if auto PM
if (isAuto) {
showAutoPMNotification(username);
}
updateStatus();
return true;
} else {
logMessage(`✗ Failed to send PM to ${username}`, 'error');
return false;
}
} catch (error) {
logMessage(`✗ Error sending PM to ${username}: ${error.message}`, 'error');
return false;
}
}
// NEW: Play audible notification
function playNotificationSound() {
// Check if sound is enabled
if (!CONFIG.soundEnabled) {
console.log('[PM Assistant] Audio notification skipped (sound disabled)');
return;
}
try {
// Create audio context
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
// Create oscillator for beep sound
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
// Connect nodes
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
// Configure sound (pleasant notification beep)
oscillator.frequency.value = 800; // Hz
oscillator.type = 'sine'; // Smooth sine wave
// Volume envelope (fade in/out for smooth sound)
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
gainNode.gain.linearRampToValueAtTime(0.3, audioContext.currentTime + 0.01);
gainNode.gain.linearRampToValueAtTime(0.3, audioContext.currentTime + 0.1);
gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + 0.2);
// Play sound
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.2);
} catch (error) {
console.error('[PM Assistant] Could not play audio notification:', error);
}
}
// NEW: Show notification when auto PM is sent
function showAutoPMNotification(username) {
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
color: white;
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 10002;
font-family: Arial, sans-serif;
font-size: 14px;
font-weight: 600;
animation: slideDown 0.3s ease;
`;
notification.textContent = `✓ Auto PM sent to ${username}`;
// Add animation
const style = document.createElement('style');
style.textContent = `
@keyframes slideDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
to {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
}
`;
document.head.appendChild(style);
document.body.appendChild(notification);
// Remove after 3 seconds
setTimeout(() => {
notification.style.animation = 'slideUp 0.3s ease';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
function handleNewUser(username, userElement, gender = 'unknown', isBroadcaster = false) {
// Check if moderator first
if (isModerator(username, userElement)) {
logMessage(`🛡️ ${username} - ${t().logModerator}`, 'info', username, gender, isBroadcaster);
return;
}
// Check token status - use MORE PERMISSIVE rule for broadcasters with known gender
// If either broadcaster filter OR gender filter allows no tokens, allow PM
let tokenRequired = false;
if (isBroadcaster && gender !== 'unknown') {
// Broadcaster with known gender: require tokens ONLY if BOTH filters require it
tokenRequired = CONFIG.tokenFilters.broadcaster && CONFIG.tokenFilters[gender];
} else if (isBroadcaster) {
// Broadcaster with unknown gender: use broadcaster filter only
tokenRequired = CONFIG.tokenFilters.broadcaster;
} else if (gender !== 'unknown') {
// Non-broadcaster with known gender: use gender filter only
tokenRequired = CONFIG.tokenFilters[gender];
}
if (tokenRequired && !isTokenUser(username, userElement)) {
const filterType = isBroadcaster && gender !== 'unknown' ? `${gender} broadcasters` :
isBroadcaster ? 'broadcasters' : gender;
logMessage(`⚪ ${username} - ${t().logNoTokens} ${filterType})`, 'info', username, gender, isBroadcaster);
return;
}
// Check gender filter (only filter if gender is known)
if (gender !== 'unknown' && !CONFIG.genderFilters[gender]) {
logMessage(`⚧ ${username} - ${t().logGenderFiltered} (${gender})`, 'info', username, gender, isBroadcaster);
return;
}
// Check broadcaster filter
if (isBroadcaster && !CONFIG.sendToBroadcasters) {
logMessage(`📷 ${username} - ${t().logBroadcasterFiltered}`, 'info', username, gender, isBroadcaster);
return;
}
// Check if should PM
const pmCheck = shouldPMUser(username);
if (!pmCheck.should) {
logMessage(`⏭️ ${username} - ${pmCheck.reason}`, 'info', username, gender, isBroadcaster);
return;
}
// Check exclusion list (applies to both auto and manual mode)
if (CONFIG.exclusionList.includes(username)) {
logMessage(`🚫 ${username} - ${t().logExclusionList}`, 'info', username, gender, isBroadcaster);
return;
}
// Auto mode - send automatically (with 5 second delay)
if (CONFIG.autoMode) {
logMessage(`⏱️ ${username} - ${t().logPMScheduled} (5s delay)`, 'info', username, gender, isBroadcaster);
setTimeout(() => {
sendPM(username, CONFIG.welcomeMessage, true);
}, 5000); // 5 second delay
} else {
// Manual mode - show button
showQuickPMButton(username, gender, isBroadcaster);
}
}
// ==================== UI FUNCTIONS ====================
function createControlPanel() {
controlPanel = document.createElement('div');
controlPanel.id = 'pm-assistant-panel';
controlPanel.className = 'minimized'; // Start minimized
controlPanel.innerHTML = `
<style>
#pm-assistant-panel {
position: fixed;
top: 10px;
right: 10px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
padding: 8px 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
z-index: 10000;
font-family: Arial, sans-serif;
color: white;
min-width: 250px;
max-width: 350px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.pm-content {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
#pm-assistant-panel h3 {
margin: 0 0 12px 0;
font-size: 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
user-select: none;
padding: 4px 0;
}
#pm-assistant-panel h3 > div {
cursor: move;
}
#pm-assistant-panel h3 button {
cursor: pointer;
margin: 0;
white-space: nowrap;
}
#pm-assistant-panel button {
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.3);
color: white;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.3s;
margin: 4px 0;
}
#pm-assistant-panel .pm-content > button {
width: 100%; /* Only buttons directly in content take full width */
}
#pm-assistant-panel button:hover {
background: rgba(255,255,255,0.3);
transform: translateY(-2px);
}
#pm-assistant-panel button.active {
background: #4ade80;
border-color: #22c55e;
}
#pm-assistant-panel button.danger {
background: #ef4444;
}
.pm-button-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 4px;
margin: 4px 0;
}
.pm-button-row button {
padding: 6px 8px;
font-size: 11px;
white-space: nowrap;
}
.pm-settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
background: rgba(0,0,0,0.2);
border-radius: 6px;
margin-bottom: 8px;
cursor: pointer;
user-select: none;
font-size: 12px;
font-weight: bold;
}
.pm-settings-header:hover {
background: rgba(0,0,0,0.3);
}
.pm-settings-toggle {
font-size: 16px;
transition: transform 0.3s;
display: inline-block;
}
.pm-settings-section {
max-height: 500px;
overflow: hidden;
transition: max-height 0.3s ease;
}
.pm-settings-section.collapsed {
max-height: 0;
}
.pm-resize-handle {
position: absolute;
bottom: 0;
right: 0;
width: 20px;
height: 20px;
cursor: nwse-resize;
user-select: none;
font-size: 16px;
line-height: 20px;
text-align: center;
color: rgba(255,255,255,0.5);
}
.pm-resize-handle:hover {
color: rgba(255,255,255,0.8);
}
/* Floating PM button with progress bar */
.pm-floating-button {
position: relative;
overflow: hidden;
}
.pm-floating-button::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
height: 3px;
background: rgba(255, 255, 255, 0.5);
animation: progressBar linear forwards;
animation-play-state: running;
}
.pm-floating-button.paused::after {
animation-play-state: paused;
}
@keyframes progressBar {
from {
width: 100%;
}
to {
width: 0%;
}
}
.pm-status {
background: rgba(0,0,0,0.2);
padding: 10px;
border-radius: 6px;
margin: 10px 0;
font-size: 12px;
}
.pm-status-table {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 12px;
width: 100%;
}
.pm-status-cell {
text-align: left;
white-space: nowrap;
}
.pm-status-label {
font-weight: bold;
opacity: 0.8;
display: inline;
}
.pm-status-value {
display: inline;
margin-left: 4px;
}
.pm-log {
background: rgba(0,0,0,0.2);
padding: 8px;
border-radius: 6px;
margin: 10px 0;
overflow-y: auto;
font-size: 11px;
font-family: monospace;
line-height: 1.2; /* Compact line spacing */
flex: 1;
min-height: 100px;
}
.pm-log-entry {
margin: 2px 0; /* Reduced from 4px */
padding: 3px; /* Reduced from 4px */
border-radius: 3px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.pm-log-entry-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2; /* Compact text */
}
.pm-log-entry-btn {
background: rgba(255,255,255,0.3);
border: 1px solid rgba(255,255,255,0.4);
color: white;
padding: 2px 6px;
border-radius: 3px;
cursor: pointer;
font-size: 9px;
white-space: nowrap;
transition: all 0.2s;
flex-shrink: 0;
flex-grow: 0;
width: fit-content;
max-width: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
}
.pm-log-entry-btn:hover {
background: rgba(255,255,255,0.5);
}
.pm-log-success { background: rgba(74, 222, 128, 0.3); }
.pm-log-error { background: rgba(239, 68, 68, 0.3); }
.pm-log-warning { background: rgba(251, 191, 36, 0.3); }
.pm-log-info { background: rgba(96, 165, 250, 0.3); }
.pm-toggle {
display: flex;
align-items: center;
gap: 10px;
margin: 8px 0;
}
.pm-top-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.pm-top-row button {
flex: 0 0 auto;
width: auto;
min-width: 70px;
padding: 6px 12px;
}
.pm-top-row .pm-toggle {
flex: 1;
justify-content: flex-start;
margin: 0;
}
.pm-gender-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px; /* EDIT THIS: Gap between grid cells (default: 2px) */
}
.pm-gender-grid.with-broadcaster {
grid-template-columns: 1fr 1fr 1fr;
}
.pm-gender-toggle {
display: flex;
align-items: center;
gap: 4px; /* EDIT THIS: Space between checkbox and label (default: 4px) */
margin: 0; /* EDIT THIS: Margin around each toggle (default: 0) */
padding: 1px 0; /* EDIT THIS: Padding for each row (default: 1px 0) */
}
/* Token diamond icon */
.token-diamond {
cursor: pointer;
font-size: 14px;
margin-left: 4px;
opacity: 0.9;
transition: opacity 0.2s, transform 0.1s;
user-select: none;
}
.token-diamond:hover {
opacity: 1;
transform: scale(1.1);
}
.token-diamond.hidden {
opacity: 0.3;
}
.pm-minimize {
cursor: pointer;
font-size: 20px;
transition: transform 0.3s ease;
display: inline-block;
}
#pm-assistant-panel.minimized {
padding: 8px 12px;
min-width: auto;
width: auto !important;
height: auto !important;
max-width: none !important;
}
#pm-assistant-panel.minimized .pm-content {
display: none;
}
#pm-assistant-panel.minimized h3 {
margin: 0;
font-size: 16px;
}
/* NEW: Manual exclusion input */
.pm-exclude-input {
display: flex;
gap: 4px;
margin: 6px 0;
}
.pm-exclude-input input {
flex: 1;
padding: 6px;
border: 1px solid rgba(255,255,255,0.3);
background: rgba(0,0,0,0.2);
color: white;
border-radius: 4px;
font-size: 11px;
}
.pm-exclude-input input::placeholder {
color: rgba(255,255,255,0.5);
}
.pm-exclude-input button {
padding: 6px 10px;
margin: 0;
font-size: 11px;
}
/* NEW: Exclusion modal */
.pm-modal {
display: none;
position: fixed;
z-index: 10001;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.6);
}
.pm-modal-content {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 5% auto;
padding: 20px;
border-radius: 10px;
max-width: 400px;
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
color: white;
}
.pm-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.pm-modal-title {
font-size: 16px;
font-weight: bold;
}
.pm-modal-close {
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
line-height: 30px;
}
.pm-modal-close:hover {
opacity: 0.7;
transform: none;
}
.pm-modal-list {
max-height: 400px;
overflow-y: auto;
background: rgba(0,0,0,0.2);
padding: 10px;
border-radius: 6px;
}
.pm-modal-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
margin: 4px 0;
background: rgba(255,255,255,0.1);
border-radius: 4px;
}
.pm-modal-item button {
padding: 4px 8px;
margin: 0;
font-size: 10px;
background: rgba(255,255,255,0.2);
}
.pm-modal-item button:hover {
background: rgba(255,255,255,0.3);
}
.pm-modal-empty {
padding: 20px;
text-align: center;
opacity: 0.7;
font-style: italic;
}
</style>
<h3>
<div style="display: flex; align-items: center; gap: 8px;">
<span id="pm-title-text">💬 PM Assistant</span>
<span class="pm-minimize" id="pm-minimize-btn">V</span>
</div>
<button id="pm-auto-toggle" title="${t().autoPMOff}" style="margin: 0; padding: 4px 12px; font-size: 12px;">${CONFIG.autoMode ? t().autoPMOn : t().autoPMOff}</button>
</h3>
<div class="pm-content">
<div class="pm-settings-header" id="pm-settings-header">
<span>⚙️ Settings</span>
<span class="pm-settings-toggle" id="pm-settings-toggle">V</span>
</div>
<div class="pm-settings-section" id="pm-settings-section">
<div class="pm-top-row">
<div class="pm-toggle" style="margin: 0;">
<input type="checkbox" id="pm-sound-enabled" ${CONFIG.soundEnabled ? 'checked' : ''} title="Enable or disable sound notifications when PM popups appear">
<label for="pm-sound-enabled" style="font-size: 11px;">${t().sound}</label>
</div>
<button id="pm-lang-toggle" title="Switch language / Cambiar idioma" style="font-size: 13px; padding: 2px 6px;">${t().langLabel} ${CONFIG.language.toUpperCase()}</button>
</div>
<div style="margin: 6px 0; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 6px;">
<div style="font-weight: bold; margin-bottom: 4px; font-size: 11px;" id="pm-gender-section-label">${t().genderSection}</div>
<div class="pm-gender-grid with-broadcaster">
<div class="pm-gender-toggle">
<input type="checkbox" id="pm-gender-male" ${CONFIG.genderFilters.male ? 'checked' : ''} title="${t().genderTip('male')}">
<label for="pm-gender-male" style="font-size: 11px;">${t().male}</label>
<span class="token-diamond ${CONFIG.tokenFilters.male ? '' : 'hidden'}" id="token-male" title="${t().tokenTip('males')}">💎</span>
</div>
<div class="pm-gender-toggle">
<input type="checkbox" id="pm-gender-female" ${CONFIG.genderFilters.female ? 'checked' : ''} title="${t().genderTip('female')}">
<label for="pm-gender-female" style="font-size: 11px;">${t().female}</label>
<span class="token-diamond ${CONFIG.tokenFilters.female ? '' : 'hidden'}" id="token-female" title="${t().tokenTip('females')}">💎</span>
</div>
<div class="pm-gender-toggle">
<input type="checkbox" id="pm-broadcaster" ${CONFIG.sendToBroadcasters ? 'checked' : ''} title="${t().genderTip('broadcaster/model')}">
<label for="pm-broadcaster" style="font-size: 11px;">${t().models}</label>
<span class="token-diamond ${CONFIG.tokenFilters.broadcaster ? '' : 'hidden'}" id="token-broadcaster" title="${t().tokenTip('broadcasters')}">💎</span>
</div>
<div class="pm-gender-toggle">
<input type="checkbox" id="pm-gender-trans" ${CONFIG.genderFilters.trans ? 'checked' : ''} title="${t().genderTip('trans')}">
<label for="pm-gender-trans" style="font-size: 11px;">${t().trans}</label>
<span class="token-diamond ${CONFIG.tokenFilters.trans ? '' : 'hidden'}" id="token-trans" title="${t().tokenTip('trans')}">💎</span>
</div>
<div class="pm-gender-toggle">
<input type="checkbox" id="pm-gender-couple" ${CONFIG.genderFilters.couple ? 'checked' : ''} title="${t().genderTip('couple')}">
<label for="pm-gender-couple" style="font-size: 11px;">${t().couple}</label>
<span class="token-diamond ${CONFIG.tokenFilters.couple ? '' : 'hidden'}" id="token-couple" title="${t().tokenTip('couples')}">💎</span>
</div>
</div>
</div>
<div class="pm-button-row">
<button id="pm-clear-log" title="${t().clearLogTip}">${t().clearLog}</button>
<button id="pm-settings" title="${t().messageTip}">${t().message}</button>
<button id="pm-exclusion-list" title="${t().listTip}">${t().list(CONFIG.exclusionList.length)}</button>
</div>
</div>
<div class="pm-status" id="pm-status">
<div class="pm-status-table">
<div class="pm-status-cell">
<span class="pm-status-label">Mode:</span>
<span class="pm-status-value" id="pm-status-mode">MANUAL</span>
</div>
<div class="pm-status-cell">
<span class="pm-status-label">Sent:</span>
<span class="pm-status-value" id="pm-status-sent">0/30</span>
</div>
<div class="pm-status-cell">
<span class="pm-status-label">Remaining:</span>
<span class="pm-status-value" id="pm-status-remaining">30</span>
</div>
<div class="pm-status-cell">
<span class="pm-status-label">Reset:</span>
<span class="pm-status-value" id="pm-status-reset">60min</span>
</div>
</div>
</div>
<div class="pm-log" id="pm-log">
<div class="pm-log-entry pm-log-info">
<div class="pm-log-entry-text">System ready</div>
</div>
</div>
<div class="pm-resize-handle" id="pm-resize-handle">⋰</div>
</div>
`;
document.body.appendChild(controlPanel);
// NEW: Create exclusion modal
const modal = document.createElement('div');
modal.id = 'pm-exclusion-modal';
modal.className = 'pm-modal';
modal.innerHTML = `
<div class="pm-modal-content">
<div class="pm-modal-header">
<div class="pm-modal-title" id="pm-modal-title">🚫 ${t().excludeTitle}</div>
<button class="pm-modal-close" id="pm-modal-close">×</button>
</div>
<div style="margin-bottom: 12px;">
<div style="font-weight: bold; margin-bottom: 6px; font-size: 12px;" id="pm-modal-add-label">${t().addExclude}</div>
<div class="pm-exclude-input">
<input type="text" id="pm-exclude-username" placeholder="${t().excludePlaceholder}" title="${t().inputTip}">
<button id="pm-add-exclude" title="${t().addBtnTip}">${t().addBtn}</button>
</div>
</div>
<div class="pm-modal-list" id="pm-modal-list"></div>
</div>
`;
document.body.appendChild(modal);
// Event listeners
document.getElementById('pm-auto-toggle').addEventListener('click', () => {
toggleAutoMode(!CONFIG.autoMode);
});
document.getElementById('pm-sound-enabled').addEventListener('change', (e) => {
CONFIG.soundEnabled = e.target.checked;
GM_setValue('soundEnabled', CONFIG.soundEnabled);
logMessage(`${t().sound}: ${CONFIG.soundEnabled ? 'ON' : 'OFF'}`, 'info');
});
document.getElementById('pm-lang-toggle').addEventListener('click', () => {
const langs = Object.keys(LANG);
const currentIndex = langs.indexOf(CONFIG.language);
CONFIG.language = langs[(currentIndex + 1) % langs.length];
GM_setValue('language', CONFIG.language);
applyLanguage();
});
document.getElementById('pm-clear-log').addEventListener('click', () => {
if (confirm(t().confirmClearLog)) {
logDisplay.innerHTML = '';
}
});
['male', 'female', 'couple', 'trans'].forEach(gender => {
document.getElementById(`pm-gender-${gender}`).addEventListener('change', (e) => {
CONFIG.genderFilters[gender] = e.target.checked;
GM_setValue(`genderFilter_${gender}`, e.target.checked);
logMessage(`${gender} filter: ${e.target.checked ? 'ON' : 'OFF'}`, 'info');
});
});
// Broadcaster filter checkbox
document.getElementById('pm-broadcaster').addEventListener('change', (e) => {
CONFIG.sendToBroadcasters = e.target.checked;
GM_setValue('sendToBroadcasters', e.target.checked);
logMessage(`Broadcaster filter: ${e.target.checked ? 'ON' : 'OFF'}`, 'info');
});
// Token diamond click handlers
['male', 'female', 'couple', 'trans', 'broadcaster'].forEach(type => {
const diamond = document.getElementById(`token-${type}`);
if (diamond) {
diamond.addEventListener('click', () => {
CONFIG.tokenFilters[type] = !CONFIG.tokenFilters[type];
GM_setValue(`tokenFilter_${type}`, CONFIG.tokenFilters[type]);
// Toggle visual state
if (CONFIG.tokenFilters[type]) {
diamond.classList.remove('hidden');
} else {
diamond.classList.add('hidden');
}
const typeName = type === 'broadcaster' ? 'broadcasters' : `${type}s`;
logMessage(`Token requirement for ${typeName}: ${CONFIG.tokenFilters[type] ? 'ON' : 'OFF'}`, 'info');
});
}
});
document.getElementById('pm-settings').addEventListener('click', showSettings);
// NEW: Manual exclusion input - now in modal, set up in showExclusionModal()
document.getElementById('pm-exclusion-list').addEventListener('click', () => {
showExclusionModal();
});
// NEW: Modal close handlers
document.getElementById('pm-modal-close').addEventListener('click', closeExclusionModal);
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeExclusionModal();
}
});
// Settings toggle
document.getElementById('pm-settings-header').addEventListener('click', () => {
const section = document.getElementById('pm-settings-section');
const toggle = document.getElementById('pm-settings-toggle');
if (section.classList.contains('collapsed')) {
section.classList.remove('collapsed');
toggle.style.transform = 'rotate(180deg)'; // Flip V upside down when expanded
} else {
section.classList.add('collapsed');
toggle.style.transform = 'rotate(0deg)'; // V pointing down when collapsed
}
});
// Minimize/maximize button
document.getElementById('pm-minimize-btn').addEventListener('click', () => {
const isMinimized = controlPanel.classList.contains('minimized');
const btn = document.getElementById('pm-minimize-btn');
const titleText = document.getElementById('pm-title-text');
const autoToggleBtn = document.getElementById('pm-auto-toggle');
if (isMinimized) {
// Maximizing
controlPanel.classList.remove('minimized');
btn.style.transform = 'rotate(180deg)'; // Upside down when maximized
titleText.textContent = '💬 PM Assistant';
// Show Auto PM button
if (autoToggleBtn) autoToggleBtn.style.display = 'block';
// Restore custom size if it was set
const savedSize = GM_getValue('panelSize', null);
if (savedSize) {
controlPanel.style.width = savedSize.width;
controlPanel.style.height = savedSize.height;
controlPanel.style.maxWidth = 'none';
}
} else {
// Minimizing
controlPanel.classList.add('minimized');
btn.style.transform = 'rotate(0deg)'; // Normal V when minimized
// Hide Auto PM button
if (autoToggleBtn) autoToggleBtn.style.display = 'none';
// Remove inline size styles so minimized CSS can work
controlPanel.style.width = '';
controlPanel.style.height = '';
controlPanel.style.maxWidth = '';
// Show auto mode status in title
const statusIcon = CONFIG.autoMode ? '🟢' : '⚪';
titleText.textContent = `${statusIcon} PM Assistant`;
}
});
statusDisplay = document.getElementById('pm-status');
logDisplay = document.getElementById('pm-log');
// Restore saved position on load
const savedPos = GM_getValue('panelPosition', null);
if (savedPos) {
controlPanel.style.top = savedPos.top;
controlPanel.style.left = savedPos.left;
controlPanel.style.right = 'auto';
controlPanel.style.bottom = 'auto';
}
// Update minimized title with auto mode status
const statusIcon = CONFIG.autoMode ? '🟢' : '⚪';
document.getElementById('pm-title-text').textContent = `${statusIcon} PM Assistant`;
// Make panel draggable
makeDraggable(controlPanel);
// Set initial minimize button rotation (upside down when maximized)
const initialMinBtn = document.getElementById('pm-minimize-btn');
if (initialMinBtn) {
initialMinBtn.style.transform = 'rotate(180deg)';
}
// Make panel resizable
makeResizable(controlPanel);
// Restore saved size
const savedSize = GM_getValue('panelSize', null);
if (savedSize) {
controlPanel.style.width = savedSize.width;
controlPanel.style.height = savedSize.height;
controlPanel.style.maxWidth = 'none';
}
}
// NEW: Add user manually to exclusion list
function addManualExclusion() {
const input = document.getElementById('pm-exclude-username');
const username = input.value.trim();
if (!username) {
logMessage('Please enter a username', 'warning');
return;
}
if (CONFIG.exclusionList.includes(username)) {
logMessage(`${username} already excluded`, 'warning');
return;
}
CONFIG.exclusionList.push(username);
GM_setValue('exclusionList', CONFIG.exclusionList);
input.value = '';
updateExclusionButton();
logMessage(`🚫 ${username} added to exclusion list`, 'success');
// Refresh the modal list
showExclusionModal();
}
// NEW: Show exclusion modal with sorted list
function showExclusionModal() {
const modal = document.getElementById('pm-exclusion-modal');
const list = document.getElementById('pm-modal-list');
// Update modal text with current language
const L = t();
const titleEl = document.getElementById('pm-modal-title');
if (titleEl) titleEl.textContent = `🚫 ${L.excludeTitle}`;
const addLabel = document.getElementById('pm-modal-add-label');
if (addLabel) addLabel.textContent = L.addExclude;
const input = document.getElementById('pm-exclude-username');
if (input) input.placeholder = L.excludePlaceholder;
const addBtn = document.getElementById('pm-add-exclude');
if (addBtn) addBtn.textContent = L.addBtn;
if (CONFIG.exclusionList.length === 0) {
list.innerHTML = `<div class="pm-modal-empty">${L.noExcluded}</div>`;
} else {
// Sort alphabetically
const sorted = [...CONFIG.exclusionList].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
// Clear the list first
list.innerHTML = '';
// Create elements with proper event listeners instead of inline onclick
sorted.forEach(username => {
const item = document.createElement('div');
item.className = 'pm-modal-item';
const nameSpan = document.createElement('span');
nameSpan.textContent = username;
const removeBtn = document.createElement('button');
removeBtn.textContent = L.removeBtn;
removeBtn.onclick = () => {
removeFromExclusionList(username);
showExclusionModal(); // Refresh the modal
};
item.appendChild(nameSpan);
item.appendChild(removeBtn);
list.appendChild(item);
});
}
modal.style.display = 'block';
// Set up event listeners for the input
const freshAddBtn = document.getElementById('pm-add-exclude');
const freshInput = document.getElementById('pm-exclude-username');
if (freshAddBtn && freshInput) {
// Remove old listeners if any
freshAddBtn.replaceWith(freshAddBtn.cloneNode(true));
freshInput.replaceWith(freshInput.cloneNode(true));
// Get fresh references
const newAddBtn = document.getElementById('pm-add-exclude');
const newInput = document.getElementById('pm-exclude-username');
newAddBtn.addEventListener('click', addManualExclusion);
newInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') addManualExclusion();
});
}
}
// NEW: Close exclusion modal
function closeExclusionModal() {
document.getElementById('pm-exclusion-modal').style.display = 'none';
}
function applyLanguage() {
const L = t();
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
const setTitle = (id, val) => { const el = document.getElementById(id); if (el) el.title = val; };
const setAttr = (id, attr, val) => { const el = document.getElementById(id); if (el) el[attr] = val; };
// Title
const titleEl = document.querySelector('#pm-assistant-panel .pm-title span');
if (titleEl) titleEl.textContent = L.title;
// Top row buttons
const autoBtn = document.getElementById('pm-auto-toggle');
if (autoBtn) autoBtn.textContent = CONFIG.autoMode ? L.autoPMOn : L.autoPMOff;
const langBtn = document.getElementById('pm-lang-toggle');
if (langBtn) langBtn.textContent = `${L.langLabel} ${CONFIG.language.toUpperCase()}`;
// Sound label
const soundLabel = document.querySelector('label[for="pm-sound-enabled"]');
if (soundLabel) soundLabel.textContent = L.sound;
// Gender section label
set('pm-gender-section-label', L.genderSection);
// Gender labels
const labelMap = {
'pm-gender-male': L.male,
'pm-gender-female': L.female,
'pm-gender-couple': L.couple,
'pm-gender-trans': L.trans,
'pm-broadcaster': L.models,
};
Object.entries(labelMap).forEach(([id, text]) => {
const el = document.querySelector(`label[for="${id}"]`);
if (el) el.textContent = text;
});
// Bottom buttons
set('pm-clear-log', L.clearLog);
setTitle('pm-clear-log', L.clearLogTip);
set('pm-settings', L.message);
setTitle('pm-settings', L.messageTip);
// Exclusion list button (preserves count)
const listBtn = document.getElementById('pm-exclusion-list');
if (listBtn) listBtn.textContent = L.list(CONFIG.exclusionList.length);
setTitle('pm-exclusion-list', L.listTip);
// Status labels
const statusCells = document.querySelectorAll('.pm-status-label');
if (statusCells.length >= 4) {
statusCells[0].textContent = L.modeLabel;
statusCells[1].textContent = L.sentLabel;
statusCells[2].textContent = L.remainingLabel;
statusCells[3].textContent = L.cooldownLabel;
}
// Status mode value
const modeVal = document.getElementById('pm-status-mode');
if (modeVal) modeVal.textContent = CONFIG.autoMode ? L.auto : L.manual;
}
function toggleAutoMode(enabled) {
CONFIG.autoMode = enabled;
GM_setValue('autoMode', enabled);
const button = document.getElementById('pm-auto-toggle');
if (enabled) {
button.textContent = t().autoPMOn;
button.classList.add('active');
logMessage(t().logAutoEnabled, 'success');
} else {
button.textContent = t().autoPMOff;
button.classList.remove('active');
logMessage(t().logAutoDisabled, 'info');
}
// Update minimized title if panel is minimized
const panel = document.getElementById('pm-assistant-panel');
if (panel && panel.classList.contains('minimized')) {
const statusIcon = enabled ? '🟢' : '⚪';
document.getElementById('pm-title-text').textContent = `${statusIcon} ${t().title}`;
}
updateStatus();
}
function makeDraggable(element) {
let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
// Find the h3 title div (contains title and minimize button)
const h3 = element.querySelector('h3');
const titleDiv = h3 ? h3.querySelector('div') : null;
if (!titleDiv) return;
titleDiv.onmousedown = dragMouseDown;
function dragMouseDown(e) {
// Don't drag if clicking on the minimize button
if (e.target.id === 'pm-minimize-btn' || e.target.className.includes('pm-minimize')) {
return;
}
e.preventDefault();
pos3 = e.clientX;
pos4 = e.clientY;
document.onmouseup = closeDragElement;
document.onmousemove = elementDrag;
}
function elementDrag(e) {
e.preventDefault();
pos1 = pos3 - e.clientX;
pos2 = pos4 - e.clientY;
pos3 = e.clientX;
pos4 = e.clientY;
// Calculate new position
let newTop = element.offsetTop - pos2;
let newLeft = element.offsetLeft - pos1;
// Keep within viewport bounds
const maxX = window.innerWidth - element.offsetWidth;
const maxY = window.innerHeight - element.offsetHeight;
newTop = Math.max(0, Math.min(newTop, maxY));
newLeft = Math.max(0, Math.min(newLeft, maxX));
element.style.top = newTop + "px";
element.style.left = newLeft + "px";
element.style.right = "auto";
element.style.bottom = "auto";
}
function closeDragElement() {
document.onmouseup = null;
document.onmousemove = null;
// Save position after dragging
GM_setValue('panelPosition', {
top: element.style.top,
left: element.style.left
});
console.log('[PM Assistant] Position saved:', element.style.top, element.style.left);
}
}
function makeResizable(element) {
const resizeHandle = element.querySelector('#pm-resize-handle');
if (!resizeHandle) return;
let isResizing = false;
let startX, startY, startWidth, startHeight;
resizeHandle.addEventListener('mousedown', (e) => {
isResizing = true;
startX = e.clientX;
startY = e.clientY;
startWidth = element.offsetWidth;
startHeight = element.offsetHeight;
e.preventDefault();
e.stopPropagation();
document.addEventListener('mousemove', resize);
document.addEventListener('mouseup', stopResize);
});
function resize(e) {
if (!isResizing) return;
const width = startWidth + (e.clientX - startX);
const height = startHeight + (e.clientY - startY);
// Min and max dimensions
const minWidth = 280;
const maxWidth = 600;
const minHeight = 200;
const maxHeight = 800;
element.style.width = Math.min(Math.max(width, minWidth), maxWidth) + 'px';
element.style.height = Math.min(Math.max(height, minHeight), maxHeight) + 'px';
element.style.maxWidth = 'none';
}
function stopResize() {
isResizing = false;
document.removeEventListener('mousemove', resize);
document.removeEventListener('mouseup', stopResize);
// Save size
GM_setValue('panelSize', {
width: element.style.width,
height: element.style.height
});
}
}
function updateExclusionButton() {
const btn = document.getElementById('pm-exclusion-list');
if (btn) btn.textContent = t().list(CONFIG.exclusionList.length);
}
function updateStatus() {
const remaining = CONFIG.maxPMsPerHour - pmCount;
const nextResetMinutes = Math.ceil((hourlyResetTime - Date.now()) / 60000);
document.getElementById('pm-status-mode').textContent = CONFIG.autoMode ? t().auto : t().manual;
document.getElementById('pm-status-sent').textContent = `${pmCount}/${CONFIG.maxPMsPerHour}`;
document.getElementById('pm-status-remaining').textContent = remaining;
document.getElementById('pm-status-reset').textContent = `${nextResetMinutes}min`;
}
function logMessage(message, type = 'info', username = null, gender = 'unknown', isBroadcaster = false) {
const entry = document.createElement('div');
entry.className = `pm-log-entry pm-log-${type}`;
const textDiv = document.createElement('div');
textDiv.className = 'pm-log-entry-text';
// Add broadcaster icon to message if applicable
let displayMessage = message;
if (isBroadcaster && !message.includes('📷')) {
// Find the gender icon and add broadcaster icon after it
const genderIcons = ['♂️', '♀️', '⚧️', '👥', '❓'];
for (const icon of genderIcons) {
if (message.includes(icon)) {
displayMessage = message.replace(icon, `${icon}📷`);
break;
}
}
}
textDiv.textContent = `[${new Date().toLocaleTimeString()}] ${displayMessage}`;
entry.appendChild(textDiv);
// NEW: Add both PM and Exclude buttons if username provided
if (username && !message.includes('✓ PM sent')) {
const btnContainer = document.createElement('div');
btnContainer.style.display = 'flex';
btnContainer.style.gap = '4px';
// PM button
const pmBtn = document.createElement('button');
pmBtn.className = 'pm-log-entry-btn';
pmBtn.textContent = t().pmBtn;
pmBtn.title = t().pmBtnTip;
pmBtn.onclick = (e) => {
e.stopPropagation();
sendPM(username, CONFIG.welcomeMessage);
};
btnContainer.appendChild(pmBtn);
// Exclude/Unexclude button (toggles based on current status)
const excludeBtn = document.createElement('button');
excludeBtn.className = 'pm-log-entry-btn';
const isExcluded = CONFIG.exclusionList.includes(username);
if (isExcluded) {
excludeBtn.textContent = '✅';
excludeBtn.title = 'Remove from exclusion list';
excludeBtn.style.background = 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)';
excludeBtn.onclick = (e) => {
e.stopPropagation();
removeFromExclusionList(username);
};
} else {
excludeBtn.textContent = t().excludeBtn;
excludeBtn.title = t().excludeBtnTip;
excludeBtn.onclick = (e) => {
e.stopPropagation();
addToExclusionList(username);
};
}
btnContainer.appendChild(excludeBtn);
entry.appendChild(btnContainer);
}
logDisplay.insertBefore(entry, logDisplay.firstChild);
// Keep only last 50 entries
while (logDisplay.children.length > 50) {
logDisplay.removeChild(logDisplay.lastChild);
}
}
function showQuickPMButton(username, gender = 'unknown', isBroadcaster = false) {
// Create or get the button container
let container = document.getElementById('pm-button-container');
if (!container) {
container = document.createElement('div');
container.id = 'pm-button-container';
document.body.appendChild(container);
}
// Position container based on panel position and available space
const panel = document.getElementById('pm-assistant-panel');
const panelRect = panel.getBoundingClientRect();
const viewportWidth = window.innerWidth;
// Check if there's space on the right side of panel
const spaceOnRight = viewportWidth - (panelRect.right + 10);
const spaceOnLeft = panelRect.left - 10;
let positionStyle;
if (spaceOnRight > 260) {
// Position on right side
positionStyle = `
position: fixed;
left: ${panelRect.right + 10}px;
top: ${panelRect.top}px;
`;
} else if (spaceOnLeft > 260) {
// Position on left side
positionStyle = `
position: fixed;
right: ${viewportWidth - panelRect.left + 10}px;
top: ${panelRect.top}px;
`;
} else {
// Not enough space on either side, position below
positionStyle = `
position: fixed;
left: ${panelRect.left}px;
top: ${panelRect.bottom + 10}px;
`;
}
container.style.cssText = `
${positionStyle}
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 250px;
`;
// Gender icon for button
const genderIcon = {
'male': '♂️',
'female': '♀️',
'couple': '👥',
'trans': '⚧️',
'unknown': ''
}[gender] || '';
const broadcasterIcon = isBroadcaster ? '📷' : '';
// Create the button
const button = document.createElement('button');
button.className = 'pm-floating-button';
button.textContent = `${genderIcon}${broadcasterIcon}💌 PM ${username}`;
button.title = `${t().pmBtnTip.replace('this user', username)} (auto-closes in ${Math.round(CONFIG.buttonDisplayTime / 1000)}s)`;
button.style.cssText = `
padding: 10px 15px;
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
font-size: 14px;
font-weight: 500;
transition: all 0.3s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
padding-right: 45px;
`;
// Track countdown state for pause/resume
let timeoutId;
let remainingTime = CONFIG.buttonDisplayTime;
let startTime = Date.now();
let isPaused = false;
const closeBtn = document.createElement('span');
closeBtn.textContent = t().excludeBtn;
closeBtn.title = t().excludeBtnPopupTip;
closeBtn.style.cssText = `
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
font-size: 16px;
cursor: pointer;
padding: 2px 4px;
line-height: 1;
background: white;
border-radius: 3px;
opacity: 0.9;
transition: opacity 0.2s;
`;
closeBtn.onmouseenter = () => closeBtn.style.opacity = '1';
closeBtn.onmouseleave = () => closeBtn.style.opacity = '0.9';
closeBtn.onclick = (e) => {
e.stopPropagation();
addToExclusionList(username);
button.remove();
if (container.children.length === 0) {
container.remove();
}
};
button.appendChild(closeBtn);
// Add progress bar animation
button.style.setProperty('--progress-duration', `${CONFIG.buttonDisplayTime}ms`);
const style = document.createElement('style');
style.textContent = `
.pm-floating-button::after {
animation-duration: ${CONFIG.buttonDisplayTime}ms !important;
}
`;
document.head.appendChild(style);
// Hover effect
button.onmouseenter = () => {
button.style.transform = 'translateY(-2px)';
button.style.boxShadow = '0 6px 16px rgba(0,0,0,0.4)';
// Pause progress bar animation
button.classList.add('paused');
// Pause countdown
if (!isPaused && timeoutId) {
clearTimeout(timeoutId);
remainingTime = remainingTime - (Date.now() - startTime);
isPaused = true;
}
};
button.onmouseleave = () => {
button.style.transform = 'translateY(0)';
button.style.boxShadow = '0 4px 12px rgba(0,0,0,0.3)';
// Resume progress bar animation
button.classList.remove('paused');
// Resume countdown
if (isPaused) {
startTime = Date.now();
isPaused = false;
timeoutId = setTimeout(() => {
if (button.parentNode) {
button.remove();
if (container.children.length === 0) {
container.remove();
}
}
}, remainingTime);
}
};
// Click handler
button.onclick = () => {
sendPM(username, CONFIG.welcomeMessage);
button.remove();
// Remove container if no more buttons
if (container.children.length === 0) {
container.remove();
}
};
// Add to container
container.appendChild(button);
// Play audio notification to alert user that popup appeared
playNotificationSound();
// Auto-remove after configured time (with pause/resume support)
timeoutId = setTimeout(() => {
if (button.parentNode) {
button.remove();
// Remove container if no more buttons
if (container.children.length === 0) {
container.remove();
}
}
}, CONFIG.buttonDisplayTime);
}
function showSettings() {
const newMessage = prompt(`${t().msgLabel}`, CONFIG.welcomeMessage);
if (newMessage && newMessage.trim()) {
CONFIG.welcomeMessage = newMessage.trim();
GM_setValue('welcomeMessage', CONFIG.welcomeMessage);
logMessage('✓ Welcome message updated', 'success');
}
}
function addToExclusionList(username) {
if (!CONFIG.exclusionList.includes(username)) {
CONFIG.exclusionList.push(username);
GM_setValue('exclusionList', CONFIG.exclusionList);
logMessage(`🚫 ${username} ${t().logAddedExclusion}`, 'info');
updateExclusionButton();
} else {
logMessage(`ℹ️ ${username} ${t().logAlreadyExcluded}`, 'info');
}
}
function removeFromExclusionList(username) {
const index = CONFIG.exclusionList.indexOf(username);
if (index > -1) {
CONFIG.exclusionList.splice(index, 1);
GM_setValue('exclusionList', CONFIG.exclusionList);
logMessage(`✓ ${username} ${t().logRemovedExclusion}`, 'success');
updateExclusionButton();
} else {
logMessage(`⚠️ ${username} ${t().logNotInExclusion}`, 'warning');
}
}
// ==================== CHAT MONITORING ====================
function findUserListElement() {
// Try multiple possible selectors for user list
const selectors = [
'#user-list',
'.user-list',
'#room-user-list',
'.room-user-list',
'#chat-list',
'.chat-list',
'[data-role="user-list"]',
'[data-testid="user-list"]',
'.userlist',
'#userlist'
];
for (const selector of selectors) {
const element = document.querySelector(selector);
if (element) {
console.log(`[PM Assistant] Found user list: ${selector}`);
return element;
}
}
console.warn('[PM Assistant] User list not found. Please configure selector manually.');
return null;
}
function extractUsername(element) {
// Try multiple ways to extract username from element
let text = (
element.textContent?.trim() ||
element.dataset?.username ||
element.dataset?.user ||
element.getAttribute('data-username') ||
element.querySelector('.username')?.textContent?.trim() ||
element.querySelector('[data-username]')?.dataset.username ||
null
);
if (!text) return null;
// ALWAYS skip messages that start with "Notice: " (these are bot messages)
if (text.startsWith('Notice:')) {
console.log('[PM Assistant] Skipping Notice message');
return null;
}
// Pattern 1: "username has joined the room." (Chaturbate's built-in notification)
const joinedMatch = text.match(/^(.+?)\s+has joined the room\.?/i);
if (joinedMatch) {
const username = joinedMatch[1].trim();
return username;
}
// Pattern 2: "@username ..." (with @ symbol)
const atIndex = text.indexOf('@');
if (atIndex !== -1) {
const afterAt = text.substring(atIndex + 1);
const spaceIndex = afterAt.indexOf(' ');
let username;
if (spaceIndex === -1) {
username = afterAt.trim();
} else {
username = afterAt.substring(0, spaceIndex).trim();
}
// Remove trailing special characters
username = username.replace(/[^\w-]+$/g, '');
if (username.length >= 2 && username.length <= 25) {
return username;
}
}
// Pattern 3: Just the username (no special format)
const username = text.trim();
if (username.length >= 2 && username.length <= 25 && !username.includes(' ')) {
console.log(`[PM Assistant] Using raw username: "${username}"`);
return username;
}
console.log(`[PM Assistant] Could not extract username from: "${text}"`);
return null;
}
// cleanUsername is no longer needed with this simpler approach
function cleanUsername(username) {
// Keep for backwards compatibility but not used
return username;
}
function startMonitoring() {
logMessage('Starting chat monitor...', 'info');
// Find the user list container
const userList = findUserListElement();
if (!userList) {
logMessage('⚠️ User list not found!', 'error');
logMessage('Click "Test Detection" for help', 'warning');
console.log('%c[PM Assistant] USER LIST NOT FOUND', 'color: red; font-size: 14px; font-weight: bold;');
console.log('Click the "Test Detection" button in the panel for diagnostics.');
return;
}
logMessage(`✓ Monitoring started`, 'success');
console.log('[PM Assistant] Watching element:', userList);
// Use MutationObserver to watch for new users
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== 1) return; // Only process element nodes
// Get the raw text/username from the node
const rawText = node.textContent?.trim() ||
node.dataset?.username ||
node.dataset?.user ||
null;
if (!rawText) return;
// Skip Notice messages - we don't need them anymore with popup detection
if (rawText.startsWith('Notice:')) {
return;
}
// Try to extract username from join messages
const username = extractUsername(node);
if (!username) {
return;
}
// Check if this user is already pending processing
if (pendingUsers.has(username)) {
return;
}
// Mark as pending
pendingUsers.add(username);
// Add to detection queue for popup-based detection
detectionQueue.push({ username, userElement: node });
// Start processing the queue
processDetectionQueue();
});
});
});
observer.observe(userList, {
childList: true,
subtree: true,
});
logMessage('✓ Observer active', 'success');
}
// ==================== DEBUGGING HELPER ====================
function inspectorMode() {
console.log('%c=== PM ASSISTANT INSPECTOR ===', 'color: #667eea; font-size: 16px; font-weight: bold;');
console.log('');
console.log('%c1. Room Configuration:', 'color: #4ade80; font-weight: bold;');
console.log(' Room name:', getRoomName());
console.log(' Broadcaster:', getBroadcasterUsername());
console.log(' CSRF token:', getCSRFToken() ? '✓ Found' : '✗ Not found');
console.log('');
console.log('%c2. User List Detection:', 'color: #4ade80; font-weight: bold;');
const userList = findUserListElement();
if (userList) {
console.log(' ✓ User list found:', userList);
console.log(' Element ID:', userList.id);
console.log(' Element classes:', userList.className);
console.log(' Current children:', userList.children.length);
} else {
console.log(' ✗ User list not found');
console.log(' To configure:');
console.log(' 1. Right-click user list → Inspect');
console.log(' 2. Note the element\'s ID or class');
console.log(' 3. Add selector to findUserListElement()');
}
console.log('');
console.log('%c3. PM Status:', 'color: #4ade80; font-weight: bold;');
console.log(' Auto mode:', CONFIG.autoMode ? 'ON' : 'OFF');
console.log(' Token filter:', CONFIG.onlyTokenUsers ? 'ON' : 'OFF');
console.log(' PMs sent this hour:', pmCount);
console.log(' Users PMed:', Object.keys(pmHistory).length);
console.log('');
console.log('%c4. Test PM Function:', 'color: #4ade80; font-weight: bold;');
console.log(' To test, run in console:');
console.log(' sendPrivateMessage("username", "test message")');
console.log('');
console.log('%c=== END REPORT ===', 'color: #667eea; font-size: 16px; font-weight: bold;');
}
// ==================== INITIALIZATION ====================
function init() {
console.log('[PM Assistant] Initializing...');
// Wait for page to fully load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
return;
}
// Check if we're on a broadcaster page
// You may need to adjust this check
if (!window.location.pathname.includes('/b/')) {
console.log('[PM Assistant] Not on broadcaster page');
return;
}
// Create UI
createControlPanel();
updateStatus();
// Start monitoring
setTimeout(startMonitoring, 2000); // Wait 2s for chat to load
// Debugging helper
GM_registerMenuCommand('🔍 Inspector Mode', inspectorMode);
logMessage('PM Assistant loaded! Configure functions for your setup.', 'success');
}
// Start the script
init();
})();