Skip to content

Instantly share code, notes, and snippets.

@shinmai
Last active November 13, 2025 21:30
Show Gist options
  • Select an option

  • Save shinmai/340d8d851f3b2a09cca921c244747bef to your computer and use it in GitHub Desktop.

Select an option

Save shinmai/340d8d851f3b2a09cca921c244747bef to your computer and use it in GitHub Desktop.
LurkEngaygement - twitch engaygement enhancer
// ==UserScript==
// @name LurkEngaygement
// @namespace https://twitch.lurk.shi.wtf
// @author @shi - shi.wtf
// @version 0.3
// @description twitch engaygement enhancer (fuck jeff bezos)
// @match https://www.twitch.tv/*
// @run-at document-end
// @updateURL https://gist.github.com/shinmai/340d8d851f3b2a09cca921c244747bef/raw/LurkEngaygement.user.js
// @downloadURL https://gist.github.com/shinmai/340d8d851f3b2a09cca921c244747bef/raw/LurkEngaygement.user.js
// @grant none
// ==/UserScript==
/**
* Changelog:
* 0.1 (251113) - initial version
* 0.2 (251113) - prefix for clarity
* 0.3 (251113) - bugfix, add indicator
*/
(function () {
"use strict";
// ---------------------------------------------------------------------
// config
// ---------------------------------------------------------------------
const MIN_DELAY_MIN = 20,
MAX_DELAY_MIN = 28,
MARKOV_MAX_WORDS = 7,
corpusText = `
this is some example text for the markov chain
replace this block with your own phrases or chat logs or whatever
the more text you give it the more interesting the output will be
`
// ---------------------------------------------------------------------
// markov chain generator
// ---------------------------------------------------------------------
function buildMarkovChain(text) {
const words = text.replace(/\s+/g, " ").trim().split(" ").filter(Boolean),
chain = Object.create(null)
for(let i = 0; i < words.length - 1; i++) {
const w1 = words[i], w2 = words[i + 1];
if(!chain[w1]) chain[w1] = []
chain[w1].push(w2)
}
return chain
}
function generateMarkov(chain, maxWords = MARKOV_MAX_WORDS) {
const keys = Object.keys(chain)
if(!keys.length) return ""
let word = keys[Math.floor(Math.random() * keys.length)]
const out = [word]
for(let i = 1; i < maxWords; i++) {
const options = chain[word]
if(!options || !options.length) break
word = options[Math.floor(Math.random() * options.length)]
out.push(word)
}
return out.join(" ")
}
const markovChain = buildMarkovChain(corpusText)
// ---------------------------------------------------------------------
// twitch chat helpers
// ---------------------------------------------------------------------
let chatInput = null
const CHAT_INPUT = 'textarea[data-a-target="chat-input"], div[data-a-target="chat-input"]';
function getReactInstance(element) {
for(const key in element)
if(key.startsWith('__reactInternalInstance$') || key.startsWith('__reactFiber$')) return element[key]
return null
}
function searchReactParents(node, predicate, maxDepth = 15, depth = 0) {
try { if (predicate(node)) return node } catch (_) {}
if(!node || depth > maxDepth) return null
const {return: parent} = node
if(parent) return searchReactParents(parent, predicate, maxDepth, depth + 1)
return null
}
function getChatInput(element = null) {
let chatInput
try { chatInput = searchReactParents(getReactInstance(element || document.querySelector(CHAT_INPUT)), (n) => n.memoizedProps && n.memoizedProps.componentType != null && n.memoizedProps.value != null) } catch (_) {}
return chatInput
}
function getChatInputEditor(element = null) {
let chatInputEditor
try { chatInputEditor = searchReactParents(getReactInstance(element || document.querySelector(CHAT_INPUT)), (n) => n.memoizedProps?.value?.editor != null || n.stateNode?.state?.slateEditor != null) } catch (_) {}
return chatInputEditor?.memoizedProps?.value?.editor ?? chatInputEditor?.stateNode?.state?.slateEditor
}
function getChatInputValue() {
const element = document.querySelector(CHAT_INPUT),
{value: currentValue} = element
if(currentValue != null) return currentValue
const chatInput = getChatInput(element)
if(chatInput == null) return null
return chatInput.memoizedProps.value
}
function setChatInputValue(text) {
const element = document.querySelector(CHAT_INPUT)
const {value: currentValue, selectionStart} = element
if(currentValue != null) {
element.value = text
element.dispatchEvent(new Event('input', {bubbles: true}))
const instance = getReactInstance(element)
if(instance) {
const props = instance.memoizedProps
if(props && props.onChange) props.onChange({target: element})
}
const selectionEnd = selectionStart + text.length
element.setSelectionRange(selectionEnd, selectionEnd)
return
}
const chatInput = getChatInput(element)
if(chatInput == null) return
chatInput.memoizedProps.value = text
chatInput.memoizedProps.setInputValue(text)
chatInput.memoizedProps.onValueUpdate(text)
}
function onChatKeyDown(ev) {
if(!chatInput) return
if(ev.key !== "Enter" || ev.shiftKey) return
const value = getChatInputValue(),
trimmed = value.trim().toLowerCase()
if(trimmed.startsWith("!lurk")) startLurk()
else if(trimmed.startsWith("!unlurk")) stopLurk()
}
function installChatListener() {
if(chatInput) return
const input = document.querySelector('[role="textbox"]')
if(!input) return
chatInput = input
chatInput.addEventListener("keydown", onChatKeyDown, true)
console.log("[LurkEngaygement] chat input hooked")
}
function removeChatListener() {
if(!chatInput) return
chatInput.removeEventListener("keydown", onChatKeyDown, true)
chatInput = null
}
function sendChatMessage(text) {
setChatInputValue(text)
requestAnimationFrame(()=>document.querySelector('[data-a-target="chat-send-button"]').click())
}
// ---------------------------------------------------------------------
// lurk timer logic (chained timeouts)
// ---------------------------------------------------------------------
let lurking = false,
lurkTimeout = null
function getRandomDelayMs() {
const min = MIN_DELAY_MIN * 60 * 1000
const extraRange = (MAX_DELAY_MIN - MIN_DELAY_MIN) * 60 * 1000
return min + Math.random() * extraRange
}
function scheduleNextTick() {
if(!lurking) return
const delay = getRandomDelayMs()
console.log(`[LurkEngaygement] next message in ~${ Math.round(delay / 60000) } minutes`);
lurkTimeout = setTimeout(onLurkTick, delay)
}
function onLurkTick() {
lurkTimeout = null
if(!lurking) return
if(isStreamOffline()) {
console.log("[LurkEngaygement] stream offline detected, stopping lurk")
stopLurk()
return
}
const msg = generateMarkov(markovChain)
if(msg.trim()) sendChatMessage(`nonsense for engaygement: ${msg}`)
scheduleNextTick()
}
function showLurkBadge() {
if(document.getElementById("lurking-indicator")) return
const el = document.createElement("div")
el.id = "lurking-indicator"
el.textContent = "lurking..."
Object.assign(el.style, {
position: "fixed",
bottom: "10px",
right: "100px",
padding: "6px 10px",
background: "rgba(128, 0, 0, 0.6)",
color: "#fff",
font: "12px sans-serif",
borderRadius: "4px",
zIndex: "2147483647",
opacity: "0.85",
pointerEvents: "none",
})
document.body.appendChild(el)
}
function hideLurkBadge() { document.getElementById("lurking-indicator")?.remove() }
function startLurk() {
if(lurking) return
if(isStreamOffline()) return
lurking = true
showLurkBadge()
console.log("[LurkEngaygement] lurk started")
scheduleNextTick()
}
function stopLurk() {
if(!lurking) return
lurking = false
hideLurkBadge()
if (lurkTimeout) {
clearTimeout(lurkTimeout)
lurkTimeout = null
}
console.log("[LurkEngaygement] lurk stopped")
}
// ---------------------------------------------------------------------
// stream offline / navigation detection
// ---------------------------------------------------------------------
function isStreamOffline() { return document.querySelector('.tw-channel-status-text-indicator')?.textContent.toLowerCase()!="live" }
function onLocationChange() {
stopLurk()
removeChatListener()
installChatListener()
}
const origPushState = history.pushState
history.pushState = function () {
origPushState.apply(this, arguments)
onLocationChange()
}
const origReplaceState = history.replaceState
history.replaceState = function () {
origReplaceState.apply(this, arguments);
onLocationChange()
}
window.addEventListener("popstate", onLocationChange)
window.addEventListener("beforeunload", () => stopLurk())
// ---------------------------------------------------------------------
// DOM watcher to wait for chat box
// ---------------------------------------------------------------------
const observer = new MutationObserver(() => { if(!chatInput) installChatListener() })
if (document.body) {
observer.observe(document.body, { childList: true, subtree: true })
installChatListener()
}
})()
@shinmai
Copy link
Author

shinmai commented Nov 13, 2025

If you have a User Script extension/addon for your browser installed (GreseMonkey, TamperMonkey, ViolentMonkey, a lot of monkeys), visit this link to install.

Edit the MIN_DELAY_MIN, MAX_DELAY_MIN, MARKOV_MAX_WORDS, and corpusText constants at the top of the script to configure (the delays are fine, but do replace the text corpus it generates messages from at the very least).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment