├── .gitignore ├── web ├── public │ ├── logo.png │ ├── 4Head.png │ ├── github.png │ ├── spotify.png │ ├── index.html │ └── global.css ├── src │ ├── main.js │ ├── Nav.svelte │ ├── routes │ │ ├── Spotify.svelte │ │ ├── SpotifyCallback.svelte │ │ ├── Home.svelte │ │ └── Commands.svelte │ └── App.svelte └── index.js ├── ecosystem.config.js ├── .github └── dependabot.yml ├── lib ├── utils │ ├── cooldown.js │ ├── regex.js │ ├── constants.json │ ├── uberduck.js │ ├── tts.js │ ├── logger.js │ ├── spotify.js │ ├── pajbot.js │ ├── utils.js │ ├── notify.js │ └── emotes.js ├── commands │ ├── donger.js │ ├── %.js │ ├── neko.js │ ├── clear.js │ ├── sql.js │ ├── tags.js │ ├── dadjoke.js │ ├── yourmom.js │ ├── feelsgoodman.js │ ├── fill.js │ ├── pardon.js │ ├── botinfo.js │ ├── tts.js │ ├── eval.js │ ├── tenor.js │ ├── wp.js │ ├── 8ball.js │ ├── confusables.js │ ├── geoip.js │ ├── math.js │ ├── funfact.js │ ├── query.js │ ├── split.js │ ├── mods.js │ ├── boobatv.js │ ├── avatar.js │ ├── botsubs.js │ ├── steam.js │ ├── mode.js │ ├── say.js │ ├── announce.js │ ├── randclip.js │ ├── mostsent.js │ ├── prefix.js │ ├── help.js │ ├── firstmsg.js │ ├── dex.js │ ├── uberduck.js │ ├── spam.js │ ├── clip.js │ ├── stalk.js │ ├── weather.js │ ├── copypasta.js │ ├── lines.js │ ├── findmsg.js │ ├── google.js │ ├── banlist.js │ ├── stats.js │ ├── hug.js │ ├── pyramid.js │ ├── ignore.js │ ├── pajbot.js │ ├── stablediffusion.js │ ├── chatters.js │ ├── dislikes.js │ ├── searchsong.js │ ├── addvoice.js │ ├── pet.js │ ├── randline.js │ ├── ping.js │ ├── cmd.js │ ├── prompt.js │ ├── topartists.js │ ├── toptracks.js │ ├── gelbooru.js │ ├── recentlyplayed.js │ ├── emote.js │ ├── howlongtobeat.js │ ├── shiro.js │ ├── kick.js │ ├── history.js │ ├── scan.js │ ├── deadchannels.js │ ├── chatsettings.js │ ├── streaminfo.js │ ├── song.js │ ├── notify.js │ ├── user.js │ ├── nuke.js │ ├── channel.js │ ├── xd.js │ ├── esearch.js │ ├── suggest.js │ ├── epicgames.js │ └── transform.js └── misc │ ├── connections.js │ ├── commands.js │ ├── pubsubEvents.js │ ├── handler.js │ └── pubsub.js ├── LICENSE ├── README.md ├── package.json ├── config_template.json ├── rollup.config.js └── schema.sql /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | config.json 3 | package-lock.json 4 | web/public/build/ 5 | -------------------------------------------------------------------------------- /web/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0Supa/okeybot/HEAD/web/public/logo.png -------------------------------------------------------------------------------- /web/public/4Head.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0Supa/okeybot/HEAD/web/public/4Head.png -------------------------------------------------------------------------------- /web/public/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0Supa/okeybot/HEAD/web/public/github.png -------------------------------------------------------------------------------- /web/public/spotify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0Supa/okeybot/HEAD/web/public/spotify.png -------------------------------------------------------------------------------- /web/src/main.js: -------------------------------------------------------------------------------- 1 | import App from './App.svelte'; 2 | 3 | const app = new App({ 4 | target: document.body, 5 | }); 6 | 7 | export default app; 8 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [{ 3 | name: "okeybot", 4 | script: "index.js", 5 | "node_args": "--inspect=127.0.0.1:9240" 6 | }] 7 | } 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /web/src/Nav.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | {#each data as nav} 7 |
  • {nav.text}
  • 8 | {/each} 9 | -------------------------------------------------------------------------------- /lib/utils/cooldown.js: -------------------------------------------------------------------------------- 1 | const cooldown = new Set(); 2 | 3 | exports.set = (id, ttl) => { 4 | cooldown.add(id); 5 | setTimeout(() => { 6 | this.delete(id); 7 | }, ttl); 8 | } 9 | 10 | exports.delete = (id) => { 11 | cooldown.delete(id) 12 | } 13 | 14 | exports.has = (id) => { 15 | return cooldown.has(id) 16 | } -------------------------------------------------------------------------------- /lib/commands/donger.js: -------------------------------------------------------------------------------- 1 | const dongers = require('../../data/dongers.json') 2 | 3 | module.exports = { 4 | name: 'donger', 5 | description: 'Random donger', 6 | cooldown: 4, 7 | async execute(client, msg, utils) { 8 | const donger = utils.randArray(dongers); 9 | return { text: donger, reply: true }; 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /lib/commands/%.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: '%', 3 | description: 'Random precentage between 0% and 100%', 4 | cooldown: 3, 5 | preview: "https://i.nuuls.com/ctb5R.png", 6 | execute(client, msg, utils) { 7 | const procent = Math.floor(Math.random() * 100); 8 | return { text: `${procent}%`, reply: true } 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /lib/commands/neko.js: -------------------------------------------------------------------------------- 1 | const got = require('got') 2 | 3 | module.exports = { 4 | name: 'neko', 5 | aliases: ['ayaya'], 6 | description: "Random anime neko picture", 7 | cooldown: 3, 8 | async execute(client, msg, utils) { 9 | const data = await got('https://nekos.life/api/v2/img/neko').json() 10 | return { text: data.url, reply: true } 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /lib/commands/clear.js: -------------------------------------------------------------------------------- 1 | const twitchapi = require('../utils/twitchapi.js') 2 | 3 | module.exports = { 4 | name: 'clear', 5 | description: 'Clears the chat 50 times', 6 | access: 'mod', 7 | botRequires: 'mod', 8 | cooldown: 10, 9 | async execute(client, msg, utils) { 10 | for (let xd = 0; xd < 50; xd++) { 11 | twitchapi.clearChat(msg.channel.id) 12 | } 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /lib/commands/sql.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | 3 | module.exports = { 4 | name: 'sql', 5 | async execute(client, msg, utils) { 6 | if (msg.user.id !== config.owner.userId) return; 7 | 8 | const query = await utils.query(msg.args.join(' ')) 9 | console.log(query) 10 | return { text: `length: ${query.length} | affected rows: ${query.affectedRows || "N/A"}` } 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /lib/commands/tags.js: -------------------------------------------------------------------------------- 1 | const { paste } = require("../utils/twitchapi.js") 2 | 3 | module.exports = { 4 | name: 'tags', 5 | cooldown: 3, 6 | async execute(client, msg, utils) { 7 | const sTags = Object.entries(msg.tags).map(([k, v]) => `${k}: ${v}`).join('\n') 8 | const text = `${msg.user.login}: ${msg.text}\n\n${sTags}` 9 | 10 | return { text: await paste(text, true), reply: true } 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /lib/utils/regex.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | invisChars: new RegExp(/[\u034f\u2800\u{E0000}\u180e\ufeff\u2000-\u200d\u206D\uDC00\uDB40]/gu), 3 | racism: new RegExp(/(?:(?:\b(??@[\]^_`{|}~]/g) 6 | } 7 | -------------------------------------------------------------------------------- /lib/commands/dadjoke.js: -------------------------------------------------------------------------------- 1 | const got = require('got'); 2 | 3 | module.exports = { 4 | name: 'dadjoke', 5 | description: 'Random dad joke', 6 | cooldown: 3, 7 | preview: "https://i.nuuls.com/x82Tr.png", 8 | async execute(client, msg, utils) { 9 | const { joke } = await got("https://icanhazdadjoke.com/").json() 10 | if (!msg.args.length) return { text: joke } 11 | else return { text: `${msg.args.join(' ')}, ${joke}` } 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /lib/commands/yourmom.js: -------------------------------------------------------------------------------- 1 | const data = require('../../data/mom_jokes.json').data 2 | 3 | module.exports = { 4 | name: 'yourmom', 5 | description: 'Random mom joke 😹', 6 | aliases: ['momjoke'], 7 | cooldown: 2, 8 | usage: "[username]", 9 | execute(client, msg, utils) { 10 | const joke = utils.randArray(data); 11 | if (!msg.args.length) return { text: joke } 12 | return { text: `${msg.args.join(' ')}, ${joke}` } 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /lib/commands/feelsgoodman.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | 3 | module.exports = { 4 | name: 'feelsgoodman', 5 | async execute(client, msg, utils) { 6 | if (msg.user.id !== config.owner.userId && msg.user.login !== 'kazimir33') return 7 | 8 | let res = '' 9 | for (let xd = 1; xd <= 500; xd++) { 10 | res += String.fromCharCode(Math.floor(Math.random() * 1114111)) 11 | } 12 | return { text: res } 13 | }, 14 | }; -------------------------------------------------------------------------------- /web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Okeybot 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /lib/commands/fill.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'fill', 3 | description: 'Fills the entire chat input with your specified phrase', 4 | cooldown: 10, 5 | botRequires: 'vip', 6 | usage: "", 7 | async execute(client, msg, utils) { 8 | if (!msg.args.length) return { text: `FeelsDankMan`, reply: true } 9 | let arr = '' 10 | const base = msg.args.join(' ').replace('!', 'ǃ') 11 | 12 | while (arr.length + base.length + 1 < 485) arr += base.repeat(1) + ' ' 13 | 14 | return { text: arr } 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /lib/commands/pardon.js: -------------------------------------------------------------------------------- 1 | const twitchapi = require('../utils/twitchapi.js'); 2 | 3 | module.exports = { 4 | name: 'pardon', 5 | access: 'mod', 6 | botRequires: 'mod', 7 | cooldown: 20, 8 | async execute(client, msg, utils) { 9 | const users = JSON.parse((await utils.redis.get(`ob:channel:nuke:${msg.channel.id}`))) 10 | if (!users) return { text: `no nuke to reverse :\\`, reply: true } 11 | 12 | const userCount = users.length 13 | for (let i = 0; i < userCount; i++) { 14 | twitchapi.unbanUser(msg.channel.id, users[i]) 15 | } 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /lib/utils/constants.json: -------------------------------------------------------------------------------- 1 | { 2 | "fakeUA": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:104.0) Gecko/20100101 Firefox/104.0", 3 | "clientId": "kimne78kx3ncx6brgo4mv6wki5h1ko", 4 | "operationHashes": { 5 | "ChatRoomState": "04cc4f104a120ea0d9f9d69be8791233f2188adf944406783f0c3a3e71aff8d2", 6 | "EmoteCard": "a05d2613c6601717c6feaaa85469d4fd7d761eafbdd4c1e4e8fdea961bd9617f", 7 | "MessageBufferChatHistory": "323028b2fa8f8b5717dfdc5069b3880a2ad4105b168773c3048275b79ab81e2f", 8 | "CommunityTab": "2e71a3399875770c1e5d81a9774d9803129c44cf8f6bad64973aa0d239a88caf" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /lib/commands/botinfo.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | 3 | module.exports = { 4 | name: 'botinfo', 5 | description: 'Basic bot details', 6 | aliases: ['bot', 'info', 'okeybot', 'okey_bot'], 7 | cooldown: 10, 8 | execute(client, msg, utils) { 9 | return { 10 | text: `the bot provides a variety of fun and utility commands, if you have any questions use the "${msg.prefix}suggest" command | if you want the bot added in your channel use the "${msg.prefix}addbot" command | Command list: ${config.website.url}/commands`, 11 | reply: true 12 | } 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /lib/commands/tts.js: -------------------------------------------------------------------------------- 1 | const tts = require('../utils/tts.js') 2 | 3 | module.exports = { 4 | name: 'tts', 5 | description: 'Brian TTS message with your specified phrase', 6 | cooldown: 5, 7 | usage: "", 8 | async execute(client, msg, utils) { 9 | if (!msg.args.length) return { text: `you need to specify a message`, reply: true } 10 | const url = await tts.polly(msg.args.join(' '), "Brian") 11 | .catch((error) => { 12 | return { text: `error: ${error.message}`, reply: true } 13 | }); 14 | return { text: `${url} 🗣`, reply: true } 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /lib/commands/eval.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const { paste } = require('../utils/twitchapi.js') 3 | 4 | module.exports = { 5 | name: 'eval', 6 | async execute(client, msg, utils) { 7 | if (msg.user.id !== config.owner.userId) return 8 | 9 | try { 10 | const result = await eval('(async () => {' + msg.args.join(' ') + '})()') 11 | const textOutput = String(result) 12 | return { text: textOutput.length > 300 ? await paste(textOutput) : `➡ ${textOutput}` } 13 | } catch (err) { 14 | return { text: `⚠ ${err}` } 15 | } 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /lib/commands/tenor.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const got = require('got'); 3 | 4 | module.exports = { 5 | name: 'tenor', 6 | description: 'Search a gif on Tenor', 7 | aliases: ['gif'], 8 | cooldown: 3, 9 | usage: "", 10 | async execute(client, msg, utils) { 11 | if (!msg.args.length) return { text: `you need to specify a phrase to search`, reply: true } 12 | const { results: gifs } = await got(`https://g.tenor.com/v1/search?q=${encodeURIComponent(msg.args.join(' '))}&key=${config.auth.tenor}&limit=1`).json() 13 | return { text: gifs[0].url, reply: true } 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /lib/commands/wp.js: -------------------------------------------------------------------------------- 1 | const got = require('got'); 2 | 3 | module.exports = { 4 | name: 'wp', 5 | access: 'mod', 6 | botRequires: 'vip', 7 | cooldown: 3, 8 | async execute(client, msg, utils) { 9 | for (let i = 0; i < 3; i++) { 10 | try { 11 | const data = await got.post('https://api.waifu.pics/many/sfw/waifu', { 12 | json: { "exclude": [] } 13 | }).json() 14 | 15 | for (const url of data.files) 16 | msg.send(url) 17 | } catch (e) { 18 | console.error(e) 19 | } 20 | } 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /lib/commands/8ball.js: -------------------------------------------------------------------------------- 1 | const answers = ["as I see it, yes.", "ask again later.", "better not tell you now.", "cannot predict now.", "concentrate and ask again.", 2 | "don’t count on it.", "it is certain.", "it is decidedly so.", "most likely.", "my reply is no.", "my sources say no.", 3 | "outlook not so good.", "outlook good.", "reply hazy, try again.", "signs point to yes.", "very doubtful.", "without a doubt.", 4 | "yes.", "yes – definitely.", "you may rely on it."] 5 | 6 | module.exports = { 7 | name: '8ball', 8 | description: 'Magic fortune-telling 8 Ball', 9 | cooldown: 3, 10 | execute(client, msg, utils) { 11 | return { text: utils.randArray(answers), reply: true } 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /lib/commands/confusables.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'confusables', 3 | description: 'Find similar looking unicode characters', 4 | aliases: ['cf'], 5 | cooldown: 5, 6 | usage: '', 7 | async execute(client, msg, utils) { 8 | if (!msg.args.length) return { text: 'you need to specify the character to find confusables', reply: true } 9 | 10 | const data = await utils.query(`SELECT conf FROM confusables WHERE \`char\` = BINARY ?`, [msg.args[0]]) 11 | if (!data.length) return { text: 'no confusables found', reply: true } 12 | 13 | const conf = data.map(x => x.conf) 14 | return { text: `${msg.args[0]} → ${conf.join(', ')}`, reply: true } 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /lib/commands/geoip.js: -------------------------------------------------------------------------------- 1 | const got = require('got') 2 | 3 | module.exports = { 4 | name: 'geoip', 5 | description: "Lookup an IP address", 6 | preview: "https://i.nuuls.com/LCAV1.png", 7 | cooldown: 5, 8 | aliases: ['lookup', 'whois'], 9 | usage: '', 10 | async execute(client, msg, utils) { 11 | const geo = await got(`http://ip-api.com/json/${msg.args[0]}`).json() 12 | if (geo.status !== 'success') return { text: `${geo.message || "an unexpected error occurred"}`, reply: true } 13 | return { text: `${utils.flag(geo.country) ?? geo.country} (${geo.countryCode}) • ${geo.regionName} • ${geo.city} | ZIP: ${geo.zip}, ISP: ${geo.isp}, ORG: ${geo.org}, ASN: ${geo.as}`, reply: true } 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /lib/commands/math.js: -------------------------------------------------------------------------------- 1 | const got = require('got') 2 | 3 | module.exports = { 4 | name: 'math', 5 | description: 'Solves your math expression', 6 | cooldown: 3, 7 | usage: '', 8 | async execute(client, msg, utils) { 9 | if (!msg.args.length) return { text: 'you need to supply a math expression', reply: true, error: true } 10 | 11 | const { body: data } = await got.post("https://api.mathjs.org/v4", { 12 | throwHttpErrors: false, 13 | responseType: 'json', 14 | json: { 15 | expr: msg.args.join(' ') 16 | } 17 | }) 18 | 19 | if (data.error) return { text: `❌ ${data.error}`, reply: true } 20 | return { text: data.result, reply: true } 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /lib/commands/funfact.js: -------------------------------------------------------------------------------- 1 | const got = require('got') 2 | 3 | module.exports = { 4 | name: 'funfact', 5 | description: 'Random fun fact', 6 | aliases: ['omgscoots'], 7 | cooldown: 4, 8 | async execute(client, msg, utils) { 9 | const year = r(2017, new Date().getFullYear()) 10 | const randomDate = new Date(r(1, 12), year) 11 | 12 | const data = await got(`https://uselessfacts.net/api/posts?d=${encodeURIComponent(randomDate.toJSON())}`).json() 13 | if (!data.length) return { text: 'no fun facts found', reply: true } 14 | const fact = utils.randArray(data); 15 | return { text: fact.title, reply: true } 16 | }, 17 | }; 18 | 19 | function r(min, max) { 20 | return Math.floor(Math.random() * (max - min + 1) + min); 21 | } 22 | -------------------------------------------------------------------------------- /lib/commands/query.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const got = require('got') 3 | 4 | module.exports = { 5 | name: 'query', 6 | description: "Wolfram|Alpha query", 7 | cooldown: 15, 8 | usage: "", 9 | async execute(client, msg, utils) { 10 | if (!msg.args.length) return { text: 'you need to supply a query', reply: true, error: true } 11 | 12 | const { body: res } = await got(`https://api.wolframalpha.com/v1/result`, 13 | { 14 | throwHttpErrors: false, 15 | searchParams: { 16 | appid: config.auth.wolfram, 17 | i: msg.args.join(' ') 18 | } 19 | }) 20 | 21 | return { text: res, reply: true } 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /lib/commands/split.js: -------------------------------------------------------------------------------- 1 | const regex = require('../utils/regex.js') 2 | 3 | module.exports = { 4 | name: 'split', 5 | description: 'Split every argument in a different message', 6 | access: 'vip', 7 | botRequires: 'vip', 8 | cooldown: 10, 9 | usage: "", 10 | async execute(client, msg, utils) { 11 | if (msg.args.length < 3 || msg.args.length > 100) return { text: `the maximum split size is 100, and the minimum 3`, reply: true } 12 | 13 | const message = msg.text.replace('!', 'ǃ').replace('=', '꓿').split(' ').slice(1) 14 | if (regex.racism.test(message)) return { text: "the split message violates an internal banphrase", reply: true } 15 | 16 | for (const split of message) { 17 | await msg.send(split); 18 | } 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /lib/commands/mods.js: -------------------------------------------------------------------------------- 1 | const { paste, getMods, getVips } = require('../utils/twitchapi.js') 2 | 3 | module.exports = { 4 | name: 'mods', 5 | aliases: ['vips'], 6 | cooldown: 5, 7 | async execute(client, msg, utils) { 8 | const user = msg.args[0] ? msg.args[0].replace('@', '').toLowerCase() : msg.channel.login 9 | 10 | let data = [] 11 | if (msg.commandName === 'mods') 12 | data = await getMods(user, true) 13 | else 14 | data = await getVips(user, true) 15 | 16 | if (!data.length) return { text: `channel has no ${msg.commandName}`, reply: true } 17 | 18 | return { text: `there are currently ${data.length} ${msg.commandName} in ${user === msg.channel.login ? 'this' : 'that'} channel: ${await paste(JSON.stringify(data, null, 4))}`, reply: true } 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /lib/commands/boobatv.js: -------------------------------------------------------------------------------- 1 | const got = require('got'); 2 | const { nanoid } = require('nanoid'); 3 | 4 | module.exports = { 5 | name: 'boobatv', 6 | description: "Random steamer from booba.tv", 7 | aliases: ['booba'], 8 | cooldown: 5, 9 | async execute(client, msg, utils) { 10 | const boobas = await got('https://api.booba.tv/').json() 11 | if (!boobas?.length) return { text: "no channels available at the moment", reply: true } 12 | 13 | const booba = utils.randArray(boobas) 14 | const userTag = `@${booba.user_display_name.toLowerCase() === booba.user_login ? booba.user_display_name : booba.user_login}` 15 | 16 | return { text: `${userTag} • ${booba.stream_viewer_count} viewers https://static-cdn.jtvnw.net/previews-ttv/live_user_${booba.user_login}.jpg?${nanoid(4)}`, reply: true } 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /lib/commands/avatar.js: -------------------------------------------------------------------------------- 1 | const got = require('got') 2 | const twitchapi = require('../utils/twitchapi.js') 3 | 4 | module.exports = { 5 | name: 'avatar', 6 | description: 'Avatar of a Twitch user, supports 7TV', 7 | cooldown: 4, 8 | aliases: ['pfp', 'av'], 9 | usage: "[username | userid]", 10 | async execute(client, msg, utils) { 11 | const user = await twitchapi.getUser(msg.args[0] ? msg.args[0].replace('@', '') : msg.user.login) 12 | if (!user) return { text: `couldn't resolve the user provided`, reply: true } 13 | 14 | const stv = await got(`https://7tv.io/v3/users/twitch/${user.id}`, { throwHttpErrors: false }).json() 15 | const stvPFP = stv.user?.avatar_url ?? "" 16 | 17 | return { text: `imGlitch ${user.logo}${stvPFP.includes('cdn.7tv.app') ? ` • (7TV) https:${stvPFP}` : ''}`, reply: true } 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /web/src/routes/Spotify.svelte: -------------------------------------------------------------------------------- 1 | 2 | Spotify / Okeybot 3 | 4 | 5 | 12 | 13 | 39 | -------------------------------------------------------------------------------- /lib/commands/botsubs.js: -------------------------------------------------------------------------------- 1 | const { ivr } = require('../utils/twitchapi.js') 2 | const { randArray } = require('../utils/utils.js') 3 | const ignoredSets = ['0', '300374282'] 4 | 5 | module.exports = { 6 | name: 'botsubs', 7 | aliases: ['emotesets'], 8 | description: 'All the sub emotes from Twitch channels where the bot is currently subscribed', 9 | cooldown: 5, 10 | async execute(client, msg, utils) { 11 | const emoteSets = client.userStateTracker.globalState.emoteSets.filter(e => !ignoredSets.includes(e)) 12 | if (!emoteSets.length) return { text: 'the bot is not subscribed to any channels FeelsBadMan', reply: true } 13 | 14 | const { body: data } = await ivr(`emotes/sets?set_id=${emoteSets.join()}`) 15 | const emotes = data.map(data => randArray(data.emoteList).code) 16 | 17 | return { text: emotes.join(' '), reply: true } 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /lib/commands/steam.js: -------------------------------------------------------------------------------- 1 | const cheerio = require('cheerio'); 2 | const got = require('got'); 3 | 4 | module.exports = { 5 | name: 'steam', 6 | description: 'Search a game on Steam', 7 | cooldown: 3, 8 | usage: "", 9 | async execute(client, msg, utils) { 10 | if (!msg.args.length) return { text: `you need to provide a game name to search`, reply: true } 11 | const game = msg.args.join(' ') 12 | let body = await got(`https://store.steampowered.com/search/results?term=${encodeURIComponent(game)}`).text() 13 | body = body.split('').pop().split('')[0] 14 | const $ = cheerio.load(body); 15 | const games = $('a[href]') 16 | if (!games.length) return { text: `no games found`, reply: true } 17 | const url = games[0].attribs.href.split('?')[0] 18 | return { text: url, reply: true } 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /lib/commands/mode.js: -------------------------------------------------------------------------------- 1 | const modes = { 2 | 0: "disabled", 3 | 1: "default", 4 | 2: "offline-only", 5 | } 6 | 7 | module.exports = { 8 | name: 'mode', 9 | description: 'Change the bot mode for the current channel (offline-only)', 10 | access: 'mod', 11 | cooldown: 10, 12 | usage: "", 13 | extended: ` 14 |
      ${Object.values(modes).map(v => `
    1. ${v}
    2. `).join('')}
    15 | Note: Mods can still run commands under any channel mode 16 | `, 17 | async execute(client, msg, utils) { 18 | const mode = parseInt(msg.args[0]) 19 | if (!modes[mode]) 20 | return { text: `you need to specify a valid mode type, check "${msg.prefix}help mode" for more info`, reply: true } 21 | 22 | await utils.change(msg.channel.id, 'bot_mode', mode, msg.channel.query) 23 | return { text: `✅ The channel mode has been successfully set to ${mode}: ${modes[mode]}` } 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /lib/commands/say.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | 3 | module.exports = { 4 | name: 'say', 5 | aliases: ['echo'], 6 | async execute(client, msg, utils) { 7 | if (msg.user.id !== config.owner.userId) return 8 | if (!msg.args.length) return { text: 'invalid usage' } 9 | 10 | try { 11 | switch (msg.commandName) { 12 | case "say": { 13 | if (msg.args.length < 2) return { text: 'invalid usage' } 14 | await client.privmsg(msg.args[0].toLowerCase(), msg.args.slice(1).join(' ')) 15 | return { text: 'BroBalt' } 16 | } 17 | case "echo": { 18 | await client.privmsg(msg.channel.login, msg.args.join(' ')) 19 | } 20 | } 21 | } catch (e) { 22 | console.error(e) 23 | return { text: `monkaS error: ${e.message}` } 24 | } 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /lib/misc/connections.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const mariadb = require('mariadb') 3 | const Redis = require("ioredis"); 4 | const { ChatClient, AlternateMessageModifier, SlowModeRateLimiter } = require("dank-twitch-irc"); 5 | 6 | const client = new ChatClient({ 7 | username: config.bot.login, 8 | password: config.auth.twitch.gql.token ?? config.auth.twitch.helix.token, 9 | rateLimits: 'verifiedBot', 10 | ignoreUnhandledPromiseRejections: true 11 | }); 12 | 13 | client.use(new AlternateMessageModifier(client)); 14 | client.use(new SlowModeRateLimiter(client, 5)); 15 | client.connect() 16 | 17 | const pool = mariadb.createPool({ 18 | user: config.auth.database.user, 19 | password: config.auth.database.pass, 20 | database: config.auth.database.name, 21 | host: config.auth.database.host, 22 | connectionLimit: config.auth.database.connectionLimit 23 | }); 24 | 25 | const redis = new Redis(); 26 | 27 | module.exports = { client, pool, redis }; 28 | -------------------------------------------------------------------------------- /lib/commands/announce.js: -------------------------------------------------------------------------------- 1 | const regex = require('../utils/regex.js'); 2 | const twitchapi = require('../utils/twitchapi.js'); 3 | const colors = ['blue', 'green', 'orange', 'purple', 'primary'] 4 | 5 | module.exports = { 6 | name: 'announce', 7 | description: 'Spam a Twitch announcement in chat with all the available colors', 8 | access: 'mod', 9 | botRequires: 'mod', 10 | cooldown: 15, 11 | usage: "", 12 | aliases: ['ann'], 13 | async execute(client, msg, utils) { 14 | const usage = `usage: ${msg.prefix}announce yo TriHard` 15 | 16 | if (!msg.args.length) return { text: usage, reply: true, error: true } 17 | 18 | const phrase = msg.args.join(' ').replace('!', 'ǃ').replace('=', '꓿').replace('$', '💲') 19 | if (regex.racism.test(phrase)) return { text: "the announcement violates an internal banphrase", reply: true } 20 | 21 | for (const color of colors) 22 | twitchapi.announceMessage(msg.channel.id, phrase, color) 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /lib/commands/randclip.js: -------------------------------------------------------------------------------- 1 | const { helix } = require('../utils/twitchapi.js') 2 | 3 | module.exports = { 4 | name: 'randclip', 5 | description: 'Random Twitch clip from the top 100 clips of the current channel', 6 | aliases: ['rc'], 7 | cooldown: 7, 8 | async execute(client, msg, utils) { 9 | let clips; 10 | const cacheData = await utils.redis.get(`ob:channel:clips:${msg.channel.id}`) 11 | 12 | if (cacheData) { 13 | clips = JSON.parse(cacheData) 14 | } else { 15 | const { body } = await helix.get(`clips?broadcaster_id=${msg.channel.id}&first=100`) 16 | clips = body.data.map(clip => clip.url) 17 | if (!clips.length) return { text: 'this channel has no clips', reply: true } 18 | 19 | utils.redis.set(`ob:channel:clips:${msg.channel.id}`, JSON.stringify(clips), "EX", 86400) 20 | } 21 | 22 | const randomClip = utils.randArray(clips) 23 | return { text: randomClip, reply: true } 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /lib/commands/mostsent.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | 3 | module.exports = { 4 | name: 'mostsent', 5 | description: 'Most sent message of an user in the current channel', 6 | aliases: ['msm'], 7 | cooldown: 5, 8 | async execute(client, msg, utils) { 9 | let user = msg.user.login 10 | if (msg.args.length) { 11 | user = msg.args[0].toLowerCase().replace('@', '') 12 | if (user === config.bot.login) return { text: 'Stare', reply: true } 13 | } 14 | const query = await utils.query(`SELECT message, COUNT(message) AS message_count FROM messages WHERE user_login=? AND channel_id=? GROUP BY message ORDER BY message_count DESC LIMIT 1`, [user, msg.channel.id]) 15 | if (!query.length) return { text: "that user has not said anything in this channel", reply: true } 16 | return { text: `${user === msg.user.login ? 'your' : "that user's"} most sent message (${query[0].message_count} times) is: ${query[0].message}`, reply: true } 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /lib/commands/prefix.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'prefix', 3 | description: 'Change the bot prefix for the current channel', 4 | aliases: ['prefix', 'botprefix'], 5 | access: 'mod', 6 | cooldown: 5, 7 | usage: "", 8 | async execute(client, msg, utils) { 9 | if (!msg.args.length) return { text: `you need to specify the prefix`, reply: true } 10 | const prefix = msg.args[0].toLowerCase() 11 | if (prefix.length > 15) return { text: `prefix is too long, the maximum length is 15 characters`, reply: true } 12 | if (msg.prefix === prefix) return { text: `the channel prefix is already set to "${prefix}"`, reply: true } 13 | if (prefix.startsWith('.') || prefix.startsWith('/')) return { text: `prefix not set, this character is reserved for twitch commands`, reply: true } 14 | 15 | await utils.change(msg.channel.id, 'prefix', prefix, msg.channel.query) 16 | return { text: `✅ The channel prefix has been successfully set to "${prefix}"` } 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /lib/commands/help.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const commands = require('../misc/commands.js') 3 | 4 | module.exports = { 5 | name: 'help', 6 | description: 'Lookup bot commands', 7 | aliases: ['commands', 'cmds'], 8 | cooldown: 4, 9 | usage: '[cmd name]', 10 | execute(client, msg, utils) { 11 | let text = `command list: ${config.website.url}/commands` 12 | 13 | if (msg.args.length) { 14 | const commandName = msg.args[0].toLowerCase() 15 | const command = commands.get(commandName) 16 | 17 | if (command && client.knownCommands.includes(command.name)) { 18 | text = `${command.name}${command.aliases.length ? ` (${command.aliases.join(', ')})` : ''} • ${command.description}, cooldown: ${command.cooldown ?? '0'}s, access: ${command.access ?? `everyone`} | More details: ${config.website.url}/commands/${encodeURIComponent(command.name)}` 19 | } 20 | } 21 | 22 | return { text, reply: true } 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /lib/commands/firstmsg.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | 3 | module.exports = { 4 | name: 'firstmsg', 5 | description: 'First seen message of an user in the current channel', 6 | aliases: ['fm', 'fl', 'firstline'], 7 | cooldown: 5, 8 | usage: '[username]', 9 | async execute(client, msg, utils) { 10 | if (!msg.channel.query.logging) return { text: `this channel has message logging disabled, contact ${config.owner.login} for more info`, reply: true } 11 | const user = msg.args.length ? msg.args[0].replace('@', '') : msg.user.login 12 | const query = await utils.query(`SELECT message, timestamp FROM messages WHERE user_login=? AND channel_id=? ORDER BY id LIMIT 1`, [user, msg.channel.id]) 13 | if (!query.length) return { text: 'that user has not said anything in this channel', reply: true } 14 | return { text: `${user === msg.user.login ? 'your' : "that user's"} first seen message was sent ${utils.humanize(query[0].timestamp)} ago: ${query[0].message}`, reply: true } 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /lib/commands/dex.js: -------------------------------------------------------------------------------- 1 | const got = require('got'); 2 | 3 | module.exports = { 4 | name: 'dex', 5 | description: 'Search a word in the Romanian dictionary, dexonline.ro', 6 | aliases: ['dexonline'], 7 | cooldown: 7, 8 | async execute(client, msg, utils) { 9 | if (!msg.args.length) return { text: `you need to provide a word to search`, reply: true } 10 | 11 | const searchWord = msg.args[0] 12 | const { body: data } = await got(`https://dexonline.ro/definitie/${encodeURIComponent(searchWord)}/json`, { responseType: "json", throwHttpErrors: false }) 13 | if (!data.definitions.length) return { text: 'niciun rezultat gasit', reply: true } 14 | 15 | let redirected = false 16 | if (searchWord.toLowerCase() !== data.word.toLowerCase()) redirected = true 17 | 18 | const def = data.definitions[0] 19 | return { text: `${redirected ? `[${searchWord} → ${data.word}]` : `[${data.word}]`} (Sursa: ${def.sourceName ?? "N/A"}): ${def.htmlRep.replace(/<[^>]*>?/gm, '')}`, reply: true } 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /lib/commands/uberduck.js: -------------------------------------------------------------------------------- 1 | const uberduck = require('../utils/uberduck.js') 2 | 3 | module.exports = { 4 | name: 'uberduck', 5 | description: 'Generate an uberduck TTS message', 6 | extended: 'Uberduck voice options', 7 | cooldown: 20, 8 | usage: " ", 9 | async execute(client, msg, utils) { 10 | if (msg.args.length < 2) return { text: `usage: ${msg.prefix}${this.name} ${this.usage}`, reply: true, error: true } 11 | 12 | const voice = msg.args[0].toLowerCase() 13 | const text = msg.args.slice(1).join(' ') 14 | try { 15 | const uuid = await uberduck.queue(voice, text) 16 | const res = await uberduck.getResult(uuid) 17 | const time = Date.parse(res.finished_at) - Date.parse(res.started_at) 18 | return { text: `${res.path} | ⌛ Your TTS message took ${utils.humanizeMS(time)} to finish`, reply: true } 19 | } catch (err) { 20 | return { text: `❌ ${err}`, reply: true } 21 | } 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 supa.codes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/utils/uberduck.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const auth = Buffer.from(`${config.auth.uberduck.key}:${config.auth.uberduck.secret}`).toString('base64') 3 | const got = require('got') 4 | const utils = require('./utils.js') 5 | 6 | async function check(uuid) { 7 | const { body: res } = await got(`https://api.uberduck.ai/speak-status?uuid=${uuid}`, { responseType: 'json' }) 8 | return res 9 | } 10 | 11 | exports.getResult = async (uuid) => { 12 | while (true) { 13 | await utils.sleep(1000) 14 | const result = await check(uuid) 15 | if (result.path) return result 16 | if (Date.parse(result.started_at) > Date.now() - 600000) throw "Your TTS result was thrown because it didn't return in less than 10 minutes :P" 17 | } 18 | } 19 | 20 | exports.queue = async (voice, speech) => { 21 | const { body: res } = await got.post('https://api.uberduck.ai/speak', { 22 | throwHttpErrors: false, 23 | responseType: 'json', 24 | headers: { Authorization: `Basic ${auth}` }, 25 | json: { voice, speech } 26 | }) 27 | if (!res.uuid) throw res.detail || "Unknown error" 28 | return res.uuid 29 | } 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![Okey](https://cdn.frankerfacez.com/emoticon/275054/1) Okey BOT 2 | 3 | ![MEGADANK](https://cdn.frankerfacez.com/emoticon/239630/1) Twitch chatbot made by [Supa](https://www.twitch.tv/8supa) 4 | 5 | ## I just want the bot added in my Twitch chat 6 | 7 | You can use the **?addbot** command in the [bot's chat](https://www.twitch.tv/popout/okey_bot/chat) 8 | _or you can contact [8supa](https://www.twitch.tv/8supa) through a whisper_ 9 | 10 | ## Issues/Suggestions 11 | 12 | You can create a [GitHub Issue](https://github.com/0Supa/okeybot/issues) 13 | or you can use the **?suggest** command followed by the suggestion in the [bot's chat](https://www.twitch.tv/popout/okey_bot/chat) 14 | 15 | ## Installation 16 | 17 | - `git clone https://github.com/0Supa/okeybot.git` 18 | - `cd okeybot` 19 | - `npm install` 20 | - `npm run build` 21 | - *this builds the needed files for the web interface in `./web/public/build`* 22 | - make a copy of `config_template.json` named `config.json` and add your credentials into it 23 | - **the bot requires a database, you'll either need to create a MariaDB Database and import the tables from the [schema](schema.sql), or adjust the code yourself** 24 | -------------------------------------------------------------------------------- /lib/commands/spam.js: -------------------------------------------------------------------------------- 1 | const regex = require('../utils/regex.js') 2 | 3 | module.exports = { 4 | name: 'spam', 5 | description: 'Spam a message in chat', 6 | access: 'mod', 7 | botRequires: 'vip', 8 | cooldown: 30, 9 | usage: " ", 10 | async execute(client, msg, utils) { 11 | const usage = `usage: ${msg.prefix}spam 10 yo TriHard` 12 | 13 | if (msg.args.length < 2) return { text: usage, reply: true, error: true } 14 | 15 | const count = msg.args[0] 16 | const phrase = msg.args.slice(1).join(' ').replace('!', 'ǃ').replace('=', '꓿').replace('$', '💲') 17 | if (isNaN(count)) return { text: `the spam count should be a number, ${usage}`, reply: true, error: true } 18 | if (count > 100) return { text: `the maximum spam count is 100`, reply: true, error: true } 19 | if (count < 2) return { text: `the minimum spam count is 2`, reply: true, error: true } 20 | 21 | if (regex.racism.test(phrase)) return { text: "the spam message violates an internal banphrase", reply: true } 22 | 23 | for (let xd = 0; xd < count; xd++) { 24 | msg.send(phrase) 25 | } 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /lib/commands/clip.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const got = require('got') 3 | 4 | const cooldown = new Set() 5 | module.exports = { 6 | name: 'clip', 7 | description: 'Create a 30 second clip of your desired Twitch stream', 8 | cooldown: 10, 9 | aliases: ['preview'], 10 | usage: "[username]", 11 | async execute(client, msg, utils) { 12 | const channelName = msg.args[0] ? msg.args[0].replace('@', '').toLowerCase() : msg.channel.login 13 | const key = `${channelName}-${msg.channel.id}` 14 | 15 | if (cooldown.has(key) && msg.user.id !== config.owner.userId) return 16 | 17 | cooldown.add(key) 18 | setTimeout(() => { 19 | cooldown.delete(key) 20 | }, 15_000); 21 | 22 | const res = await got.post(`http://127.0.0.1:8989/clip/${encodeURIComponent(channelName)}`, { throwHttpErrors: false }).json() 23 | if (res.error || !res.file) { 24 | cooldown.delete(key) 25 | return { text: `error: ${res.message || res.error || "unknown"}`, reply: true } 26 | } 27 | 28 | return { text: `https://clips.supa.sh/${res.file}`, reply: true } 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /lib/commands/stalk.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | 3 | module.exports = { 4 | name: 'stalk', 5 | description: 'Last seen chat message of an user', 6 | cooldown: 5, 7 | aliases: ['lastseen', 'ls'], 8 | usage: "", 9 | async execute(client, msg, utils) { 10 | if (!msg.args.length) return { text: 'you need to specify an username', reply: true } 11 | const user = msg.args[0].toLowerCase().replace('@', '') 12 | if (user === msg.user.login) return { text: 'you are here 4Head', reply: true } 13 | if (user === config.bot.login) return { text: "I'm here FeelsDankMan", reply: true } 14 | const query = await utils.query(`SELECT message, timestamp, channel_login FROM messages WHERE user_login=? ORDER BY id DESC LIMIT 1`, [user]) 15 | if (!query.length) return { text: "I've never seen that user in chat", reply: true } 16 | return { 17 | text: `that user's last seen message was sent ${utils.humanize(query[0].timestamp)} ago in ${query[0].channel_login === user ? 'their' : `${query[0].channel_login}'s`} chat: ${query[0].message}`, 18 | reply: true 19 | } 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /lib/commands/weather.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const appid = config.auth.openweathermap 3 | const got = require('got'); 4 | const moment = require('moment'); 5 | 6 | module.exports = { 7 | name: 'weather', 8 | description: 'Weather info for a specific location', 9 | cooldown: 4, 10 | usage: "", 11 | async execute(client, msg, utils) { 12 | if (!msg.args.length) return { text: `you need to specify a location`, reply: true } 13 | let w = await got(`http://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(msg.args.join(' '))}&units=metric&appid=${appid}`, { throwHttpErrors: false, responseType: "json" }) 14 | if (w.statusCode === 404) return { text: `couldn't find weather info for this location`, reply: true } 15 | w = w.body 16 | return { 17 | text: `${w.name} ${utils.flag(w.sys.country) ?? `(${w.sys.country})`} • Main: ${w.weather[0].main}, Temperature: ${w.main.temp}°C, Feels like: ${w.main.feels_like}°C, Humidity: ${w.main.humidity}%, Sunrise: ${moment.unix(w.sys.sunrise).format('HH:mm:ss')}, Sunset: ${moment.unix(w.sys.sunset).format('HH:mm:ss')}`, 18 | reply: true 19 | } 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /lib/commands/copypasta.js: -------------------------------------------------------------------------------- 1 | const got = require('got'); 2 | const cheerio = require('cheerio'); 3 | 4 | module.exports = { 5 | name: 'copypasta', 6 | description: 'Random Twitch-related copypasta', 7 | cooldown: 5, 8 | preview: "https://i.nuuls.com/JN7Ev.png", 9 | async execute(client, msg, utils) { 10 | const maxTries = 5 11 | 12 | async function getCopypasta() { 13 | const html = await got("https://www.twitchquotes.com/random").text(); 14 | const $ = cheerio.load(html); 15 | const copypasta = $(`div[id^="clipboard_copy_content"]`).text(); 16 | 17 | return copypasta; 18 | } 19 | 20 | let copypasta; 21 | let tries = 0; 22 | do { 23 | copypasta = await getCopypasta(); 24 | tries++; 25 | } while (copypasta.length > 480 && tries > maxTries); 26 | 27 | if (tries >= maxTries) { 28 | return { 29 | text: `couldn't get a copypasta within ${tries} tries`, 30 | reply: true 31 | }; 32 | } 33 | 34 | return { 35 | text: copypasta || 'no copypasta found', 36 | reply: true 37 | }; 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /lib/utils/tts.js: -------------------------------------------------------------------------------- 1 | const got = require('got'); 2 | const config = require('../../config.json') 3 | 4 | const client = got.extend({ 5 | prefixUrl: 'https://api.supa.codes/tts', 6 | throwHttpErrors: false, 7 | responseType: 'json', 8 | headers: { 9 | 'Authorization': config.auth.tts 10 | } 11 | }); 12 | 13 | module.exports = { 14 | google: function (phrase, lang, speed) { 15 | return new Promise(async (resolve, reject) => { 16 | if (!phrase) return reject('no phrase specified') 17 | const options = `google?text=${encodeURIComponent(phrase)}&lang=${encodeURIComponent(lang) || "en"}&speed=${encodeURIComponent(speed) || "1"}` 18 | const { body: tts } = await client.get(options) 19 | if (tts.error) return reject(tts.error || "N/A") 20 | resolve(tts.url) 21 | }); 22 | }, 23 | polly: function (phrase, voice) { 24 | return new Promise(async (resolve, reject) => { 25 | if (!phrase) return reject('no phrase specified') 26 | const options = `polly?voice=${encodeURIComponent(voice)}&text=${encodeURIComponent(phrase)}` 27 | const { body: tts } = await client.get(options) 28 | if (tts.error) return reject(tts.error || "N/A") 29 | resolve(tts.url) 30 | }); 31 | } 32 | }; -------------------------------------------------------------------------------- /lib/commands/lines.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | 3 | module.exports = { 4 | name: 'lines', 5 | description: 'Logged messages count of an user in the current channel', 6 | cooldown: 5, 7 | usage: '[username]', 8 | async execute(client, msg, utils) { 9 | if (!msg.channel.query.logging) return { text: `this channel has message logging disabled, contact ${config.owner.login} for more info`, reply: true } 10 | const user = msg.args.length ? msg.args[0].replace('@', '') : msg.user.login 11 | 12 | const linesTotal = (await utils.query(`SELECT COUNT(id) AS entries FROM messages WHERE channel_id=? AND user_login=?`, [msg.channel.id, user]))[0].entries 13 | const linesToday = (await utils.query(`SELECT COUNT(id) AS entries FROM messages WHERE timestamp > CURDATE() AND channel_id=? AND user_login=?`, [msg.channel.id, user]))[0].entries 14 | if (!linesTotal) return { text: 'that user has not said anything in this channel', reply: true } 15 | 16 | return { text: `${user === msg.user.login ? 'you have' : 'that user has'} a total of ${utils.formatNumber(linesTotal)} logged messages in this channel, messages today: ${utils.formatNumber(linesToday)}`, reply: true } 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /web/src/App.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 26 | 27 |
    28 | 29 | 30 | 31 | 32 | 33 |
    34 |
    35 | -------------------------------------------------------------------------------- /lib/commands/findmsg.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | 3 | module.exports = { 4 | name: 'findmsg', 5 | description: 'Find who sent a chat message', 6 | extended: "Supports SQL Wildcards", 7 | cooldown: 5, 8 | aliases: ['find'], 9 | usage: '', 10 | async execute(client, msg, utils) { 11 | if (!msg.channel.query.logging) return { text: `this channel has message logging disabled, contact ${config.owner.login} for more info`, reply: true } 12 | if (!msg.args.length) return { text: `you need to specify the input to search`, reply: true } 13 | 14 | const query = await utils.query(`SELECT id FROM messages WHERE channel_id=? AND message LIKE ?`, [msg.channel.id, msg.args.join(' ')]) 15 | if (!query.length) return { text: "couldn't find a message", reply: true } 16 | const rand = utils.randArray(query) 17 | const message = await getMessage(rand.id) 18 | return { text: `(${utils.humanize(message.timestamp)} ago) ${message.user_login}: ${message.text}`, reply: true } 19 | 20 | async function getMessage(id) { 21 | const message = await utils.query(`SELECT user_login, message AS text, timestamp FROM messages WHERE id=? LIMIT 1`, [id]) 22 | return message[0] 23 | } 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /lib/commands/google.js: -------------------------------------------------------------------------------- 1 | const got = require('got') 2 | const { shortLink } = require('../utils/twitchapi.js'); 3 | 4 | module.exports = { 5 | name: 'google', 6 | description: 'Search anything on Google', 7 | aliases: ['g'], 8 | cooldown: 5, 9 | usage: "", 10 | async execute(client, msg, utils) { 11 | if (!msg.args.length) return { text: `you need to specify a search query`, reply: true } 12 | 13 | const { body: data, statusCode } = await got(`https://searx.supa.codes/search`, { 14 | responseType: 'json', 15 | throwHttpErrors: false, 16 | searchParams: { 17 | format: 'json', 18 | safesearch: 1, 19 | language: 'en-US', 20 | q: msg.args.join(' ') 21 | } 22 | }) 23 | const res = data?.results[0] 24 | 25 | if (statusCode === 404 || !res) return { text: `nothing found ppL`, reply: true } 26 | if (statusCode !== 200) return { text: `bad status code (${statusCode})`, reply: true } 27 | 28 | const prettyLink = res.url === res.pretty_url ? res.url : await shortLink(res.url) 29 | 30 | return { 31 | text: `${res.title} ${prettyLink} • ${res.content}`, 32 | reply: true 33 | } 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /lib/commands/banlist.js: -------------------------------------------------------------------------------- 1 | const twitch = require('../utils/twitchapi.js') 2 | const normalizeUrl = require('normalize-url') 3 | const got = require('got') 4 | 5 | module.exports = { 6 | name: 'banlist', 7 | access: 'mod', 8 | botRequires: 'mod', 9 | cooldown: 20, 10 | usage: " [reason]", 11 | async execute(client, msg, utils) { 12 | if (!msg.args.length) return { text: `usage: ${msg.prefix}${this.name} ${this.usage}`, reply: true } 13 | 14 | const reason = msg.args.slice(1).join(' ') 15 | 16 | let users = [] 17 | try { 18 | const url = new URL(normalizeUrl(msg.args[0])) 19 | const listURL = url.toString() 20 | const res = await got(listURL) 21 | if (res.headers['content-type'] !== 'text/plain; charset=utf-8') return { text: `invalid content-type`, reply: true } 22 | users = res.body.split(/\r?\n/) 23 | } catch (e) { 24 | return { text: `couldn't fetch user list`, reply: true } 25 | } 26 | if (!users.length) return { text: `invalid list`, reply: true } 27 | 28 | for (let i = 0; i < users.length; i++) { 29 | client.privmsg(msg.channel.login, `/ban ${users[i]} ${reason}`) 30 | } 31 | 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /lib/commands/stats.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | 3 | module.exports = { 4 | name: 'stats', 5 | description: 'Current bot channel chat stats', 6 | cooldown: 5, 7 | async execute(client, msg, utils) { 8 | if (!msg.channel.query.logging) return { text: `this channel has message logging disabled, contact ${config.owner.login} for more info`, reply: true } 9 | 10 | const [messages, MPM, mostActive] = await Promise.all([ 11 | utils.query(`SELECT COUNT(id) AS entries FROM messages WHERE channel_id=?`, msg.channel.id), 12 | utils.query(`SELECT COUNT(id) AS entries FROM messages WHERE channel_id=? AND timestamp > DATE_SUB(NOW(),INTERVAL 1 MINUTE)`, msg.channel.id), 13 | utils.query(`SELECT user_login, COUNT(id) AS message_count FROM messages WHERE channel_id=? GROUP BY user_login ORDER BY message_count DESC LIMIT 1`, msg.channel.id) 14 | ]) 15 | const addedOn = new Date(msg.channel.query.added) 16 | 17 | return { text: `the bot was added on ${addedOn.toDateString()}, logged messages: ${utils.formatNumber(messages[0].entries)}, the most active chatter is ${mostActive[0].user_login} with ${utils.formatNumber(mostActive[0].message_count)} messages sent, messages per minute: ${MPM[0].entries}`, reply: true } 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /lib/commands/hug.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const twitchapi = require('../utils/twitchapi.js') 3 | 4 | module.exports = { 5 | name: 'hug', 6 | description: 'Hug somebody 🤗', 7 | cooldown: 3, 8 | usage: "", 9 | async execute(client, msg, utils) { 10 | if (!msg.args.length) return { text: "you need to specify the user you want to hug", reply: true } 11 | 12 | const user = await twitchapi.getUser(msg.args[0].replace('@', '')) 13 | if (!user) return { text: `${msg.user.name} hugs ${msg.args[0]} VirtualHug` } 14 | if (user.id === msg.user.id) return { text: `${msg.user.name} hugs themself FeelsBadMan` } 15 | if (user.id === config.bot.userId) return { text: `MrDestructoid // 🤗`, reply: true } 16 | 17 | let hugs; 18 | const data = await utils.query(`SELECT count FROM hugs WHERE user_id=?`, [user.id]) 19 | 20 | if (!data.length) { 21 | await utils.query(`INSERT INTO hugs (user_id, count) VALUES (?, 1)`, [user.id]) 22 | hugs = 1 23 | } else { 24 | await utils.query(`UPDATE hugs SET count = count + 1 WHERE user_id=?`, [user.id]) 25 | hugs = data[0].count + 1 26 | } 27 | 28 | return { text: `${msg.user.name} hugs ${user.displayName} VirtualHug (${hugs})` } 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /lib/commands/pyramid.js: -------------------------------------------------------------------------------- 1 | const regex = require('../utils/regex.js') 2 | 3 | module.exports = { 4 | name: 'pyramid', 5 | description: 'Creates a text pyramid', 6 | access: 'vip', 7 | botRequires: 'vip', 8 | cooldown: 10, 9 | usage: " ", 10 | async execute(client, msg, utils) { 11 | const usage = `usage: ${msg.prefix}pyramid 5 TriHard` 12 | const size = msg.args[0] 13 | const emote = msg.args.slice(1).join(' ').replace('!', 'ǃ').replace('=', '꓿').replace('$', '💲') + ' ' 14 | 15 | if (msg.args.length < 2) return { text: usage, reply: true, error: true } 16 | if (isNaN(size)) return { text: `size should be a number, ${usage}`, reply: true, error: true } 17 | if (size > 40) return { text: `the maximum size is 40`, reply: true, error: true } 18 | if (size < 2) return { text: `the minimum size is 2`, reply: true, error: true } 19 | 20 | if (regex.racism.test(emote)) return { text: "the pyramid message violates an internal banphrase", reply: true } 21 | 22 | for (let i = 1; i <= size; i++) { 23 | msg.send(emote.repeat(i)); 24 | await utils.sleep(100) 25 | } 26 | 27 | for (let i = (size - 1); i > 0; i--) { 28 | msg.send(emote.repeat(i)); 29 | await utils.sleep(100) 30 | } 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /lib/commands/ignore.js: -------------------------------------------------------------------------------- 1 | const twitchapi = require('../utils/twitchapi.js') 2 | const config = require('../../config.json') 3 | 4 | // Author: lucas19961 5 | module.exports = { 6 | name: 'ignore', 7 | description: "add/remove User from the Ignore-List", 8 | async execute(client, msg, utils) { 9 | if (msg.user.id !== config.owner.userId) return 10 | if (!msg.args.length) return { text: `you need to specify an username to ignore`, reply: true } 11 | 12 | const updateCache = async () => { 13 | const ignoredUsers = (await utils.query('SELECT user_id FROM ignored_users')).map(data => data.user_id) 14 | client.ignoredUsers = new Set(ignoredUsers) 15 | } 16 | 17 | const user = await twitchapi.getUser(msg.args[0].replace('@', '')) 18 | const count = await utils.query(`SELECT COUNT(id) AS query FROM ignored_users WHERE user_id=?`, [user.id]) 19 | if (count[0].query) { 20 | await utils.query(`DELETE FROM ignored_users WHERE user_id=?`, [user.id]) 21 | await updateCache() 22 | return { 23 | text: `user removed from Ignore List`, 24 | reply: true 25 | } 26 | } 27 | 28 | const reason = msg.args.slice(1).join(' ') 29 | await utils.query(`INSERT INTO ignored_users (user_id, reason) VALUES (?, ?)`, [user.id, reason || null]) 30 | await updateCache() 31 | 32 | return { 33 | text: `user added to Ignore List`, 34 | reply: true 35 | } 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /lib/commands/pajbot.js: -------------------------------------------------------------------------------- 1 | const got = require('got') 2 | const normalizeUrl = require('normalize-url') 3 | 4 | module.exports = { 5 | name: 'pajbot', 6 | description: 'Set the channel pajbot banphrase API', 7 | access: 'mod', 8 | cooldown: 5, 9 | usage: "", 10 | async execute(client, msg, utils) { 11 | if (!msg.args.length) return { text: `you need to specify the pajbot website URL ( example: https://pajlada.pajbot.com/ ) *or remove*`, reply: true } 12 | 13 | if (msg.args[0] === 'remove') { 14 | await utils.change(msg.channel.id, 'pajbot_api', null, msg.channel.query) 15 | return { text: `the banphrase API has been successfully removed FeelsOkayMan 👍`, reply: true } 16 | } 17 | 18 | try { 19 | const url = new URL(normalizeUrl(msg.args[0])) 20 | 21 | url.pathname = '/api/v1/banphrases/test' 22 | const urlString = url.toString() 23 | 24 | await got.post(urlString, { 25 | responseType: 'json', 26 | json: { message: 'test' }, 27 | }) 28 | 29 | await utils.change(msg.channel.id, 'pajbot_api', urlString, msg.channel.query) 30 | return { text: `the banphrase API has been successfully updated FeelsOkayMan 👍`, reply: true } 31 | } catch (e) { 32 | return { text: `couldn't validate the specified URL ( valid example: https://pajlada.pajbot.com/ )`, reply: true } 33 | } 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /lib/commands/stablediffusion.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const got = require('got') 3 | const FormData = require('form-data') 4 | 5 | module.exports = { 6 | name: 'stablediffusion', 7 | description: 'Run a Stable Diffusion text-to-image prompt', 8 | cooldown: 30, 9 | aliases: ['sd'], 10 | usage: "", 11 | async execute(client, msg, utils) { 12 | if (!msg.args.length) return { text: "you need to provide a prompt", reply: true, error: true } 13 | 14 | const prompt = msg.args.join(' ') 15 | 16 | msg.send("ppHop", true) 17 | 18 | const res = await got.post(`https://api.cloudflare.com/client/v4/accounts/${config.auth.cloudflare.account}/ai/run/@cf/stabilityai/stable-diffusion-xl-base-1.0`, { 19 | throwHttpErrors: false, 20 | headers: { 21 | Authorization: `Bearer ${config.auth.cloudflare.key}` 22 | }, 23 | json: { 24 | prompt, 25 | num_steps: 20, 26 | } 27 | }).buffer() 28 | 29 | const form = new FormData(); 30 | form.append("file", res, { filename: `image.png` }) 31 | const upload = await got.post("https://kappa.lol/api/upload", { 32 | throwHttpErrors: false, 33 | body: form 34 | }).json() 35 | if (!upload.link) return { text: `upload failed: ${JSON.stringify(upload)}` } 36 | 37 | return { text: `${upload.link}`, reply: true } 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /lib/commands/chatters.js: -------------------------------------------------------------------------------- 1 | const { gql, paste } = require("../utils/twitchapi.js") 2 | 3 | module.exports = { 4 | name: 'chatters', 5 | description: 'Twitch viewer list info', 6 | aliases: ['everyone', 'massping'], 7 | cooldown: 5, 8 | async execute(client, msg, utils) { 9 | const user = msg.args[0] ? msg.args[0].replace('@', '').toLowerCase() : msg.channel.login 10 | const { body } = await gql({ 11 | json: { 12 | "operationName": "CommunityTab", 13 | "variables": { "login": user }, 14 | } 15 | }) 16 | 17 | const channel = body.data.user?.channel 18 | if (!channel) return { text: `#${user} was not found`, reply: true } 19 | 20 | const chatters = channel.chatters 21 | if (!chatters.count) return { text: `there are no chatters in #${user}`, reply: true } 22 | 23 | const list = [ 24 | `* count: ${chatters.count}`, 25 | `* broadcasters:\n${chatters['broadcasters'].map(u => u.login).join('\n')}`, 26 | `* moderators:\n${chatters['moderators'].map(u => u.login).join('\n')}`, 27 | `* vips:\n${chatters['vips'].map(u => u.login).join('\n')}`, 28 | `* viewers:\n${chatters['viewers'].map(u => u.login).join('\n')}` 29 | ] 30 | 31 | return { text: `${chatters.count} chatters (${chatters.moderators.length} Moderators, ${chatters.vips.length} VIPs): ${await paste(list.join('\n\n'))}.txt`, reply: true } 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /lib/commands/dislikes.js: -------------------------------------------------------------------------------- 1 | const got = require('got') 2 | 3 | module.exports = { 4 | name: 'dislikes', 5 | description: "Like/Dislike count of a YouTube video", 6 | extended: 'Return YouTube Dislike', 7 | cooldown: 5, 8 | usage: "", 9 | async execute(client, msg, utils) { 10 | if (!msg.args.length) return { text: `you need to specify the Youtube Video ID/URL to get stats`, reply: true } 11 | 12 | const regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#\&\?]*).*/; 13 | const match = msg.args[0].match(regExp); 14 | let videoId; 15 | if (match && match[7].length === 11) { 16 | videoId = match[7] 17 | } else { 18 | videoId = msg.args[0] 19 | } 20 | 21 | const { body: data, statusCode } = await got(`https://returnyoutubedislikeapi.com/votes?videoId=${encodeURIComponent(videoId)}`, { 22 | responseType: 'json', 23 | throwHttpErrors: false 24 | }) 25 | 26 | if (statusCode !== 200) return { text: `${data.title || "an unexpected error occurred"} (you need to specify the YouTube Video ID)`, reply: true } 27 | 28 | return { text: `👍Likes: ${utils.formatNumber(data.likes)} || 👎Dislikes: ${utils.formatNumber(data.dislikes)} || 👁Views: ${utils.formatNumber(data.viewCount)} || 📅Stats Created: ${utils.humanize(data.dateCreated)} ago`, reply: true } 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /lib/commands/searchsong.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const got = require('got') 3 | const spotify = require('../utils/spotify.js') 4 | 5 | function format(ms) { 6 | var minutes = Math.floor(ms / 60000); 7 | var seconds = ((ms % 60000) / 1000).toFixed(0); 8 | return minutes + ":" + (seconds < 10 ? '0' : '') + seconds; 9 | } 10 | 11 | module.exports = { 12 | name: 'searchsong', 13 | description: "Search a song on Spotify", 14 | cooldown: 7, 15 | aliases: ['ssearch'], 16 | usage: '', 17 | async execute(client, msg, utils) { 18 | if (!msg.args.length) return { text: "you need to specify a song name to search", reply: true } 19 | 20 | const auth = await spotify.getToken(config.owner.userId) 21 | if (!auth) return { text: "couldn't get the Spotify authorization :/", reply: true } 22 | 23 | const { body: data } = await got(`https://api.spotify.com/v1/search?q=${encodeURIComponent(msg.args.join(' '))}&type=track&market=US&limit=1`, { 24 | responseType: 'json', 25 | headers: { 26 | Authorization: `${auth.token_type} ${auth.access_token}` 27 | } 28 | }) 29 | 30 | const item = data.tracks.items[0] 31 | if (!item) return { text: "no tracks found", reply: true } 32 | return { text: `(${format(item.duration_ms)}) ${item.name} • by: ${item.artists.map(artist => artist.name).join(', ')} 🔗 ${item.external_urls.spotify}`, reply: true } 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /web/public/global.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Noto Sans'; 3 | font-style: normal; 4 | font-weight: 400; 5 | font-display: swap; 6 | src: url(https://fonts.gstatic.com/s/notosans/v27/o-0IIpQlx3QUlC5A4PNr5TRA.woff2) format('woff2'); 7 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 8 | } 9 | 10 | body { 11 | color: white; 12 | background-color: #222222; 13 | font-family: 'Noto Sans'; 14 | padding-bottom: 40px; 15 | max-width: 1000px; 16 | margin: 0 auto; 17 | } 18 | 19 | main { 20 | padding: 10px; 21 | } 22 | 23 | .logo { 24 | height: 2em; 25 | width: auto; 26 | margin-right: 1rem; 27 | } 28 | 29 | a { 30 | color: #22b14c; 31 | } 32 | 33 | img { 34 | vertical-align: middle; 35 | } 36 | 37 | .link { 38 | color: #ababab; 39 | text-decoration: none; 40 | } 41 | 42 | .link:hover { 43 | color: white; 44 | } 45 | 46 | img.link { 47 | fill: #ababab; 48 | height: 2em; 49 | } 50 | 51 | .m-l { 52 | margin-left: auto; 53 | } 54 | 55 | [aria-current] { 56 | color: white; 57 | font-weight: bold; 58 | } 59 | 60 | nav { 61 | background-color: #303030; 62 | position: relative; 63 | display: flex; 64 | align-items: center; 65 | padding: 5px; 66 | border-radius: 0 0 5px 5px; 67 | } 68 | 69 | ul.nav li { 70 | display: inline; 71 | } 72 | 73 | ul.nav li:not(:last-child) { 74 | margin-right: 10px; 75 | } 76 | 77 | ul.nav { 78 | padding: 0; 79 | list-style-type: none; 80 | margin: 5px 0; 81 | } 82 | -------------------------------------------------------------------------------- /lib/commands/addvoice.js: -------------------------------------------------------------------------------- 1 | const got = require('got') 2 | const normalizeUrl = require('normalize-url') 3 | 4 | module.exports = { 5 | name: 'addvoice', 6 | cooldown: 3, 7 | aliases: ['delvoice'], 8 | async execute(client, msg, utils) { 9 | if (!['8supa', 'shiro836_'].includes(msg.user.login)) return 10 | 11 | if (msg.commandName === 'addvoice') { 12 | if (msg.args.length < 2) return { text: `you must specify a name and an audio reference link`, reply: true, error: true } 13 | 14 | const name = msg.args[0].toLowerCase() 15 | 16 | let mediaUrl 17 | try { 18 | mediaUrl = new URL(normalizeUrl(msg.args[1])) 19 | } catch (err) { 20 | return { text: `couldn't validate your specified URL`, reply: true, error: true } 21 | } 22 | 23 | const file = await got(mediaUrl).buffer() 24 | const ref = file.toString('base64') 25 | await utils.redis.hset("ob:shiro:voices", name, ref) 26 | 27 | return { text: `voice \`${name}\` saved`, reply: true } 28 | } else if (msg.commandName === 'delvoice') { 29 | if (!msg.args.length) return { text: `no voice specified`, reply: true, error: true } 30 | const name = msg.args[0].toLowerCase() 31 | 32 | const deleted = await utils.redis.hdel("ob:shiro:voices", name) 33 | if (deleted) 34 | return { text: `voice \`${name}\` deleted`, reply: true } 35 | else 36 | return { text: `no voice found`, reply: true } 37 | } 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "okeybot", 3 | "version": "1.5.0", 4 | "main": "index.js", 5 | "dependencies": { 6 | "body-parser": "^1.19.0", 7 | "cheerio": "^1.0.0-rc.10", 8 | "country-emoji": "^1.5.4", 9 | "dank-twitch-irc": "^4.3.0", 10 | "express": "^4.17.1", 11 | "form-data": "^4.0.0", 12 | "got": "^11.8.2", 13 | "humanize-duration": "^3.27.0", 14 | "ioredis": "^5.0.1", 15 | "mariadb": "^3.0.0", 16 | "moment": "^2.29.1", 17 | "ms": "^2.0.0", 18 | "nanoid": "^3.1.29", 19 | "reconnecting-websocket": "^4.4.0", 20 | "sirv-cli": "^2.0.0", 21 | "svelte-routing": "^1.6.0", 22 | "winston": "^3.3.3", 23 | "ws": "^8.0.0" 24 | }, 25 | "scripts": { 26 | "build": "rollup -c", 27 | "dev": "rollup -c -w", 28 | "start": "sirv web/public --no-clear -s" 29 | }, 30 | "devDependencies": { 31 | "@rollup/plugin-commonjs": "^22.0.2", 32 | "@rollup/plugin-node-resolve": "^14.0.0", 33 | "rollup": "^2.3.4", 34 | "rollup-plugin-css-only": "^3.1.0", 35 | "rollup-plugin-livereload": "^2.0.0", 36 | "rollup-plugin-svelte": "^7.0.0", 37 | "rollup-plugin-terser": "^7.0.0", 38 | "svelte": "^3.0.0" 39 | }, 40 | "author": "Supa", 41 | "license": "ISC", 42 | "description": "Twitch chat bot that provides a variety of fun and utility commands", 43 | "directories": { 44 | "lib": "lib" 45 | }, 46 | "repository": { 47 | "type": "git", 48 | "url": "git+https://github.com/0Supa/okeybot.git" 49 | }, 50 | "bugs": { 51 | "url": "https://github.com/0Supa/okeybot/issues" 52 | }, 53 | "homepage": "https://github.com/0Supa/okeybot#readme" 54 | } 55 | -------------------------------------------------------------------------------- /lib/commands/pet.js: -------------------------------------------------------------------------------- 1 | const got = require('got') 2 | 3 | module.exports = { 4 | name: 'pet', 5 | description: 'Random image of a pet', 6 | aliases: ['animal', 'cat', 'pussy', '🐱', 'dog', '🐶', 'fox', '🦊'], 7 | usage: '[name]', 8 | cooldown: 3, 9 | async execute(client, msg, utils) { 10 | const animalsAPI = { 11 | cat: async () => { 12 | const cat = await got('https://api.thecatapi.com/v1/images/search').json() 13 | return { text: `${cat[0].url} 🐱`, reply: true } 14 | }, 15 | dog: async () => { 16 | const dog = await got('https://dog.ceo/api/breeds/image/random').json() 17 | return { text: `${dog.message} 🐶`, reply: true } 18 | }, 19 | fox: async () => { 20 | const fox = await got('https://randomfox.ca/floof/').json() 21 | return { text: `${fox.image} 🦊`, reply: true } 22 | } 23 | } 24 | const animals = Object.keys(animalsAPI) 25 | 26 | switch (msg.commandName === this.name ? msg.args[0]?.toLowerCase() : null || msg.commandName) { 27 | case "cat": 28 | case "pussy": 29 | case "🐱": 30 | return await animalsAPI['cat']() 31 | 32 | case "dog": 33 | case "🐶": 34 | return await animalsAPI['dog']() 35 | 36 | case "fox": 37 | case "🦊": 38 | return await animalsAPI['fox']() 39 | 40 | default: 41 | const api = animalsAPI[utils.randArray(animals)] 42 | return await api() 43 | } 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /lib/commands/randline.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | 3 | module.exports = { 4 | name: 'randline', 5 | description: 'Random chat line from the current channel', 6 | aliases: ['rm', 'rl'], 7 | cooldown: 5, 8 | usage: "[username]", 9 | async execute(client, msg, utils) { 10 | if (!msg.channel.query.logging) return { text: `this channel has message logging disabled, contact ${config.owner.login} for more info`, reply: true } 11 | 12 | if (!msg.args.length) { 13 | const query = await utils.query(`SELECT id FROM messages WHERE channel_id=?`, [msg.channel.id]) 14 | if (!query.length) return { text: "there are no logged messages in this channel", reply: true } 15 | const rand = utils.randArray(query) 16 | const message = await getMessage(rand.id) 17 | return { text: `(${utils.humanize(message.timestamp)} ago) ${message.user_login}: ${message.text}`, reply: true } 18 | } 19 | 20 | const user = msg.args[0].replace('@', '') 21 | const query = await utils.query(`SELECT id FROM messages WHERE channel_id=? AND user_login=?`, [msg.channel.id, user]) 22 | if (!query.length) return { text: "that user has not said anything in this channel", reply: true } 23 | const rand = utils.randArray(query) 24 | const message = await getMessage(rand.id) 25 | return { text: `(${utils.humanize(message.timestamp)} ago): ${message.text}`, reply: true } 26 | 27 | async function getMessage(id) { 28 | const message = await utils.query(`SELECT user_login, message AS text, timestamp FROM messages WHERE id=? LIMIT 1`, [id]) 29 | return message[0] 30 | } 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /lib/commands/ping.js: -------------------------------------------------------------------------------- 1 | const { performance } = require('perf_hooks'); 2 | const { banphrasePing } = require('../utils/pajbot.js') 3 | const pubsub = require('../misc/pubsub.js') 4 | 5 | module.exports = { 6 | name: 'ping', 7 | description: 'Pong! 🏓', 8 | aliases: ['pong'], 9 | cooldown: 10, 10 | async execute(client, msg, utils) { 11 | const rtt = (performance.now() - msg.received).toFixed(3); 12 | const latency = Date.now() - msg.timestamp; 13 | 14 | let banphraseStatus = 'not active'; 15 | const pajbotApi = msg.channel.query.pajbot_api 16 | if (pajbotApi) { 17 | banphraseStatus = await banphrasePing(pajbotApi).catch(err => { 18 | banphraseStatus = err 19 | }) 20 | } 21 | 22 | const [dbUptime, dbQueries, redisKeys] = await Promise.all([ 23 | utils.query(`SELECT variable_value FROM information_schema.global_status WHERE variable_name='Uptime'`), 24 | utils.query(`SELECT variable_value FROM information_schema.global_status WHERE variable_name = 'Questions'`), 25 | utils.redis.dbsize() 26 | ]) 27 | 28 | return { 29 | text: `MrDestructoid 🏓 BOT Uptime: ${utils.humanize(client.connectedAt)} • DB Uptime: ${utils.humanizeMS(dbUptime[0].variable_value * 1000)} • QPS: ${(dbQueries[0].variable_value / dbUptime[0].variable_value).toFixed(3)} • Redis Keys: ${redisKeys} • Active Channels: ${Object.keys(client.userStateTracker.channelStates).length} • Issued Commands: ${client.issuedCommands} • RAM: ${Math.round(process.memoryUsage().rss / 1024 / 1024)}mb • TMI: ${latency}ms • Handler RTT: ${rtt}ms • PubSub connections: ${pubsub.connections.length} • Banphrase API: ${banphraseStatus}`, 30 | reply: true 31 | } 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /lib/commands/cmd.js: -------------------------------------------------------------------------------- 1 | const commands = require('../misc/commands.js') 2 | 3 | module.exports = { 4 | name: 'cmd', 5 | description: 'Disable/Enable a bot command in the current channel', 6 | access: 'mod', 7 | cooldown: 5, 8 | aliases: ['command'], 9 | usage: ' ', 10 | async execute(client, msg, utils) { 11 | if (msg.args.length < 2) return { text: `usage: ${msg.prefix}${this.name} ${this.usage}`, reply: true } 12 | 13 | const option = msg.args[0].toLowerCase() 14 | const commandName = msg.args[1].toLowerCase() 15 | 16 | const command = commands.get(commandName) 17 | if (!command || !client.knownCommands.includes(command.name)) return { text: `that command doesn't exist`, reply: true } 18 | 19 | if (command.name === this.name) return { text: "you can't disable this command 4Head", reply: true } 20 | 21 | const key = `ob:channel:${msg.channel.id}:disabledCmd:${command.name}` 22 | switch (option) { 23 | case "disable": { 24 | if (await utils.redis.exists(key)) return { text: `this command is already disabled`, reply: true } 25 | await utils.redis.set(key, 1) 26 | 27 | return { text: `successfully disabled command ${command.name}`, reply: true } 28 | } 29 | 30 | case "enable": { 31 | if (!await utils.redis.exists(key)) return { text: `this command is already enabled`, reply: true } 32 | await utils.redis.del(key) 33 | 34 | return { text: `successfully enabled command ${command.name}`, reply: true } 35 | } 36 | 37 | default: { 38 | return { text: `usage: ${msg.prefix}${this.name} ${this.usage}`, reply: true } 39 | } 40 | } 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /lib/commands/prompt.js: -------------------------------------------------------------------------------- 1 | const { parseArgs } = require('node:util') 2 | const config = require('../../config.json') 3 | const { paste } = require("../utils/twitchapi.js") 4 | const got = require('got') 5 | 6 | const options = { 7 | model: { 8 | type: 'string', 9 | short: 'm', 10 | } 11 | }; 12 | 13 | const models = { 14 | "mistral": "@cf/mistral/mistral-7b-instruct-v0.1", 15 | "llama": "@cf/meta/llama-2-7b-chat-int8" 16 | } 17 | const modelList = Object.keys(models) 18 | 19 | module.exports = { 20 | name: 'prompt', 21 | description: 'Run a Text AI prompt', 22 | cooldown: 10, 23 | aliases: ['llm', 'ask'], 24 | usage: "", 25 | async execute(client, msg, utils) { 26 | const { values, positionals } = parseArgs({ args: msg.args, options, allowPositionals: true }); 27 | 28 | if (!positionals.length) return { text: "you need to provide a prompt", reply: true, error: true } 29 | 30 | let model = models[values.model ?? "mistral"] 31 | if (!model) return { text: `invalid model provided (${modelList.join(', ')})`, reply: true, error: true } 32 | 33 | const data = await got.post(`https://api.cloudflare.com/client/v4/accounts/${config.auth.cloudflare.account}/ai/run/${model}`, { 34 | throwHttpErrors: false, 35 | headers: { 36 | Authorization: `Bearer ${config.auth.cloudflare.key}` 37 | }, 38 | json: { 39 | "prompt": positionals.join(' '), 40 | "stream": false 41 | } 42 | }).json() 43 | 44 | const res = data.result?.response 45 | if (!data.success || !res) return { text: `error: ${await paste(JSON.stringify(data, null, 4))}` } 46 | 47 | return { text: `${res.length > 450 ? await paste(res, true) : ""} ${res}`, reply: true } 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /lib/utils/logger.js: -------------------------------------------------------------------------------- 1 | const { createLogger, format, transports, addColors } = require('winston'); 2 | const { combine, colorize, timestamp, printf } = format; 3 | const util = require('util'); 4 | 5 | const loggerlevels = { 6 | colors: { 7 | info: 'green', 8 | error: 'underline bold red', 9 | debug: 'bold magenta', 10 | warn: 'yellow', 11 | }, 12 | }; 13 | 14 | const logFormat = printf(({ level, message, timestamp }) => { 15 | return `${timestamp} [${level}]: ${message}`; 16 | }); 17 | 18 | module.exports.logger = createLogger({ 19 | format: combine( 20 | format((info) => { 21 | info.level = info.level.toUpperCase(); 22 | return info; 23 | })(), 24 | timestamp({ 25 | format: 'DD.MM.YY HH:mm:ss', 26 | }), 27 | colorize(), 28 | logFormat, 29 | ), 30 | transports: [ 31 | new transports.Console({ 32 | stderrLevels: ['error'], 33 | colorize: true, 34 | }), 35 | ], 36 | }); 37 | addColors(loggerlevels.colors); 38 | 39 | if (process.env.loglevel) { 40 | this.logger.transports[0].level = process.env.loglevel; 41 | this.logger.info(`Setting loglevel to ${this.logger.transports[0].level}`); 42 | } else { 43 | this.logger.transports[0].level = 'info'; 44 | this.logger.info(`Setting loglevel to ${this.logger.transports[0].level}`); 45 | } 46 | 47 | module.exports.info = (...args) => { 48 | this.logger.info(...args); 49 | }; 50 | 51 | module.exports.error = (...args) => { 52 | this.logger.error(...args); 53 | }; 54 | 55 | module.exports.debug = (...args) => { 56 | this.logger.debug(...args); 57 | }; 58 | 59 | module.exports.warn = (...args) => { 60 | this.logger.warn(...args); 61 | }; 62 | 63 | module.exports.json = (...args) => { 64 | this.logger.debug(util.inspect(...args)); 65 | }; 66 | -------------------------------------------------------------------------------- /lib/commands/topartists.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const got = require('got') 3 | const spotify = require('../utils/spotify.js') 4 | const { getUser } = require('../utils/twitchapi.js') 5 | 6 | module.exports = { 7 | name: 'topartists', 8 | description: "Top 3 listened artists on Spotify", 9 | cooldown: 7, 10 | aliases: ['top-artists'], 11 | usage: '[username]', 12 | async execute(client, msg, utils) { 13 | let users = [] 14 | 15 | if (msg.args[0]) { 16 | let target = msg.args[0]; 17 | let forced = false; 18 | 19 | if (target.startsWith('@')) { 20 | target = target.slice(1) 21 | forced = true 22 | } 23 | 24 | const user = await getUser(target) 25 | if (user) users.push({ login: user.login, id: user.id, forced }) 26 | else if (forced) { 27 | return { text: 'user was not found', reply: true } 28 | } 29 | } 30 | users.push({ login: msg.user.login, id: msg.user.id }) 31 | 32 | const { user, auth, error } = await spotify.getBest(users) 33 | 34 | if (error) return { text: error, reply: true } 35 | if (!auth) return { text: `you don't have Spotify connected with your Twitch account, you can login here: ${config.website.url}/spotify`, reply: true } 36 | 37 | const { body: data } = await got("https://api.spotify.com/v1/me/top/artists?limit=10", { 38 | responseType: 'json', 39 | headers: { 40 | Authorization: `${auth.token_type} ${auth.access_token}` 41 | } 42 | }) 43 | 44 | const topArtists = data.items.map(i => `${i.name}${i.genres.length ? ` (${i.genres[0]})` : ''}`) 45 | return { text: `${user.id === msg.user.id ? 'your' : `${user.login}'s`} top listened artists: ${topArtists.join(' \u{2022} ')}`, reply: true } 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /lib/commands/toptracks.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const got = require('got') 3 | const spotify = require('../utils/spotify.js') 4 | const { getUser } = require('../utils/twitchapi.js') 5 | 6 | module.exports = { 7 | name: 'toptracks', 8 | description: "Top 5 listened tracks on Spotify", 9 | cooldown: 7, 10 | aliases: ['top-tracks', 'topsongs'], 11 | usage: '[username]', 12 | async execute(client, msg, utils) { 13 | let users = [] 14 | 15 | if (msg.args[0]) { 16 | let target = msg.args[0]; 17 | let forced = false; 18 | 19 | if (target.startsWith('@')) { 20 | target = target.slice(1) 21 | forced = true 22 | } 23 | 24 | const user = await getUser(target) 25 | if (user) users.push({ login: user.login, id: user.id, forced }) 26 | else if (forced) { 27 | return { text: 'user was not found', reply: true } 28 | } 29 | } 30 | users.push({ login: msg.user.login, id: msg.user.id }) 31 | 32 | const { user, auth, error } = await spotify.getBest(users) 33 | 34 | if (error) return { text: error, reply: true } 35 | if (!auth) return { text: `you don't have Spotify connected with your Twitch account, you can login here: ${config.website.url}/spotify`, reply: true } 36 | 37 | const { body: data } = await got("https://api.spotify.com/v1/me/top/tracks?limit=10", { 38 | responseType: 'json', 39 | headers: { 40 | Authorization: `${auth.token_type} ${auth.access_token}` 41 | } 42 | }) 43 | 44 | const topTracks = data.items.map(i => `${i.artists.map(artist => artist.name).join(', ')} - ${i.name}`) 45 | return { text: `${user.id === msg.user.id ? 'your' : `${user.login}'s`} top listened tracks: ${topTracks.join(' \u{2022} ')}`, reply: true } 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /config_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "bot": { 3 | "login": "okey_bot", 4 | "userId": "642032441", 5 | "defaultPrefix": "?" 6 | }, 7 | "owner": { 8 | "login": "8supa", 9 | "userId": "675052240" 10 | }, 11 | "auth": { 12 | "twitch": { 13 | "helix": { 14 | "token": "[Required scopes: chat:read, chat:edit, whispers:read, user:manage:whispers, channel:moderate, moderator:manage:banned_users, moderator:manage:announcements, moderator:manage:chat_messages, moderator:manage:chat_settings] Use this for generating a custom scope token -> https://twitchtokengenerator.com/", 15 | "clientId": "^^^" 16 | }, 17 | "gql": { 18 | "token": null, 19 | "clientId": null 20 | } 21 | }, 22 | "openweathermap": "OpenWeather API Key", 23 | "tts": "", 24 | "wolfram": "WolframAlpha AppID", 25 | "supinic": { 26 | "userId": "", 27 | "key": "" 28 | }, 29 | "bttv": "BTTV Authorization", 30 | "7tv": "7TV Authorization", 31 | "tenor": "Tenor API Key", 32 | "discordWebhook": "Discord Webhook URL", 33 | "database": { 34 | "host": "127.0.0.1", 35 | "user": "", 36 | "pass": "", 37 | "name": "Database Name", 38 | "connectionLimit": 50 39 | }, 40 | "spotify": { 41 | "clientId": "", 42 | "clientSecret": "" 43 | }, 44 | "uberduck": { 45 | "key": "", 46 | "secret": "" 47 | }, 48 | "gelbooru": { 49 | "api_key": "", 50 | "user_id": "" 51 | }, 52 | "cloudflare": { 53 | "account": "", 54 | "key": "" 55 | } 56 | }, 57 | "website": { 58 | "url": "https://okey.supa.codes", 59 | "port": 8484 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/commands/gelbooru.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const got = require('got') 3 | 4 | const rating = { 5 | "questionable": "NSFW ⚠️", 6 | "sensitive": "🤨", 7 | } 8 | 9 | module.exports = { 10 | name: 'gelbooru', 11 | description: 'Search SFW posts on Gelbooru', 12 | aliases: ['gb', 'gbs'], 13 | cooldown: 5, 14 | usage: '[query]', 15 | async execute(client, msg, utils) { 16 | if (msg.commandName === 'gbs') { 17 | const tags = await got('https://gelbooru.com/index.php', { 18 | searchParams: { 19 | page: "autocomplete2", 20 | term: msg.args.join("_"), 21 | type: "tag_query", 22 | limit: 5 23 | } 24 | }).json() 25 | 26 | if (!tags.length) return { text: "no tags found", reply: true } 27 | 28 | const tagsData = tags.map(t => `${t.post_count} ${t.value}`) 29 | return { text: tagsData.join(" \u{2022} "), reply: true } 30 | } 31 | 32 | const tags = [...msg.args, "-rating:explicit"] 33 | if (!tags.find(v => /^sort:/i.test(v))) tags.push("sort:random") 34 | 35 | const data = await got('https://gelbooru.com/index.php', { 36 | searchParams: { 37 | page: "dapi", 38 | s: "post", 39 | q: "index", 40 | api_key: config.auth.gelbooru.api_key, 41 | user_id: config.auth.gelbooru.user_id, 42 | json: 1, 43 | tags: tags.join(" "), 44 | limit: 1 45 | } 46 | }).json() 47 | 48 | if (!data.post?.length) return { text: "no posts found", reply: true } 49 | 50 | const post = data.post[0] 51 | return { text: `${rating[post.rating] ?? ""} [${post.rating}] #${post.id} \u{2022} ${utils.humanize(post.created_at)} ago \u{2022} ${post.file_url}` } 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /web/src/routes/SpotifyCallback.svelte: -------------------------------------------------------------------------------- 1 | 32 | 33 |
    34 | {#if id} 35 |

    Whisper Okey_bot this message on Twitch to link your Spotify account

    36 | 37 | 38 | {:else} 39 |

    {prompt}

    40 | {/if} 41 |
    42 | 43 | 80 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import svelte from 'rollup-plugin-svelte'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import resolve from '@rollup/plugin-node-resolve'; 4 | import livereload from 'rollup-plugin-livereload'; 5 | import { terser } from 'rollup-plugin-terser'; 6 | import css from 'rollup-plugin-css-only'; 7 | 8 | const production = !process.env.ROLLUP_WATCH; 9 | 10 | function serve() { 11 | let server; 12 | 13 | function toExit() { 14 | if (server) server.kill(0); 15 | } 16 | 17 | return { 18 | writeBundle() { 19 | if (server) return; 20 | server = require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], { 21 | stdio: ['ignore', 'inherit', 'inherit'], 22 | shell: true 23 | }); 24 | 25 | process.on('SIGTERM', toExit); 26 | process.on('exit', toExit); 27 | } 28 | }; 29 | } 30 | 31 | export default { 32 | input: 'web/src/main.js', 33 | output: { 34 | sourcemap: true, 35 | format: 'iife', 36 | name: 'app', 37 | file: 'web/public/build/bundle.js' 38 | }, 39 | plugins: [ 40 | svelte({ 41 | compilerOptions: { 42 | // enable run-time checks when not in production 43 | dev: !production 44 | } 45 | }), 46 | // we'll extract any component CSS out into 47 | // a separate file - better for performance 48 | css({ output: 'bundle.css' }), 49 | 50 | // If you have external dependencies installed from 51 | // npm, you'll most likely need these plugins. In 52 | // some cases you'll need additional configuration - 53 | // consult the documentation for details: 54 | // https://github.com/rollup/plugins/tree/master/packages/commonjs 55 | resolve({ 56 | browser: true, 57 | dedupe: ['svelte'] 58 | }), 59 | commonjs(), 60 | 61 | // In dev mode, call `npm run start` once 62 | // the bundle has been generated 63 | !production && serve(), 64 | 65 | // Watch the `public` directory and refresh the 66 | // browser on changes when not in production 67 | !production && livereload('public'), 68 | 69 | // If we're building for production (npm run build 70 | // instead of npm run dev), minify 71 | production && terser() 72 | ], 73 | watch: { 74 | clearScreen: false 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /web/src/routes/Home.svelte: -------------------------------------------------------------------------------- 1 | 2 | Home / Okeybot 3 | 4 | 5 |
    6 |

    Description

    7 |
    8 |

    Okeybot provides a variety of fun and utility commands
    If you have any questions or suggestions, you can use the ?suggest command

    9 |
    10 | 11 |

    Usage

    12 |
    13 |

    The default command prefix is "?"
    This prefix can be changed per channel by using the ?prefix command
    You can run commands by typing the prefix followed by the command name, for example you would run the ping command by typing ?ping

    14 |

    Some commands have pre-defined user cooldowns to prevent spam

    15 |
    16 | 17 |

    Add the bot

    18 |
    19 |

    Use the following commands if you want Okeybot added in a channel

    20 |
      21 |
    • ?addbot for adding the bot in your chat
    • 22 |
    • ?addbot (channel name) for adding the bot in a chat that you moderate
    • 23 |
    24 |

    The bot will automatically rejoin after a name change or a Twitch suspension, this process will take up to 30 minutes

    25 |

    If you want the bot removed from your channel, you can just ban it 4Head

    26 |
    27 |
    28 | 29 | 66 | -------------------------------------------------------------------------------- /lib/utils/spotify.js: -------------------------------------------------------------------------------- 1 | const got = require('got'); 2 | const config = require('../../config.json') 3 | const utils = require('./utils.js') 4 | const appAuthorization = `Basic ${Buffer.from(`${config.auth.spotify.clientId}:${config.auth.spotify.clientSecret}`).toString('base64')}` 5 | 6 | module.exports = { 7 | token: async function (params) { 8 | const reqBody = new URLSearchParams(params).toString(); 9 | 10 | const { body, statusCode } = await got.post('https://accounts.spotify.com/api/token', { 11 | throwHttpErrors: false, 12 | responseType: 'json', 13 | headers: { 14 | 'Content-Type': "application/x-www-form-urlencoded", 15 | Authorization: appAuthorization 16 | }, 17 | body: reqBody 18 | }) 19 | 20 | return { body, statusCode } 21 | }, 22 | refreshToken: async function (refreshToken) { 23 | let { body, statusCode } = await this.token({ 24 | grant_type: 'refresh_token', 25 | refresh_token: refreshToken 26 | }) 27 | 28 | if (statusCode !== 200) throw new Error(`Token refresh failed`, { cause: { body, statusCode } }) 29 | 30 | body.refresh_token = refreshToken 31 | body.timestamp = Date.now() 32 | return body 33 | }, 34 | getToken: async function (userid) { 35 | let data 36 | 37 | data = JSON.parse((await utils.redis.get(`ob:auth:spotify:${userid}`))) 38 | if (!data) return 39 | 40 | const expiryDate = data.timestamp + data.expires_in * 1000 41 | if (Date.now() > expiryDate) { 42 | data = await this.refreshToken(data.refresh_token) 43 | utils.redis.set(`ob:auth:spotify:${userid}`, JSON.stringify(data)) 44 | } 45 | 46 | return data 47 | }, 48 | getBest: async function (arr) { 49 | for (data of arr) { 50 | const auth = await this.getToken(data.id).catch(() => null) 51 | 52 | if (auth) { 53 | return { user: data, auth } 54 | } else if (data.forced) { 55 | return { error: "the targeted user doesn't have Spotify linked with their Twitch account" } 56 | } 57 | } 58 | return {} 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /lib/commands/recentlyplayed.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const got = require('got') 3 | const spotify = require('../utils/spotify.js') 4 | const { getUser } = require('../utils/twitchapi.js') 5 | 6 | module.exports = { 7 | name: 'recentlyplayed', 8 | description: "Last 10 played tracks on Spotify", 9 | cooldown: 7, 10 | aliases: ['rp', 'previous', 'recently-played'], 11 | usage: '[username]', 12 | async execute(client, msg, utils) { 13 | let users = [] 14 | 15 | if (msg.args[0]) { 16 | let target = msg.args[0]; 17 | let forced = false; 18 | 19 | if (target.startsWith('@')) { 20 | target = target.slice(1) 21 | forced = true 22 | } 23 | 24 | const user = await getUser(target) 25 | if (user) users.push({ login: user.login, id: user.id, forced }) 26 | else if (forced) { 27 | return { text: 'user was not found', reply: true } 28 | } 29 | } 30 | users.push({ login: msg.user.login, id: msg.user.id }) 31 | 32 | const { user, auth, error } = await spotify.getBest(users) 33 | 34 | if (error) return { text: error, reply: true } 35 | if (!auth) return { text: `you don't have Spotify connected with your Twitch account, you can login here: ${config.website.url}/spotify`, reply: true } 36 | 37 | const { body: data } = await got("https://api.spotify.com/v1/me/player/recently-played?limit=5&market=US", { 38 | responseType: 'json', 39 | headers: { 40 | Authorization: `${auth.token_type} ${auth.access_token}` 41 | } 42 | }) 43 | 44 | const tracks = data.items.map(i => `${i.track.name} (${utils.humanize(i.played_at)} ago)`) 45 | const adr = user.id === msg.user.id ? 'your' : `${user.login}'s` 46 | 47 | if (msg.commandName === 'previous') { 48 | const previous = data.items[0] 49 | return { text: `${adr} previous played song: (${utils.humanize(previous.played_at)} ago) ${previous.track.name} • by: ${previous.track.artists.map(artist => artist.name).join(', ')} 🔗 ${previous.track.external_urls.spotify.substring(8)}`, reply: true } 50 | } 51 | else return { text: `${adr} last ${tracks.length} played tracks: ${tracks.join(' || ')}`, reply: true } 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /lib/commands/emote.js: -------------------------------------------------------------------------------- 1 | const { gql, ivr } = require("../utils/twitchapi.js") 2 | const relativeEmoteUri = /(?:\/emoticons\/v\d+\/)(\w+)/i 3 | 4 | module.exports = { 5 | name: 'emote', 6 | description: 'Info about a Twitch sub emote', 7 | cooldown: 3, 8 | usage: '', 9 | async execute(client, msg, utils) { 10 | if (!msg.args.length) return { text: "you need to specify the emote's name or id", reply: true } 11 | 12 | const exp = msg.args[0].match(relativeEmoteUri) 13 | let emoteId = exp ? exp[1] : msg.emotes[0]?.id || msg.args[0]; 14 | 15 | let data; 16 | 17 | const { body } = await gql({ 18 | json: { 19 | "operationName": "EmoteCard", 20 | "variables": { 21 | "emoteID": emoteId, 22 | "octaneEnabled": true 23 | } 24 | } 25 | }) 26 | const emote = body.data.emote 27 | 28 | if (emote) { 29 | data = { 30 | gql: true, 31 | emoteType: emote.type, 32 | emoteID: emote.id, 33 | emoteCode: emote.token, 34 | channelLogin: emote.owner?.login, 35 | emoteTier: emote.subscriptionTier?.substring(5), 36 | emoteSetID: emote.setID, 37 | emoteBitsTier: emote.bitsBadgeTierSummary?.self.numberOfBitsUntilUnlock 38 | } 39 | } else { 40 | data = (await ivr(`emotes/${encodeURIComponent(msg.args[0])}`)).body 41 | if (!data.emoteID) return { text: `emote was not found`, reply: true } 42 | } 43 | 44 | const channelTag = data.channelLogin ? `@${data.channelLogin}` : "(Unknown)" 45 | const types = { 46 | "SUBSCRIPTIONS": `${channelTag} T${data.emoteTier} Sub Emote`, 47 | "FOLLOWER": `${channelTag} Follower Emote`, 48 | "GLOBALS": `Twitch Global Emote`, 49 | "TWO_FACTOR": "2FA Emote", 50 | "MEGA_COMMERCE": "Mega Commerce Emote", 51 | "HYPE_TRAIN": "Hype Train Emote", 52 | "BITS_BADGE_TIERS": `${channelTag} ${data.emoteBitsTier} Bits Emote` 53 | } 54 | 55 | return { text: `${data.emoteCode} | ${data.gql ? (types[data.emoteType] || "Twitch Emote") : types['SUBSCRIPTIONS']}, setid: ${data.emoteSetID} • emotes.raccatta.cc/twitch/emote/${data.emoteID}`, reply: true } 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /lib/commands/howlongtobeat.js: -------------------------------------------------------------------------------- 1 | const got = require('got'); 2 | 3 | const parse = (hours) => { 4 | const t = parseFloat((hours / 3600).toFixed(1)) 5 | if (t) return `${t}h` 6 | else return "N/A" 7 | } 8 | 9 | module.exports = { 10 | name: 'howlongtobeat', 11 | description: 'Estimate how long it will take to beat a game', 12 | extended: 'HowLongToBeat', 13 | cooldown: 5, 14 | usage: "", 15 | aliases: ['hltb'], 16 | async execute(client, msg, utils) { 17 | if (!msg.args.length) return { text: `you need to provide a game name to search`, reply: true } 18 | 19 | const res = await got.post("https://howlongtobeat.com/api/search", { 20 | headers: { 21 | 'Referer': 'https://howlongtobeat.com/api/search', 22 | 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:104.0) Gecko/20100101 Firefox/104.0' 23 | }, 24 | json: { 25 | "searchType": "games", 26 | "searchTerms": msg.args, 27 | "searchPage": 1, 28 | "size": 1, 29 | "searchOptions": { 30 | "games": { 31 | "userId": 0, 32 | "platform": "", 33 | "sortCategory": "popular", 34 | "rangeCategory": "main", 35 | "rangeTime": { 36 | "min": 0, 37 | "max": 0 38 | }, 39 | "gameplay": { 40 | "perspective": "", 41 | "flow": "", 42 | "genre": "" 43 | }, 44 | "modifier": "" 45 | }, 46 | "users": { 47 | "sortCategory": "postcount" 48 | }, 49 | "filter": "", 50 | "sort": 0, 51 | "randomizer": 0 52 | } 53 | } 54 | }).json() 55 | const data = res.data[0] 56 | if (!data) return { text: 'no games found', reply: true } 57 | 58 | return { text: `${data.game_name} → Main Story: ${parse(data.comp_main)}, Main+Extra: ${parse(data.comp_plus)}, Completionist: ${parse(data.comp_100)}`, reply: true } 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /web/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const config = require('../config.json') 3 | const utils = require('../lib/utils/utils.js') 4 | const { logger } = require('../lib/utils/logger.js') 5 | const { client } = require('../lib/misc/connections.js') 6 | const { nanoid } = require('nanoid') 7 | const app = express() 8 | 9 | app.use('/', express.static(`${__dirname}/public`)) 10 | 11 | app.get("/api/commands", async (req, res) => { 12 | res.send(client.commandsData) 13 | }) 14 | 15 | app.get("/api/stats", async (req, res) => { 16 | const { totalIcm } = (await utils.query(`SELECT issued_commands AS totalIcm FROM bot_data`))[0] 17 | res.send({ 18 | channelCount: Object.keys(client.userStateTracker.channelStates).length, 19 | commands: client.knownCommands.length, 20 | MBram: Math.round(process.memoryUsage().rss / 1024 / 1024), 21 | uptime: { 22 | human: utils.humanize(client.connectedAt), 23 | timestamp: client.connectedAt 24 | }, 25 | issuedCommands: { 26 | sinceRestart: client.issuedCommands, 27 | total: totalIcm 28 | } 29 | }) 30 | }) 31 | 32 | app.get("/api/channels", async (req, res) => { 33 | const channels = await utils.query(`SELECT login FROM channels`) 34 | res.send(channels.map(channel => channel.login)) 35 | }) 36 | 37 | app.post("/api/spotify", async (req, res) => { 38 | const code = req.query.code 39 | if (!code) { 40 | return res.status(400).end() 41 | } 42 | 43 | let id = await utils.redis.get(`ob:auth:spotify:id:${code}`) 44 | if (!id) { 45 | id = nanoid() 46 | await Promise.all([ 47 | utils.redis.set(`ob:auth:spotify:code:${id}`, code, 'EX', 600), 48 | utils.redis.set(`ob:auth:spotify:id:${code}`, id, 'EX', 600) 49 | ]) 50 | } 51 | 52 | res.send({ 53 | id: `spotify ${id}` 54 | }); 55 | }) 56 | 57 | const scope = 'user-read-currently-playing user-read-recently-played user-top-read'; 58 | const redirectUri = `${config.website.url}/spotify/callback` 59 | app.get('/spotify/login', (req, res) => { 60 | res.redirect(`https://accounts.spotify.com/authorize?response_type=code&client_id=${config.auth.spotify.clientId}&scope=${encodeURIComponent(scope)}&redirect_uri=${encodeURIComponent(redirectUri)}`) 61 | }) 62 | 63 | app.get('*', (req, res) => { 64 | res.sendFile(`${__dirname}/public/index.html`) 65 | }) 66 | 67 | app.listen(config.website.port, () => { 68 | logger.info(`WWW listening on ${config.website.port}`) 69 | }) 70 | -------------------------------------------------------------------------------- /lib/commands/shiro.js: -------------------------------------------------------------------------------- 1 | const got = require('got') 2 | const FormData = require('form-data') 3 | const { spawn } = require('node:child_process'); 4 | 5 | const read = (stream) => { 6 | return new Promise((resolve, reject) => { 7 | const buf = []; 8 | 9 | stream.on('data', (chunk) => { 10 | buf.push(chunk) 11 | }); 12 | 13 | stream.on('end', () => { 14 | resolve(Buffer.concat(buf)) 15 | }); 16 | 17 | stream.on('error', (err) => { 18 | reject(err) 19 | }); 20 | }) 21 | } 22 | 23 | module.exports = { 24 | name: 'shiro', 25 | description: 'forsen.fun TTS service', 26 | cooldown: 5, 27 | aliases: ['tts2'], 28 | async execute(client, msg, utils) { 29 | const voiceList = await utils.redis.hkeys("ob:shiro:voices") 30 | 31 | if (msg.args.length < 2) 32 | return { 33 | text: `you must specify the voice name followed by your message -- available voice refs: ${voiceList.join()}`, 34 | reply: true, error: true 35 | } 36 | 37 | const name = msg.args[0].toLowerCase(); 38 | const ref = await utils.redis.hget("ob:shiro:voices", name) 39 | if (!ref) 40 | return { 41 | text: `invalid voice name specified -- ${voiceList.join()}`, 42 | reply: true, error: true 43 | } 44 | 45 | 46 | const res = await got.post("https://forsen.fun/tts", { 47 | json: { 48 | text: msg.args.slice(1).join(" "), 49 | ref_audio: ref 50 | } 51 | }).json() 52 | 53 | const ffmpeg = spawn('ffmpeg', [ 54 | '-i', "-", 55 | '-f', 'mp3', 56 | '-vn', 57 | '-ar', '44100', 58 | '-b:a', '96k', 59 | '-' 60 | ]) 61 | 62 | ffmpeg.on('close', code => { 63 | if (code !== 0) throw new Error(`multiplexing mp3 result failed with exit code: ${code}`) 64 | }) 65 | 66 | ffmpeg.stdin.setDefaultEncoding('base64'); 67 | ffmpeg.stdin.write(res.tts_result); 68 | ffmpeg.stdin.end(); 69 | 70 | const file = await read(ffmpeg.stdout) 71 | const form = new FormData(); 72 | form.append("file", file, { filename: `${name}.mp3` }) 73 | 74 | const upload = await got.post("https://shiro.kappa.lol/api/upload", { 75 | body: form, 76 | headers: form.getHeaders() 77 | }).json() 78 | 79 | return { text: upload.link, reply: true } 80 | }, 81 | }; 82 | -------------------------------------------------------------------------------- /lib/misc/commands.js: -------------------------------------------------------------------------------- 1 | const { client } = require('./connections.js') 2 | const fs = require('fs'); 3 | 4 | client.commands = new Map(); 5 | client.aliases = new Map(); 6 | 7 | exports.add = (command) => { 8 | if (!command.aliases) command.aliases = []; 9 | 10 | client.commands.set(command.name, command); 11 | 12 | for (const alias of command.aliases) { 13 | client.aliases.set(alias, command.name); 14 | } 15 | } 16 | 17 | exports.delete = (command) => { 18 | client.commands.delete(command.name) 19 | const commandFile = require.resolve(`../commands/${command.name}.js`) 20 | if (commandFile) delete require.cache[commandFile] 21 | 22 | for (const alias of command.aliases) { 23 | client.aliases.delete(alias); 24 | } 25 | } 26 | 27 | exports.get = (commandName) => { 28 | return client.commands.get(commandName) || client.commands.get(client.aliases.get(commandName)); 29 | } 30 | 31 | const commandFiles = fs.readdirSync('./lib/commands').filter(file => file.endsWith('.js')); 32 | 33 | for (const file of commandFiles) { 34 | const command = require(`../commands/${file}`); 35 | this.add(command) 36 | } 37 | 38 | const categorizedCommands = { 39 | Fun: ['%', '8ball', 'pet', 'copypasta', 'dadjoke', 'donger', 'funfact', 'hug', 'yourmom'], 40 | Twitch: ['user', 'avatar', 'clip', 'emote', 'esearch', 'chatters', 'history', 'randclip', 'boobatv', 'streaminfo', 'chatsettings'], 41 | Lines: ['clear', 'fill', 'spam', 'split', 'pyramid'], 42 | Utils: ['confusables', 'transform', 'geoip', 'math', 'query', 'tts', 'weather'], 43 | Apps: ['shiro', 'stablediffusion', 'prompt', 'google', 'dislikes', 'steam', 'epicgames', 'howlongtobeat', 'tenor', 'gelbooru', 'uberduck'], 44 | Spotify: ['recentlyplayed', 'searchsong', 'song', 'topartists', 'toptracks'], 45 | Bot: ['botinfo', 'botsubs', 'help', 'prefix', 'mode', 'cmd', 'pajbot', 'ping', 'suggest'], 46 | } 47 | 48 | client.knownCommands = Object.values(categorizedCommands).reduce((a, b) => a.concat(b)) 49 | 50 | client.commandsData = {} 51 | for (const cateogry of Object.keys(categorizedCommands)) { 52 | client.commandsData[cateogry] = [] 53 | for (const cmdName of categorizedCommands[cateogry]) { 54 | const cmd = this.get(cmdName) 55 | 56 | client.commandsData[cateogry].push({ 57 | name: cmd.name, 58 | aliases: cmd.aliases, 59 | description: cmd.description, 60 | extended: cmd.extended, 61 | access: cmd.access, 62 | cooldown: cmd.cooldown, 63 | usage: cmd.usage, 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/commands/kick.js: -------------------------------------------------------------------------------- 1 | const { parseArgs } = require('node:util') 2 | const { kick, paste, shortLink } = require('../utils/twitchapi.js') 3 | 4 | const options = { 5 | json: { 6 | type: 'boolean', 7 | short: 'j', 8 | }, 9 | playback: { 10 | type: 'boolean', 11 | short: 'p', 12 | } 13 | }; 14 | 15 | module.exports = { 16 | name: 'kick', 17 | description: 'Info about a Kick.com user', 18 | cooldown: 5, 19 | usage: "", 20 | async execute(client, msg, utils) { 21 | const { values, positionals } = parseArgs({ args: msg.args, options, allowPositionals: true }); 22 | 23 | const channelName = positionals[0] 24 | if (!channelName) return { text: "you need to specify a valid Kick user", reply: true } 25 | 26 | const { body: data, statusCode } = await kick(`channels/${encodeURIComponent(channelName)}`, { throwHttpErrors: false }) 27 | 28 | if (statusCode === 404) return { text: `user was not found`, reply: true } 29 | if (statusCode !== 200) return { text: `bad status code (${statusCode})`, reply: true } 30 | const stream = data.livestream ?? data.previous_livestreams[0] ?? null 31 | const chat = data.chatroom 32 | 33 | 34 | if (values.json) 35 | return { text: await paste(JSON.stringify(data, null, 4)), reply: true } 36 | else if (values.playback) { 37 | if (data.livestream) { 38 | return { text: `${stream.is_mature ? "🔞" : "🔴"} Live (${stream.viewers}) ${stream.categories[0]?.name} - "${stream.session_title}" m3u8: https://${await shortLink(data.playback_url)}`, reply: true } 39 | } 40 | 41 | if (!stream) return { text: "no active stream found", reply: true } 42 | 43 | const { body: vod } = await kick(`video/${stream.video.uuid}`) 44 | return { text: `Offline, last vod: (${utils.humanize(stream.duration, true)}) ${stream.categories[0]?.name} - "${stream.session_title}" m3u8: ${vod.source}` } 45 | } 46 | 47 | return { 48 | text: 49 | `${data.is_banned ? "(Banned)" : ""} 50 | ${data.user.id} kick.com/${data.slug} 51 | ${stream ? `${data.livestream ? `${stream.is_mature ? "🔞" : "🔴"} Live (${stream.viewers})` : 'Offline'}: ${stream.categories[0]?.name} - "${stream.session_title}" •` : ""} 52 | followers: ${data.followersCount || 0}, 53 | chat: ${chat.id}-${chat.chat_mode}${chat.slow_mode ? "-slow" : ""}, 54 | bio: ${data.user.bio}`, 55 | reply: true 56 | } 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /lib/commands/history.js: -------------------------------------------------------------------------------- 1 | const { gql, paste } = require("../utils/twitchapi.js"); 2 | 3 | const ulength = (text) => { 4 | let n = 0; 5 | for (let i = 0; i < text.length; i++) { 6 | const cur = text.charCodeAt(i); 7 | if (cur >= 0xD800 && cur <= 0xDBFF) { 8 | const next = text.charCodeAt(i + 1); 9 | // Skip second char in surrogate pair 10 | if (next >= 0xDC00 && next <= 0xDFFF) 11 | i++; 12 | } 13 | n++; 14 | } 15 | return n; 16 | } 17 | 18 | module.exports = { 19 | name: 'history', 20 | description: "Link to a channel's recent chat messages", 21 | cooldown: 3, 22 | usage: "[channel]", 23 | aliases: ['recent'], 24 | async execute(client, msg, utils) { 25 | const channel = msg.args[0] ? msg.args[0].replace(/@|#/, '').toLowerCase() : msg.channel.login 26 | 27 | const { body: res } = await gql({ 28 | json: { 29 | "operationName": "MessageBufferChatHistory", 30 | "variables": { 31 | "channelLogin": channel 32 | } 33 | } 34 | }) 35 | 36 | const messages = res.data.channel?.recentChatMessages 37 | 38 | if (!res.data.channel) return { text: 'channel was not found', reply: true } 39 | if (!messages.length) return { text: 'no recent messages found', reply: true } 40 | 41 | const tmiData = [] 42 | for (const m of messages) { 43 | const text = m.content.text 44 | let emotes = [] 45 | 46 | let pos = 0 47 | for (f of m.content.fragments) { 48 | const pos2 = pos + f.text.length - 1 49 | if (f.content?.emoteID) emotes.push(`${f.content.emoteID}:${pos}-${pos2}`) 50 | pos += ulength(f.text) 51 | } 52 | 53 | const tags = { 54 | id: m.id, 55 | badges: m.sender.displayBadges.map(b => `${b.setID}/${b.version}`).join(), 56 | color: m.sender.chatColor, 57 | emotes: emotes.join('/'), 58 | 'display-name': m.sender.displayName, 59 | 'room-id': res.data.channel.id, 60 | 'rm-received-ts': Date.parse(m.sentAt) 61 | } 62 | 63 | const rawTags = Object.entries(tags).map(([k, v]) => `${k}=${v}`).join(';') 64 | tmiData.push(`@${rawTags} :${m.sender.login} PRIVMSG #${channel} :${text}`) 65 | } 66 | 67 | return { text: `recent messages in @${channel}: https://logs.raccatta.cc/?url=${await paste(tmiData.join('\n'), true)}`, reply: true } 68 | }, 69 | }; 70 | -------------------------------------------------------------------------------- /lib/commands/scan.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const { paste } = require("../utils/twitchapi.js") 3 | 4 | module.exports = { 5 | name: 'scan', 6 | description: 'Find messages using a RegExp', 7 | cooldown: 20, 8 | async execute(client, msg, utils) { 9 | if (!msg.channel.query.logging) return { text: `this channel has message logging disabled, contact ${config.owner.login} for more info`, reply: true } 10 | if (!msg.args.length) return { text: `you need to specify the RegExp to search`, reply: true } 11 | 12 | let random = false 13 | if (msg.args.length > 1 && ["-random", "-rand"].includes(msg.args[msg.args.length - 1].toLowerCase())) { 14 | random = true 15 | msg.args.pop() 16 | } 17 | 18 | const regexp = msg.args.join(' ').match(new RegExp('^/(.*?)/([gimsuy]*)$')) 19 | if (!regexp) return { text: `invalid RegExp`, reply: true } 20 | 21 | let regex 22 | try { 23 | regex = new RegExp(regexp[1], regexp[2]) 24 | } catch (err) { 25 | return { text: err.message, reply: true } 26 | } 27 | 28 | msg.send('⌛ Searching...') 29 | const messages = await utils.query(`SELECT user_login, message AS text, timestamp FROM messages WHERE channel_id=? ORDER BY id DESC`, [msg.channel.id]) 30 | const messageCount = messages.length 31 | if (!messageCount) return { text: "there are no logged messages in this channel", reply: true } 32 | 33 | const foundMessages = [] 34 | for (let i = 0; i < messageCount; i++) { 35 | const message = messages[i] 36 | if (regex.test(message.text)) foundMessages.push(message) 37 | } 38 | if (!foundMessages.length) return { text: `no messages matched your RegExp`, reply: true } 39 | 40 | if (random) { 41 | const message = utils.randArray(foundMessages) 42 | return { text: `(${utils.humanize(message.timestamp)} ago) ${message.user_login}: ${message.text}`, reply: true } 43 | } else { 44 | let sliced = false 45 | let bruh = foundMessages 46 | if (foundMessages.length > 5000) { 47 | bruh = foundMessages.slice(0, 5000) 48 | sliced = true 49 | } 50 | 51 | const fmt = bruh.map(message => `(${utils.humanize(message.timestamp)} ago) ${message.user_login}: ${message.text}`).join('\n') 52 | return { text: `[${foundMessages.length}/${messageCount} messages matched] ${sliced ? "first 5000 matched messages: " : ""} ${await paste(fmt, true)}`, reply: true } 53 | } 54 | }, 55 | }; 56 | -------------------------------------------------------------------------------- /lib/commands/deadchannels.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const { paste } = require("../utils/twitchapi.js") 3 | 4 | module.exports = { 5 | name: 'deadchannels', 6 | async execute(client, msg, utils) { 7 | if (msg.user.id !== config.owner.userId) return 8 | 9 | const lines = [] 10 | const channels = (await utils.query("SELECT platform_id AS id, login FROM channels WHERE logging = '1'")) 11 | 12 | const l = channels.length 13 | for (let i = 0; i < l; i++) { 14 | const channel = channels[i] 15 | 16 | const data = await utils.query(`SELECT timestamp FROM messages WHERE channel_id=? ORDER BY id DESC LIMIT 1`, [channel.id]) 17 | if (!data.length) lines.push(`${channel.id} [${channel.login}] - never`) 18 | else { 19 | const ms = Date.now() - Date.parse(data[0].timestamp) 20 | if (ms > 1209600000) { // 2 weeks 21 | lines.push(`${channel.id} [${channel.login}] - ${utils.humanize(ms, true)}`) 22 | 23 | if (msg.args[0]?.toLowerCase() === 'part') { 24 | msg.send(`${channel.id} [${channel.login}] → Parting`) 25 | 26 | client.say(channel.login, `${channel.login}, your channel has been removed from Okey_bot due to inactivity`) 27 | client.part(channel.login) 28 | await Promise.all([ 29 | utils.redis.del(`ob:channel:${channel.id}`), 30 | utils.redis.del(`ob:channel:notifyUsers:${channel.id}`), 31 | utils.redis.del(`ob:channel:clips:${channel.id}`), 32 | utils.redis.del(`ob:channel:nuke:${channel.id}`), 33 | utils.query(`DELETE FROM channels WHERE platform_id=?`, [channel.id]), 34 | utils.query(`DELETE FROM notify_channels WHERE user_id=?`, [channel.id]), 35 | utils.query(`DELETE FROM emote_rewards WHERE channel_id=?`, [channel.id]), 36 | ]); 37 | 38 | (async () => { 39 | msg.send(`${channel.id} [${channel.login}] → Deleting message logs`) 40 | await utils.query(`DELETE FROM messages WHERE channel_id=?`, [channel.id]) 41 | msg.send(`${channel.id} [${channel.login}] → Successfully deleted message logs`) 42 | })() 43 | } 44 | } 45 | } 46 | } 47 | 48 | return { text: await paste(lines.join('\n')), reply: true } 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /lib/commands/chatsettings.js: -------------------------------------------------------------------------------- 1 | const { gql, paste } = require("../utils/twitchapi.js") 2 | 3 | module.exports = { 4 | name: 'chatsettings', 5 | description: 'Chat moderation settings for a Twitch channel', 6 | cooldown: 7, 7 | aliases: ['cs'], 8 | usage: '[channel]', 9 | async execute(client, msg, utils) { 10 | const channel = msg.args[0] ? msg.args[0].replace('@', '').toLowerCase() : msg.channel.login 11 | 12 | const { body } = await gql({ 13 | json: { 14 | "operationName": "ChatRoomState", 15 | "variables": { 16 | "login": channel 17 | } 18 | } 19 | }) 20 | const userData = body.data.channel 21 | if (!userData) return { text: 'user was not found', reply: true } 22 | 23 | const settings = userData.chatSettings 24 | const verification = settings.accountVerificationOptions 25 | const emailConfig = verification.partialEmailVerificationConfig 26 | const phoneConfig = verification.partialPhoneVerificationConfig 27 | 28 | const text = `Channel: ${channel} 29 | 30 | Chat Settings: 31 | Emote Only: ${settings.isEmoteOnlyModeEnabled} 32 | Followers Only: ${settings.followersOnlyDurationMinutes !== null ? `true (${utils.humanizeMS(settings.followersOnlyDurationMinutes * 60000)})` : "false"} 33 | Slow Mode: ${settings.slowModeDurationSeconds ? `true (${utils.humanizeMS(settings.slowModeDurationSeconds * 1000)})` : "false"} 34 | 35 | Account Verification: 36 | Sub Exempt: ${verification.isSubscriberExempt} 37 | VIP Exempt: ${verification.isVIPExempt} 38 | MOD Exempt: ${verification.isModeratorExempt} 39 | 40 | Email Verification ${verification.emailVerificationMode !== 'NONE' ? `${verification.phoneVerificationMode}: 41 | Restrict First-Time Chatters: ${emailConfig.shouldRestrictFirstTimeChatters} 42 | Restrict Account Age: ${emailConfig.shouldRestrictBasedOnAccountAge} (${utils.humanizeMS(emailConfig.minimumAccountAgeInMinutes * 60000)}) 43 | Restrict Follower Age: ${emailConfig.shouldRestrictBasedOnFollowerAge} (${utils.humanizeMS(emailConfig.minimumFollowerAgeInMinutes * 60000)})` 44 | : "NONE (Not Active)"} 45 | 46 | Phone Verification ${verification.phoneVerificationMode !== 'NONE' ? `${verification.phoneVerificationMode}: 47 | Restrict First-Time Chatters: ${phoneConfig.shouldRestrictFirstTimeChatters} 48 | Restrict Account Age: ${phoneConfig.shouldRestrictBasedOnAccountAge} (${utils.humanizeMS(phoneConfig.minimumAccountAgeInMinutes * 60000)}) 49 | Restrict Follower Age: ${phoneConfig.shouldRestrictBasedOnFollowerAge} (${utils.humanizeMS(phoneConfig.minimumFollowerAgeInMinutes * 60000)})` 50 | : "NONE (Not Active)"}` 51 | 52 | return { text: await paste(text), reply: true } 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /lib/commands/streaminfo.js: -------------------------------------------------------------------------------- 1 | const { helix, getUser } = require('../utils/twitchapi.js') 2 | const cheerio = require('cheerio'); 3 | const got = require('got'); 4 | const { nanoid } = require('nanoid'); 5 | 6 | module.exports = { 7 | name: 'streaminfo', 8 | description: 'Stream info about a Twitch channel', 9 | aliases: ['si', 'title', 'game', 'uptime', 'thumbnail', 'thumb'], 10 | cooldown: 5, 11 | usage: '[channel]', 12 | async execute(client, msg, utils) { 13 | const channel = msg.args[0] ? msg.args[0].replace('@', '').toLowerCase() : msg.channel.login 14 | const { body } = await helix(`streams?user_login=${encodeURIComponent(channel)}`) 15 | const stream = body.data ? body.data[0] : null 16 | 17 | if (!stream) { 18 | const user = await getUser(channel) 19 | if (!user) return { text: `user was not found`, reply: true } 20 | 21 | const lastStream = user.lastBroadcast 22 | if (!lastStream.startedAt) return { text: `${utils.antiPing(user.login)} never streamed`, reply: true } 23 | 24 | return { text: `${utils.antiPing(user.login)} last streamed ${utils.humanize(lastStream.startedAt)} ago, title: ${lastStream.title || "(none)"}`, reply: true } 25 | } 26 | 27 | 28 | switch (msg.commandName) { 29 | case "streaminfo": 30 | case "si": 31 | return { text: `Title: ${stream.title ? utils.fitText(stream.title, 50) : "(none)"}, Game: ${stream.game_name || "(none)"}, Uptime: ${utils.humanize(stream.started_at)}, Viewers: ${stream.viewer_count}, Lang: ${stream.language}`, reply: true } 32 | 33 | case "title": 34 | return { text: `Title: ${stream.title || "(none)"}`, reply: true } 35 | 36 | case "game": { 37 | let gameObject 38 | if (stream.game_name && stream.game_name !== 'Just Chatting') { 39 | const body = (await got(`https://store.steampowered.com/search/results?term=${encodeURIComponent(stream.game_name)}`).text()) 40 | .split('').pop().split('')[0] 41 | const $ = cheerio.load(body); 42 | gameObject = $('a[href]')[0] 43 | } 44 | 45 | return { text: `Game: ${stream.game_name || "(none)"}${gameObject ? ` | Steam: ${gameObject.attribs.href.split('?')[0]}` : ''}`, reply: true } 46 | } 47 | 48 | case "uptime": 49 | return { text: `Uptime: ${utils.humanize(stream.started_at)}`, reply: true } 50 | 51 | case "thumbnail": 52 | case "thumb": 53 | return { text: `Thumbnail: https://static-cdn.jtvnw.net/previews-ttv/live_user_${stream.user_login}.png?${nanoid(4)}`, reply: true } 54 | } 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /lib/commands/song.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const got = require('got') 3 | const spotify = require('../utils/spotify.js') 4 | const { getUser, shortLink } = require('../utils/twitchapi.js'); 5 | 6 | function format(ms) { 7 | var minutes = Math.floor(ms / 60000); 8 | var seconds = ((ms % 60000) / 1000).toFixed(0); 9 | return minutes + ":" + (seconds < 10 ? '0' : '') + seconds; 10 | } 11 | 12 | module.exports = { 13 | name: 'song', 14 | description: "Info about the currently playing track on Spotify", 15 | cooldown: 4, 16 | aliases: ['spotify', 'track'], 17 | usage: '[username]', 18 | async execute(client, msg, utils) { 19 | let users = [] 20 | 21 | if (msg.args[0]) { 22 | let target = msg.args[0]; 23 | let forced = false; 24 | 25 | if (target.startsWith('@')) { 26 | target = target.slice(1) 27 | forced = true 28 | } 29 | 30 | const user = await getUser(target) 31 | if (user) users.push({ login: user.login, id: user.id, forced }) 32 | else if (forced) { 33 | return { text: 'user was not found', reply: true } 34 | } 35 | } 36 | users.push({ login: msg.user.login, id: msg.user.id }, { login: msg.channel.login, id: msg.channel.id }) 37 | 38 | const { user, auth, error } = await spotify.getBest(users) 39 | 40 | if (error) return { text: error, reply: true } 41 | if (!auth) return { text: `No Spotify account linked with Twitch, you can login here: ${config.website.url}/spotify`, reply: true } 42 | 43 | const { body: song } = await got("https://api.spotify.com/v1/me/player/currently-playing?market=US", { 44 | responseType: 'json', 45 | headers: { 46 | Authorization: `${auth.token_type} ${auth.access_token}` 47 | } 48 | }) 49 | 50 | if (!song) return { text: `${user.id === msg.user.id ? 'you are' : `${utils.antiPing(user.login)} is`} not listening anything on Spotify`, reply: true } 51 | if (song.currently_playing_type !== 'track') return { text: `FeelsDankMan unknown content type: ${song.currently_playing_type}`, reply: true } 52 | 53 | let songLink = song.item.preview_url?.split('?')[0] 54 | if (!songLink || msg.commandName === 'spotify') songLink = song.item.external_urls.spotify 55 | const shortSongLink = await shortLink(songLink) 56 | 57 | return { 58 | text: `${user.id !== msg.user.id ? `${utils.antiPing(user.login)} ${song.is_playing ? 'is currently' : 'was'} listening to: ` : ''}${song.is_playing ? "▶️" : "⏸️"}[${format(song.progress_ms)}/${format(song.item.duration_ms)}] ${song.item.name} • ${song.item.artists.map(artist => artist.name).join(', ')}${shortSongLink ? ` | ${shortSongLink}` : ""}`, reply: true 59 | } 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /lib/commands/notify.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | 3 | module.exports = { 4 | name: 'notify', 5 | description: "You'll get mentioned in a message when the **current channel** stream title/game changes or the stream goes online/offline 🛎", 6 | aliases: ['notifyme', 'unnotify'], 7 | cooldown: 5, 8 | async execute(client, msg, utils) { 9 | const notifyData = await utils.query(`SELECT COUNT(id) AS query FROM notify_channels WHERE user_id=?`, [msg.channel.id]) 10 | if (!notifyData[0].query) return { text: `this feature is disabled by default, use the "${msg.prefix}suggest" command or contact @${config.owner.login} if you want this feature enabled in this channel`, reply: true } 11 | 12 | const redisKey = `ob:channel:notifyUsers:${msg.channel.id}` 13 | 14 | if (msg.commandName === 'unnotify') { 15 | const data = (await utils.query(`SELECT id, user_login FROM notify_users WHERE channel_id=? AND user_id=? LIMIT 1`, [msg.channel.id, msg.user.id]))[0] 16 | if (data) { 17 | await utils.query(`DELETE FROM notify_users WHERE id=?`, [data.id]) 18 | if (await utils.redis.exists(redisKey)) await utils.redis.srem(redisKey, data.user_login) 19 | return { text: `I'll no longer notify you`, reply: true } 20 | } 21 | return { text: `I'm currently not notifying you when a stream change happens, use "${msg.prefix}notify" if you want to be notified`, reply: true } 22 | } else { 23 | const data = (await utils.query(`SELECT user_login FROM notify_users WHERE channel_id=? AND user_id=? LIMIT 1`, [msg.channel.id, msg.user.id]))[0] 24 | if (data) { 25 | if (data.user_login !== msg.user.login) { 26 | await utils.query(`UPDATE notify_users SET user_login=? WHERE channel_id=? AND user_id=? LIMIT 1`, [msg.user.login, msg.channel.id, msg.user.id]) 27 | 28 | if (await utils.redis.exists(redisKey)) { 29 | await utils.redis.srem(redisKey, data.user_login) 30 | await utils.redis.sadd(redisKey, msg.user.login) 31 | } 32 | 33 | return { text: `successfully updated your notify entry (${data.user_login} → ${msg.user.login})`, reply: true } 34 | } else return { text: `I'm already notifying you, use "${msg.prefix}unnotify" if you don't want to be notified any more`, reply: true } 35 | } 36 | 37 | await utils.query(`INSERT INTO notify_users (channel_id, channel_login, user_id, user_login) VALUES (?, ?, ?, ?)`, [msg.channel.id, msg.channel.login, msg.user.id, msg.user.login]) 38 | if (await utils.redis.exists(redisKey)) await utils.redis.sadd(redisKey, msg.user.login) 39 | return { text: `I'll notify you when the stream title/game changes or the streamer goes online/offline BroBalt`, reply: true } 40 | } 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /lib/utils/pajbot.js: -------------------------------------------------------------------------------- 1 | const got = require('got') 2 | const { accents } = require('./regex.js') 3 | const { performance } = require('perf_hooks'); 4 | 5 | module.exports = { 6 | banphraseCheck: async function (text, api) { 7 | let banned = true 8 | 9 | do { 10 | try { 11 | const { body } = await got.post(api, { 12 | json: { message: text }, 13 | responseType: 'json' 14 | }) 15 | 16 | banned = body["banned"]; 17 | 18 | if (banned) { 19 | const banphraseData = body["banphrase_data"]; 20 | 21 | const phrase = banphraseData["phrase"]; 22 | const caseSensitive = banphraseData["case_sensitive"]; 23 | const operator = banphraseData["operator"]; 24 | const removeAccents = banphraseData["remove_accents"]; 25 | 26 | let regex; 27 | 28 | switch (operator) { 29 | case "regex": regex = phrase; break; 30 | case "contains": regex = escapeRegex(phrase); break; 31 | case "startswith": regex = `^${escapeRegex(phrase)}`; break; 32 | case "endswith": regex = `${escapeRegex(phrase)}$`; break; 33 | case "exact": regex = `^${escapeRegex(phrase)}$`; break; 34 | } 35 | 36 | let flags = "g"; 37 | if (!caseSensitive) flags += "i"; 38 | if (removeAccents) text = text.normalize("NFD").replace(accents, "") 39 | 40 | const phraseRegex = new RegExp(regex, flags); 41 | const censoredText = text.replace(phraseRegex, '***'); 42 | text = censoredText; 43 | } 44 | } catch (err) { 45 | console.error(err); 46 | break; 47 | } 48 | } while (banned); 49 | 50 | return text; 51 | }, 52 | banphrasePing: function (api) { 53 | return new Promise(async (resolve, reject) => { 54 | try { 55 | const t0 = performance.now(); 56 | const { statusCode } = await got.post(api, { 57 | json: { message: 'test' }, 58 | responseType: 'json', 59 | throwHttpErrors: false 60 | }) 61 | const t1 = performance.now(); 62 | const latency = (t1 - t0).toFixed(); 63 | 64 | const url = new URL(api) 65 | 66 | if (statusCode < 200 || statusCode > 299) return reject(`error (${statusCode})`) 67 | resolve(`${url.hostname} (${latency}ms)`) 68 | } catch (err) { 69 | reject('validation failed') 70 | } 71 | }) 72 | } 73 | }; 74 | 75 | function escapeRegex(string) { 76 | return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 77 | } -------------------------------------------------------------------------------- /lib/commands/user.js: -------------------------------------------------------------------------------- 1 | const twitchapi = require('../utils/twitchapi.js') 2 | 3 | module.exports = { 4 | name: 'user', 5 | description: 'Info about a Twitch user', 6 | cooldown: 3, 7 | aliases: ['uid', 'staff', 'isbot', 'partner', 'affiliate', 'banned', 'age', 'color', 'bio', 'banner'], 8 | usage: "[username | userid]", 9 | async execute(client, msg, utils) { 10 | const user = await twitchapi.getUser(msg.args[0] ? msg.args[0].replace('@', '') : msg.user.login) 11 | if (!user) return { text: `user was not found`, reply: true } 12 | 13 | const userTag = user.displayName.toLowerCase() === user.login ? 14 | `@${user.displayName}` : `@${user.login} (${user.displayName})`; 15 | 16 | switch (msg.commandName) { 17 | case "user": { 18 | let flags = [] 19 | 20 | if (user.roles.isPartner) flags.push('Partner') 21 | if (user.roles.isAffiliate) flags.push('Affiliate') 22 | if (user.verifiedBot) flags.push('Verified Bot') 23 | if (user.roles.isStaff) flags.push('Staff') 24 | 25 | return { text: `${user.banned ? `⛔ Banned (${user.banReason}) • ` : ""}${user.id} | @${user.login}, color: ${user.chatColor || "(none)"}, badges: ${user.badges.length ? user.badges.map(badge => badge.title).join(' | ') : "(none)"}, age: ${utils.humanize(user.createdAt)}${flags.length ? ` • ${flags.join(', ')}` : ""}`, reply: true } 26 | } 27 | 28 | case "uid": 29 | return { text: `${user.id} | @${user.login}`, reply: true } 30 | 31 | case "staff": 32 | return { text: `${userTag} • Staff: ${user.roles.isStaff ? "TRUE" : "false"}, Site Admin: ${user.roles.isSiteAdmin ? "TRUE" : "false"}`, reply: true } 33 | 34 | case "isbot": 35 | return { text: `${userTag} • Verified Bot: ${user.verifiedBot ? "TRUE" : "false"}`, reply: true } 36 | 37 | case "partner": 38 | return { text: `${userTag} • Partner: ${user.roles.isPartner ? "TRUE" : "false"}`, reply: true } 39 | 40 | case "affiliate": 41 | return { text: `${userTag} • Affiliate: ${user.roles.isAffiliate ? "TRUE" : "false"}`, reply: true } 42 | 43 | case "banned": 44 | return { text: `${userTag} • Banned: ${user.banned ? `TRUE (${user.banReason})` : "false"}`, reply: true } 45 | 46 | case "age": 47 | return { text: `${userTag} • Acc Age: ${utils.humanize(user.createdAt)}, Created: ${new Date(user.createdAt).toLocaleDateString()}`, reply: true } 48 | 49 | case "color": 50 | return { text: `${userTag} • Chat Color: ${user.chatColor || "(none)"}`, reply: true } 51 | 52 | case "bio": 53 | return { text: `${userTag} • Bio: ${user.bio ? `"${user.bio}"` : "N/A"}` } 54 | 55 | case "banner": 56 | return { text: `${userTag} • Profile Banner: ${user.banner ?? "(none)"}` } 57 | } 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /lib/commands/nuke.js: -------------------------------------------------------------------------------- 1 | const ms = require('ms') 2 | const twitchapi = require('../utils/twitchapi.js'); 3 | 4 | module.exports = { 5 | name: 'nuke', 6 | access: 'mod', 7 | botRequires: 'mod', 8 | cooldown: 15, 9 | usage: " ", 10 | async execute(client, msg, utils) { 11 | const usage = `usage: ${msg.prefix}nuke ` 12 | 13 | if (msg.args.length < 3) return { text: usage, reply: true } 14 | 15 | let interval = msg.args[0] 16 | let duration = msg.args[1].toLowerCase() 17 | 18 | if (isNaN(interval)) { 19 | interval = ms(interval) / 1000 20 | if (!interval) return { text: `the loopback seconds should be a number, ${usage}`, reply: true } 21 | } 22 | if (interval > 432000) return { text: `the maximum loopback seconds is 432000`, reply: true } 23 | if (interval < 5) return { text: `the minimum loopback seconds is 5`, reply: true } 24 | 25 | if (isNaN(duration) && duration !== 'ban') { 26 | duration = ms(duration) / 1000 27 | if (!duration) return { text: `the duration should be the timeout in seconds or "ban", ${usage}`, reply: true } 28 | } 29 | if (duration !== 'ban') { 30 | if (duration > 1209600) return { text: `the maximum timeout length is 2 weeks`, reply: true } 31 | if (duration < 1) duration = 'ban' 32 | } 33 | 34 | const regexp = msg.args.slice(2).join(' ').match(new RegExp('^/(.*?)/([gimsuy]*)$')) 35 | if (!regexp) return { text: `invalid regex`, reply: true } 36 | 37 | let regex 38 | try { 39 | regex = new RegExp(regexp[1], regexp[2]) 40 | } catch (err) { 41 | return { text: err.message, reply: true } 42 | } 43 | 44 | const messages = await utils.query(`SELECT user_id AS user, message AS text FROM messages WHERE timestamp > DATE_SUB(NOW(),INTERVAL ? SECOND) AND channel_id=?`, [interval, msg.channel.id]) 45 | const messageCount = messages.length 46 | if (!messageCount) return { text: `no messages logged in the last ${interval}s`, reply: true } 47 | 48 | const users = []; 49 | for (let i = 0; i < messageCount; i++) { 50 | const message = messages[i] 51 | if (users.includes(message.user) || !regex.test(message.text)) { continue; } 52 | 53 | users.push(message.user) 54 | } 55 | 56 | const usersCount = users.length 57 | if (!usersCount) return { text: `no messages matched your regex`, reply: true } 58 | 59 | const reason = `nuked with RegExp "${regexp[1]}"` 60 | if (duration === 'ban') { 61 | for (const target of users) 62 | twitchapi.banUser(msg.channel.id, target, reason) 63 | } else { 64 | for (const target of users) 65 | twitchapi.timeoutUser(msg.channel.id, target, duration, reason) 66 | } 67 | 68 | await utils.redis.set(`ob:channel:nuke:${msg.channel.id}`, JSON.stringify(users), "EX", 86400) // 1 day 69 | }, 70 | }; 71 | -------------------------------------------------------------------------------- /lib/commands/channel.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const twitchapi = require('../utils/twitchapi.js') 3 | const { createListener } = require('../misc/pubsub.js') 4 | const globalSubs = ['video-playback-by-id', 'chatrooms-user-v1'] 5 | 6 | module.exports = { 7 | name: 'channel', 8 | async execute(client, msg, utils) { 9 | if (msg.user.id !== config.owner.userId) return 10 | if (msg.args.length < 2) return { text: 'invalid usage' } 11 | const option = msg.args[0].toLowerCase() 12 | const channel = msg.args[1].toLowerCase() 13 | 14 | switch (option) { 15 | case "join": { 16 | const data = await utils.query(`SELECT COUNT(id) AS entries FROM channels WHERE login=?`, [channel]) 17 | 18 | if (!data[0].entries) { 19 | const user = await twitchapi.getUser(channel) 20 | if (!user) return { text: "couldn't resolve the user provided" } 21 | await utils.query(`INSERT INTO channels (platform_id, login) VALUES (?, ?)`, [user.id, user.login]) 22 | const dbID = (await utils.query(`SELECT id FROM channels WHERE platform_id=? LIMIT 1`, [user.id]))[0].id 23 | 24 | try { 25 | await client.join(user.login) 26 | for (const sub of globalSubs) { createListener({ id: user.id, login: user.login }, sub) } 27 | if (!msg.text.split(' ').includes('-silent')) await client.say(user.login, `FeelsOkayMan Successfully joined! You can use "${config.bot.defaultPrefix}help" for the command list. If you need more assistance, use "${config.bot.defaultPrefix}suggest" followed by your question or suggestion.`) 28 | } catch (e) { 29 | console.error(e) 30 | return { text: `monkaS error: ${e.message}` } 31 | } 32 | 33 | return { text: `successfully joined channel ${user.login} • ${user.id} (Database ID: ${dbID})` } 34 | } 35 | 36 | client.join(channel) 37 | return { text: 'channel already in database, tried to rejoin' } 38 | } 39 | case "part": { 40 | const user = (await utils.query(`SELECT platform_id AS id FROM channels WHERE login=? LIMIT 1`, [channel]))[0] 41 | if (!user) return { text: "channel not in database" } 42 | 43 | client.part(channel) 44 | await Promise.all([ 45 | utils.redis.del(`ob:channel:${user.id}`), 46 | utils.redis.del(`ob:channel:notifyUsers:${user.id}`), 47 | utils.redis.del(`ob:channel:clips:${user.id}`), 48 | utils.redis.del(`ob:channel:nuke:${user.id}`), 49 | utils.query(`DELETE FROM channels WHERE platform_id=?`, [user.id]), 50 | utils.query(`DELETE FROM notify_channels WHERE user_id=?`, [user.id]), 51 | utils.query(`DELETE FROM emote_rewards WHERE channel_id=?`, [user.id]), 52 | ]) 53 | 54 | await client.say(msg.channel.login, `successfully parted channel ${channel} - ${user.id}, deleting message logs... (this might take a while)`) 55 | 56 | await utils.query(`DELETE FROM messages WHERE channel_id=?`, [user.id]) 57 | return { text: `successfully deleted message logs for ${channel} - ${user.id}` } 58 | } 59 | 60 | default: return { text: 'invalid usage' } 61 | } 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /lib/commands/xd.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const { execSync, exec } = require("child_process"); 3 | const commands = require('../misc/commands.js') 4 | 5 | module.exports = { 6 | name: 'xd', 7 | async execute(client, msg, utils) { 8 | if (msg.user.id !== config.owner.userId) return 9 | if (!msg.args.length) return { text: 'Pepega' } 10 | const option = msg.args[0].toLowerCase() 11 | 12 | function getChanges(arr) { 13 | return arr.find(value => /files? changed/.test(value)); 14 | } 15 | 16 | switch (option) { 17 | case "load": { 18 | if (msg.args.length < 2) return { text: 'Pepega' } 19 | const commandName = msg.args[1].toLowerCase(); 20 | const command = commands.get(commandName) 21 | 22 | if (command) return { text: `command "${commandName}" is already loaded` }; 23 | 24 | const newCommand = require(`./${commandName}.js`); 25 | if (!newCommand) return { text: `no command file named "${commandName}" found` } 26 | commands.add(newCommand) 27 | 28 | return { text: `loaded ${commandName} BroBalt` } 29 | } 30 | case "unload": { 31 | if (msg.args.length < 2) return { text: 'Pepega' } 32 | const commandName = msg.args[1].toLowerCase(); 33 | const command = commands.get(commandName) 34 | 35 | if (!command) return { text: `no command named "${commandName}" found"` }; 36 | 37 | commands.delete(command) 38 | 39 | return { text: `unloaded ${command.name} BroBalt` } 40 | } 41 | case "reload": { 42 | if (msg.args.length < 2) return { text: 'Pepega' } 43 | const commandName = msg.args[1].toLowerCase(); 44 | const command = commands.get(commandName) 45 | 46 | if (!command) return { text: `no command named "${commandName}" found` }; 47 | 48 | commands.delete(command) 49 | const newCommand = require(`./${command.name}.js`); 50 | if (!newCommand) return { text: `no command file named "${commandName}" found` } 51 | commands.add(newCommand) 52 | 53 | return { text: `reloaded ${command.name} BroBalt` } 54 | } 55 | case "pull": { 56 | const res = execSync('git pull').toString().split('\n').filter(Boolean) 57 | if (res.includes('Already up to date.')) return { text: 'no changes detected 🤓☝' } 58 | return { text: `🤓👉 ${getChanges(res) || res.join(' | ')}` } 59 | } 60 | case "restart": { 61 | const res = execSync('git pull').toString().split('\n').filter(Boolean) 62 | if (res.includes('Already up to date.')) await msg.send('ppCircle Restarting without any changes') 63 | else await msg.send(`ppCircle Restarting 🤓👉 ${getChanges(res) || res.join(' | ')}`) 64 | exec('pm2 restart okeybot'); 65 | break; 66 | } 67 | case "reload-ignored-users": { 68 | const ignoredUsers = (await utils.query('SELECT user_id FROM ignored_users')).map(data => data.user_id) 69 | client.ignoredUsers = new Set(ignoredUsers) 70 | 71 | return { text: `reloaded ignored users BroBalt` } 72 | break; 73 | } 74 | default: { 75 | msg.send('invalid option FeelsDankMan') 76 | break; 77 | } 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /lib/commands/esearch.js: -------------------------------------------------------------------------------- 1 | const { parseArgs } = require('node:util') 2 | const got = require('got') 3 | 4 | const options = { 5 | exact: { 6 | type: 'boolean', 7 | short: 'e', 8 | }, 9 | sensitive: { 10 | type: 'boolean', 11 | short: 's', 12 | } 13 | }; 14 | 15 | module.exports = { 16 | name: 'esearch', 17 | description: 'Search an emote by name on BTTV/FFZ/7TV', 18 | aliases: ['bttv', 'ffz', '7tv'], 19 | cooldown: 3, 20 | usage: '[platform] ', 21 | async execute(client, msg, utils) { 22 | const { values, positionals } = parseArgs({ args: msg.args, options, allowPositionals: true }); 23 | 24 | const platforms = { 25 | "ffz": async (name) => { 26 | const { emoticons: emotes } = await got(`https://api.frankerfacez.com/v1/emoticons`, { 27 | searchParams: { 28 | q: name, 29 | per_page: 5, 30 | sort: "count-desc", 31 | sensitive: values.case 32 | } 33 | }).json() 34 | 35 | return emotes.map(e => ({ 36 | name: e.name, 37 | url: `https://www.frankerfacez.com/emoticon/${e.id}` 38 | })) 39 | }, 40 | "bttv": async (name) => { 41 | const emotes = await got(`https://api.betterttv.net/3/emotes/shared/search?query=${encodeURIComponent(name)}&limit=5`).json() 42 | 43 | return emotes.map(e => ({ 44 | name: e.code, 45 | url: `https://betterttv.com/emotes/${e.id}` 46 | })) 47 | }, 48 | "7tv": async (name) => { 49 | const res = await got.post(`https://7tv.io/v3/gql`, { 50 | json: { 51 | "variables": { 52 | "query": name, 53 | "page": 1, 54 | "limit": 5, 55 | "filter": { 56 | "category": "TOP", 57 | "exact_match": values.exact, 58 | "case_sensitive": values.case, 59 | "ignore_tags": false, 60 | "zero_width": false, 61 | "animated": false, 62 | "aspect_ratio": "" 63 | } 64 | }, 65 | "operationName": "SearchEmotes", 66 | "query": "query SearchEmotes($query: String!, $page: Int, $limit: Int, $filter: EmoteSearchFilter) { emotes(query: $query, page: $page, limit: $limit, filter: $filter) { items { name id } } }" 67 | }, 68 | }).json() 69 | 70 | const emotes = res.data.emotes.items 71 | return emotes.map(e => ({ 72 | name: e.name, 73 | url: `https://7tv.app/emotes/${e.id}` 74 | })) 75 | } 76 | } 77 | 78 | const validChoices = Object.keys(platforms) 79 | 80 | let choice = msg.commandName.toLowerCase() 81 | let emotes = [] 82 | if (validChoices.includes(choice)) { 83 | if (!positionals.length) return { text: 'you need to specify the emote name you want to search', reply: true } 84 | emotes = await platforms[choice](positionals[0]) 85 | } else { 86 | if (positionals.length < 2) return { text: `you need to specify the add-on (${validChoices.join('/')}) and the emote name you want to search`, reply: true } 87 | 88 | choice = positionals[0].toLowerCase() 89 | if (!validChoices.includes(choice)) return { text: `you need to specify a valid add-on to search (${validChoices.join('/')})`, reply: true } 90 | 91 | emotes = await platforms[choice](positionals[1]) 92 | } 93 | 94 | if (!emotes.length) return { text: 'no emotes found for the specified platform', reply: true } 95 | 96 | return { text: emotes.slice(0, 5).map(e => `${utils.fitText(e.name, 15)}: ${e.url}`).join(" \u{2022} "), reply: true } 97 | }, 98 | }; 99 | -------------------------------------------------------------------------------- /lib/commands/suggest.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const { paste, getUser, getMods, getVips } = require('../utils/twitchapi.js') 3 | 4 | module.exports = { 5 | name: 'suggest', 6 | description: 'Make a bot-related suggestion', 7 | aliases: ['addbot'], 8 | cooldown: 10, 9 | usage: '', 10 | async execute(client, msg, utils) { 11 | const suggestionsToday = (await utils.query(`SELECT COUNT(id) AS num FROM suggestions WHERE created > DATE_SUB(NOW(),INTERVAL 1 DAY) AND author_id=? AND status=? LIMIT 5`, [msg.user.id, 'Pending Review']))[0].num 12 | if (suggestionsToday > 5) return { text: "you can't make more than 5 suggestions a day", reply: true } 13 | 14 | let text 15 | let dink 16 | 17 | if (msg.commandName === 'addbot') { 18 | const user = await getUser(msg.args[0] ? msg.args[0].replace('@', '') : msg.user.login) 19 | if (!user) return { text: `couldn't resolve the user provided, usage: ${msg.prefix}addbot `, reply: true } 20 | if (user.banned) return { text: `the provided user is banned monkaS`, reply: true } 21 | 22 | const check = await utils.query(`SELECT COUNT(id) AS entries FROM channels WHERE platform_id=?`, [user.id]) 23 | if (check[0].entries) { 24 | if (!client.joinedChannels.has(user.login)) { 25 | client.join(user.login) 26 | return { text: "this channel is already in my database, but it's parted. I've tried rejoining", reply: true } 27 | } 28 | 29 | return { text: `I'm already in the channel "${utils.antiPing(user.login)}"` } 30 | } 31 | 32 | const [mods, vips] = await Promise.all([getMods(user.login), getVips(user.login)]) 33 | 34 | if (msg.user.id !== user.id && !mods.includes(msg.user.login)) return { text: `you can't add the bot to a channel that you don't moderate`, reply: true } 35 | 36 | let flags = [] 37 | if (user.roles.isPartner) flags.push('Partner') 38 | if (user.roles.isAffiliate) flags.push('Affiliate') 39 | if (user.bot) flags.push('Verified Bot') 40 | if (user.roles.isStaff) flags.push('Staff') 41 | 42 | const data = `requested by: ${msg.user.login}\n\nCHANNEL:\n login: ${user.login}\n display name: ${user.displayName}\n chat color: ${user.chatColor || "(none)"}\n flags: ${flags.join(', ') || "(none)"}\n badges: ${user.badges.length ? user.badges.map(badge => badge.title).join(', ') : "(none)"}\n created: ${utils.humanize(user.createdAt)} ago\n updated: ${utils.humanize(user.updatedAt)} ago\n bio: ${user.bio || "(none)"}\n\nCHAT:\n non-mod delay: ${user.chatSettings.chatDelayMs / 1000}s\n followers only: ${user.chatSettings.followersOnlyDurationMinutes ? `${user.chatSettings.followersOnlyDurationMinutes}m` : `no`}\n slow mode: ${user.chatSettings.slowModeDurationSeconds ? `${user.chatSettings.slowModeDurationSeconds}s` : `no`}\n block links: ${user.chatSettings.blockLinks ? `yes` : `no`}\n subscribers only: ${user.chatSettings.isSubscribersOnlyModeEnabled ? `yes` : `no`}\n emote only: ${user.chatSettings.isEmoteOnlyModeEnabled ? `yes` : `no`}${mods.includes(config.bot.login) ? `\n ${config.bot.login} is modded` : ''}${vips.includes(config.bot.login) ? `\n ${config.bot.login} is vipped` : ''}` 43 | const channelInfo = await paste(data) 44 | 45 | text = msg.args.length ? `Bot Addition request:\n\n${msg.args.join(' ')}\n${channelInfo}` : `Bot Addition request\n${channelInfo}` 46 | dink = `new bot request peepoDetective 👉 ${channelInfo}` 47 | } 48 | else { 49 | if (!msg.args.length) return { text: 'you need to provide a message', reply: true } 50 | text = msg.args.join(' ') 51 | dink = `new suggestion` 52 | } 53 | 54 | await utils.query(`INSERT INTO suggestions (author_login, author_id, text) VALUES (?, ?, ?)`, [msg.user.login, msg.user.id, text]) 55 | const id = (await utils.query(`SELECT id FROM suggestions WHERE author_id=? ORDER BY id DESC LIMIT 1`, [msg.user.id]))[0].id; 56 | client.say(config.bot.login, `@${config.owner.login} ${dink} (ID: ${id}) DinkDonk`) 57 | return { text: `your suggestion has been saved, and will be manually resolved asap BroBalt (ID: ${id})`, reply: true } 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /lib/commands/epicgames.js: -------------------------------------------------------------------------------- 1 | const got = require('got'); 2 | 3 | module.exports = { 4 | name: 'epicgames', 5 | description: 'Search a game on Epic Games', 6 | aliases: ['epic'], 7 | cooldown: 5, 8 | usage: '', 9 | async execute(client, msg, utils) { 10 | if (!msg.args.length) return { text: `you need to provide a game name to search`, reply: true } 11 | const game = msg.args.join(' ') 12 | 13 | const { body } = await got.post(`https://www.epicgames.com/graphql`, { 14 | responseType: 'json', 15 | json: { 16 | "query": "query searchStoreQuery($allowCountries: String, $category: String, $count: Int, $country: String!, $keywords: String, $locale: String, $namespace: String, $itemNs: String, $sortBy: String, $sortDir: String, $start: Int, $tag: String, $releaseDate: String, $withPrice: Boolean = false, $withPromotions: Boolean = false, $priceRange: String, $freeGame: Boolean, $onSale: Boolean, $effectiveDate: String) {\n Catalog {\n searchStore(\n allowCountries: $allowCountries\n category: $category\n count: $count\n country: $country\n keywords: $keywords\n locale: $locale\n namespace: $namespace\n itemNs: $itemNs\n sortBy: $sortBy\n sortDir: $sortDir\n releaseDate: $releaseDate\n start: $start\n tag: $tag\n priceRange: $priceRange\n freeGame: $freeGame\n onSale: $onSale\n effectiveDate: $effectiveDate\n ) {\n elements {\n title\n id\n namespace\n description\n effectiveDate\n keyImages {\n type\n url\n }\n currentPrice\n seller {\n id\n name\n }\n productSlug\n urlSlug\n url\n tags {\n id\n }\n items {\n id\n namespace\n }\n customAttributes {\n key\n value\n }\n categories {\n path\n }\n catalogNs {\n mappings(pageType: \"productHome\") {\n pageSlug\n pageType\n }\n }\n offerMappings {\n pageSlug\n pageType\n }\n price(country: $country) @include(if: $withPrice) {\n totalPrice {\n discountPrice\n originalPrice\n voucherDiscount\n discount\n currencyCode\n currencyInfo {\n decimals\n }\n fmtPrice(locale: $locale) {\n originalPrice\n discountPrice\n intermediatePrice\n }\n }\n lineOffers {\n appliedRules {\n id\n endDate\n discountSetting {\n discountType\n }\n }\n }\n }\n promotions(category: $category) @include(if: $withPromotions) {\n promotionalOffers {\n promotionalOffers {\n startDate\n endDate\n discountSetting {\n discountType\n discountPercentage\n }\n }\n }\n upcomingPromotionalOffers {\n promotionalOffers {\n startDate\n endDate\n discountSetting {\n discountType\n discountPercentage\n }\n }\n }\n }\n }\n paging {\n count\n total\n }\n }\n }\n}\n", 17 | "variables": { 18 | "category": "games/edition/base|bundles/games|editors|software/edition/base", 19 | "keywords": game, 20 | "country": "US", 21 | "locale": "en-US", 22 | "sortDir": "DESC", 23 | "withPrice": true, 24 | "withMapping": false 25 | } 26 | } 27 | }) 28 | const games = body.data.Catalog.searchStore.elements 29 | 30 | if (!games.length) return { text: `no games found`, reply: true } 31 | const gameData = games[0] 32 | const gamePrice = gameData.price.totalPrice.fmtPrice 33 | const discountPrice = gamePrice.discountPrice 34 | const originalPrice = gamePrice.originalPrice 35 | 36 | return { text: `https://www.epicgames.com/store/p/${encodeURIComponent(gameData.catalogNs.mappings[0].pageSlug)} | Price: ${discountPrice === "0" ? "Free" : discountPrice}${discountPrice !== originalPrice ? ` [🏷️ Discount from ${originalPrice}]` : ""}`, reply: true } 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /lib/utils/utils.js: -------------------------------------------------------------------------------- 1 | const { pool, redis } = require('../misc/connections.js') 2 | const humanize = require('humanize-duration'); 3 | const utils = this; 4 | 5 | const shortHumanize = humanize.humanizer({ 6 | language: 'shortEn', 7 | languages: { 8 | shortEn: { 9 | y: () => 'y', 10 | mo: () => 'mo', 11 | w: () => 'w', 12 | d: () => 'd', 13 | h: () => 'h', 14 | m: () => 'm', 15 | s: () => 's', 16 | ms: () => 'ms', 17 | }, 18 | }, 19 | }); 20 | 21 | const htmlEntities = { 22 | nbsp: ' ', 23 | cent: '¢', 24 | pound: '£', 25 | yen: '¥', 26 | euro: '€', 27 | copy: '©', 28 | reg: '®', 29 | lt: '<', 30 | gt: '>', 31 | quot: '"', 32 | amp: '&', 33 | apos: '\'' 34 | }; 35 | 36 | exports.formatNumber = (num) => { 37 | return num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,') 38 | }; 39 | 40 | exports.antiPing = (argument) => { 41 | const firstChar = argument.slice(0, 1) 42 | const remainingString = argument.slice(1) 43 | 44 | return firstChar + '\u{E0000}' + remainingString 45 | }; 46 | 47 | exports.humanize = (date, converted) => { 48 | let ms = date 49 | if (!converted) ms = Date.now() - Date.parse(date); 50 | const options = { 51 | units: ['y', 'mo', 'd', 'h', 'm', 's'], 52 | largest: 3, 53 | round: true, 54 | delimiter: ' ', 55 | spacer: '', 56 | }; 57 | return shortHumanize(ms, options); 58 | }; 59 | 60 | exports.humanizeMS = (ms) => { 61 | const options = { 62 | units: ['y', 'd', 'h', 'm', 's'], 63 | largest: 3, 64 | round: true, 65 | delimiter: ' ', 66 | spacer: '', 67 | }; 68 | return shortHumanize(ms, options); 69 | }; 70 | 71 | exports.fitText = (text, maxLength) => { 72 | return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text 73 | }; 74 | 75 | exports.randArray = (array) => { 76 | return array[Math.floor(Math.random() * array.length)]; 77 | } 78 | 79 | exports.unescapeHTML = (str) => { 80 | return str.replace(/\&([^;]+);/g, function (entity, entityCode) { 81 | var match; 82 | 83 | if (entityCode in htmlEntities) { 84 | return htmlEntities[entityCode]; 85 | /*eslint no-cond-assign: 0*/ 86 | } else if (match = entityCode.match(/^#x([\da-fA-F]+)$/)) { 87 | return String.fromCharCode(parseInt(match[1], 16)); 88 | /*eslint no-cond-assign: 0*/ 89 | } else if (match = entityCode.match(/^#(\d+)$/)) { 90 | return String.fromCharCode(~~match[1]); 91 | } else { 92 | return entity; 93 | } 94 | }); 95 | } 96 | 97 | exports.flag = require('country-emoji').flag; 98 | 99 | exports.redis = redis; 100 | 101 | exports.pool = pool; 102 | 103 | exports.query = async (query, data = []) => { 104 | return new Promise(async (resolve, reject) => { 105 | try { 106 | const conn = await utils.pool.getConnection() 107 | const res = await conn.query(query, data) 108 | conn.release() 109 | resolve(res) 110 | } catch (err) { 111 | reject(err) 112 | console.error(err) 113 | } 114 | }) 115 | }; 116 | 117 | exports.getChannel = async (userid) => { 118 | const cacheData = await utils.redis.get(`ob:channel:${userid}`) 119 | 120 | if (cacheData) { 121 | return JSON.parse(cacheData) 122 | } else { 123 | const channelData = (await utils.query(`SELECT login, prefix, pajbot_api, logging, added FROM channels WHERE platform_id=?`, [userid]))[0] 124 | utils.redis.set(`ob:channel:${userid}`, JSON.stringify(channelData)) 125 | 126 | if (!channelData) throw new Error(`Channel (${userid}) not found`) 127 | return channelData 128 | } 129 | }; 130 | 131 | exports.change = async (userid, value, data, channelData) => { 132 | if (!channelData) channelData = await utils.getChannel(userid) 133 | 134 | channelData[value] = data 135 | await Promise.all([ 136 | utils.redis.set(`ob:channel:${userid}`, JSON.stringify(channelData)), 137 | utils.query(`UPDATE channels SET ${value}=? WHERE platform_id=?`, [data, userid]) 138 | ]) 139 | } 140 | 141 | exports.sleep = (ms) => { 142 | return new Promise(resolve => setTimeout(resolve, ms)) 143 | }; 144 | 145 | exports.splitArray = (arr, len) => { 146 | var chunks = [], i = 0, n = arr.length; 147 | while (i < n) { 148 | chunks.push(arr.slice(i, i += len)); 149 | } 150 | return chunks; 151 | }; 152 | -------------------------------------------------------------------------------- /lib/misc/pubsubEvents.js: -------------------------------------------------------------------------------- 1 | const utils = require('../utils/utils.js') 2 | const { client } = require('./connections.js') 3 | const config = require('../../config.json') 4 | const emotes = require("../utils/emotes.js") 5 | const notify = require('../utils/notify.js') 6 | 7 | module.exports = { 8 | "stream-up": async (msg, channelData) => { 9 | await utils.change(msg.channelID, 'live', true, channelData) 10 | notify(msg.channelID, 'online') 11 | }, 12 | 13 | "stream-down": async (msg, channelData) => { 14 | await utils.change(msg.channelID, 'live', false, channelData) 15 | notify(msg.channelID, 'offline') 16 | }, 17 | 18 | "broadcast_settings_update": (msg) => { 19 | if (msg.game_id !== msg.old_game_id) { 20 | notify(msg.channelID, 'category', msg.game) 21 | if (msg.channel === 'chimichanga' && msg.game === 'Just Chatting') client.say(msg.channel, "docAwaken") 22 | } 23 | 24 | if (msg.status !== msg.old_status) notify(msg.channelID, 'title', msg.status) 25 | }, 26 | 27 | "tos-strike": (msg, channel) => { 28 | client.say(config.bot.login, `[PubSub] @${channel.login} got suspended monkaS ${JSON.stringify(msg)}`) 29 | }, 30 | 31 | "user_moderation_action": async (msg, channel) => { 32 | if (msg.data.target_id !== config.bot.userId) return 33 | 34 | if (msg.data.action === 'unban') { 35 | try { 36 | await client.join(channel.login) 37 | client.say(channel.login, `Successfully rejoined MrDestructoid`) 38 | } catch (e) { 39 | console.error(e) 40 | } 41 | } 42 | }, 43 | 44 | "create_unban_request": (msg, channel) => { 45 | client.say(channel.login, `New unban request from user "${msg.data.requester_login}" MODS`) 46 | }, 47 | 48 | "reward-redeemed": async (msg, channel) => { 49 | const redemption = msg.data.redemption 50 | 51 | const data = (await utils.query('SELECT channel_login, app_userid, emote_id, reward_title, app FROM emote_rewards WHERE channel_id=? AND reward_title=?', [redemption.channel_id, redemption.reward.title]))[0] 52 | if (!data) return; 53 | 54 | try { 55 | if (data.app === "bttv") { 56 | let bttvID 57 | 58 | if (!data.app_userid) { 59 | bttvID = await emotes.getBTTVid(redemption.channel_id) 60 | await utils.query(`UPDATE emote_rewards SET app_userid=? WHERE channel_id=? AND reward_title=?`, [bttvID, redemption.channel_id, redemption.reward.title]) 61 | } else { 62 | bttvID = data.app_userid 63 | } 64 | 65 | const parsedInput = (new RegExp(/https?:\/*betterttv\.com\/emotes\/([A-Za-z0-9]+)/)).exec(redemption.user_input); 66 | if (!parsedInput) throw "you didn't specified the emote url" 67 | 68 | const removedEmote = await emotes.BTTVemote('remove', data.emote_id, bttvID) 69 | const addedEmote = await emotes.BTTVemote('add', parsedInput[1], bttvID) 70 | 71 | await utils.query(`UPDATE emote_rewards SET emote_id=? WHERE channel_id=? AND reward_title=?`, [parsedInput[1], redemption.channel_id, redemption.reward.title]) 72 | client.say(data.channel_login, `bttvNice • VisLaud 👉 ${redemption.user.display_name} successfully added the emote ${addedEmote} and removed ${removedEmote}`) 73 | } else if (data.app === '7tv') { 74 | let stvID 75 | 76 | if (!data.app_userid) { 77 | stvID = await emotes.getSTVid(data.channel_login) 78 | await utils.query(`UPDATE emote_rewards SET app_userid=? WHERE channel_id=? AND reward_title=?`, [stvID, redemption.channel_id, redemption.reward.title]) 79 | } else { 80 | stvID = data.app_userid 81 | } 82 | 83 | const parsedInput = /7tv\.app\/emotes\/([a-z0-9]+)/i.exec(redemption.user_input); 84 | if (!parsedInput) throw "you didn't specified the emote url" 85 | 86 | const removedEmote = await emotes.STVemote('remove', data.emote_id, stvID) 87 | const addedEmote = await emotes.STVemote('add', parsedInput[1], stvID) 88 | 89 | await utils.query(`UPDATE emote_rewards SET emote_id=? WHERE channel_id=? AND reward_title=?`, [parsedInput[1], redemption.channel_id, redemption.reward.title]) 90 | client.say(data.channel_login, `(7TV) • VisLaud 👉 ${redemption.user.display_name} successfully added the emote ${addedEmote} and removed ${removedEmote}`) 91 | } 92 | } catch (err) { 93 | client.say(data.channel_login, `⚠️ ${redemption.user.display_name}, monkaS ${err}`) 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /lib/misc/handler.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | 3 | const { logger } = require('../utils/logger.js') 4 | const { client } = require('./connections.js') 5 | const commands = require('../misc/commands.js') 6 | const cooldown = require('../utils/cooldown.js') 7 | const utils = require('../utils/utils.js') 8 | 9 | const { invisChars } = require('../utils/regex.js') 10 | 11 | module.exports = { 12 | handle: async function (msg) { 13 | if (msg.user.id === config.bot.userId || client.ignoredUsers.has(msg.user.id)) return 14 | 15 | msg.text = msg.text.replace(invisChars, '') 16 | msg.args = msg.text.split(/\s+/) 17 | 18 | if (msg.tags['reply-parent-msg-id']) { 19 | msg.args.shift() 20 | msg.args.push(...msg.tags['reply-parent-msg-body'].split(/\s+/)) 21 | } 22 | 23 | msg.prefix = msg.channel.query.prefix ?? config.bot.defaultPrefix 24 | 25 | const trigger = msg.args.shift().toLowerCase() 26 | 27 | if (trigger === msg.prefix) 28 | msg.commandName = msg.args.shift()?.toLowerCase(); 29 | else if (trigger.startsWith(msg.prefix)) 30 | msg.commandName = trigger.slice(msg.prefix.length).toLowerCase(); 31 | else 32 | return; 33 | 34 | const command = commands.get(msg.commandName) 35 | if (!command) return 36 | 37 | const cooldownKey = `${command.name}-${msg.user.id}` 38 | if (cooldown.has(cooldownKey)) return 39 | 40 | if (await utils.redis.exists(`ob:channel:${msg.channel.id}:disabledCmd:${command.name}`)) return 41 | 42 | msg.user.name = msg.user.name.toLowerCase() === msg.user.login ? msg.user.name : msg.user.login 43 | 44 | const { mod, vip, broadcaster } = msg.user.perms 45 | const channelMode = msg.channel.query.bot_mode 46 | const channelLive = msg.channel.query.live 47 | 48 | if (!mod && !broadcaster) { 49 | if (channelMode === 0 || (channelLive && channelMode === 2)) return 50 | } 51 | 52 | let { access, botRequires } = command 53 | 54 | if (botRequires && msg.channel.id !== config.bot.userId) { 55 | const channelState = client.userStateTracker.channelStates[msg.channel.login] 56 | 57 | if (botRequires === 'vip') { 58 | if (!channelState.badges.hasVIP && !channelState.isMod) { 59 | cooldown.set(cooldownKey, 1000) 60 | return msg.send(`${msg.user.name}, the bot requires VIP or MOD to execute this command`) 61 | } 62 | } else if (botRequires === 'mod') { 63 | if (!channelState.isMod) { 64 | cooldown.set(cooldownKey, 1000) 65 | return msg.send(`${msg.user.name}, the bot requires MOD to execute this command`); 66 | } 67 | } 68 | } 69 | 70 | if (access && msg.user.id !== config.owner.userId) { 71 | if (access === 'vip') { 72 | if (!vip && !mod && !broadcaster) { 73 | cooldown.set(cooldownKey, 3000) 74 | return msg.send(`${msg.user.name}, you need to be a vip to use this command`) 75 | } 76 | } else if (access === 'mod') { 77 | if (!mod && !broadcaster) { 78 | cooldown.set(cooldownKey, 3000) 79 | return msg.send(`${msg.user.name}, you need to be a mod to use this command`); 80 | } 81 | } else if (access === 'broadcaster') { 82 | if (!broadcaster) { 83 | cooldown.set(cooldownKey, 3000) 84 | return msg.send(`${msg.user.name}, you need to be the channel broadcaster to use this command`); 85 | } 86 | } 87 | } 88 | 89 | try { 90 | if (command.cooldown && msg.user.id !== config.owner.userId) { 91 | cooldown.set(cooldownKey, command.cooldown * 1000) 92 | } 93 | 94 | const result = await command.execute(client, msg, utils) 95 | 96 | if (result) { 97 | if (result.error) { 98 | setTimeout(() => { 99 | cooldown.delete(cooldownKey) 100 | }, 2000); 101 | } 102 | 103 | await msg.send(result.text.replace(/\n|\r/g, ' '), result.reply) 104 | } 105 | 106 | client.issuedCommands++; 107 | await utils.query(`UPDATE bot_data SET issued_commands= issued_commands + 1`) 108 | logger.info(`${msg.user.login} executed ${command.name} in ${msg.channel.login}`) 109 | } catch (err) { 110 | console.error(`Command execution error (${err.message || "N/A"}): ${command.name} by ${msg.user.login} in ${msg.channel.login}`) 111 | 112 | const errorContext = { 113 | command: command.name, 114 | user: msg.user, 115 | channel: msg.channel, 116 | isAction: msg.isAction, 117 | text: msg.text, 118 | } 119 | 120 | await utils.query(`INSERT INTO errors (type, data, error) VALUES (?, ?, ?)`, ['Command', errorContext, err.stack || err]) 121 | msg.send(`⚠️ ${msg.user.name}, ${err.message || "the command execution resulted in an unexpected error"}`); 122 | } 123 | } 124 | }; 125 | -------------------------------------------------------------------------------- /lib/utils/notify.js: -------------------------------------------------------------------------------- 1 | const utils = require('./utils.js') 2 | const config = require('../../config.json') 3 | const { banphraseCheck } = require('./pajbot.js') 4 | const { getUser, helix } = require('./twitchapi.js') 5 | const { client } = require('../misc/connections.js') 6 | const got = require('got') 7 | 8 | const cooldown = require('./cooldown.js') 9 | 10 | const discordNotify = async (event, streamer, userid, data, cooldownActive) => { 11 | const user = await getUser(userid) 12 | const userLink = `[**${user.displayName}**](https://www.twitch.tv/${user.login})` 13 | 14 | switch (event) { 15 | case 'online': { 16 | const { body } = await helix.get(`channels?broadcaster_id=${user.id}`) 17 | const stream = body.data[0] || {} 18 | const embeds = [ 19 | { 20 | "title": user.displayName, 21 | "url": `https://www.twitch.tv/${user.login}`, 22 | "thumbnail": { 23 | "url": user.logo 24 | }, 25 | "fields": [ 26 | { 27 | "name": "ℹ Title", 28 | "value": stream.title ? stream.title.replace(/\n/g, ' ') : "N/A" 29 | }, 30 | { 31 | "name": "🎮 Game", 32 | "value": stream.game_name || "N/A" 33 | } 34 | ], 35 | "color": 9520895 36 | } 37 | ] 38 | 39 | await hook({ embeds }) 40 | 41 | if (streamer.discord_webhook && !cooldownActive) { 42 | await got.post(streamer.discord_webhook, { 43 | json: { 44 | "content": streamer.discord_message, 45 | embeds 46 | } 47 | }); 48 | } 49 | break; 50 | } 51 | 52 | case 'offline': { 53 | await hook({ 54 | embeds: [ 55 | { 56 | "description": `${userLink} is now offline`, 57 | "color": 3092790 58 | } 59 | ] 60 | }) 61 | break; 62 | } 63 | case 'title': { 64 | await hook({ 65 | embeds: [ 66 | { 67 | "description": `${userLink} changed the stream title to \`${data}\``, 68 | "color": 3092790 69 | } 70 | ] 71 | }) 72 | break; 73 | } 74 | case 'category': { 75 | await hook({ 76 | embeds: [ 77 | { 78 | "description": `${userLink} changed the stream category to \`${data}\``, 79 | "color": 3092790 80 | } 81 | ] 82 | }) 83 | break; 84 | } 85 | } 86 | } 87 | 88 | const hook = async (json) => { 89 | await got.post(config.auth.discordWebhook, { json }); 90 | } 91 | 92 | module.exports = async (userid, event, data) => { 93 | const [channel, streamer] = await Promise.all([ 94 | utils.getChannel(userid), 95 | utils.query(`SELECT online_format, offline_format, title_format, category_format, discord_webhook, discord_message FROM notify_channels WHERE user_id=?`, [userid]) 96 | ]) 97 | 98 | if (!streamer.length) return; 99 | data = data ? data.replace(/\n|\r/g, ' ') : "N/A" 100 | 101 | const cooldownActive = cooldown.has(`${userid}:noPing:${event}`) 102 | 103 | if (event === 'online' || event === 'offline') { 104 | cooldown.set(`${userid}:noPing:online`, 240000) 105 | cooldown.set(`${userid}:noPing:offline`, 240000) 106 | } else cooldown.set(`${userid}:noPing:${event}`, 60000) 107 | 108 | discordNotify(event, streamer[0], userid, data, cooldownActive); 109 | 110 | const eventMessages = { 111 | "online": streamer[0].online_format, 112 | "offline": streamer[0].offline_format, 113 | "title": streamer[0].title_format?.replace('%DATA%', data), 114 | "category": streamer[0].category_format?.replace('%DATA%', data) 115 | } 116 | 117 | let message = eventMessages[event] 118 | if (!message) return 119 | 120 | if (cooldownActive) { 121 | return await client.say(channel.login, message) 122 | } 123 | 124 | let users; 125 | const cachedUsers = await utils.redis.smembers(`ob:channel:notifyUsers:${userid}`) 126 | if (cachedUsers.length) { 127 | users = cachedUsers 128 | } else { 129 | const dbUsers = await utils.query(`SELECT user_login FROM notify_users WHERE channel_id=?`, [userid]) 130 | users = dbUsers.map(notify => notify.user_login) 131 | await utils.redis.sadd(`ob:channel:notifyUsers:${userid}`, users) 132 | } 133 | 134 | let input; 135 | if (users.length) { 136 | input = users.join(' ') 137 | } else input = "(no users to notify)" 138 | 139 | const len = 475 - message.length; 140 | let curr = len; 141 | let prev = 0; 142 | 143 | output = []; 144 | 145 | while (input[curr]) { 146 | if (input[curr++] === ' ') { 147 | output.push(input.substring(prev, curr)); 148 | prev = curr; 149 | curr += len; 150 | } 151 | } 152 | output.push(input.substr(prev)); 153 | 154 | for (const users of output) { 155 | message = message + ` 👉 ${users}` 156 | if (channel.pajbot_api) message = await banphraseCheck(message, channel.pajbot_api) 157 | await client.say(channel.login, message) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /lib/misc/pubsub.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const RWS = require('reconnecting-websocket'); 3 | const WS = require('ws'); 4 | const crypto = require('crypto'); 5 | const logger = require('../utils/logger.js') 6 | const utils = require('../utils/utils.js'); 7 | const pubsubEvents = require('./pubsubEvents.js') 8 | 9 | exports.topics = []; 10 | exports.connections = []; 11 | let id = 0 12 | 13 | const listen = (channels, subs) => { 14 | for (const channel of channels) { 15 | for (const sub of subs) { 16 | const nonce = crypto.randomBytes(20).toString('hex').slice(-8); 17 | 18 | this.topics.push({ channel, sub, nonce }); 19 | } 20 | } 21 | } 22 | 23 | exports.init = async () => { 24 | listen((await utils.query('SELECT user_id AS id, login FROM notify_channels')), ['broadcast-settings-update']) // notify command 25 | 26 | listen((await utils.query('SELECT channel_id AS id, channel_login AS login FROM emote_rewards')), ['community-points-channel-v1']) // emote redeems 27 | 28 | listen((await utils.query('SELECT login, platform_id AS id FROM channels')), ['video-playback-by-id', 'chatrooms-user-v1']) // global subs 29 | 30 | // KKona 31 | // listen([{ login: 'chimichanga', id: '227322800' }], ['crowd-chant-channel-v1']) 32 | 33 | const unbanNotifs = [{ login: "8supa", id: "675052240" }, { login: "kazimir33", id: "108311159" }] 34 | listen(unbanNotifs, [`channel-unban-requests.${config.bot.userId}`]) 35 | 36 | const splitTopics = utils.splitArray(this.topics, 50) 37 | 38 | for (const topics of splitTopics) { 39 | const ws = new RWS('wss://pubsub-edge.twitch.tv/v1', [], { WebSocket: WS, startClosed: true }); 40 | this.connections.push({ ws, topics }) 41 | connect(ws, topics, ++id) 42 | await utils.sleep(1000) 43 | } 44 | } 45 | 46 | exports.createListener = (channel, sub) => { 47 | const nonce = crypto.randomBytes(20).toString('hex').slice(-8); 48 | const c = this.connections.find(({ topics }) => topics.length < 50) 49 | 50 | if (c) { 51 | const message = { 52 | 'data': { 53 | 'auth_token': config.auth.twitch.gql.token ?? config.auth.twitch.helix.token, 54 | 'topics': [`${sub}.${channel.id}`] 55 | }, 56 | 'nonce': nonce, 57 | 'type': 'LISTEN', 58 | }; 59 | 60 | c.ws.send(JSON.stringify(message)) 61 | c.topics.push({ channel, sub, nonce }) 62 | } else { 63 | const ws = new RWS('wss://pubsub-edge.twitch.tv/v1', [], { WebSocket: WS, startClosed: true }); 64 | const topics = [{ channel, sub, nonce }] 65 | connect(ws, topics, ++id) 66 | this.connections.push({ ws, topics }) 67 | } 68 | 69 | this.topics.push({ channel, sub, nonce }); 70 | } 71 | 72 | const connect = (ws, topics, id) => { 73 | ws.addEventListener('error', (e) => { 74 | console.error(e) 75 | }); 76 | 77 | ws.addEventListener('close', () => { 78 | logger.info(`[${id}] PubSub Disconnected`) 79 | }); 80 | 81 | ws.addEventListener('open', () => { 82 | logger.info(`[${id}] PubSub Connected`); 83 | 84 | for (const topic of topics) { 85 | const message = { 86 | 'data': { 87 | 'auth_token': config.auth.twitch.gql.token ?? config.auth.twitch.helix.token, 88 | 'topics': [`${topic.sub}.${topic.channel.id}`] 89 | }, 90 | 'nonce': topic.nonce, 91 | 'type': 'LISTEN', 92 | }; 93 | 94 | ws.send(JSON.stringify(message)) 95 | } 96 | }); 97 | 98 | ws.addEventListener('message', ({ data }) => { 99 | const msg = JSON.parse(data); 100 | switch (msg.type) { 101 | case 'PONG': 102 | break; 103 | 104 | case 'RESPONSE': 105 | handleWSResp(msg); 106 | break; 107 | 108 | case 'MESSAGE': 109 | if (!msg.data) return logger.error(`No data associated with message [${JSON.stringify(msg)}]`); 110 | 111 | const msgData = JSON.parse(msg.data.message); 112 | const msgTopic = msg.data.topic; 113 | 114 | handleWSMsg({ channelID: msgData.data?.channel_id || msgTopic.split('.').pop(), ...msgData }) 115 | break; 116 | 117 | case 'RECONNECT': 118 | logger.info(`[${id}] PubSub server sent a reconnect message. restarting the socket`); 119 | if (ws.readyState === 1) ws.reconnect(); 120 | break; 121 | 122 | default: 123 | logger.error(`Unknown PubSub Message Type: ${msg.type}`); 124 | } 125 | }); 126 | 127 | setInterval(() => { 128 | ws.send(JSON.stringify({ 129 | type: 'PING', 130 | })); 131 | }, 250 * 1000); 132 | 133 | ws.reconnect(); 134 | }; 135 | 136 | const handleWSMsg = async (msg = {}) => { 137 | if (!msg.type) return logger.error(`Unknown message without type: ${JSON.stringify(msg)}`); 138 | 139 | const channel = await utils.getChannel(msg.channelID) 140 | if (!channel) return logger.error(`[PubSub] Channel '${msg.channelID}' not found`) 141 | 142 | const event = pubsubEvents[msg.type] 143 | if (event) event(msg, channel) 144 | }; 145 | 146 | const handleWSResp = (msg) => { 147 | if (!msg.nonce) return logger.error(`Unknown message without nonce: ${JSON.stringify(msg)}`); 148 | 149 | const topic = this.topics.find(topic => topic.nonce === msg.nonce); 150 | 151 | if (msg.error && msg.error !== 'ERR_BADAUTH') { 152 | this.topics.splice(this.topics.indexOf(topic), 1); 153 | logger.error(`Error occurred while subscribing to topic ${topic.sub} for channel ${topic.channel.login}: ${msg.error}`); 154 | } 155 | }; 156 | -------------------------------------------------------------------------------- /schema.sql: -------------------------------------------------------------------------------- 1 | -- -------------------------------------------------------- 2 | -- Host: 127.0.0.1 3 | -- Server version: 10.3.32-MariaDB-0ubuntu0.20.04.1 - Ubuntu 20.04 4 | -- Server OS: debian-linux-gnu 5 | -- HeidiSQL Version: 11.3.0.6295 6 | -- -------------------------------------------------------- 7 | 8 | /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; 9 | /*!40101 SET NAMES utf8 */; 10 | /*!50503 SET NAMES utf8mb4 */; 11 | /*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; 12 | /*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; 13 | /*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; 14 | 15 | 16 | -- Dumping database structure for okey_bot 17 | CREATE DATABASE IF NOT EXISTS `okey_bot` /*!40100 DEFAULT CHARACTER SET utf8mb4 */; 18 | USE `okey_bot`; 19 | 20 | -- Dumping structure for table okey_bot.bot_data 21 | CREATE TABLE IF NOT EXISTS `bot_data` ( 22 | `issued_commands` int(255) NOT NULL DEFAULT 0 23 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 24 | 25 | -- Data exporting was unselected. 26 | 27 | -- Dumping structure for table okey_bot.channels 28 | CREATE TABLE IF NOT EXISTS `channels` ( 29 | `id` int(11) NOT NULL AUTO_INCREMENT, 30 | `platform_id` varchar(255) NOT NULL, 31 | `login` varchar(25) NOT NULL, 32 | `prefix` varchar(15) NOT NULL DEFAULT '?', 33 | `pajbot_api` varchar(500) DEFAULT NULL, 34 | `logging` tinyint(4) NOT NULL DEFAULT 1, 35 | `added` timestamp NOT NULL DEFAULT current_timestamp(), 36 | `bot_banned` tinyint(4) NOT NULL DEFAULT 0, 37 | `suspended` tinyint(4) NOT NULL DEFAULT 0, 38 | PRIMARY KEY (`id`), 39 | UNIQUE KEY `platform_id` (`platform_id`), 40 | UNIQUE KEY `login` (`login`) 41 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 42 | 43 | -- Data exporting was unselected. 44 | 45 | -- Dumping structure for table okey_bot.confusables 46 | CREATE TABLE IF NOT EXISTS `confusables` ( 47 | `id` int(11) NOT NULL AUTO_INCREMENT, 48 | `char` varchar(50) NOT NULL DEFAULT '0', 49 | `conf` varchar(50) NOT NULL DEFAULT '0', 50 | PRIMARY KEY (`id`), 51 | KEY `char` (`char`) 52 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 53 | 54 | -- Data exporting was unselected. 55 | 56 | -- Dumping structure for table okey_bot.emote_rewards 57 | CREATE TABLE IF NOT EXISTS `emote_rewards` ( 58 | `id` int(11) NOT NULL AUTO_INCREMENT, 59 | `channel_login` varchar(25) NOT NULL, 60 | `channel_id` varchar(255) NOT NULL, 61 | `app_userid` varchar(500) DEFAULT NULL, 62 | `emote_id` varchar(500) NOT NULL, 63 | `reward_title` varchar(50) NOT NULL, 64 | `app` varchar(50) NOT NULL, 65 | PRIMARY KEY (`id`), 66 | KEY `FK_emotes_channels` (`channel_id`), 67 | CONSTRAINT `FK_emotes_channels` FOREIGN KEY (`channel_id`) REFERENCES `channels` (`platform_id`) 68 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 69 | 70 | -- Data exporting was unselected. 71 | 72 | -- Dumping structure for table okey_bot.errors 73 | CREATE TABLE IF NOT EXISTS `errors` ( 74 | `id` int(11) NOT NULL AUTO_INCREMENT, 75 | `type` varchar(500) NOT NULL DEFAULT '0', 76 | `data` text DEFAULT NULL, 77 | `error` text NOT NULL, 78 | `timestamp` timestamp NOT NULL DEFAULT current_timestamp(), 79 | PRIMARY KEY (`id`) 80 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 81 | 82 | -- Data exporting was unselected. 83 | 84 | -- Dumping structure for table okey_bot.hugs 85 | CREATE TABLE IF NOT EXISTS `hugs` ( 86 | `id` int(11) NOT NULL AUTO_INCREMENT, 87 | `user_id` varchar(255) NOT NULL, 88 | `count` int(11) NOT NULL, 89 | PRIMARY KEY (`id`), 90 | UNIQUE KEY `user_id` (`user_id`) 91 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 92 | 93 | -- Data exporting was unselected. 94 | 95 | -- Dumping structure for table okey_bot.ignored_users 96 | CREATE TABLE IF NOT EXISTS `ignored_users` ( 97 | `id` int(11) NOT NULL AUTO_INCREMENT, 98 | `user_id` varchar(255) NOT NULL, 99 | `reason` varchar(500) DEFAULT NULL, 100 | PRIMARY KEY (`id`), 101 | UNIQUE KEY `user_id` (`user_id`) 102 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 103 | 104 | -- Data exporting was unselected. 105 | 106 | -- Dumping structure for table okey_bot.messages 107 | CREATE TABLE IF NOT EXISTS `messages` ( 108 | `id` int(11) NOT NULL AUTO_INCREMENT, 109 | `channel_id` varchar(255) NOT NULL, 110 | `channel_login` varchar(25) NOT NULL, 111 | `user_id` varchar(255) NOT NULL, 112 | `user_login` varchar(25) NOT NULL, 113 | `message` varchar(500) NOT NULL, 114 | `timestamp` timestamp NULL DEFAULT NULL, 115 | PRIMARY KEY (`id`), 116 | KEY `channel_id` (`channel_id`), 117 | KEY `user_login` (`user_login`), 118 | KEY `timestamp` (`timestamp`) 119 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 120 | 121 | -- Data exporting was unselected. 122 | 123 | -- Dumping structure for table okey_bot.notify_channels 124 | CREATE TABLE IF NOT EXISTS `notify_channels` ( 125 | `id` int(11) NOT NULL AUTO_INCREMENT, 126 | `user_id` varchar(255) NOT NULL, 127 | `login` varchar(25) NOT NULL, 128 | `live` tinyint(4) NOT NULL DEFAULT 0, 129 | `online_format` varchar(350) NOT NULL, 130 | `offline_format` varchar(350) NOT NULL, 131 | `title_format` varchar(350) NOT NULL, 132 | `category_format` varchar(350) NOT NULL, 133 | `discord_webhook` varchar(500) DEFAULT NULL, 134 | `discord_message` varchar(2000) DEFAULT NULL, 135 | PRIMARY KEY (`id`), 136 | UNIQUE KEY `user_id` (`user_id`) 137 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 138 | 139 | -- Data exporting was unselected. 140 | 141 | -- Dumping structure for table okey_bot.notify_users 142 | CREATE TABLE IF NOT EXISTS `notify_users` ( 143 | `id` int(11) NOT NULL AUTO_INCREMENT, 144 | `channel_id` varchar(255) NOT NULL, 145 | `channel_login` varchar(25) NOT NULL, 146 | `user_id` varchar(255) NOT NULL, 147 | `user_login` varchar(25) NOT NULL, 148 | PRIMARY KEY (`id`), 149 | KEY `FK_notify_channels` (`channel_id`) 150 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 151 | 152 | -- Data exporting was unselected. 153 | 154 | -- Dumping structure for table okey_bot.suggestions 155 | CREATE TABLE IF NOT EXISTS `suggestions` ( 156 | `id` int(11) NOT NULL AUTO_INCREMENT, 157 | `author_login` varchar(25) NOT NULL, 158 | `author_id` varchar(255) NOT NULL, 159 | `status` varchar(200) NOT NULL DEFAULT 'Pending Review', 160 | `text` varchar(2000) DEFAULT NULL, 161 | `notes` varchar(2000) DEFAULT NULL, 162 | `created` timestamp NOT NULL DEFAULT current_timestamp(), 163 | `last_update` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), 164 | PRIMARY KEY (`id`), 165 | KEY `author_id` (`author_id`), 166 | KEY `created` (`created`), 167 | KEY `status` (`status`) 168 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 169 | 170 | -- Data exporting was unselected. 171 | 172 | /*!40101 SET SQL_MODE=IFNULL(@OLD_SQL_MODE, '') */; 173 | /*!40014 SET FOREIGN_KEY_CHECKS=IFNULL(@OLD_FOREIGN_KEY_CHECKS, 1) */; 174 | /*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; 175 | /*!40111 SET SQL_NOTES=IFNULL(@OLD_SQL_NOTES, 1) */; 176 | -------------------------------------------------------------------------------- /lib/commands/transform.js: -------------------------------------------------------------------------------- 1 | const maps = { 2 | superscript: { "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", "+": "⁺", "-": "⁻", "=": "⁼", "(": "⁽", ")": "⁾", "a": "ᵃ", "b": "ᵇ", "c": "ᶜ", "d": "ᵈ", "e": "ᵉ", "f": "ᶠ", "g": "ᵍ", "h": "ʰ", "i": "ⁱ", "j": "ʲ", "k": "ᵏ", "l": "ˡ", "m": "ᵐ", "n": "ⁿ", "o": "ᵒ", "p": "ᵖ", "r": "ʳ", "s": "ˢ", "t": "ᵗ", "u": "ᵘ", "v": "ᵛ", "w": "ʷ", "x": "ˣ", "y": "ʸ", "z": "ᶻ", "A": "ᴬ", "B": "ᴮ", "D": "ᴰ", "E": "ᴱ", "G": "ᴳ", "H": "ᴴ", "I": "ᴵ", "J": "ᴶ", "K": "ᴷ", "L": "ᴸ", "M": "ᴹ", "N": "ᴺ", "O": "ᴼ", "P": "ᴾ", "R": "ᴿ", "T": "ᵀ", "U": "ᵁ", "V": "ⱽ", "W": "ᵂ" }, 3 | italic: { "a": "𝘢", "b": "𝘣", "c": "𝘤", "d": "𝘥", "e": "𝘦", "f": "𝘧", "g": "𝘨", "h": "𝘩", "i": "𝘪", "j": "𝘫", "k": "𝘬", "l": "𝘭", "m": "𝘮", "n": "𝘯", "o": "𝘰", "p": "𝘱", "q": "𝘲", "r": "𝘳", "s": "𝘴", "t": "𝘵", "u": "𝘶", "v": "𝘷", "w": "𝘸", "x": "𝘹", "y": "𝘺", "z": "𝘻", "A": "𝘈", "B": "𝘉", "C": "𝘊", "D": "𝘋", "E": "𝘌", "F": "𝘍", "G": "𝘎", "H": "𝘏", "I": "𝘐", "J": "𝘑", "K": "𝘒", "L": "𝘓", "M": "𝘔", "N": "𝘕", "O": "𝘖", "P": "𝘗", "Q": "𝘘", "R": "𝘙", "S": "𝘚", "T": "𝘛", "U": "𝘜", "V": "𝘝", "W": "𝘞", "X": "𝘟", "Y": "𝘠", "Z": "𝘡" }, 4 | bold: { "0": "𝟬", "1": "𝟭", "2": "𝟮", "3": "𝟯", "4": "𝟰", "5": "𝟱", "6": "𝟲", "7": "𝟳", "8": "𝟴", "9": "𝟵", "a": "𝗮", "b": "𝗯", "c": "𝗰", "d": "𝗱", "e": "𝗲", "f": "𝗳", "g": "𝗴", "h": "𝗵", "i": "𝗶", "j": "𝗷", "k": "𝗸", "l": "𝗹", "m": "𝗺", "n": "𝗻", "o": "𝗼", "p": "𝗽", "q": "𝗾", "r": "𝗿", "s": "𝘀", "t": "𝘁", "u": "𝘂", "v": "𝘃", "w": "𝘄", "x": "𝘅", "y": "𝘆", "z": "𝘇", "A": "𝗔", "B": "𝗕", "C": "𝗖", "D": "𝗗", "E": "𝗘", "F": "𝗙", "G": "𝗚", "H": "𝗛", "I": "𝗜", "J": "𝗝", "K": "𝗞", "L": "𝗟", "M": "𝗠", "N": "𝗡", "O": "𝗢", "P": "𝗣", "Q": "𝗤", "R": "𝗥", "S": "𝗦", "T": "𝗧", "U": "𝗨", "V": "𝗩", "W": "𝗪", "X": "𝗫", "Y": "𝗬", "Z": "𝗭" }, 5 | alien: { "a": "ᗩ", "b": "ᗷ", "c": "ᑢ", "d": "ᕲ", "e": "ᘿ", "f": "ᖴ", "g": "ᘜ", "h": "ᕼ", "i": "ᓰ", "j": "ᒚ", "k": "ᖽᐸ", "l": "ᒪ", "m": "ᘻ", "n": "ᘉ", "o": "ᓍ", "p": "ᕵ", "q": "ᕴ", "r": "ᖇ", "s": "S", "t": "ᖶ", "u": "ᑘ", "v": "ᐺ", "w": "ᘺ", "x": "᙭", "y": "ᖻ", "z": "ᗱ", "A": "ᗩ", "B": "ᗷ", "C": "ᑢ", "D": "ᕲ", "E": "ᘿ", "F": "ᖴ", "G": "ᘜ", "H": "ᕼ", "I": "ᓰ", "J": "ᒚ", "K": "ᖽᐸ", "L": "ᒪ", "M": "ᘻ", "N": "ᘉ", "O": "ᓍ", "P": "ᕵ", "Q": "ᕴ", "R": "ᖇ", "S": "S", "T": "ᖶ", "U": "ᑘ", "V": "ᐺ", "W": "ᘺ", "X": "᙭", "Y": "ᖻ", "Z": "ᗱ" }, 6 | asian: { "a": "卂", "b": "乃", "c": "匚", "d": "ᗪ", "e": "乇", "f": "千", "g": "Ꮆ", "h": "卄", "i": "丨", "j": "フ", "k": "Ҝ", "l": "ㄥ", "m": "爪", "n": "几", "o": "ㄖ", "p": "卩", "q": "Ɋ", "r": "尺", "s": "丂", "t": "ㄒ", "u": "ㄩ", "v": "ᐯ", "w": "山", "x": "乂", "y": "ㄚ", "z": "乙", "A": "卂", "B": "乃", "C": "匚", "D": "ᗪ", "E": "乇", "F": "千", "G": "Ꮆ", "H": "卄", "I": "丨", "J": "フ", "K": "Ҝ", "L": "ㄥ", "M": "爪", "N": "几", "O": "ㄖ", "P": "卩", "Q": "Ɋ", "R": "尺", "S": "丂", "T": "ㄒ", "U": "ㄩ", "V": "ᐯ", "W": "山", "X": "乂", "Y": "ㄚ", "Z": "乙" }, 7 | square: { "a": "🄰", "b": "🄱", "c": "🄲", "d": "🄳", "e": "🄴", "f": "🄵", "g": "🄶", "h": "🄷", "i": "🄸", "j": "🄹", "k": "🄺", "l": "🄻", "m": "🄼", "n": "🄽", "o": "🄾", "p": "🄿", "q": "🅀", "r": "🅁", "s": "🅂", "t": "🅃", "u": "🅄", "v": "🅅", "w": "🅆", "x": "🅇", "y": "🅈", "z": "🅉", "A": "🄰", "B": "🄱", "C": "🄲", "D": "🄳", "E": "🄴", "F": "🄵", "G": "🄶", "H": "🄷", "I": "🄸", "J": "🄹", "K": "🄺", "L": "🄻", "M": "🄼", "N": "🄽", "O": "🄾", "P": "🄿", "Q": "🅀", "R": "🅁", "S": "🅂", "T": "🅃", "U": "🅄", "V": "🅅", "W": "🅆", "X": "🅇", "Y": "🅈", "Z": "🅉" }, 8 | currency: { "a": "₳", "b": "฿", "c": "₵", "d": "Đ", "e": "Ɇ", "f": "₣", "g": "₲", "h": "Ⱨ", "i": "ł", "j": "J", "k": "₭", "l": "Ⱡ", "m": "₥", "n": "₦", "o": "Ø", "p": "₱", "q": "Q", "r": "Ɽ", "s": "₴", "t": "₮", "u": "Ʉ", "v": "V", "w": "₩", "x": "Ӿ", "y": "Ɏ", "z": "Ⱬ", "A": "₳", "B": "฿", "C": "₵", "D": "Đ", "E": "Ɇ", "F": "₣", "G": "₲", "H": "Ⱨ", "I": "ł", "J": "J", "K": "₭", "L": "Ⱡ", "M": "₥", "N": "₦", "O": "Ø", "P": "₱", "Q": "Q", "R": "Ɽ", "S": "₴", "T": "₮", "U": "Ʉ", "V": "V", "W": "₩", "X": "Ӿ", "Y": "Ɏ", "Z": "Ⱬ" }, 9 | wide: { "`": "`", "1": "1", "2": "2", "3": "3", "4": "4", "5": "5", "6": "6", "7": "7", "8": "8", "9": "9", "0": "0", "-": "-", "=": "=", "~": "~", "!": "!", "@": "@", "#": "#", "$": "$", "%": "%", "^": "^", "&": "&", "*": "*", "(": "(", ")": ")", "_": "_", "+": "+", "q": "q", "w": "w", "e": "e", "r": "r", "t": "t", "y": "y", "u": "u", "i": "i", "o": "o", "p": "p", "[": "[", "]": "]", "\\": "\\", "Q": "Q", "W": "W", "E": "E", "R": "R", "T": "T", "Y": "Y", "U": "U", "I": "I", "O": "O", "P": "P", "{": "{", "}": "}", "|": "|", "a": "a", "s": "s", "d": "d", "f": "f", "g": "g", "h": "h", "j": "j", "k": "k", "l": "l", ";": ";", "'": "'", "A": "A", "S": "S", "D": "D", "F": "F", "G": "G", "H": "H", "J": "J", "K": "K", "L": "L", ":": ":", "\"": "\"", "z": "z", "x": "x", "c": "c", "v": "v", "b": "b", "n": "n", "m": "m", ",": ",", ".": ".", "/": "/", "Z": "Z", "X": "X", "C": "C", "V": "V", "B": "B", "N": "N", "M": "M", "<": "<", ">": ">", "?": "?" }, 10 | invertedsquare: { "q": "🆀", "w": "🆆", "e": "🅴", "r": "🆁", "t": "🆃", "y": "🆈", "u": "🆄", "i": "🅸", "o": "🅾", "p": "🅿", "a": "🅰", "s": "🆂", "d": "🅳", "f": "🅵", "g": "🅶", "h": "🅷", "j": "🅹", "k": "🅺", "l": "🅻", "z": "🆉", "x": "🆇", "c": "🅲", "v": "🆅", "b": "🅱", "n": "🅽", "m": "🅼", "Q": "🆀", "W": "🆆", "E": "🅴", "R": "🆁", "T": "🆃", "Y": "🆈", "U": "🆄", "I": "🅸", "O": "🅾", "P": "🅿", "A": "🅰", "S": "🆂", "D": "🅳", "F": "🅵", "G": "🅶", "H": "🅷", "J": "🅹", "K": "🅺", "L": "🅻", "Z": "🆉", "X": "🆇", "C": "🅲", "V": "🆅", "B": "🅱", "N": "🅽", "M": "🅼" }, 11 | random: {} 12 | } 13 | 14 | module.exports = { 15 | name: 'transform', 16 | description: 'Text transform', 17 | aliases: ['tt'], 18 | cooldown: 5, 19 | usage: " ", 20 | async execute(client, msg, utils) { 21 | const validT = Object.keys(maps) 22 | if (msg.args.length < 2) return { text: `you need to specify a transform and the text, transforms: ${validT.join(', ')}`, reply: true } 23 | 24 | const t = msg.args[0].toLowerCase() 25 | const text = msg.args.slice(1).join(' ') 26 | 27 | if (t === 'random') { 28 | let res = ""; 29 | for (let i = 0; i < text.length; i++) { 30 | const char = text[i] 31 | const data = await utils.query(`SELECT conf FROM confusables WHERE \`char\` = BINARY ?`, [char]) 32 | 33 | if (!data.length) res += char 34 | else res += utils.randArray(data.map(x => x.conf)); 35 | } 36 | return { text: res, reply: true } 37 | } else { 38 | if (!validT.includes(t)) return { text: `invalid transform, valids: ${validT.join(', ')}`, reply: true } 39 | return { text: applyCharMap(maps[t], text), reply: true } 40 | } 41 | 42 | function applyCharMap(map, text) { 43 | let res = ""; 44 | for (let i = 0; i < text.length; i++) { 45 | const char = text[i] 46 | res += map[char] || char; 47 | } 48 | return res; 49 | } 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /web/src/routes/Commands.svelte: -------------------------------------------------------------------------------- 1 | 56 | 57 | 58 | Commands / Okeybot 59 | 60 | 61 |
    62 |
    63 | {#each categories as text} 64 | 65 | {/each} 66 | 67 |
    68 | 69 |
    70 | {#if command} 71 |
    expand()}> 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 102 | 103 |
    Name{command.name}
    Aliases{command.aliases.length ? command.aliases.join(", ") : "N/A"}
    Access{command.access ?? "everyone"}
    Cooldown{command.cooldown ? `${command.cooldown} seconds` : "N/A"}
    Usage?{command.name} {command.usage ?? ""}
    Description{command.description}
    Code 100 | e.stopPropagation()} target="_blank" href="https://github.com/0Supa/okeybot/blob/main/lib/commands/{encodeURIComponent(command.name)}.js">GitHub 101 |
    104 | {#if command.extended} 105 |
    e.stopPropagation()} class="extended"> 106 | {@html command.extended} 107 |
    108 | {/if} 109 |
    110 | {:else if td.length} 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | {#each td as command} 121 | 122 | 123 | 124 | 125 | 126 | {/each} 127 | 128 |
    CommandDescriptionCooldown
    {command.name}{command.description}{command.cooldown} seconds
    129 | {:else if data} 130 |

    Nothing found :(

    131 | {/if} 132 |
    133 |
    134 | 135 | 250 | -------------------------------------------------------------------------------- /lib/utils/emotes.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config.json') 2 | const got = require('got'); 3 | 4 | module.exports = { 5 | getFFZemotes: function (user) { 6 | return new Promise(async (resolve, reject) => { 7 | if (!user) throw 'no user provided' 8 | try { 9 | const data = await got(`https://api.frankerfacez.com/v1/room/${user}`).json() 10 | const sets = data.sets[Object.keys(data.sets)[0]] 11 | const emotes = sets.emoticons.map(emote => emote.name) 12 | resolve(emotes) 13 | } catch (e) { 14 | reject(e) 15 | } 16 | }) 17 | }, 18 | getBTTVemotes: function (userID) { 19 | return new Promise(async (resolve, reject) => { 20 | try { 21 | const data = await got(`https://api.betterttv.net/3/cached/users/twitch/${userID}`).json() 22 | const channelEmotes = data.channelEmotes.map(emote => emote.code) 23 | const sharedEmotes = data.sharedEmotes.map(emote => emote.code) 24 | const emotes = [...channelEmotes, ...sharedEmotes] 25 | resolve(emotes) 26 | } catch (e) { 27 | reject(e) 28 | } 29 | }) 30 | }, 31 | getBTTVemote: function (emoteID) { 32 | return new Promise(async (resolve, reject) => { 33 | const { body, statusCode } = await got(`https://api.betterttv.net/3/emotes/${encodeURIComponent(emoteID)}`, { 34 | throwHttpErrors: false, 35 | responseType: 'json' 36 | }); 37 | if (statusCode < 200 || statusCode > 299) return reject(body.message ? `${body.message} (${statusCode})` : `an unexpected error occurred (${statusCode})`) 38 | resolve(body) 39 | }); 40 | }, 41 | getBTTVid: function (userID) { 42 | return new Promise(async (resolve, reject) => { 43 | const { body, statusCode } = await got(`https://api.betterttv.net/3/cached/users/twitch/${encodeURIComponent(userID)}`, { 44 | throwHttpErrors: false, 45 | responseType: 'json' 46 | }); 47 | if (statusCode < 200 || statusCode > 299) return reject(body.message ? `${body.message} (${statusCode})` : `an unexpected error occurred (${statusCode})`) 48 | resolve(body.id) 49 | }); 50 | }, 51 | getSTVid: function (user) { 52 | return new Promise(async (resolve, reject) => { 53 | const { body, statusCode } = await got.post(`https://api.7tv.app/v2/gql`, { 54 | throwHttpErrors: false, 55 | responseType: 'json', 56 | json: { "query": `{user(id: \"${user.replaceAll('"', '\\"')}\") {...FullUser}}fragment FullUser on User {id}` } 57 | }); 58 | if (statusCode < 200 || statusCode > 299) return reject(`an unexpected error occurred (${statusCode})`) 59 | if (!body.data.user.id) return reject(`user not found`) 60 | resolve(body.data.user.id) 61 | }); 62 | }, 63 | getSTVemote: function (emoteID) { 64 | return new Promise(async (resolve, reject) => { 65 | const res = await got.post(`https://7tv.io/v3/gql`, { 66 | json: { 67 | "variables": { 68 | "id": emoteID 69 | }, 70 | "operationName": "Emote", 71 | "query": "query Emote($id: ObjectID!) {\n emote(id: $id) {\n id\n created_at\n name\n lifecycle\n listed\n owner {\n id\n username\n display_name\n avatar_url\n tag_color\n }\n flags\n versions {\n id\n name\n description\n created_at\n lifecycle\n images {\n name\n format\n url\n width\n height\n }\n }\n animated\n }\n}" 72 | } 73 | }).json() 74 | 75 | if (res.errors) { 76 | const err = res.errors[0].extensions 77 | if (err.code === 70440) return resolve(null) // Unknown Emote (not found) 78 | reject(err.message) 79 | } else 80 | resolve(res.data.emote) 81 | }); 82 | }, 83 | BTTVemote: function (option, emoteID, bttvID) { 84 | return new Promise(async (resolve, reject) => { 85 | try { 86 | let method; 87 | let emote; 88 | 89 | switch (option) { 90 | case "add": { 91 | emote = await this.getBTTVemote(emoteID) 92 | if (!emote.sharing) throw "sharing is disabled for this emote" 93 | method = 'PUT' 94 | break; 95 | } 96 | case "remove": { 97 | try { 98 | emote = await this.getBTTVemote(emoteID) 99 | } catch (e) { 100 | return resolve("(unknown)") 101 | } 102 | 103 | method = 'DELETE'; 104 | break; 105 | } 106 | } 107 | 108 | const { body, statusCode } = await got(`https://api.betterttv.net/3/emotes/${encodeURIComponent(emote.id)}/shared/${encodeURIComponent(bttvID)}`, { 109 | method, 110 | headers: { 111 | 'Authorization': `Bearer ${config.auth.bttv}` 112 | }, 113 | throwHttpErrors: false, 114 | responseType: 'json' 115 | }); 116 | 117 | if (option === 'add' && (statusCode < 200 || statusCode > 299)) throw body.message ? `${body.message} | [HTTP/${statusCode}]` : `an unexpected error occurred (${statusCode})`; 118 | 119 | resolve(emote.code ?? "(unknown)") 120 | } catch (err) { 121 | reject(err) 122 | } 123 | }) 124 | }, 125 | STVemote: function (option, emoteID, userID) { 126 | return new Promise(async (resolve, reject) => { 127 | try { 128 | let emote; 129 | 130 | switch (option) { 131 | case "add": { 132 | emote = await this.getSTVemote(emoteID) 133 | if (!emote.listed) throw "this emote is unlisted" 134 | break; 135 | } 136 | case "remove": { 137 | try { 138 | emote = await this.getSTVemote(emoteID) 139 | } catch (e) { 140 | return resolve("(unknown)") 141 | } 142 | break; 143 | } 144 | } 145 | 146 | const res = await got.post(`https://7tv.io/v3/gql`, { 147 | headers: { 148 | 'Authorization': `Bearer ${config.auth['7tv']}` 149 | }, 150 | json: { 151 | "variables": { 152 | "action": option.toUpperCase(), 153 | "emote_id": emoteID, 154 | "id": userID, 155 | }, 156 | "operationName": "ChangeEmoteInSet", 157 | "query": "mutation ChangeEmoteInSet($id: ObjectID!, $action: ListItemAction!, $emote_id: ObjectID!, $name: String) {\n emoteSet(id: $id) {\n id\n emotes(id: $emote_id, action: $action, name: $name) {\n id\n name\n }\n }\n}" 158 | } 159 | }).json() 160 | 161 | if (option === 'add' && res.errors) 162 | throw res.errors.map(e => e.extensions?.message ?? e.message).join(' & ') 163 | 164 | resolve(emote.name ?? "(unknown)") 165 | } catch (err) { 166 | reject(err) 167 | } 168 | }) 169 | } 170 | }; 171 | --------------------------------------------------------------------------------