Last active
March 7, 2024 21:37
-
-
Save RealityRipple/03bd5dfeb2b31839cdee068693b15bf1 to your computer and use it in GitHub Desktop.
Twitch Shout-Out by Command
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!doctype html> | |
<html lang="en"> | |
<head> | |
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> | |
<title>ShouterOuter</title> | |
<script> | |
'use strict'; | |
const cfg = { | |
channel: 'CHANNEL', | |
login: { | |
oauth_refresh: 'OAUTH_REFRESH', // requires chat:read + moderator:manage:shoutouts | |
client: 's0cuaugljigq1tn56a2gmi5mkqkeed' | |
}, | |
commands: [ | |
'!so', | |
'!shoutout' | |
], | |
queue: true, | |
access: 0x800 | 0x400 | |
}; | |
const cURLs = { | |
twitch: { | |
ws: 'wss://irc-ws.chat.twitch.tv', | |
users: 'https://api.twitch.tv/helix/users?login=%USER%', | |
followers: 'https://api.twitch.tv/helix/channels/followers?user_id=%USER_ID%&broadcaster_id=%CHANNEL_ID%', | |
shoutouts: 'https://api.twitch.tv/helix/chat/shoutouts?to_broadcaster_id=%USER_ID%&from_broadcaster_id=%CHANNEL_ID%&moderator_id=%CHANNEL_ID%' | |
}, | |
rr: 'https://realityripple.com/Tools/Twitch/ShouterOuter/oauth.php?refresh=%REFRESH_TOKEN%' | |
}; | |
let lastSO = 0; | |
let queueSO = []; | |
let chID = 0; | |
async function sendShoutOut(toU) | |
{ | |
if (cfg.queue && queueSO.length > 0) | |
{ | |
queueSO.push({u: toU, t: 0}); | |
return; | |
} | |
if (lastSO > new Date().getTime() - 2 * 60 * 1000) | |
{ | |
if (cfg.queue) | |
{ | |
queueSO.push({u: toU, t: 0}); | |
return; | |
} | |
return; | |
} | |
const toInf = await getUserInfo(toU.toLowerCase()); | |
if (toInf === false) | |
return; | |
const toID = toInf.id; | |
const u = cURLs.twitch.shoutouts.replaceAll('%USER_ID%', toID).replaceAll('%CHANNEL_ID%', chID); | |
const h = { | |
'Authorization': 'Bearer ' + cfg.login.oauth, | |
'Client-Id': cfg.login.client | |
}; | |
const r = await httpSend('POST', u, h); | |
if (!r.success) | |
{ | |
if (r.code !== 429) | |
{ | |
queueSO = []; | |
return; | |
} | |
if (cfg.queue) | |
queueSO.push({u: toU, t: new Date().getTime()}); | |
return; | |
} | |
lastSO = new Date().getTime(); | |
} | |
async function handleQueue() | |
{ | |
if (queueSO.length === 0) | |
{ | |
window.setTimeout(handleQueue, 1000); | |
return; | |
} | |
if (lastSO > new Date().getTime() - 2 * 60 * 1000) | |
{ | |
window.setTimeout(handleQueue, 1000); | |
return; | |
} | |
const maxT = queueSO.length; | |
let tries = 0; | |
while(queueSO[0].t > new Date().getTime() - 2 * 60 * 1000) | |
{ | |
queueSO.push(queueSO.shift()); | |
tries++; | |
if (tries > maxT) | |
{ | |
window.setTimeout(handleQueue, 1000); | |
return; | |
} | |
} | |
const toU = queueSO[0].u; | |
const toInf = await getUserInfo(toU.toLowerCase()); | |
if (toInf === false) | |
{ | |
window.setTimeout(handleQueue, 1000); | |
return; | |
} | |
const toID = toInf.id; | |
const u = cURLs.twitch.shoutouts.replaceAll('%USER_ID%', toID).replaceAll('%CHANNEL_ID%', chID); | |
const h = { | |
'Authorization': 'Bearer ' + cfg.login.oauth, | |
'Client-Id': cfg.login.client | |
}; | |
const r = await httpSend('POST', u, h); | |
if (!r.success) | |
{ | |
if (r.code !== 429) | |
{ | |
queueSO = []; | |
window.setTimeout(handleQueue, 1000) | |
return; | |
} | |
queueSO.push({u: queueSO.shift().u, t: new Date().getTime()}); | |
window.setTimeout(handleQueue, 1000); | |
return; | |
} | |
queueSO.shift(); | |
lastSO = new Date().getTime(); | |
window.setTimeout(handleQueue, 1000); | |
} | |
const irc = function() | |
{ | |
let _wsRetry = 0; | |
let _firstRS = true; | |
let _ws = null; | |
const _lPing = 30000; | |
const _lTimeout = 35000; | |
const _lExpire = 5000; | |
const _wWS = 5; | |
let _tTimeout = false; | |
let _tExpire = false; | |
let _tPing = 0; | |
let _dead = false; | |
function $c_irc() | |
{ | |
_tPing = 0; | |
_firstRS = true; | |
_ws = new WebSocket(cURLs.twitch.ws); | |
_ws.onopen = _wsOpen; | |
_ws.onclose = _wsClose; | |
_ws.onmessage = _wsMessage; | |
} | |
function _wsOpen() | |
{ | |
if (_ws === null) | |
return; | |
if (_ws.readyState !== 1) | |
return; | |
_ws.onopen = null; | |
_tExpire = window.setTimeout(_wsExpire, _lExpire); | |
_ws.send('CAP REQ :twitch.tv/commands twitch.tv/tags'); | |
_ws.send('PASS oauth:' + cfg.login.oauth); | |
_ws.send('NICK ' + cfg.channel); | |
_ws.send('JOIN #' + cfg.channel); | |
} | |
function _wsClose() | |
{ | |
if (_tPing !== 0) | |
{ | |
window.clearInterval(_tPing); | |
_tPing = 0; | |
} | |
if (_tExpire !== false) | |
{ | |
window.clearTimeout(_tExpire); | |
_tExpire = false; | |
} | |
if (_tTimeout !== false) | |
{ | |
window.clearTimeout(_tTimeout); | |
_tTimeout = false; | |
} | |
if (_ws === null) | |
return; | |
_ws.onopen = null; | |
_ws.onmessage = null; | |
_ws.onclose = null; | |
_ws = null; | |
if (_dead) | |
return; | |
const wsWait = Math.floor(_wWS ** (1 + _wsRetry) * 1000); | |
if (_wsRetry < 2) | |
_wsRetry += 0.2; | |
window.setTimeout(irc, wsWait); | |
} | |
const _wsMessage = function() | |
{ | |
async function $c_wsMessage(ev) | |
{ | |
if (_tTimeout !== false) | |
{ | |
window.clearTimeout(_tTimeout); | |
_tTimeout = false; | |
} | |
if (_tPing !== 0) | |
{ | |
window.clearInterval(_tPing); | |
_tPing = 0; | |
} | |
_tPing = window.setInterval(_wsPing, _lPing); | |
if (_ws === null) | |
return; | |
if (_dead) | |
{ | |
if (_tExpire !== false) | |
{ | |
window.clearTimeout(_tExpire); | |
_tExpire = false; | |
} | |
_ws.onopen = null; | |
_ws.onmessage = null; | |
_ws.onclose = null; | |
_ws.close(); | |
_ws = null; | |
return; | |
} | |
_tTimeout = window.setTimeout(_wsTimeout, _lTimeout); | |
_wsRetry = 0; | |
const data = ev.data.split('\r\n'); | |
for (let i = 0, l = data.length; i < l; i++) | |
{ | |
if (data[i].length === 0) | |
continue; | |
const cmd = parse.message(data[i]); | |
if (cmd === false) | |
continue; | |
switch (cmd.command) | |
{ | |
case 'PING': | |
await _PING(cmd); | |
break; | |
case 'PRIVMSG': | |
await _PRIVMSG(cmd); | |
break; | |
case 'NOTICE': | |
if (await _NOTICE(cmd)) | |
return; | |
break; | |
case 'ROOMSTATE': | |
await _ROOMSTATE(cmd); | |
break; | |
case 'RECONNECT': | |
_RECONNECT(); | |
return; | |
} | |
} | |
} | |
async function _PING(cmd) | |
{ | |
if (_ws.readyState === 1) | |
_ws.send('PONG ' + cmd.params[0]); | |
if (cfg.login.hasOwnProperty('oauth_refresh') && cfg.login.oauth_refresh !== false && cfg.login.oauth_refresh !== 'OAUTH_REFRESH') | |
await updateOAuth(); | |
} | |
async function _PRIVMSG(cmd) | |
{ | |
await parse.command(cmd); | |
} | |
async function _NOTICE(cmd) | |
{ | |
if (cmd.params.length < 2) | |
return false; | |
if (cmd.params[1] !== 'Login authentication failed') | |
return false; | |
if (_tExpire !== false) | |
{ | |
window.clearTimeout(_tExpire); | |
_tExpire = false; | |
} | |
if (_tTimeout !== false) | |
{ | |
window.clearTimeout(_tTimeout); | |
_tTimeout = false; | |
} | |
if (_tPing !== 0) | |
{ | |
window.clearInterval(_tPing); | |
_tPing = 0; | |
} | |
_ws.onmessage = null; | |
_ws.onclose = null; | |
_ws.close(); | |
_ws = null; | |
if (await updateOAuth(true)) | |
{ | |
window.setTimeout(irc, 1000); | |
return true; | |
} | |
_dead = true; | |
return false; | |
} | |
async function _ROOMSTATE(cmd) | |
{ | |
if (!cmd.hasOwnProperty('tags')) | |
return; | |
if (!cmd.tags.hasOwnProperty('room-id')) | |
return; | |
chID = cmd.tags['room-id']; | |
if (!_firstRS) | |
return; | |
if (_tExpire !== false) | |
{ | |
window.clearTimeout(_tExpire); | |
_tExpire = false; | |
} | |
_firstRS = false; | |
} | |
function _RECONNECT() | |
{ | |
if (_tExpire !== false) | |
{ | |
window.clearTimeout(_tExpire); | |
_tExpire = false; | |
} | |
if (_tTimeout !== false) | |
{ | |
window.clearTimeout(_tTimeout); | |
_tTimeout = false; | |
} | |
if (_tPing !== 0) | |
{ | |
window.clearInterval(_tPing); | |
_tPing = 0; | |
} | |
_ws.onmessage = null; | |
_ws.onclose = null; | |
_ws.close(); | |
_ws = null; | |
if (_dead === true) | |
return; | |
window.setTimeout(irc, 1000); | |
} | |
return $c_wsMessage; | |
}(); | |
function _wsExpire() | |
{ | |
if (_tTimeout !== false) | |
{ | |
window.clearTimeout(_tTimeout); | |
_tTimeout = false; | |
} | |
if (_tPing !== 0) | |
{ | |
window.clearInterval(_tPing); | |
_tPing = 0; | |
} | |
if (_ws === null) | |
return; | |
if (_dead === true) | |
return; | |
_dead = true; | |
if (_tExpire !== false) | |
{ | |
window.clearTimeout(_tExpire); | |
_tExpire = false; | |
} | |
_ws.close(); | |
_ws = null; | |
} | |
function _wsPing() | |
{ | |
if (_ws === null) | |
return; | |
if (_ws.readyState !== 1) | |
return; | |
_ws.send('PING'); | |
} | |
function _wsTimeout() | |
{ | |
if (_tExpire !== false) | |
{ | |
window.clearTimeout(_tExpire); | |
_tExpire = false; | |
} | |
if (_tTimeout !== false) | |
{ | |
window.clearTimeout(_tTimeout); | |
_tTimeout = false; | |
} | |
if (_tPing !== 0) | |
{ | |
window.clearInterval(_tPing); | |
_tPing = 0; | |
} | |
if (_ws === null) | |
return; | |
_ws.onopen = null; | |
_ws.onmessage = null; | |
_ws.onclose = null; | |
_ws.close(); | |
_ws = null; | |
if (_dead === true) | |
return; | |
irc(); | |
} | |
return $c_irc; | |
}(); | |
const parse = function() | |
{ | |
function $message(line) | |
{ | |
const cmd = {}; | |
if (line.slice(0, 1) === '@') | |
{ | |
line = line.slice(1); | |
if (!line.includes(' ')) | |
return false; | |
cmd.tags = {}; | |
const t = line.slice(0, line.indexOf(' ')); | |
line = line.slice(line.indexOf(' ') + 1); | |
const a = t.split(';'); | |
for (let i = 0, l = a.length; i < l; i++) | |
{ | |
const k = a[i].slice(0, a[i].indexOf('=')); | |
let v = a[i].slice(a[i].indexOf('=') + 1); | |
v = v.replace(/\\s/g, ' '); | |
cmd.tags[k] = v; | |
} | |
} | |
if (line.slice(0, 1) === ':') | |
{ | |
line = line.slice(1); | |
if (!line.includes(' ')) | |
return false; | |
cmd.prefix = line.slice(0, line.indexOf(' ')); | |
line = line.slice(line.indexOf(' ') + 1); | |
} | |
if (!line.includes(' ')) | |
{ | |
cmd.command = line; | |
return cmd; | |
} | |
cmd.command = line.slice(0, line.indexOf(' ')); | |
line = line.slice(line.indexOf(' ') + 1); | |
cmd.params = []; | |
if (!line.includes(' ')) | |
{ | |
cmd.params.push(line); | |
return cmd; | |
} | |
while (line.includes(' ')) | |
{ | |
if (line.slice(0, 1) === ':') | |
{ | |
cmd.params.push(line.slice(1)); | |
return cmd; | |
} | |
cmd.params.push(line.slice(0, line.indexOf(' '))); | |
line = line.slice(line.indexOf(' ') + 1); | |
} | |
if (line.slice(0, 1) === ':') | |
line = line.slice(1); | |
cmd.params.push(line); | |
return cmd; | |
} | |
async function $command(cmd) | |
{ | |
if (cmd.params.length < 2) | |
return; | |
const lvl = await _level(cmd); | |
if ((lvl & cfg.access) === 0) | |
return; | |
for (let i = 0; i < cfg.commands.length; i++) | |
{ | |
if (cmd.params[1].slice(0, cfg.commands[i].length + 1).toLowerCase() !== cfg.commands[i].toLowerCase() + ' ') | |
continue; | |
let u = cmd.params[1].slice(cfg.commands[i].length + 1); | |
while (u.slice(0, 1) === ' ') | |
u = u.slice(1); | |
if (u.slice(0, 1) === '@') | |
u = u.slice(1); | |
if (u.indexOf(' ') > -1) | |
u = u.slice(0, u.indexOf(' ')); | |
sendShoutOut(u); | |
} | |
} | |
const _level = function() | |
{ | |
const _fList = []; | |
const _rFH = 3600000; | |
const _access = Object.freeze({ | |
STRANGER: 0x001, | |
FOLLOWER: 0x002, | |
CHEERER: 0x004, | |
SUBSCRIBER_T1: 0x010, | |
SUBSCRIBER_T2: 0x020, | |
SUBSCRIBER_T3: 0x040, | |
ARTIST: 0x080, | |
VIP: 0x100, | |
FOUNDER: 0x200, | |
MODERATOR: 0x400, | |
BROADCASTER: 0x800 | |
}); | |
async function $c_level(cmd) | |
{ | |
let r = _access.STRANGER; | |
if (cmd.tags.hasOwnProperty('mod') && cmd.tags.mod === '1') | |
r |= _access.MODERATOR; | |
if (cmd.tags.hasOwnProperty('vip') && cmd.tags.vip === '1') | |
r |= _access.VIP; | |
if (cmd.tags.hasOwnProperty('badges')) | |
{ | |
const badges = cmd.tags.badges.split(','); | |
for (let i = 0, l = badges.length; i < l; i++) | |
{ | |
const bData = _jSplit(badges[i], '/', 2); | |
switch (bData[0]) | |
{ | |
case 'broadcaster': | |
r |= _access.BROADCASTER; | |
break; | |
case 'moderator': | |
r |= _access.MODERATOR; | |
break; | |
case 'vip': | |
r |= _access.VIP; | |
break; | |
case 'artist-badge': | |
r |= _access.ARTIST; | |
break; | |
case 'founder': | |
r |= _access.FOUNDER; | |
break; | |
case 'bits': | |
r |= _access.CHEERER; | |
break; | |
case 'subscriber': | |
const badge = parseInt(bData[1], 10); | |
if (badge < 2000) | |
r |= _access.SUBSCRIBER_T1; | |
else if (badge < 3000) | |
r |= _access.SUBSCRIBER_T2; | |
else | |
r |= _access.SUBSCRIBER_T3; | |
break; | |
} | |
} | |
} | |
/* api-heavy, only check if follower access is allowed and there's a chance it matters */ | |
let needF = false; | |
if ((cfg.access & _access.FOLLOWER) === _access.FOLLOWER && (cfg.access & _access.STRANGER) !== _access.STRANGER) | |
needF = true; | |
if (needF) | |
{ | |
const f = await _checkFollower(cmd); | |
if (f) | |
r |= _access.FOLLOWER; | |
} | |
const accessSub = _access.SUBSCRIBER_T1 | _access.SUBSCRIBER_T2 | _access.SUBSCRIBER_T3; | |
if ((r & _access.BROADCASTER) === _access.BROADCASTER && (r & accessSub) !== 0) | |
return r & ~accessSub; | |
return r; | |
} | |
async function _checkFollower(cmd, retry = true) | |
{ | |
const uID = cmd.tags['user-id']; | |
if (_fList.hasOwnProperty(uID)) | |
{ | |
const tDif = new Date().getTime() - _fList[uID].t; | |
if (tDif < _rFH) | |
return _fList[uID].value; | |
} | |
const url = cURLs.twitch.followers.replaceAll('%USER_ID%', uID).replaceAll('%CHANNEL_ID%', cmd.tags['room-id']); | |
const h = { | |
'Authorization': 'Bearer ' + cfg.login.oauth, | |
'Client-Id': cfg.login.client | |
}; | |
const r = await httpSend('GET', url, h); | |
if (!r.success) | |
{ | |
if (r.code === 401) | |
{ | |
if (retry && await updateOAuth(true)) | |
return _checkFollower(cmd, false); | |
} | |
return false; | |
} | |
const j = r.json; | |
if (j === null) | |
return false; | |
if (j.hasOwnProperty('data') && j.data.length > 0) | |
{ | |
_fList[uID] = {value: true, t: new Date().getTime()}; | |
return true; | |
} | |
_fList[uID] = {value: false, t: new Date().getTime()}; | |
return false; | |
} | |
function _jSplit(s, sep, limit) | |
{ | |
const arr = []; | |
let v = ''; | |
for (let i = 0, l = s.length; i < l; i++) | |
{ | |
if (arr.length < limit - 1) | |
{ | |
if (s[i] === sep) | |
{ | |
arr.push(v); | |
v = ''; | |
continue; | |
} | |
} | |
v += s[i]; | |
} | |
if (v.length > 0) | |
arr.push(v); | |
return arr; | |
} | |
return $c_level; | |
}(); | |
return { | |
message: $message, | |
command: $command, | |
uReg: /[^A-Za-z0-9_]/ | |
}; | |
}(); | |
async function getUserInfo(u) | |
{ | |
const url = cURLs.twitch.users.replaceAll('%USER%', u); | |
const h = { | |
'Authorization': 'Bearer ' + cfg.login.oauth, | |
'Client-Id': cfg.login.client | |
}; | |
const r = await httpSend('GET', url, h); | |
if (!r.success) | |
return false; | |
const j = r.json; | |
if (j === null) | |
return false; | |
if (!j.hasOwnProperty('data')) | |
return false; | |
if (j.data.length !== 1) | |
return false; | |
return j.data[0]; | |
} | |
async function _getOAuthToken(t) | |
{ | |
const url = cURLs.rr.replaceAll('%REFRESH_TOKEN%', t); | |
const r = await httpSend('GET', url); | |
if (!r.success) | |
return false; | |
const j = r.json; | |
if (j === null) | |
return false; | |
if (!j.hasOwnProperty('access_token')) | |
return false; | |
if (!j.hasOwnProperty('refresh_token')) | |
return false; | |
return j; | |
} | |
async function updateOAuth(force = false) | |
{ | |
if (!force && cfg.login.hasOwnProperty('oauth_refreshed') && cfg.login.oauth_refreshed > 0) | |
{ | |
const tokAge = Math.floor(new Date().getTime() / 1000) - cfg.login.oauth_refreshed; | |
if (tokAge < 60 * 60) | |
return false; | |
} | |
const ret = await _getOAuthToken(cfg.login.oauth_refresh); | |
if (ret === false) | |
return false; | |
cfg.login.oauth = ret.access_token; | |
cfg.login.oauth_refresh = ret.refresh_token; | |
cfg.login.oauth_refreshed = Math.floor(new Date().getTime() / 1000); | |
return true; | |
} | |
function httpSend(type, url, hdrs = {}) | |
{ | |
const p = new Promise( | |
function(resolve) | |
{ | |
const x = new XMLHttpRequest(); | |
x.open(type, url); | |
for (const hK in hdrs) | |
{ | |
if (!hdrs.hasOwnProperty(hK)) | |
continue; | |
x.setRequestHeader(hK, hdrs[hK]); | |
} | |
x.onreadystatechange = function() | |
{ | |
if (x.readyState !== 4) | |
return; | |
x.onreadystatechange = null; | |
resolve({code: x.status, data: x.responseText, get success(){return Math.floor(this.code / 100) === 2;}, get json(){try{return JSON.parse(this.data);}catch(ex){return null;}}}); | |
}; | |
x.send(); | |
} | |
); | |
return p; | |
} | |
async function init() | |
{ | |
if (cfg.login.hasOwnProperty('oauth_refresh') && cfg.login.oauth_refresh !== false && cfg.login.oauth_refresh !== 'OAUTH_REFRESH') | |
await updateOAuth(); | |
if (cfg.queue) | |
window.setTimeout(handleQueue, 1000); | |
irc(); | |
} | |
window.addEventListener('load', init); | |
</script> | |
</head> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment