twitch-auto-msg

Pasted Oct 25, 2026, 10:03 PM1 file447 lines

Scan to open on your phone

Point a camera at the QR code - it opens this exact paste, no app needed.

https://codepastes.com/twitch-auto-msg
twitch-auto-msg script
// ==UserScript==
// @name         Twitch Auto Message (Macro configurável)
// @namespace    https://tampermonkey.net/
// @version      1.3
// @description  Envia mensagens automáticas no chat da Twitch, uma caixa por mensagem, em ordem, com intervalo configurável
// @match        https://www.twitch.tv/*
// @match        https://twitch.tv/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  console.log('[TAM] script carregado');

  const MIN_INTERVAL = 5; // segundos (trava de segurança)

  // ---------- Configuração persistente ----------
  const defaults = {
    messages: [
      { text: 'Mensagem 1', enabled: true },
      { text: 'Mensagem 2', enabled: true },
    ],
    interval: 300,
    jitter: 0,
    minimized: false,
    posX: null,
    posY: null,
  };
  let saved = {};
  try { saved = GM_getValue('cfg', {}) || {}; } catch (e) { console.warn('[TAM] GM_getValue falhou', e); }
  const cfg = Object.assign({}, defaults, saved);

  // Migra o formato antigo (texto único, uma mensagem por linha) para caixas separadas
  if (typeof cfg.messages === 'string') {
    cfg.messages = cfg.messages
      .split('\n').map((t) => t.trim()).filter(Boolean)
      .map((text) => ({ text, enabled: true }));
  }
  if (!Array.isArray(cfg.messages) || !cfg.messages.length) {
    cfg.messages = [{ text: '', enabled: true }];
  }

  const save = () => { try { GM_setValue('cfg', cfg); } catch (e) { console.warn('[TAM] GM_setValue falhou', e); } };

  // ---------- Estado ----------
  let running = false;
  let nextAt = 0;
  let nextPos = 0; // posição (na lista completa) de onde a busca da próxima mensagem começa
  let tickId = null;
  let rowEls = [];

  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

  // Procura a próxima caixa ativa e preenchida, a partir de "from", em ordem circular
  function findNext(from) {
    const n = cfg.messages.length;
    for (let i = 0; i < n; i++) {
      const j = (from + i) % n;
      const m = cfg.messages[j];
      if (m.enabled && m.text.trim()) return j;
    }
    return -1;
  }

  // ---------- Envio da mensagem ----------
  function getEditor() {
    return (
      document.querySelector('[data-a-target="chat-input"]') ||
      document.querySelector('.chat-wysiwyg-input__editor') ||
      document.querySelector('div[role="textbox"][contenteditable="true"]')
    );
  }
  const editorText = (el) => (el.textContent || '').replace(/[\u200B\uFEFF]/g, '').trim();

  function clearEditor(el) {
    el.focus();
    document.execCommand('selectAll', false, null);
    document.execCommand('delete', false, null);
  }

  // Tenta inserir o texto no editor da Twitch (Slate) por 3 métodos diferentes
  async function typeInto(el, text) {
    if (editorText(el)) clearEditor(el);
    el.focus();

    // Método 1: evento de colar (funciona mesmo com a aba sem foco)
    try {
      const dt = new DataTransfer();
      dt.setData('text/plain', text);
      el.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: dt }));
    } catch (e) { console.warn('[TAM] paste falhou', e); }
    await sleep(300);
    if (editorText(el)) return 'paste';

    // Método 2: execCommand
    el.focus();
    document.execCommand('insertText', false, text);
    await sleep(300);
    if (editorText(el)) return 'execCommand';

    // Método 3: beforeinput (o Slate escuta esse evento)
    try {
      el.dispatchEvent(new InputEvent('beforeinput', {
        bubbles: true, cancelable: true, inputType: 'insertText', data: text,
      }));
    } catch (e) { console.warn('[TAM] beforeinput falhou', e); }
    await sleep(300);
    if (editorText(el)) return 'beforeinput';

    return null;
  }

  async function sendMessage(text) {
    const el = getEditor();
    if (!el) return 'no_editor';

    const method = await typeInto(el, text);
    console.log('[TAM] método de digitação que funcionou:', method);
    if (!method) return 'no_text';

    await sleep(400); // dá tempo do React habilitar o botão de enviar

    const btn = document.querySelector('[data-a-target="chat-send-button"]');
    if (btn && !btn.disabled && btn.getAttribute('aria-disabled') !== 'true') {
      btn.click();
    } else {
      const opts = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true };
      el.dispatchEvent(new KeyboardEvent('keydown', opts));
      el.dispatchEvent(new KeyboardEvent('keyup', opts));
    }

    await sleep(700);
    return editorText(el) === '' ? 'ok' : 'not_sent';
  }

  function schedule() {
    const base = Math.max(MIN_INTERVAL, Number(cfg.interval) || 60) * 1000;
    const jit = cfg.jitter > 0 ? base * ((Math.random() * 2 - 1) * cfg.jitter) / 100 : 0;
    nextAt = Date.now() + Math.max(MIN_INTERVAL * 1000, base + jit);
  }

  async function sendByIndex(j) {
    const text = cfg.messages[j].text.trim().replace(/\s*\n\s*/g, ' ');
    const res = await sendMessage(text);
    const msgs = {
      ok: 'Enviada #' + (j + 1) + ': ' + text.slice(0, 28),
      no_editor: 'Erro: campo do chat não encontrado (recarregue a página)',
      no_text: 'Erro: o texto não entrou no campo do chat',
      not_sent: 'Erro: o texto entrou mas não foi enviado (slow mode? chat restrito?)',
    };
    setStatus(msgs[res] || res);
    console.log('[TAM] resultado do envio:', res);
    return res;
  }

  async function fire() {
    const j = findNext(nextPos);
    if (j < 0) return setStatus('Nenhuma mensagem ativa/preenchida');
    nextPos = (j + 1) % cfg.messages.length;
    await sendByIndex(j);
    highlight();
  }

  function tick() {
    if (!running) return;
    const left = Math.max(0, Math.ceil((nextAt - Date.now()) / 1000));
    countdownEl.textContent = left + 's';
    if (Date.now() >= nextAt) {
      schedule();
      fire();
    }
  }

  function start() {
    readInputs();
    if (findNext(0) < 0) return setStatus('Preencha ao menos uma caixa');
    running = true;
    nextPos = 0;
    schedule();
    clearInterval(tickId);
    tickId = setInterval(tick, 500);
    toggleBtn.textContent = '⏹ Parar';
    toggleBtn.style.background = '#c0392b';
    setStatus('Rodando...');
    highlight();
  }

  function stop() {
    running = false;
    clearInterval(tickId);
    toggleBtn.textContent = '▶ Iniciar';
    toggleBtn.style.background = '#9147ff';
    countdownEl.textContent = '--';
    setStatus('Parado');
    highlight();
  }

  // ---------- Interface (sem innerHTML, compatível com Trusted Types) ----------
  function h(tag, props, children) {
    const el = document.createElement(tag);
    Object.entries(props || {}).forEach(([k, v]) => {
      if (k === 'style') el.style.cssText = v;
      else if (k === 'text') el.textContent = v;
      else el.setAttribute(k, v);
    });
    (children || []).forEach((c) => el.append(c));
    return el;
  }

  const labelCss = 'display:flex;flex-direction:column;gap:3px;font-size:12px;color:#adadb8;';
  const fieldCss =
    'background:#0e0e10;color:#efeff1;border:1px solid #3a3a3d;border-radius:4px;' +
    'padding:5px;font:13px Arial,sans-serif;box-sizing:border-box;width:100%;';
  const smallBtnCss =
    'background:#26262c;color:#efeff1;border:1px solid #3a3a3d;border-radius:3px;' +
    'cursor:pointer;font-size:10px;width:22px;height:20px;padding:0;line-height:1;';

  const minBtn = h('button', {
    title: 'Minimizar', text: '—',
    style: 'background:none;border:none;color:#fff;cursor:pointer;font-size:14px;',
  });
  const head = h('div', {
    style: 'display:flex;justify-content:space-between;align-items:center;padding:8px 10px;' +
      'background:#9147ff;border-radius:7px 7px 0 0;cursor:move;font-weight:bold;user-select:none;',
  }, [h('span', { text: '🤖 Auto Mensagem' }), minBtn]);

  const listEl = h('div', {
    style: 'display:flex;flex-direction:column;gap:6px;max-height:300px;overflow-y:auto;padding-right:2px;',
  });
  const addBtn = h('button', {
    text: '+ Adicionar mensagem',
    style: 'background:#26262c;color:#efeff1;border:1px dashed #5a5a60;border-radius:4px;' +
      'padding:6px;cursor:pointer;font-size:12px;',
  });
  const intervalEl = h('input', { type: 'number', min: String(MIN_INTERVAL), style: fieldCss });
  const jitterEl = h('input', { type: 'number', min: '0', max: '90', style: fieldCss });
  const toggleBtn = h('button', {
    text: '▶ Iniciar',
    style: 'border:none;color:#fff;background:#9147ff;padding:8px;border-radius:4px;cursor:pointer;font-weight:bold;',
  });
  const testBtn = h('button', {
    text: '✉ Testar envio agora',
    style: 'background:#26262c;color:#efeff1;border:1px solid #3a3a3d;border-radius:4px;padding:6px;cursor:pointer;font-size:12px;',
  });
  const countdownEl = h('b', { text: '--' });
  const statusEl = h('div', { style: 'font-size:11px;color:#adadb8;min-height:14px;' });

  const bodyEl = h('div', { style: 'padding:10px;display:flex;flex-direction:column;gap:8px;' }, [
    h('div', { style: 'font-size:11px;color:#adadb8;', text: 'As mensagens são enviadas da 1ª caixa até a última e depois recomeçam.' }),
    listEl,
    addBtn,
    h('div', { style: 'display:flex;gap:8px;' }, [
      h('label', { style: labelCss + 'flex:1;' }, [document.createTextNode('Intervalo (seg)'), intervalEl]),
      h('label', { style: labelCss + 'flex:1;' }, [document.createTextNode('Variação (%)'), jitterEl]),
    ]),
    toggleBtn,
    testBtn,
    h('div', {}, [document.createTextNode('Próxima em: '), countdownEl]),
    statusEl,
  ]);

  const panel = h('div', {
    id: 'tam-panel',
    style: 'position:fixed;z-index:2147483647;width:310px;background:#18181b;color:#efeff1;' +
      'border:1px solid #3a3a3d;border-radius:8px;font:13px/1.4 Arial,sans-serif;' +
      'box-shadow:0 4px 16px rgba(0,0,0,.5);',
  }, [head, bodyEl]);

  // Evita que atalhos de teclado da Twitch interceptem o que você digita no painel
  ['keydown', 'keyup', 'keypress'].forEach((ev) =>
    panel.addEventListener(ev, (e) => e.stopPropagation())
  );

  function setStatus(t) { statusEl.textContent = t; }

  // Destaca a caixa que será enviada a seguir
  function highlight() {
    const upcoming = running ? findNext(nextPos) : -1;
    rowEls.forEach((row, i) => {
      row.style.borderColor = i === upcoming ? '#9147ff' : '#3a3a3d';
      row.style.boxShadow = i === upcoming ? '0 0 0 1px #9147ff' : 'none';
    });
  }

  function move(i, dir) {
    const j = i + dir;
    if (j < 0 || j >= cfg.messages.length) return;
    const tmp = cfg.messages[i];
    cfg.messages[i] = cfg.messages[j];
    cfg.messages[j] = tmp;
    save();
    render();
  }

  function removeAt(i) {
    if (cfg.messages.length === 1) {
      cfg.messages[0] = { text: '', enabled: true };
    } else {
      cfg.messages.splice(i, 1);
    }
    nextPos = nextPos % cfg.messages.length;
    save();
    render();
  }

  function render() {
    listEl.textContent = '';
    rowEls = [];
    cfg.messages.forEach((m, i) => {
      const num = h('span', { text: String(i + 1), style: 'font-weight:bold;color:#bf94ff;font-size:12px;' });
      const chk = h('input', { type: 'checkbox', title: 'Ativar/desativar esta mensagem' });
      chk.checked = m.enabled;
      chk.addEventListener('change', () => { m.enabled = chk.checked; save(); highlight(); });

      const ta = h('textarea', {
        rows: '2', placeholder: 'Mensagem ' + (i + 1),
        style: fieldCss + 'resize:vertical;flex:1;min-width:0;',
      });
      ta.value = m.text;
      ta.addEventListener('input', () => { m.text = ta.value; save(); });

      const mk = (t, title, fn) => {
        const b = h('button', { text: t, title, style: smallBtnCss });
        b.addEventListener('click', fn);
        return b;
      };

      const row = h('div', {
        style: 'display:flex;gap:5px;align-items:flex-start;padding:5px;border:1px solid #3a3a3d;border-radius:6px;',
      }, [
        h('div', { style: 'display:flex;flex-direction:column;align-items:center;gap:4px;padding-top:2px;' }, [num, chk]),
        ta,
        h('div', { style: 'display:flex;flex-direction:column;gap:2px;' }, [
          mk('▲', 'Subir', () => move(i, -1)),
          mk('▼', 'Descer', () => move(i, 1)),
          mk('✕', 'Remover', () => removeAt(i)),
        ]),
      ]);
      listEl.appendChild(row);
      rowEls.push(row);
    });
    highlight();
  }

  function fillInputs() {
    intervalEl.value = cfg.interval;
    jitterEl.value = cfg.jitter;
    bodyEl.style.display = cfg.minimized ? 'none' : 'flex';
    render();
  }

  function readInputs() {
    cfg.interval = Math.max(MIN_INTERVAL, parseInt(intervalEl.value, 10) || 60);
    cfg.jitter = Math.min(90, Math.max(0, parseInt(jitterEl.value, 10) || 0));
    intervalEl.value = cfg.interval;
    jitterEl.value = cfg.jitter;
    save();
  }

  function place() {
    let x = cfg.posX != null ? cfg.posX : window.innerWidth - 340;
    let y = cfg.posY != null ? cfg.posY : 80;
    x = Math.min(Math.max(0, x), Math.max(0, window.innerWidth - 120));
    y = Math.min(Math.max(0, y), Math.max(0, window.innerHeight - 40));
    panel.style.left = x + 'px';
    panel.style.top = y + 'px';
  }

  [intervalEl, jitterEl].forEach((el) => el.addEventListener('change', readInputs));
  toggleBtn.addEventListener('click', () => (running ? stop() : start()));
  testBtn.addEventListener('click', async () => {
    const j = findNext(0);
    if (j < 0) return setStatus('Preencha ao menos uma caixa');
    setStatus('Testando...');
    await sendByIndex(j);
  });
  addBtn.addEventListener('click', () => {
    cfg.messages.push({ text: '', enabled: true });
    save();
    render();
    const tas = listEl.querySelectorAll('textarea');
    if (tas.length) {
      tas[tas.length - 1].focus();
      listEl.scrollTop = listEl.scrollHeight;
    }
  });
  minBtn.addEventListener('click', () => {
    cfg.minimized = !cfg.minimized;
    bodyEl.style.display = cfg.minimized ? 'none' : 'flex';
    save();
  });

  // Arrastar painel
  (function drag() {
    let sx, sy, ox, oy, dragging = false;
    head.addEventListener('mousedown', (e) => {
      if (e.target === minBtn) return;
      dragging = true;
      sx = e.clientX; sy = e.clientY;
      ox = panel.offsetLeft; oy = panel.offsetTop;
      e.preventDefault();
    });
    document.addEventListener('mousemove', (e) => {
      if (!dragging) return;
      panel.style.left = ox + e.clientX - sx + 'px';
      panel.style.top = oy + e.clientY - sy + 'px';
    });
    document.addEventListener('mouseup', () => {
      if (!dragging) return;
      dragging = false;
      cfg.posX = panel.offsetLeft;
      cfg.posY = panel.offsetTop;
      save();
    });
  })();

  // Garante que o painel exista (a Twitch é uma SPA e pode recriar o DOM)
  function ensurePanel() {
    if (!document.body) return;
    if (!document.getElementById('tam-panel')) {
      document.body.appendChild(panel);
      place();
      console.log('[TAM] painel adicionado à página');
    }
  }

  fillInputs();
  ensurePanel();
  setInterval(ensurePanel, 2000);

  // Atalho no menu do Tampermonkey para recuperar o painel se sumir
  try {
    GM_registerMenuCommand('Mostrar / resetar posição do painel', () => {
      cfg.posX = null;
      cfg.posY = null;
      cfg.minimized = false;
      save();
      fillInputs();
      ensurePanel();
      place();
    });
  } catch (e) { /* ignora */ }
})();

15,458 chars · codepastes.com