Skip to content

Instantly share code, notes, and snippets.

@RealityRipple
Last active March 7, 2024 21:37
Show Gist options
  • Save RealityRipple/03bd5dfeb2b31839cdee068693b15bf1 to your computer and use it in GitHub Desktop.
Save RealityRipple/03bd5dfeb2b31839cdee068693b15bf1 to your computer and use it in GitHub Desktop.
Twitch Shout-Out by Command
<!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