├── .env.sample ├── .eslintrc.js ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── scripts ├── moveCommands.js ├── updateAllUsers.js └── updateUsersTeamCountry.js └── src ├── app.js ├── channels.js ├── db.js ├── index.js ├── lib ├── createCommandsService.js ├── getCountries.js ├── getIcons.js ├── parseEmotes.js ├── twitchAPI.js ├── validPronounChoices.js └── youtubeAPI.js ├── middlewares.js ├── public └── favicon.ico ├── services ├── github │ ├── sponsors.functions.js │ └── sponsors.service.js ├── icons │ └── icons.service.js ├── index.js ├── patreon │ ├── pledges.functions.js │ └── pledges.service.js ├── twitch │ ├── badges.service.js │ ├── chat.functions.js │ ├── chat.service.js │ ├── commands.service.js │ ├── login.service.js │ ├── rewards.service.js │ ├── subs.service.js │ └── users.service.js ├── vox │ └── populi.service.js └── youtube │ ├── chat.functions.js │ ├── chat.service.js │ ├── commands.service.js │ ├── members.config.sample.js │ ├── members.functions.js │ ├── members.service.js │ ├── stats.service.js │ └── youtube.users.js └── streamlabs.js /.env.sample: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=2020 3 | PATREON_DEVICE_ID=your-value-here 4 | PATREON_SESSION_ID=your-value-here 5 | STREAMLABS_SOCKET_TOKEN=your-value-here 6 | TWITCH_CHANNEL_NAME=codinggarden 7 | TWITCH_CHANNEL_ID=413856795 8 | MONGO_URI=your-value-here 9 | CLIENT_API_KEY=keyboardcat 10 | TWITCH_REWARDS_TOKEN=your-value-here 11 | TWITCH_SUB_CLIENT_ID=your-value-here 12 | TWITCH_SUB_OAUTH_TOKEN=your-value-here 13 | TWITCH_SUB_REFRESH_TOKEN=your-value-here 14 | YOUTUBE_OPERATIONAL_API_ENDPOINT=http://localhost:8989/ 15 | YOUTUBE_CHANNEL_ID=UCLNgu_OupwoeESgtab33CCw 16 | GITHUB_TOKEN=your-value-here 17 | GITHUB_WEBHOOK_SECRET=your-value-here -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'airbnb-base', 3 | rules: { 4 | 'comma-dangle': 0, 5 | 'no-underscore-dangle': 0, 6 | 'no-param-reassign': 0, 7 | 'no-return-assign': 0, 8 | camelcase: 0, 9 | 'class-methods-use-this': 0, 10 | 'no-plusplus': 0 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | src/services/youtube/members.config.js 76 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2020 Coding Garden 2 | 3 | Permission is hereby granted, free 4 | of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Coding Garden API 2 | 3 | An API for the Coding Garden YouTube / Twitch channel. 4 | 5 | ## Endpoints 6 | 7 | * `GET /patreon/pledges` 8 | * `GET /youtube/members` 9 | * `GET /youtube/stats` 10 | 11 | ## Configuration 12 | 13 | ```sh 14 | npm install 15 | cp .env.sample .env # update accordingly 16 | ``` 17 | 18 | Copy `src/services/youtube/members.config.sample.js` to `src/services/youtube/members.config.js` and update accordingly. Needed values can be found by inspecting network traffic in the YouTube Dashboard: https://studio.youtube.com/channel/channel-id-here/monetization/memberships. 19 | 20 | ```sh 21 | cp src/services/youtube/members.config.sample.js src/services/youtube/members.config.js 22 | ``` 23 | 24 | ## Lint 25 | 26 | ```sh 27 | npm run lint 28 | ``` 29 | 30 | ## Development 31 | 32 | ```sh 33 | npm run dev 34 | ``` 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "patreon-pledges-api", 3 | "version": "1.0.0", 4 | "description": "A simple API to list the active pledges for a given patreon campaign.", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "start": "node src/index.js", 8 | "dev": "nodemon src/index.js", 9 | "lint": "eslint --fix src" 10 | }, 11 | "keywords": [], 12 | "author": "CJ R. (https://w3cj.now.sh)", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/CodingGarden/patreon-pledges-api.git" 16 | }, 17 | "license": "MIT", 18 | "dependencies": { 19 | "@feathersjs/errors": "^4.5.15", 20 | "@feathersjs/express": "^4.5.15", 21 | "@feathersjs/feathers": "^4.5.15", 22 | "@feathersjs/socketio": "^4.5.15", 23 | "axios": "^0.27.2", 24 | "cors": "^2.8.5", 25 | "date-fns": "^2.28.0", 26 | "dotenv": "^16.0.1", 27 | "express": "^4.18.1", 28 | "helmet": "^5.1.0", 29 | "jws": "^4.0.0", 30 | "monk": "^7.3.4", 31 | "morgan": "^1.10.0", 32 | "serve-favicon": "^2.5.0", 33 | "simple-icons": "^7.5.0", 34 | "socket.io-client": "^2.3.0", 35 | "tmi.js": "^1.9.0-pre.1", 36 | "twitchps": "^1.6.0" 37 | }, 38 | "devDependencies": { 39 | "eslint": "^8.20.0", 40 | "eslint-config-airbnb-base": "^15.0.0", 41 | "eslint-plugin-import": "^2.26.0", 42 | "nodemon": "^2.0.19" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /scripts/moveCommands.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const { 4 | db, 5 | twitchChats, 6 | twitchCommands, 7 | } = require('../src/db'); 8 | 9 | async function moveCommands() { 10 | try { 11 | const messages = await twitchChats.find({ 12 | message: { 13 | $regex: /^!\w/ 14 | } 15 | }); 16 | await twitchCommands.insert(messages); 17 | await twitchChats.remove({ 18 | _id: { 19 | $in: messages.map((m) => m._id), 20 | } 21 | }); 22 | db.close(); 23 | } catch (error) { 24 | console.error(error); 25 | } 26 | } 27 | 28 | moveCommands(); 29 | -------------------------------------------------------------------------------- /scripts/updateAllUsers.js: -------------------------------------------------------------------------------- 1 | const app = require('../src/app'); 2 | const { 3 | twitchChats, 4 | } = require('../src/db'); 5 | 6 | async function updateAllUsers() { 7 | try { 8 | const messages = await twitchChats.find({}); 9 | const names = [...new Set(messages.map((message) => message.username))]; 10 | const users = await app.service('twitch/users').find({ 11 | query: { 12 | names, 13 | } 14 | }); 15 | console.log('updated', users.length); 16 | } catch (error) { 17 | console.error(error); 18 | } 19 | } 20 | 21 | updateAllUsers(); 22 | -------------------------------------------------------------------------------- /scripts/updateUsersTeamCountry.js: -------------------------------------------------------------------------------- 1 | const app = require('../src/app'); 2 | const users = require('./users.json'); 3 | const getBrands = require('../src/lib/getIcons'); 4 | const getCountries = require('../src/lib/getCountries'); 5 | 6 | async function updateUsersTeamCountry() { 7 | try { 8 | const brands = getBrands(); 9 | const countries = await getCountries(); 10 | const results = await Promise.all( 11 | Object.entries(users).map(async ([username, info]) => { 12 | const updates = {}; 13 | const country = countries.get(info.country_code); 14 | if (info.country_code) { 15 | updates.country = country; 16 | } 17 | if (info.team && brands.has(info.team)) { 18 | updates.team = info.team; 19 | } 20 | if (!Object.keys(updates).length) { 21 | updates.country = null; 22 | updates.team = null; 23 | } 24 | await app.service('twitch/users').patch(username, updates); 25 | }) 26 | ); 27 | console.log('updated', results.length); 28 | } catch (error) { 29 | console.error(error); 30 | } 31 | } 32 | 33 | updateUsersTeamCountry(); 34 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const favicon = require('serve-favicon'); 3 | const morgan = require('morgan'); 4 | const helmet = require('helmet'); 5 | const cors = require('cors'); 6 | 7 | require('dotenv').config(); 8 | 9 | const feathers = require('@feathersjs/feathers'); 10 | const express = require('@feathersjs/express'); 11 | const socketio = require('@feathersjs/socketio'); 12 | 13 | const middlewares = require('./middlewares'); 14 | const services = require('./services'); 15 | const channels = require('./channels'); 16 | const listenStreamlabs = require('./streamlabs'); 17 | 18 | const app = express(feathers()); 19 | 20 | app.use(cors()); 21 | app.configure(express.rest()); 22 | app.configure(socketio((io) => { 23 | io.set('transports', ['websocket']); 24 | io.use((socket, next) => { 25 | socket.feathers.apiKey = socket.handshake.query.key; 26 | next(); 27 | }); 28 | })); 29 | 30 | app.set('trust proxy', 'loopback'); 31 | app.use(morgan('[:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length]')); 32 | app.use(favicon(path.join(__dirname, 'public', 'favicon.ico'))); 33 | app.use(express.json({ 34 | verify: (req, res, buf) => { 35 | req.feathers.rawBody = buf; 36 | } 37 | })); 38 | app.use(helmet()); 39 | 40 | app.get('/', (req, res) => { 41 | res.json({ 42 | message: '🌱🦄🌈✨👋🌎🌍🌏✨🌈🦄🌱' 43 | }); 44 | }); 45 | 46 | app.use((req, res, next) => { 47 | req.feathers.res = res; 48 | next(); 49 | }); 50 | 51 | app.configure(services); 52 | app.configure(channels); 53 | app.configure(listenStreamlabs); 54 | 55 | app.use(middlewares.notFound); 56 | app.use((error, req, res, next) => { 57 | // console.error(error); 58 | console.error(error.stack); 59 | console.error(error.message); 60 | if (error.response && error.response.data) { 61 | console.error(JSON.stringify(error.response.data, null, 2)); 62 | } 63 | next(error); 64 | }); 65 | app.use(express.errorHandler()); 66 | 67 | module.exports = app; 68 | -------------------------------------------------------------------------------- /src/channels.js: -------------------------------------------------------------------------------- 1 | module.exports = function channels(app) { 2 | if (typeof app.channel !== 'function') { 3 | return; 4 | } 5 | 6 | app.on('connection', (connection) => { 7 | if (connection.apiKey && connection.apiKey === process.env.CLIENT_API_KEY) { 8 | app.channel('api-key').join(connection); 9 | } 10 | app.channel('anonymous').join(connection); 11 | }); 12 | 13 | const apiKeyPaths = new Set([ 14 | 'youtube/users', 15 | 'youtube/chat', 16 | 'youtube/commands', 17 | 'twitch/chat', 18 | 'twitch/rewards', 19 | 'twitch/commands', 20 | 'github/sponsors', 21 | ]); 22 | 23 | const anonymousPaths = new Set([ 24 | 'vox/populi', 25 | 'youtube/users', 26 | 'twitch/users', 27 | ]); 28 | 29 | app.publish((data, hook) => { 30 | const all = []; 31 | if (anonymousPaths.has(hook.path)) { 32 | all.push(app.channel('anonymous')); 33 | } else if (apiKeyPaths.has(hook.path)) { 34 | all.push(app.channel('api-key')); 35 | } 36 | return all; 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /src/db.js: -------------------------------------------------------------------------------- 1 | const monk = require('monk'); 2 | 3 | const db = monk(process.env.MONGO_URI); 4 | 5 | const youtubeChats = db.get('youtube-chats'); 6 | youtubeChats.createIndex('id author_id author_display_name author_handle message live_chat_id'); 7 | 8 | const youtubeUsers = db.get('youtube-users'); 9 | youtubeUsers.createIndex('id display_name handle'); 10 | 11 | const youtubeCommands = db.get('youtube-commands'); 12 | youtubeCommands.createIndex('id author_id author_display_name author_handle message live_chat_id'); 13 | 14 | const twitchChats = db.get('twitch-chats'); 15 | twitchChats.createIndex('username name userId id created_at message'); 16 | 17 | const twitchCommands = db.get('twitch-commands'); 18 | twitchCommands.createIndex('username command name userId id created_at message'); 19 | 20 | const twitchUsers = db.get('twitch-users'); 21 | twitchChats.createIndex('name display_name id created_at'); 22 | 23 | const twitchRewards = db.get('twitch-rewards'); 24 | twitchChats.createIndex('ack'); 25 | 26 | const counter = db.get('counter'); 27 | counter.createIndex('name'); 28 | 29 | module.exports = { 30 | db, 31 | twitchRewards, 32 | twitchCommands, 33 | twitchChats, 34 | twitchUsers, 35 | counter, 36 | youtubeChats, 37 | youtubeUsers, 38 | youtubeCommands, 39 | }; 40 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const app = require('./app'); 2 | 3 | const port = process.env.PORT || 2020; 4 | app.listen(port, () => { 5 | /* eslint-disable no-console */ 6 | console.log(`Listening: http://localhost:${port}`); 7 | /* eslint-enable no-console */ 8 | }); 9 | -------------------------------------------------------------------------------- /src/lib/createCommandsService.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const { sub } = require('date-fns'); 4 | 5 | const { 6 | counter, 7 | } = require('../db'); 8 | const pronounChoices = require('./validPronounChoices'); 9 | const getCountries = require('./getCountries'); 10 | const { simpleIcons, fontAwesome } = require('./getIcons'); 11 | 12 | module.exports = function createCommandService({ 13 | dbCollection, getUserService, getUserQuery, getIsModOrOwner 14 | }) { 15 | class CommandsService { 16 | constructor(app) { 17 | this.app = app; 18 | } 19 | 20 | async find(params) { 21 | const query = { 22 | deleted_at: { 23 | $eq: null, 24 | }, 25 | created_at: { 26 | $gte: sub(new Date(), { 27 | hours: 6, 28 | }), 29 | }, 30 | ack: { 31 | $ne: true, 32 | }, 33 | }; 34 | if (params.query) { 35 | if (params.query.commands === 'false' || params.query.commands === false) { 36 | query.message = { 37 | $regex: /^(?!\\!)\w+/, 38 | }; 39 | } 40 | if (params.query.user_id) { 41 | query.user_id = params.query.user_id; 42 | } 43 | if (params.query.created_at) { 44 | query.created_at = params.query.created_at; 45 | } 46 | } 47 | const commands = await dbCollection.find(query, { 48 | sort: { 49 | created_at: -1 50 | }, 51 | limit: 1000, 52 | }); 53 | return commands; 54 | } 55 | 56 | async remove(id) { 57 | await dbCollection.update({ id }, { $set: { deleted_at: new Date() } }); 58 | return id; 59 | } 60 | 61 | async patch(_id, updates) { 62 | const updated = await dbCollection.findOneAndUpdate({ 63 | _id, 64 | }, { 65 | $set: updates, 66 | }, { 67 | upsert: true, 68 | }); 69 | return updated; 70 | } 71 | 72 | async create(message) { 73 | const user = message.user || await getUserService(this.app).get(getUserQuery({ message })); 74 | delete message.user; 75 | const archiveQuestion = message.message.match(/^!archive\s+#?(\d+)$/); 76 | if (archiveQuestion) { 77 | const num = +archiveQuestion[1]; 78 | const question = await dbCollection.findOne({ 79 | num, 80 | }); 81 | const isNotArchivedOrDeleted = question && !question.archived && !question.deleted_at; 82 | const isModOrOwner = getIsModOrOwner({ question, message, user }); 83 | if (isNotArchivedOrDeleted && isModOrOwner) { 84 | await this.app.service('vox/populi').remove(question._id); 85 | } 86 | } else if (message.message.match(/^!(ask|idea|submit)/)) { 87 | const [, ...args] = message.message.split(' '); 88 | if (!args.join(' ').trim()) return; 89 | const existing = await dbCollection.findOne({ 90 | id: message.id, 91 | }); 92 | const count = existing ? { value: existing.num } : await counter.findOneAndUpdate({ 93 | name: 'question', 94 | }, { 95 | $inc: { value: 1 } 96 | }, { 97 | upsert: true, 98 | }); 99 | message.num = count.value; 100 | const now = new Date(); 101 | await getUserService(this.app).patch(getUserQuery({ user }), { 102 | last_seen: now, 103 | }); 104 | user.last_seen = now; 105 | } 106 | const created = await dbCollection.findOneAndUpdate({ 107 | id: message.id, 108 | }, { 109 | $set: message, 110 | }, { 111 | upsert: true, 112 | }); 113 | if (message.message.match(/^!here$/)) { 114 | const now = new Date(); 115 | await getUserService(this.app).patch(getUserQuery({ user }), { 116 | last_seen: now, 117 | }); 118 | user.last_seen = now; 119 | } else if (message.message.match(/^!setstatus /)) { 120 | // TODO: limit status length 121 | const args = (message.parsedMessage || message.message).split(' '); 122 | args.shift().slice(1); 123 | const status = args.join(' '); 124 | user.status = status; 125 | await getUserService(this.app).patch(getUserQuery({ user }), { 126 | status, 127 | }); 128 | } else if (message.message.match(/^!clearstatus/)) { 129 | user.status = null; 130 | await getUserService(this.app).patch(getUserQuery({ user }), { 131 | status: null, 132 | }); 133 | } else if (message.message.match(/^!(country|flag|team|team-color|team-colour|pronoun|color)/)) { 134 | const args = message.message.split(' '); 135 | const command = args.shift().slice(1); 136 | if (args.length === 0) return; 137 | if (command === 'country' || command === 'flag') { 138 | const countryLookup = args.shift().toLowerCase().trim(); 139 | if (countryLookup === 'clear' || countryLookup === 'remove') { 140 | user.country = undefined; 141 | await getUserService(this.app).patch(getUserQuery({ user }), { 142 | country: undefined, 143 | }); 144 | } else { 145 | const countries = await getCountries(); 146 | const country = countries.get(countryLookup); 147 | if (country) { 148 | user.country = country; 149 | await getUserService(this.app).patch(getUserQuery({ user }), { 150 | country, 151 | }); 152 | } 153 | } 154 | } else if (command === 'team') { 155 | const team = args.shift().toLowerCase().trim(); 156 | if (team === 'clear' || team === 'remove') { 157 | user.team = undefined; 158 | await getUserService(this.app).patch(getUserQuery({ user }), { 159 | team: undefined, 160 | }); 161 | } else if (fontAwesome.has(team) || simpleIcons.has(team)) { 162 | user.team = team; 163 | await getUserService(this.app).patch(getUserQuery({ user }), { 164 | team, 165 | }); 166 | } 167 | } else if (command === 'team-color' || command === 'team-colour') { 168 | const color = args.shift().toLowerCase().trim().replace('#', ''); 169 | if (color === 'clear' || color === 'remove') { 170 | user.team_color = undefined; 171 | await getUserService(this.app).patch(getUserQuery({ user }), { 172 | team_color: undefined, 173 | }); 174 | } else if (color.match(/^(([a-f0-9]){3}){1,2}$/)) { 175 | user.team_color = color; 176 | await getUserService(this.app).patch(getUserQuery({ user }), { 177 | team_color: color, 178 | }); 179 | } 180 | } else if (command === 'pronoun') { 181 | const pronoun = (args.shift() || '').toLowerCase().trim(); 182 | if (pronoun === 'clear' || pronoun === 'remove') { 183 | user.pronoun = undefined; 184 | await getUserService(this.app).patch(getUserQuery({ user }), { 185 | pronoun: undefined, 186 | }); 187 | } else if (pronounChoices.has(pronoun)) { 188 | user.pronoun = pronoun; 189 | await getUserService(this.app).patch(getUserQuery({ user }), { 190 | pronoun, 191 | }); 192 | } 193 | } else if (command === 'color') { 194 | const color = args.shift().toLowerCase().trim().replace('#', ''); 195 | if (color === 'clear' || color === 'remove') { 196 | user.color = undefined; 197 | await getUserService(this.app).patch(getUserQuery({ user }), { 198 | color: undefined, 199 | }); 200 | } else if (color.match(/^(([a-f0-9]){3}){1,2}$/)) { 201 | user.color = color; 202 | await getUserService(this.app).patch(getUserQuery({ user }), { 203 | color, 204 | }); 205 | } 206 | } 207 | } 208 | created.user = user; 209 | return created; 210 | } 211 | } 212 | 213 | return CommandsService; 214 | }; 215 | -------------------------------------------------------------------------------- /src/lib/getCountries.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | let countries; 4 | 5 | async function getCountries() { 6 | if (countries) return countries; 7 | countries = (async () => { 8 | const { data } = await axios.get('https://iso-3166-flags.netlify.app/dist/metadata.min.json'); 9 | const result = new Map(); 10 | data.forEach((info) => { 11 | const item = { 12 | code: info.route, 13 | name: info.name, 14 | }; 15 | result.set(item.code, item); 16 | }); 17 | return result; 18 | })(); 19 | return countries; 20 | } 21 | 22 | module.exports = getCountries; 23 | -------------------------------------------------------------------------------- /src/lib/getIcons.js: -------------------------------------------------------------------------------- 1 | const icons = require('simple-icons/icons'); 2 | 3 | // Brand names from here: https://fontawesome.com/search?m=free&s=solid%2Cbrands 4 | const fontAwesomeNames = 'turn-up,steam-symbol,person-circle-check,hand-holding-medical,graduation-cap,video,plus-minus,barcode,square-share-nodes,jet-fighter,handshake-simple,pix,hand-point-left,kip-sign,masks-theater,korvue,odnoklassniki-square,zhihu,d,trowel,umbrella,building-columns,person-military-pointing,users-gear,magnifying-glass-dollar,x,dollar-sign,intercom,turkish-lira-sign,google-pay,print,arrows-spin,exclamation,tent-arrows-down,plane,face-sad-tear,user-injured,bone,arrow-up-from-water-pump,bug-slash,note-sticky,jar,magnet,1,wodu,git-square,square-full,stopwatch-20,cloud-sun,syringe,nutritionix,user-nurse,train-tram,wordpress-simple,user-large,ruler-vertical,pushed,magnifying-glass-minus,mask,brush,teamspeak,mug-saucer,canadian-maple-leaf,ember,strava,window-minimize,file-invoice,hand-dots,creative-commons-sampling-plus,user-clock,gulp,buy-n-large,thumbs-up,magnifying-glass-plus,id-card-clip,earth-asia,hotel,9,gifts,circle-xmark,wolf-pack-battalion,calendar-minus,bandage,brain,bowling-ball,airbnb,dice,square-envelope,person-walking-arrow-right,mastodon,turn-down,wheelchair-move,face-grimace,arrows-up-down,joomla,water-ladder,calendar-day,discourse,sitrox,hands,life-ring,money-bill-1-wave,critical-role,viacoin,pinterest-square,align-right,flipboard,dungeon,mixcloud,road-circle-exclamation,paper-plane,right-left,hill-rockslide,mask-face,bottle-droplet,amilia,medium,share,database,reacteurope,triangle-exclamation,handcuffs,book-journal-whills,quora,book-open,baht-sign,rectangle-xmark,cart-flatbed-suitcase,earth-europe,rug,user-tag,image-portrait,person-falling,vaadin,briefcase,cloud-moon,tumblr,hand-fist,arrows-split-up-and-left,truck-arrow-right,diagram-successor,angle-left,usb,house-circle-check,battery-three-quarters,check,user-plus,book,medrt,slack,edge-legacy,comment-sms,node-js,table-list,4,m,desktop,shower,gauge-simple-high,scale-balanced,draw-polygon,rss,arrow-trend-down,old-republic,cookie-bite,face-angry,border-all,anchor,book-quran,file-circle-plus,tornado,fire-flame-curved,arrow-up-z-a,person-walking-arrow-loop-left,telegram,clone,link-slash,plug-circle-exclamation,hand-holding,face-grin-tongue-wink,circle-plus,shoe-prints,dragon,sd-card,bahai,trailer,calendar,cloud-moon-rain,up-down,internet-explorer,person-walking-luggage,fan,voicemail,person-arrow-down-to-line,vial-circle-check,infinity,chart-column,hippo,stumbleupon-circle,t,oil-can,up-right-and-down-left-from-center,hashtag,square-xmark,earth-oceania,dribbble,gopuram,shield-heart,broom,person-biking,temperature-low,code-commit,btc,yammer,music,yin-yang,npm,cheese,wine-glass-empty,moon,carrot,buffer,pinterest,dog,wand-magic,tents,tent-arrow-turn-left,pen-nib,rockrms,house-chimney-window,circle-chevron-left,golf-ball-tee,twitter,house-medical,jenkins,arrow-up-short-wide,shield,file-export,face-grin-beam-sweat,clock-rotate-left,suse,replyd,behance-square,qrcode,angles-left,building,ticket-simple,award,firefox-browser,person-falling-burst,arrow-turn-down,mercury,transgender,sim-card,dice-four,face-grin-hearts,splotch,arrow-up-right-dots,warehouse,gears,peace,robot,skyatlas,spinner,rotate,youtube,hand-peace,alipay,hammer,person-praying,trophy,thumbtack,face-smile,display,glass-water-droplet,truck-moving,percent,hand-middle-finger,php,github,uber,vr-cardboard,thermometer,traffic-light,baby-carriage,radio,cc-amex,battery-quarter,video-slash,yandex-international,pump-soap,car-rear,retweet,chevron-right,sketch,genderless,briefcase-medical,disease,dev,hand-lizard,bacteria,git,chevron-left,holly-berry,ideal,drumstick-bite,angle-up,fill,js,school-flag,tablet,bell-slash,hornbill,bezier-curve,sign-hanging,plug-circle-minus,arrow-trend-up,store,folder-minus,photo-film,rocket,map,shekel-sign,free-code-camp,bots,teeth,blender,equals,android,copyright,ruler-combined,lines-leaning,arrow-down-up-lock,python,trash-arrow-up,github-square,tractor,themeco,black-tie,quote-left,users-line,leanpub,connectdevelop,weebly,baby,building-shield,peso-sign,chalkboard-user,grunt,arrows-up-down-left-right,perbyte,xmark,computer,expand,get-pocket,plus,square-phone,2,creative-commons-nc-jp,trello,gun,share-from-square,grip,hand-sparkles,volume-xmark,copy,diagram-project,jet-fighter-up,baseball,circle-radiation,clapperboard,circle-half-stroke,user-graduate,fish,goodreads-g,truck-fast,cpanel,deviantart,person-snowboarding,kaggle,y,car-burst,wind,user-shield,circle-user,jug-detergent,staylinked,list-check,shrimp,tv,arrow-up,landmark-dome,brazilian-real-sign,css3,person-harassing,chess-king,ranking-star,rebel,down-long,tower-cell,school-lock,supple,pied-piper-square,list-ul,code-compare,skull-crossbones,wix,rust,battery-full,chevron-down,person-circle-exclamation,suitcase-rolling,square-pen,receipt,file-pen,walkie-talkie,cart-arrow-down,naira-sign,trash-can-arrow-up,person-dots-from-line,table-tennis-paddle-ball,fantasy-flight-games,snapchat,magnifying-glass,rupiah-sign,cc-jcb,font,play,tree-city,ear-listen,link,boxes-stacked,star-half,check-to-slot,waze,paragraph,fax,heart-circle-bolt,fort-awesome,ice-cream,drum,person-walking-with-cane,apper,hotdog,pinterest-p,dharmachakra,ns8,medapps,bars-staggered,chess-rook,wine-bottle,hospital,envelope-open-text,gauge-high,octopus-deploy,section,inbox,socks,battery-empty,dove,person-burst,earth-americas,eraser,droplet,salesforce,buromobelexperte,wirsindhandwerk,arrow-down,person-swimming,greater-than,sass,file,sellsy,location-dot,html5,readme,yandex,handshake-angle,paint-roller,kickstarter-k,phone-slash,star-of-life,plug-circle-xmark,pied-piper-hat,superscript,gg-circle,bell,temperature-full,podcast,square-h,cc-diners-club,bed,medal,temperature-arrow-up,file-circle-xmark,microblog,d-and-d,truck-droplet,dice-d20,arrows-left-right-to-line,gitter,bluetooth,mountain-sun,book-open-reader,screenpal,hand-holding-hand,car,credit-card,bug,hands-holding-circle,toolbox,person-through-window,docker,kit-medical,chess-pawn,ellipsis,jira,anchor-circle-xmark,arrow-right-long,user-lock,thumbs-down,grip-lines,head-side-cough,raspberry-pi,deezer,car-tunnel,angle-down,imdb,less-than,arrow-down-short-wide,bitbucket,florin-sign,simplybuilt,arrows-turn-to-dots,servicestack,google-drive,circle-down,industry,bore-hole,horse-head,face-laugh-wink,guitar,line,sun,viadeo,google-play,ruble-sign,slideshare,yen-sign,plane-circle-xmark,toilet,mars,vault,oil-well,glass-water,bolt,shield-cat,caravan,file-arrow-down,compact-disc,toilet-portable,mound,hurricane,upload,code-merge,stop,up-long,whatsapp,truck-pickup,tower-broadcast,autoprefixer,trade-federation,skull,earlybirds,ussunnah,phabricator,fort-awesome-alt,bowl-rice,mill-sign,arrow-up-wide-short,utensils,rotate-right,uikit,arrow-right-arrow-left,kiwi-bird,jedi-order,gripfire,dice-one,file-medical,b,laptop-medical,calendar-week,house,heart-circle-exclamation,outdent,id-card,book-skull,align-center,face-meh,facebook,mobile,forward,cmplid,hourglass-empty,file-circle-check,eye-dropper,delete-left,instagram,face-grin,download,arrows-down-to-line,dropbox,cc-amazon-pay,ship,building-circle-xmark,creative-commons-remix,cloud-rain,n,figma,people-pulling,js-square,calculator,images,uniregistry,venus-double,person-hiking,closed-captioning,house-medical-circle-exclamation,person-rifle,diagram-next,mendeley,tty,laptop-file,flag-usa,vimeo,right-long,power-off,ticket,ellipsis-vertical,wand-sparkles,person-pregnant,flask,mixer,cent-sign,hamsa,ravelry,map-pin,network-wired,folder-tree,sliders,twitch,khanda,coins,mountain-city,deploydog,hubspot,battery-half,piggy-bank,linkedin,glide,linux,plane-up,mobile-screen,renren,temperature-three-quarters,house-chimney-medical,caret-down,keyboard,timeline,s,baseball-bat-ball,dribbble-square,faucet,pallet,backward-step,clock,square-root-variable,tiktok,u,steam-square,arrow-turn-up,searchengin,grip-vertical,place-of-worship,plug-circle-plus,xbox,resolving,superpowers,palfed,creative-commons-nd,elementor,stack-exchange,vials,cart-shopping,person-military-rifle,star-of-david,mosquito,mosque,droplet-slash,gear,42-group,hands-asl-interpreting,volume-off,franc-sign,street-view,plug-circle-check,arrow-left-long,chess,child-rifle,minus,virus-slash,dna,dashcube,cotton-bureau,left-long,money-bill-1,crop-simple,indian-rupee-sign,chart-gantt,train-subway,erlang,circle-left,plane-arrival,bilibili,calendar-plus,person-skiing-nordic,think-peaks,house-medical-circle-check,tarp-droplet,rectangle-list,dumbbell,face-grin-squint-tears,hard-drive,arrow-rotate-left,cookie,money-bill-wheat,audible,facebook-messenger,etsy,arrow-down-a-z,check-double,maxcdn,vest-patches,symfony,tent,person-cane,crosshairs,truck,landmark,prescription-bottle-medical,braille,calendar-check,water,hand-holding-droplet,tencent-weibo,arrow-down-1-9,sleigh,car-on,meteor,square-virus,camera,cloud-meatball,circle-info,user-doctor,mountain,hourglass,truck-field-un,digg,soundcloud,indent,parachute-box,circle-nodes,viber,border-none,flickr,litecoin-sign,blogger,cuttlefish,arrow-up-9-1,lungs,ribbon,arrow-right-to-city,itunes,paperclip,angles-up,envelope,cake-candles,comment,aviato,tag,plane-lock,satellite,lira-sign,child,clipboard-user,hat-cowboy-side,fly,phone-volume,table-cells-large,business-time,comment-dollar,person-dress,otter,user-secret,optin-monster,suitcase-medical,o,book-bible,file-pdf,table-cells,up-right-from-square,spotify,file-video,users-viewfinder,j,restroom,dice-d6,align-left,magento,ruler,person-circle-xmark,arrow-left,megaport,usps,republican,hands-clapping,cloudversify,wpforms,bandcamp,hands-holding,headphones,tenge-sign,divide,cubes,schlix,0,shirt,mizuni,quote-right,poo,book-medical,forumbee,cloudscale,fulcrum,gauge-simple,trash,person-digging,pen-fancy,hat-wizard,periscope,text-width,person-booth,d-and-d-beyond,bridge-water,mosquito-net,dice-six,shield-virus,door-closed,arrow-up-1-9,user-gear,google-wallet,osi,head-side-virus,first-order-alt,child-reaching,calendar-xmark,phone,mandalorian,face-grin-tears,lungs-virus,users-between-lines,child-dress,mars-double,location-crosshairs,biohazard,arrow-rotate-right,hands-praying,facebook-f,hand-scissors,drum-steelpan,hand-pointer,bacterium,hand-holding-dollar,face-grin-squint,diamond,bimobject,gofore,plant-wilt,comment-dots,sort-up,scale-unbalanced,vihara,floppy-disk,shop,prescription,ello,face-frown,window-maximize,house-chimney,avianex,patreon,heart-circle-xmark,bus,creative-commons-nc-eu,signal,file-code,tower-observation,pen,user-slash,file-shield,person-military-to-person,audio-description,face-sad-cry,eye,bus-simple,tape,speaker-deck,basket-shopping,house-medical-flag,mobile-button,terminal,ban-smoking,cart-flatbed,ethereum,faucet-drip,bars-progress,nfc-symbol,odnoklassniki,house-signal,square-parking,bootstrap,face-meh-blank,forward-fast,wine-glass,tags,file-circle-minus,pied-piper-pp,affiliatetheme,teeth-open,dailymotion,comment-medical,panorama,clipboard-question,yahoo,route,truck-field,anchor-circle-exclamation,cat,truck-front,phone-flip,bridge,reddit-alien,hat-cowboy,code-branch,book-bookmark,handshake-slash,plane-departure,circle-question,typo3,file-image,rupee-sign,gitlab,bugs,wrench,burger,paypal,file-audio,clipboard-check,wallet,users-rays,volume-high,vine,mobile-screen-button,circle-up,satellite-dish,opencart,basketball,trademark,plane-slash,blogger-b,less,quinscape,user-astronaut,recycle,backward-fast,playstation,creative-commons-pd,circle,square-poll-horizontal,house-flood-water-circle-arrow-right,money-bill-trend-up,blackberry,money-bill-transfer,elevator,lock-open,solar-panel,shield-dog,bitcoin-sign,xing,cube,temperature-quarter,r,kitchen-set,apple-whole,dhl,toilet-paper-slash,gg,circle-pause,bottle-water,face-surprise,martini-glass-citrus,house-crack,dumpster-fire,house-user,mars-and-venus,heart,houzz,stripe,draft2digital,ubuntu,plug-circle-bolt,galactic-senate,arrows-to-eye,screwdriver-wrench,tarp,h,umbraco,itch-io,person-circle-question,opera,keycdn,bitcoin,xing-square,creative-commons-share,cash-register,signs-post,cc-stripe,arrow-right,mask-ventilator,chart-line,object-group,face-rolling-eyes,plane-circle-check,circle-right,eject,squarespace,cc-paypal,helmet-safety,toilet-paper,kaaba,location-pin,poop,object-ungroup,face-grin-beam,building-flag,react,fish-fins,file-contract,file-excel,hashnode,page4,sack-xmark,themeisle,bolt-lightning,chart-pie,person-circle-plus,taxi,road,leaf,f,sith,austral-sign,virus-covid,won-sign,jsfiddle,cubes-stacked,arrow-up-right-from-square,magnifying-glass-chart,building-circle-exclamation,whiskey-glass,code,star-half-stroke,money-check,puzzle-piece,hand-holding-heart,house-chimney-user,up-down-left-right,goodreads,linode,file-signature,question,firefox,filter,face-kiss-wink-heart,product-hunt,language,bread-slice,vector-square,money-check-dollar,person-dress-burst,list-ol,sort,locust,filter-circle-xmark,wordpress,circle-arrow-right,rectangle-ad,pied-piper,newspaper,snowflake,p,feather-pointed,temperature-arrow-down,a,road-lock,studiovinari,person-skiing,z,democrat,comments-dollar,envira,empire,church,y-combinator,italic,angrycreative,cedi-sign,couch,martini-glass-empty,speakap,angular,swift,square,file-zipper,house-chimney-crack,heart-circle-check,archway,researchgate,hackerrank,neos,arrows-to-dot,shopify,layer-group,envelope-circle-check,virus,book-atlas,shield-halved,phoenix-framework,greater-than-equal,fedex,stumbleupon,instagram-square,amazon-pay,cruzeiro-sign,fire-extinguisher,padlet,arrows-rotate,guarani-sign,apple-pay,mattress-pillow,handshake-simple-slash,envelope-open,keybase,user-large-slash,chalkboard,children,cloud-arrow-down,gitkraken,house-lock,hive,arrows-left-right,file-powerpoint,file-word,apple,reddit-square,gratipay,face-smile-wink,text-slash,cloud-bolt,arrow-down-wide-short,candy-cane,bowl-food,group-arrows-rotate,circle-arrow-left,boxes-packing,left-right,seedling,arrows-down-to-people,ferry,vest,arrows-turn-right,palette,cloud-arrow-up,font-awesome,asymmetrik,crutch,smog,money-bills,face-tired,house-laptop,burst,500px,diamond-turn-right,subscript,scale-unbalanced-flip,address-card,registered,bomb,temperature-empty,hill-avalanche,yarn,pause,location-pin-lock,spa,scroll,box-open,square-arrow-up-right,file-import,unsplash,circle-arrow-down,amazon,camera-retro,filter-circle-dollar,person-skating,suitcase,reply-all,certificate,tent-arrow-down-to-line,arrow-down-long,ebay,user-tie,square-person-confined,accessible-icon,dochub,viruses,sterling-sign,mdb,person-walking-dashed-line-arrow-right,volcano,lari-sign,circle-chevron-up,confluence,wpbeginner,universal-access,helicopter-symbol,face-laugh-beam,marker,cc-discover,id-badge,neuter,person-shelter,icicles,plate-wheat,compass-drafting,app-store-ios,circle-stop,chrome,circle-check,chair,euro-sign,ethernet,tablets,circle-play,cannabis,angles-right,snowplow,monument,minimize,smoking,dolly,discord,gem,reddit,handshake,head-side-mask,lemon,behance,table-columns,rotate-left,martini-glass,arrow-up-from-ground-water,square-nfi,house-tsunami,folder-closed,screwdriver,hips,caret-up,3,cow,creative-commons-zero,microphone,image,facebook-square,bucket,frog,torii-gate,sticker-mule,square-plus,window-restore,sailboat,crow,bluetooth-b,eye-low-vision,train,hotjar,laravel,less-than-equal,hands-bubbles,chart-bar,cloud-showers-water,square-caret-up,hand-back-fist,mars-stroke-right,person-chalkboard,stripe-s,fedora,building-circle-check,chess-board,joget,glasses,chess-queen,skype,whatsapp-square,dice-two,gift,nfc-directional,galactic-republic,angellist,car-battery,mug-hot,square-poll-vertical,jedi,map-location-dot,border-top-left,not-equal,manat-sign,building-ngo,anchor-lock,bold,wpressr,stroopwafel,signature,user-pen,underline,bath,wifi,file-arrow-up,house-circle-exclamation,people-arrows-left-right,clipboard,champagne-glasses,mars-stroke-up,user-minus,road-circle-xmark,store-slash,wikipedia-w,headset,colon-sign,unlock,pepper-hot,microphone-lines,firstdraft,uncharted,city,code-fork,heart-circle-plus,weibo,folder-open,face-laugh,shuttle-space,bed-pulse,fire,grav,l,person-walking,toggle-on,circle-arrow-up,kickstarter,golang,itunes-note,wheelchair,face-laugh-squint,cc-mastercard,app-store,fonticons-fi,ioxhost,chess-knight,arrow-up-a-z,user-group,accusoft,weight-scale,vuejs,file-prescription,xmarks-lines,weight-hanging,crown,microchip,temperature-high,people-carry-box,heart-pulse,freebsd,passport,venus,delicious,right-to-bracket,person-breastfeeding,building-wheat,blender-phone,hourglass-start,shop-lock,virus-covid-slash,r-project,server,shop-slash,youtube-square,arrow-right-to-bracket,computer-mouse,slash,sellcast,spell-check,x-ray,expeditedssl,plane-circle-exclamation,file-invoice-dollar,hands-bound,spider,grip-lines-vertical,mobile-retro,person-running,shuffle,shapes,instalod,openid,charging-station,maximize,scribd,arrow-pointer,venus-mars,box,cross,repeat,star,cc-apple-pay,steam,spray-can-sparkles,mix,node,camera-rotate,ban,person-circle-minus,house-flag,tumblr-square,chart-area,money-bill-wave,pizza-slice,building-lock,tablet-button,codiepie,c,location-arrow,road-bridge,person-half-dress,synagogue,globe,pied-piper-alt,bullhorn,key,highlighter,square-caret-left,building-user,van-shuttle,dumpster,staff-aesculapius,school-circle-check,user,bridge-circle-exclamation,pen-clip,vimeo-square,e,wand-magic-sparkles,cloudsmith,gauge,vial,adn,mars-stroke,chart-simple,radiation,centos,file-waveform,folder,arrow-up-from-bracket,hand-point-down,bacon,meetup,bullseye,helmet-un,umbrella-beach,6,wizards-of-the-coast,align-justify,bookmark,windows,rev,lyft,git-alt,money-bill,hand-point-up,codepen,face-frown-open,poo-storm,capsules,dong-sign,temperature-half,twitter-square,notes-medical,g,q,horse,angle-right,joint,igloo,school,road-barrier,shirtsinbulk,mortar-pestle,snowman,wheat-awn-circle-exclamation,truck-medical,weixin,head-side-cough-slash,fonticons,bicycle,v,tooth,watchman-monitoring,creative-commons,face-grin-wide,pills,hryvnia-sign,i,viadeo-square,adversal,stairs,stamp,creative-commons-sampling,i-cursor,5,land-mine-on,square-rss,dice-five,road-circle-check,ear-deaf,face-grin-wink,chess-bishop,hacker-news,evernote,chromecast,nimblr,digital-ocean,google-plus-square,face-grin-tongue,bridge-circle-xmark,face-kiss,stopwatch,linkedin-in,atlassian,hand-spock,square-font-awesome-stroke,google,chevron-up,plug,house-circle-xmark,safari,worm,creative-commons-sa,om,hand,flask-vial,eye-slash,users,person-rays,red-river,mitten,arrow-down-z-a,bag-shopping,algolia,sink,microscope,heart-circle-minus,pagelines,share-nodes,car-side,pen-to-square,phoenix-squadron,sack-dollar,bridge-lock,github-alt,stack-overflow,tree,house-flood-water,map-location,foursquare,hot-tub-person,gas-pump,lock,paintbrush,futbol,folder-plus,campground,house-medical-circle-xmark,diaspora,egg,face-dizzy,circle-dot,gamepad,google-plus,cart-plus,square-phone-flip,sourcetree,list,ghost,heading,peseta-sign,markdown,square-check,artstation,asterisk,hands-holding-child,ankh,wheat-awn,compress,cloud-sun-rain,comment-slash,google-plus-g,volume-low,feather,napster,hanukiah,flag,edge,fire-burner,road-spikes,hacker-news-square,memory,circle-dollar-to-slot,sitemap,headphones-simple,cloud-showers-heavy,unlock-keyhole,the-red-yeti,circle-chevron-down,arrow-right-from-bracket,school-circle-xmark,circle-exclamation,battle-net,caret-left,lightbulb,people-robbery,sistrix,8,ruler-horizontal,film,lastfm-square,face-kiss-beam,square-up-right,heart-crack,hourglass-end,people-group,bars,prescription-bottle,swatchbook,laptop-code,file-circle-question,square-caret-down,compass,helicopter,deskpro,square-minus,house-fire,star-and-crescent,reply,clover,users-slash,square-font-awesome,contao,tachograph-digital,magnifying-glass-arrow-right,vimeo-v,table,hockey-puck,toilets-portable,sun-plant-wilt,scissors,square-caret-right,mars-and-venus-burst,css3-alt,snapchat-square,mailchimp,pen-ruler,bell-concierge,untappd,motorcycle,vk,box-tissue,rocketchat,microphone-slash,binoculars,whmcs,unity,creative-commons-by,gavel,tent-arrow-left-right,hospital-user,face-flushed,trowel-bricks,cloud,hire-a-helper,paw,tablet-screen-button,circle-notch,rainbow,earth-africa,w,truck-monster,spray-can,face-grin-tongue-squint,arrow-down-9-1,drupal,person-drowning,box-archive,toggle-off,glide-g,centercode,broom-ball,creative-commons-pd-alt,scroll-torah,invision,person-arrow-up-from-line,java,user-ninja,7,blog,sheet-plastic,orcid,vial-virus,user-check,truck-ramp-box,clipboard-list,code-pull-request,qq,paste,comments,caret-right,backward,pencil,landmark-flag,microsoft,k,strikethrough,address-book,vnv,pager,circle-h,guilded,file-circle-exclamation,envelopes-bulk,jar-wheat,spoon,arrow-down-up-across-line,spaghetti-monster-flying,modx,bong,face-grin-stars,first-order,record-vinyl,truck-plane,menorah,file-csv,laptop,person,fire-flame-simple,buysellads,arrow-up-long,diagram-predecessor,beer-mug-empty,stackpath,people-line,bity,dyalog,people-roof,users-rectangle,angles-down,crop,school-circle-exclamation,football,flag-checkered,face-smile-beam,forward-step,magnifying-glass-location,hand-point-right,wpexplorer,ups,fingerprint,pump-medical,bridge-circle-check,microphone-lines-slash,icons,soap,cloudflare,yoast,redhat,atom,right-from-bracket,door-open,circle-minus,sort-down,arrows-up-to-line,volleyball,building-circle-arrow-right,anchor-circle-check,calendar-days,dice-three,building-un,ring,wave-square,aws,file-lines,explosion,down-left-and-up-right-to-center,creative-commons-nc,info,message,shopware,stethoscope,user-xmark,text-height,lastfm,trash-can,at,cc-visa,circle-chevron-right,yelp,arrows-to-circle,hooli,monero,fill-drip'; 5 | 6 | const fontAwesome = new Set(fontAwesomeNames.split(',')); 7 | const simpleIcons = Object 8 | .values(icons) 9 | .reduce((all, icon) => { 10 | all.add(icon.slug); 11 | return all; 12 | }, new Set()); 13 | 14 | module.exports = { 15 | fontAwesome, 16 | simpleIcons, 17 | }; 18 | -------------------------------------------------------------------------------- /src/lib/parseEmotes.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const emotes = {}; 4 | const sources = {}; 5 | let regexStr = ''; 6 | let emoteRegex; 7 | let lastRequest; 8 | const emoteTimeout = 24 * 60 * 60 * 1000; 9 | 10 | const appendEmote = (selector) => (emote) => { 11 | const { 12 | code, 13 | source, 14 | url 15 | } = selector(emote); 16 | const lowerCaseCode = code.toLowerCase(); 17 | emotes[lowerCaseCode] = url; 18 | sources[lowerCaseCode] = source; 19 | regexStr += `${lowerCaseCode.replace(/\(/, '\\(').replace(/\)/, '\\)')}|`; 20 | }; 21 | 22 | async function getBttvEmotes() { 23 | let { 24 | data: allEmotes 25 | } = await axios.get('https://api.betterttv.net/3/cached/emotes/global'); 26 | const { 27 | data: { 28 | channelEmotes, 29 | sharedEmotes 30 | } 31 | } = await axios.get(`https://api.betterttv.net/3/cached/users/twitch/${process.env.TWITCH_CHANNEL_ID}`); 32 | allEmotes = allEmotes.concat(channelEmotes).concat(sharedEmotes); 33 | const appenderizer3000 = appendEmote(({ 34 | code, 35 | id 36 | }) => ({ 37 | code, 38 | source: 'BTTV', 39 | url: `https://cdn.betterttv.net/emote/${id}/3x` 40 | })); 41 | allEmotes.forEach(appenderizer3000); 42 | } 43 | 44 | async function getFfzEmotes() { 45 | const { data: { sets } } = await axios.get('https://api.frankerfacez.com/v1/set/global'); 46 | const { data: { sets: channelSets } } = await axios.get(`https://api.frankerfacez.com/v1/room/${process.env.TWITCH_CHANNEL_NAME}`); 47 | const all = sets[3].emoticons.concat(channelSets[609613].emoticons); 48 | const appenderizer9000 = appendEmote(({ 49 | name: code, 50 | urls 51 | }) => { 52 | const url = Object.values(urls).pop(); 53 | return { 54 | code, 55 | source: 'FFZ', 56 | url: url.startsWith('http') ? url : `https:${url}` 57 | }; 58 | }); 59 | all.forEach(appenderizer9000); 60 | } 61 | 62 | async function get7tvEmotes() { 63 | const { data: globalEmotes } = await axios.get('https://7tv.io/v3/emote-sets/global'); 64 | const { data: channelEmotes } = await axios.get(`https://7tv.io/v3/users/twitch/${process.env.TWITCH_CHANNEL_ID}`); 65 | const all = globalEmotes.emotes.concat(channelEmotes.emote_set.emotes); 66 | const appenderizer9000 = appendEmote(({ 67 | name: code, 68 | id 69 | }) => ({ 70 | code, 71 | source: '7TV', 72 | url: `https://cdn.7tv.app/emote/${id}/4x.webp` 73 | })); 74 | all.forEach(appenderizer9000); 75 | } 76 | 77 | async function getEmoteRegex() { 78 | if (!emoteRegex || (lastRequest && Date.now() - lastRequest > emoteTimeout)) { 79 | console.log('Refreshing BTTV, 7TV and FFZ cache...'); 80 | await Promise.all([ 81 | getBttvEmotes().catch((error) => { 82 | console.log('Error loading BTTV emotes:', error); 83 | }), 84 | getFfzEmotes().catch((error) => { 85 | console.log('Error loading FFZ emotes:', error); 86 | }), 87 | get7tvEmotes().catch((error) => { 88 | console.log('Error loading 7TV emotes:', error); 89 | }), 90 | ]); 91 | lastRequest = Date.now(); 92 | regexStr = regexStr.slice(0, -1); 93 | emoteRegex = new RegExp(`(?<=^|\\s)(${regexStr})(?=$|\\s)`, 'gi'); 94 | } 95 | return emoteRegex; 96 | } 97 | 98 | module.exports = async function parseEmotes(message, messageEmotes = {}) { 99 | await getEmoteRegex(); 100 | let parsedMessage = ''; 101 | if (messageEmotes) { 102 | const emoteIds = Object.keys(messageEmotes); 103 | const emoteStart = emoteIds.reduce((starts, id) => { 104 | messageEmotes[id].forEach((startEnd) => { 105 | const [start, end] = startEnd.split('-'); 106 | const name = message.substring(+start, +end + 1); 107 | starts[start] = { 108 | url: `![Twitch - ${name}](https://static-cdn.jtvnw.net/emoticons/v2/${id}/default/dark/3.0#emote)`, 109 | end, 110 | }; 111 | }); 112 | return starts; 113 | }, {}); 114 | const parts = Array.from(message); 115 | for (let i = 0; i < parts.length; i++) { 116 | const char = parts[i]; 117 | const emoteInfo = emoteStart[i]; 118 | if (emoteInfo) { 119 | parsedMessage += emoteInfo.url; 120 | i = emoteInfo.end; 121 | } else { 122 | parsedMessage += char; 123 | } 124 | } 125 | } 126 | const result = (parsedMessage || message) 127 | .replace(emoteRegex, (code) => `![${sources[code.toLowerCase()]} - ${code}](${emotes[code.toLowerCase()]}#emote)`); 128 | if (result === message) return undefined; 129 | return result; 130 | }; 131 | -------------------------------------------------------------------------------- /src/lib/twitchAPI.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const helixAPI = axios.create({ 4 | baseURL: 'https://api.twitch.tv/helix', 5 | headers: { 6 | 'Client-ID': process.env.TWITCH_SUB_CLIENT_ID, 7 | Authorization: `Bearer ${process.env.TWITCH_SUB_OAUTH_TOKEN}` 8 | }, 9 | }); 10 | 11 | async function getChannel(channelId) { 12 | const { data } = await helixAPI.get(`/channels?broadcaster_id=${channelId}`); 13 | return data; 14 | } 15 | 16 | async function getChannelFollows(channelId, cursor = '', followers = []) { 17 | const { 18 | data: { 19 | _cursor, 20 | follows 21 | } 22 | } = await helixAPI.get(`/users/follows?to_id=${channelId}&first=100&after=${cursor}`); 23 | if (_cursor) { 24 | return followers.concat(await getChannelFollows(channelId, _cursor, follows)); 25 | } 26 | const all = followers.concat(follows); 27 | return all; 28 | } 29 | 30 | async function getTeam(teamName) { 31 | const { data: { users } } = await helixAPI.get(`/teams?name=${teamName}`); 32 | return users; 33 | } 34 | 35 | async function getStream(channelId) { 36 | const { data: [stream] } = await helixAPI.get(`/streams?user_id=${channelId}`); 37 | return stream; 38 | } 39 | 40 | async function getUserFollow(userId, channelId) { 41 | try { 42 | const { data: { data: [follow] } } = await helixAPI.get(`/users/follows?to_id=${channelId}&from_id=${userId}`); 43 | if (!follow) return false; 44 | // TODO: missing notifications property... 45 | return { created_at: follow.followed_at }; 46 | } catch (error) { 47 | console.log(userId, error.response.data); 48 | return false; 49 | } 50 | } 51 | 52 | async function getUsers(...usernames) { 53 | const url = `/users?login=${usernames.map((u) => encodeURIComponent(u)).join('&login=')}`; 54 | const { data: { data: users } } = await helixAPI.get(url); 55 | return users.map((u) => { 56 | u.name = u.login; 57 | u.logo = u.profile_image_url; 58 | return u; 59 | }); 60 | } 61 | 62 | async function getChannelByUsername(username) { 63 | const [user] = await getUsers(username); 64 | if (user) { 65 | return getChannel(user._id); 66 | } 67 | throw new Error('Not Found!'); 68 | } 69 | 70 | async function updateRedemption(redemption) { 71 | const { data } = await helixAPI.patch(`/channel_points/custom_rewards/redemptions?id=${redemption.id}&reward_id=${redemption.reward.id}&broadcaster_id=${redemption.channel_id}`, { 72 | status: 'FULFILLED', 73 | }); 74 | return data; 75 | } 76 | 77 | function formatBadgeResponse(data) { 78 | return { 79 | badge_sets: data.reduce((all, set) => { 80 | all[set.set_id] = { 81 | versions: set.versions.reduce((allVersions, version) => { 82 | allVersions[version.id] = version; 83 | return allVersions; 84 | }, {}), 85 | }; 86 | return all; 87 | }, {}), 88 | }; 89 | } 90 | 91 | async function getChannelChatBadges(broadcaster_id) { 92 | const { data: { data } } = await helixAPI.get(`/chat/badges?broadcaster_id=${broadcaster_id}`); 93 | return formatBadgeResponse(data); 94 | } 95 | 96 | async function getGlobalChatBadges() { 97 | const { data: { data } } = await helixAPI.get('/chat/badges/global'); 98 | return formatBadgeResponse(data); 99 | } 100 | 101 | module.exports = { 102 | getChannel, 103 | getChannelByUsername, 104 | getGlobalChatBadges, 105 | getChannelChatBadges, 106 | getStream, 107 | getTeam, 108 | getUserFollow, 109 | getUsers, 110 | getChannelFollows, 111 | updateRedemption, 112 | }; 113 | -------------------------------------------------------------------------------- /src/lib/validPronounChoices.js: -------------------------------------------------------------------------------- 1 | module.exports = new Set([ 2 | 'she', 3 | 'he', 4 | 'ze', 5 | 'they', 6 | 'co', 7 | 'none', 8 | 'xe', 9 | 'hy', 10 | 'it', 11 | 'one', 12 | 'em', 13 | '\'em', 14 | 'yo', 15 | ]); -------------------------------------------------------------------------------- /src/lib/youtubeAPI.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const youtubeAPI = axios.create({ 4 | baseURL: process.env.YOUTUBE_OPERATIONAL_API_ENDPOINT, 5 | }); 6 | 7 | async function getLiveEvents(channelId) { 8 | const params = new URLSearchParams({ 9 | part: 'snippet', 10 | channelId: channelId || process.env.YOUTUBE_CHANNEL_ID, 11 | order: 'date', 12 | type: 'video', 13 | eventType: 'live', 14 | }); 15 | 16 | const { data } = await youtubeAPI(`/noKey/search?${params.toString()}`); 17 | return data; 18 | } 19 | 20 | async function getLiveStreamDetails(id) { 21 | const params = new URLSearchParams({ 22 | part: 'liveStreamingDetails,snippet', 23 | id, 24 | }); 25 | 26 | const { data } = await youtubeAPI(`/noKey/videos?${params.toString()}`); 27 | return data.items[0]; 28 | } 29 | 30 | async function getLiveStreamChats(liveChatId, pageToken) { 31 | const params = new URLSearchParams({ 32 | part: 'snippet,authorDetails', 33 | liveChatId, 34 | maxResults: 2000, 35 | }); 36 | 37 | if (pageToken) { 38 | params.append('pageToken', pageToken); 39 | } 40 | 41 | const { data } = await youtubeAPI(`/noKey/liveChat/messages?${params.toString()}`); 42 | return data; 43 | } 44 | 45 | async function getUsers(...ids) { 46 | const params = new URLSearchParams({ 47 | part: 'id,snippet', 48 | id: ids.join(','), 49 | maxResults: 50, 50 | }); 51 | 52 | const { data } = await youtubeAPI(`/noKey/channels?${params.toString()}`); 53 | return data.items.map((item) => ({ 54 | id: item.id, 55 | display_name: item.snippet.title, 56 | handle: item.snippet.customUrl, 57 | logo: item.snippet.thumbnails.default.url, 58 | created_at: new Date(item.snippet.publishedAt), 59 | })); 60 | } 61 | 62 | async function getChannelMeta(id) { 63 | const { data } = await youtubeAPI(`/channels?part=about,approval&id=${id}`); 64 | if (data.items) { 65 | return data.items[0]; 66 | } 67 | return null; 68 | } 69 | 70 | class YTLiveChatManager { 71 | constructor() { 72 | this.liveChatListeners = new Map(); 73 | this.liveChatTimeoutIds = new Map(); 74 | } 75 | 76 | hasListeners() { 77 | return this.liveChatListeners.size > 0; 78 | } 79 | 80 | isListening(id) { 81 | return this.liveChatListeners.has(id); 82 | } 83 | 84 | async pollChat(id, pageToken) { 85 | clearTimeout(this.liveChatTimeoutIds.get(id)); 86 | const result = await getLiveStreamChats(id, pageToken); 87 | if (result.items) { 88 | if (result.items.length) { 89 | const cb = this.liveChatListeners.get(id); 90 | cb(result.items); 91 | } 92 | const timeoutId = setTimeout(async () => { 93 | this.pollChat(id, result.nextPageToken); 94 | }, result.pollingIntervalMillis || 2000); 95 | this.liveChatTimeoutIds.set(id, timeoutId); 96 | } else { 97 | console.log(`Live stream "${id}" ended. Removing listeners`); 98 | this.liveChatListeners.delete(id); 99 | this.liveChatTimeoutIds.delete(id); 100 | } 101 | } 102 | 103 | async listen({ videoId, id }, cb) { 104 | if (!this.isListening(id)) { 105 | console.log('Start listening to LIVE YT event:', videoId); 106 | this.liveChatListeners.set(id, cb); 107 | this.pollChat(id); 108 | } 109 | } 110 | } 111 | 112 | module.exports = { 113 | getChannelMeta, 114 | getLiveEvents, 115 | getLiveStreamDetails, 116 | getUsers, 117 | YTLiveChatManager: new YTLiveChatManager(), 118 | }; 119 | -------------------------------------------------------------------------------- /src/middlewares.js: -------------------------------------------------------------------------------- 1 | function notFound(req, res, next) { 2 | res.status(404); 3 | const error = new Error(`🔍 - Not Found - ${req.originalUrl}`); 4 | next(error); 5 | } 6 | 7 | /* eslint-disable no-unused-vars */ 8 | function errorHandler(err, req, res, next) { 9 | /* eslint-enable no-unused-vars */ 10 | const statusCode = res.statusCode !== 200 ? res.statusCode : 500; 11 | res.status(statusCode); 12 | res.json({ 13 | message: err.message, 14 | stack: process.env.NODE_ENV === 'production' ? '🥞' : err.stack, 15 | response: err.response ? err.response.data : null, 16 | }); 17 | } 18 | 19 | module.exports = { 20 | notFound, 21 | errorHandler 22 | }; 23 | -------------------------------------------------------------------------------- /src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingGarden/api/74b8a719a5b86394236ec5e5b656e8bd0ce5a407/src/public/favicon.ico -------------------------------------------------------------------------------- /src/services/github/sponsors.functions.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const query = `query { 4 | organization(login:"CodingGarden") { 5 | ... on Sponsorable { 6 | monthlyEstimatedSponsorsIncomeInCents 7 | sponsors(first: 100) { 8 | totalCount 9 | nodes { 10 | ... on User { 11 | login 12 | avatarUrl 13 | sponsorshipsAsSponsor(first: 100) { 14 | totalRecurringMonthlyPriceInCents 15 | nodes { 16 | createdAt 17 | tier { 18 | isCustomAmount 19 | monthlyPriceInCents 20 | } 21 | isOneTimePayment 22 | isActive 23 | privacyLevel 24 | } 25 | } 26 | } 27 | ... on Organization { 28 | login 29 | avatarUrl 30 | sponsorshipsAsSponsor(first: 100) { 31 | totalRecurringMonthlyPriceInCents 32 | nodes { 33 | createdAt 34 | tier { 35 | isCustomAmount 36 | monthlyPriceInCents 37 | } 38 | isOneTimePayment 39 | isActive 40 | privacyLevel 41 | } 42 | } 43 | } 44 | } 45 | } 46 | } 47 | } 48 | }`; 49 | 50 | async function getSponsors() { 51 | const { data } = await axios.post('https://api.github.com/graphql', { query }, { 52 | headers: { 53 | Authorization: `Bearer ${process.env.GITHUB_TOKEN}` 54 | } 55 | }); 56 | const monthlySponsors = []; 57 | const oneTimeSponsors = []; 58 | 59 | data.data.organization.sponsors.nodes.forEach((sponsor) => { 60 | const info = { 61 | login: sponsor.login, 62 | avatarUrl: sponsor.avatarUrl, 63 | }; 64 | const sponsorship = sponsor.sponsorshipsAsSponsor.nodes[0]; 65 | info.amount = sponsorship.tier.monthlyPriceInCents; 66 | info.private = sponsorship.privacyLevel === 'PRIVATE'; 67 | if (sponsorship.isOneTimePayment) { 68 | oneTimeSponsors.push(info); 69 | } else { 70 | monthlySponsors.push(info); 71 | } 72 | }); 73 | 74 | return { 75 | monthlyCount: monthlySponsors.length, 76 | monthlyIncomeTotal: data.data.organization.monthlyEstimatedSponsorsIncomeInCents, 77 | monthlySponsors, 78 | oneTimeSponsors, 79 | }; 80 | } 81 | 82 | module.exports = { 83 | getSponsors, 84 | }; 85 | -------------------------------------------------------------------------------- /src/services/github/sponsors.service.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const { getSponsors } = require('./sponsors.functions'); 3 | 4 | function verifySecret(body, signature) { 5 | try { 6 | const hash = crypto 7 | .createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET) 8 | .update(body) 9 | .digest('hex'); 10 | return signature === `sha256=${hash}`; 11 | } catch (error) { 12 | console.error(error); 13 | throw error; 14 | } 15 | } 16 | 17 | class GithubSponsorsService { 18 | constructor() { 19 | this.data = null; 20 | } 21 | 22 | async find() { 23 | this.data = this.data || (await getSponsors()); 24 | return this.data; 25 | } 26 | 27 | async create(body, params) { 28 | if (!verifySecret(params.rawBody, params.headers['x-hub-signature-256'])) { throw new Error('Invalid signature.'); } 29 | this.data = this.data || (await getSponsors()); 30 | if (body.action === 'created') { 31 | const { sponsor } = body.sponsorship; 32 | const info = { 33 | login: sponsor.login, 34 | avatarUrl: sponsor.avatarUrl, 35 | }; 36 | const { sponsorship } = body; 37 | info.amount = sponsorship.tier.monthly_price_in_cents; 38 | info.private = sponsorship.privacy_level.toLowerCase() === 'private'; 39 | if (sponsorship.tier.is_one_time) { 40 | info.is_one_time = true; 41 | } 42 | return { 43 | action: 'created', 44 | info, 45 | }; 46 | } 47 | return { message: 'OK' }; 48 | } 49 | } 50 | 51 | module.exports = GithubSponsorsService; 52 | -------------------------------------------------------------------------------- /src/services/icons/icons.service.js: -------------------------------------------------------------------------------- 1 | const { 2 | fontAwesome, 3 | simpleIcons, 4 | } = require('../../lib/getIcons'); 5 | 6 | class IconsService { 7 | async find() { 8 | return { 9 | fontAwesome: [...fontAwesome], 10 | simpleIcons: [...simpleIcons], 11 | }; 12 | } 13 | } 14 | 15 | module.exports = IconsService; 16 | -------------------------------------------------------------------------------- /src/services/index.js: -------------------------------------------------------------------------------- 1 | const { FeathersError } = require('@feathersjs/errors'); 2 | 3 | const PledgeService = require('./patreon/pledges.service'); 4 | const MemberService = require('./youtube/members.service'); 5 | const StatsService = require('./youtube/stats.service'); 6 | const YouTubeChatService = require('./youtube/chat.service'); 7 | const YouTubeUsersService = require('./youtube/youtube.users'); 8 | const YouTubeCommandsService = require('./youtube/commands.service'); 9 | const TwitchChatService = require('./twitch/chat.service'); 10 | const VoxPopuliService = require('./vox/populi.service'); 11 | const TwitchSubsService = require('./twitch/subs.service'); 12 | const TwitchUsersService = require('./twitch/users.service'); 13 | const TwitchRewardsService = require('./twitch/rewards.service'); 14 | const TwitchLoginService = require('./twitch/login.service'); 15 | const TwitchBadgesService = require('./twitch/badges.service'); 16 | const TwitchCommandsService = require('./twitch/commands.service'); 17 | const IconsService = require('./icons/icons.service'); 18 | const GithubSponsorsService = require('./github/sponsors.service'); 19 | 20 | const unAuthorizedMessage = 'Un-Authorized. 👮🚨 This event will be reported to the internet police. 🚨👮'; 21 | 22 | const internalOnly = async (context) => { 23 | if (!context.params.provider) return context; 24 | throw new FeathersError(unAuthorizedMessage, 'un-authorized', 401, 'UnAuthorizedError', null); 25 | }; 26 | 27 | const verifyAPIKey = async (context) => { 28 | if (!context.params.provider) return context; 29 | if ((context.params.query && context.params.query.key === process.env.CLIENT_API_KEY) 30 | || context.params.headers['X-API-KEY'] === process.env.CLIENT_API_KEY 31 | || context.params.apiKey === process.env.CLIENT_API_KEY) return context; 32 | throw new FeathersError(unAuthorizedMessage, 'un-authorized', 401, 'UnAuthorizedError', null); 33 | }; 34 | 35 | module.exports = function configure(app) { 36 | const apiKeyFindHooks = { 37 | before: { 38 | get: [verifyAPIKey], 39 | find: [verifyAPIKey], 40 | } 41 | }; 42 | app.use('patreon/pledges', new PledgeService()); 43 | app.service('patreon/pledges').hooks({ 44 | before: { 45 | get: [verifyAPIKey], 46 | find: [verifyAPIKey], 47 | create: [internalOnly], 48 | } 49 | }); 50 | app.use('youtube/chat', new YouTubeChatService(app)); 51 | app.service('youtube/chat').hooks({ 52 | before: { 53 | get: [verifyAPIKey], 54 | find: [verifyAPIKey], 55 | patch: [verifyAPIKey], 56 | create: [internalOnly], 57 | remove: [internalOnly], 58 | }, 59 | }); 60 | app.use('youtube/users', new YouTubeUsersService(app)); 61 | app.service('youtube/users').hooks({ 62 | before: { 63 | get: [verifyAPIKey], 64 | find: [verifyAPIKey], 65 | patch: [internalOnly], 66 | create: [internalOnly, (context) => context.event = null], 67 | }, 68 | }); 69 | app.use('youtube/commands', new YouTubeCommandsService(app)); 70 | app.service('youtube/commands').hooks({ 71 | before: { 72 | find: [verifyAPIKey], 73 | patch: [verifyAPIKey], 74 | create: [internalOnly], 75 | remove: [internalOnly], 76 | }, 77 | }); 78 | app.use('youtube/stats', new StatsService(app)); 79 | app.service('youtube/stats').hooks(apiKeyFindHooks); 80 | app.use('youtube/members', new MemberService(app)); 81 | app.service('youtube/members').hooks(apiKeyFindHooks); 82 | app.use('twitch/subs', new TwitchSubsService()); 83 | app.service('twitch/subs').hooks(apiKeyFindHooks); 84 | app.use('twitch/rewards', new TwitchRewardsService(app)); 85 | app.service('twitch/rewards').hooks({ 86 | before: { 87 | find: [verifyAPIKey], 88 | patch: [verifyAPIKey], 89 | create: [internalOnly], 90 | }, 91 | }); 92 | app.use('twitch/chat', new TwitchChatService(app)); 93 | app.service('twitch/chat').hooks({ 94 | before: { 95 | find: [verifyAPIKey], 96 | patch: [verifyAPIKey], 97 | create: [internalOnly], 98 | remove: [internalOnly], 99 | }, 100 | }); 101 | app.use('twitch/commands', new TwitchCommandsService(app)); 102 | app.service('twitch/commands').hooks({ 103 | before: { 104 | find: [verifyAPIKey], 105 | patch: [verifyAPIKey], 106 | create: [internalOnly], 107 | remove: [internalOnly], 108 | }, 109 | }); 110 | app.use('twitch/users', new TwitchUsersService(app)); 111 | app.service('twitch/users').hooks({ 112 | before: { 113 | get: [verifyAPIKey], 114 | find: [verifyAPIKey], 115 | patch: [internalOnly], 116 | create: [internalOnly, (context) => context.event = null], 117 | }, 118 | }); 119 | app.use('twitch/badges', new TwitchBadgesService(app)); 120 | app.service('twitch/badges').hooks({ 121 | before: { 122 | get: [verifyAPIKey], 123 | }, 124 | }); 125 | app.use('vox/populi', new VoxPopuliService(app)); 126 | app.service('vox/populi').hooks({ 127 | before: { 128 | create: [internalOnly], 129 | patch: [verifyAPIKey], 130 | remove: [verifyAPIKey], 131 | }, 132 | }); 133 | app.use('github/sponsors', new GithubSponsorsService(app)); 134 | app.service('github/sponsors').hooks({ 135 | before: { 136 | get: [verifyAPIKey], 137 | find: [verifyAPIKey], 138 | }, 139 | }); 140 | app.use('icons', new IconsService()); 141 | app.use('twitch/login', new TwitchLoginService(app)); 142 | }; 143 | -------------------------------------------------------------------------------- /src/services/patreon/pledges.functions.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const { 4 | PATREON_DEVICE_ID: patreonDeviceId, 5 | PATREON_SESSION_ID: patreonSessionId, 6 | } = process.env; 7 | 8 | const API_BASE = 'https://www.patreon.com/api/'; 9 | 10 | function addPledge(item, pledgesByUserId, rewardIdsByCost) { 11 | const { attributes: { pledge_amount_cents: amount_cents, }, relationships, } = item; 12 | const level = { 13 | amount_cents, 14 | level_id: relationships.reward ? relationships.reward.data.id : null, 15 | }; 16 | pledgesByUserId[relationships.user.data.id] = level; 17 | rewardIdsByCost[amount_cents] = rewardIdsByCost[amount_cents] || null; 18 | if (level.level_id) { 19 | rewardIdsByCost[amount_cents] = level.level_id; 20 | } 21 | } 22 | 23 | function addReward(item, rewardsById) { 24 | rewardsById[item.id] = item; 25 | } 26 | 27 | function addUser(item, pledgesByUserId, users) { 28 | const { attributes: { full_name: name, }, id } = item; 29 | const user = { 30 | id, 31 | name, 32 | level: pledgesByUserId[item.id], 33 | }; 34 | users[id] = user; 35 | } 36 | 37 | async function getPledges({ 38 | pledgesByUserId = {}, 39 | rewardsById = {}, 40 | rewardIdsByCost = {}, 41 | users = {}, 42 | }) { 43 | let url = `${API_BASE}/members?include=user,reward,latest_pledge.campaign,address,follow-setting&${encodeURIComponent('fields[campaign]')}=is_anniversary_billing&${encodeURIComponent('fields[follow_settings]')}=shipping_opt_out&${encodeURIComponent('fields[member]')}=access_expires_at,reward_id,pledge_amount_cents,pledge_cap_amount_cents,pledge_cadence,pledge_relationship_start,pledge_relationship_end,last_charge_date,last_charge_status,next_charge_date,patron_status,is_follower,is_free_trial,note,currency,email,campaign_currency,campaign_pledge_amount_cents,campaign_lifetime_support_cents,is_reward_fulfilled,discord_vanity&${encodeURIComponent('fields[user]')}=full_name,thumb_url,url&${encodeURIComponent('fields[reward]')}=requires_shipping,description,amount_cents,currency,title,discord_role_ids&${encodeURIComponent('fields[pledge]')}=amount_cents,cadence,currency&${encodeURIComponent('filter[timezone]')}=America/Denver&${encodeURIComponent('filter[membership_type]')}=active_patron&${encodeURIComponent('filter[campaign_id]')}=1192685&sort=&${encodeURIComponent('page[offset]')}=0&${encodeURIComponent('page[count]')}=50&json-api-use-default-includes=false&json-api-version=1.0`; 44 | while (url) { 45 | // eslint-disable-next-line 46 | const { data } = await axios.get(url, { 47 | headers: { 48 | Cookie: `patreon_device_id=${patreonDeviceId}; patreon_location_country_code=US; patreon_locale_code=en-US; session_id=${patreonSessionId};`, 49 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/111.0', 50 | Accept: '*/*', 51 | 'Accept-Language': 'en-US,en;q=0.5', 52 | 'Accept-Encoding': 'gzip, deflate, br', 53 | Referer: 'https://www.patreon.com/members', 54 | 'Content-Type': 'application/vnd.api+json', 55 | Connection: 'keep-alive', 56 | 'Sec-Fetch-Dest': 'empty', 57 | 'Sec-Fetch-Mode': 'cors', 58 | 'Sec-Fetch-Site': 'same-origin', 59 | DNT: '1', 60 | 'Sec-GPC': '1', 61 | TE: 'trailers', 62 | } 63 | }); 64 | 65 | data.data.forEach((item) => { 66 | if (item.type === 'member') { 67 | addPledge(item, pledgesByUserId, rewardIdsByCost); 68 | } 69 | }); 70 | data.included.forEach((item) => { 71 | if (item.type === 'reward') { 72 | addReward(item, rewardsById); 73 | } else if (item.type === 'user' && pledgesByUserId[item.id]) { 74 | addUser(item, pledgesByUserId, users); 75 | } 76 | }); 77 | url = data.links.next; 78 | } 79 | 80 | Object.values(users).forEach((user) => { 81 | user.level.level_id = user.level.level_id || rewardIdsByCost[user.level.amount_cents]; 82 | }); 83 | 84 | return { 85 | pledgesByUserId, 86 | rewardsById, 87 | rewardIdsByCost, 88 | users, 89 | }; 90 | } 91 | 92 | module.exports = { 93 | addPledge, 94 | addReward, 95 | addUser, 96 | getPledges, 97 | }; 98 | -------------------------------------------------------------------------------- /src/services/patreon/pledges.service.js: -------------------------------------------------------------------------------- 1 | const { 2 | getPledges, 3 | } = require('./pledges.functions'); 4 | 5 | class PledgeService { 6 | constructor() { 7 | this.data = null; 8 | } 9 | 10 | async find(params) { 11 | this.data = this.data || await getPledges({}); 12 | const result = { 13 | users: Object.values(this.data.users), 14 | levels: this.data.rewardsById, 15 | }; 16 | const { sort } = params.query; 17 | result.users = result.users.sort((a, b) => { 18 | if (sort === 'amount') { 19 | const priceDiff = b.level.amount_cents - a.level.amount_cents; 20 | if (priceDiff !== 0) return priceDiff; 21 | } 22 | return new Date(a.level.created_at) - new Date(b.level.created_at); 23 | }); 24 | return result; 25 | } 26 | 27 | async create() { 28 | const oldUsers = this.data.users; 29 | this.data = await getPledges({}); 30 | return Object.values(this.data.users).find((user) => !oldUsers[user.id]); 31 | } 32 | } 33 | 34 | module.exports = PledgeService; 35 | -------------------------------------------------------------------------------- /src/services/twitch/badges.service.js: -------------------------------------------------------------------------------- 1 | const { getGlobalChatBadges, getChannelChatBadges } = require('../../lib/twitchAPI'); 2 | 3 | class TwitchBadges { 4 | constructor() { 5 | this.cache = new Map(); 6 | } 7 | 8 | async get(id) { 9 | if (this.cache.has(id)) return this.cache.get(id); 10 | this.cache.set(id, id === 'global' ? getGlobalChatBadges() : getChannelChatBadges(id)); 11 | return this.cache.get(id); 12 | } 13 | } 14 | 15 | module.exports = TwitchBadges; 16 | -------------------------------------------------------------------------------- /src/services/twitch/chat.functions.js: -------------------------------------------------------------------------------- 1 | const tmi = require('tmi.js'); 2 | const { sub } = require('date-fns'); 3 | 4 | const tmiParser = require('tmi.js/lib/parser'); 5 | 6 | const parseEmotes = require('../../lib/parseEmotes'); 7 | 8 | const client = new tmi.Client({ 9 | connection: { 10 | secure: true, 11 | reconnect: true 12 | }, 13 | identity: { 14 | username: process.env.TWITCH_CHANNEL_NAME, 15 | password: `oauth:${process.env.TWITCH_SUB_OAUTH_TOKEN}`, 16 | }, 17 | channels: [process.env.TWITCH_CHANNEL_NAME] 18 | }); 19 | client 20 | .connect() 21 | .then(() => { 22 | console.log('Connected to twitch chat...'); 23 | }) 24 | .catch((error) => { 25 | console.log(error); 26 | console.log('Error connecting to twitch...', error.message); 27 | }); 28 | 29 | async function createMessage(tags, message, app) { 30 | if (!message) { 31 | console.log('empty message', JSON.stringify({ 32 | tags, 33 | message 34 | }, null, 2)); 35 | } 36 | message = message || ''; 37 | tags.badges = tags.badges || {}; 38 | const item = Object.entries(tags).reduce((all, [key, value]) => { 39 | all[key.replace(/-/g, '_')] = value; 40 | return all; 41 | }, {}); 42 | item.username = item.login || item.username; 43 | item.name = item.display_name || item.login || item.username; 44 | item.created_at = new Date(+item.tmi_sent_ts); 45 | item.deleted_at = null; 46 | item.message = message; 47 | try { 48 | item.parsedMessage = await parseEmotes(message, item.emotes); 49 | } catch (error) { 50 | console.error('error parsing emotes...', error.message, item); 51 | } 52 | if (message.match(/^!\w/)) { 53 | item.args = message.split(' '); 54 | item.command = item.args.shift().slice(1); 55 | app.service('twitch/commands').create(item); 56 | } else { 57 | app.service('twitch/chat').create(item); 58 | } 59 | } 60 | 61 | function listenChats(app) { 62 | client.on('raw_message', (messageClone) => { 63 | if (messageClone.command === 'USERNOTICE') { 64 | tmiParser.badges(messageClone.tags); 65 | tmiParser.badgeInfo(messageClone.tags); 66 | tmiParser.emotes(messageClone.tags); 67 | 68 | // TODO: look at msg-id for empty messages 69 | createMessage(messageClone.tags, messageClone.params[1], app); 70 | } 71 | }); 72 | client.on('message', (channel, tags, message) => { 73 | if (tags['message-type'] === 'whisper') return; 74 | createMessage(tags, message, app); 75 | }); 76 | client.on('timeout', async (channel, username, reason, duration, tags) => { 77 | const user_id = tags['target-user-id']; 78 | const recentChats = await app.service('twitch/chat').find({ 79 | query: { 80 | user_id, 81 | }, 82 | created_at: { 83 | $gte: sub(new Date(), { 84 | minutes: 10, 85 | }), 86 | } 87 | }); 88 | await Promise.all( 89 | recentChats.map(async (chat) => { 90 | await app.service('twitch/chat').remove(chat.id); 91 | await app.service('twitch/commands').remove(chat.id); 92 | }) 93 | ); 94 | }); 95 | client.on('messagedeleted', (channel, username, deletedMessage, userstate) => { 96 | const id = userstate['target-msg-id']; 97 | app.service('twitch/chat').remove(id); 98 | app.service('twitch/commands').remove(id); 99 | }); 100 | } 101 | 102 | async function getModerators() { 103 | try { 104 | const mods = await client.mods(process.env.TWITCH_CHANNEL_NAME); 105 | return mods; 106 | } catch (error) { 107 | console.error(error); 108 | return []; 109 | } 110 | } 111 | 112 | module.exports = { 113 | listenChats, 114 | getModerators, 115 | }; 116 | -------------------------------------------------------------------------------- /src/services/twitch/chat.service.js: -------------------------------------------------------------------------------- 1 | const { sub } = require('date-fns'); 2 | const { listenChats } = require('./chat.functions'); 3 | const { twitchChats } = require('../../db'); 4 | 5 | class TwitchService { 6 | constructor(app) { 7 | this.app = app; 8 | listenChats(app); 9 | } 10 | 11 | async find(params) { 12 | const query = { 13 | deleted_at: { 14 | $eq: null, 15 | }, 16 | created_at: { 17 | $gte: sub(new Date(), { 18 | hours: 4, 19 | }), 20 | }, 21 | ack: { 22 | $ne: true, 23 | }, 24 | }; 25 | if (params.query) { 26 | if (params.query.user_id) { 27 | query.user_id = params.query.user_id; 28 | } 29 | if (params.query.created_at) { 30 | query.created_at = { 31 | $gte: new Date(params.query.created_at), 32 | }; 33 | } 34 | } 35 | const messages = await twitchChats.find(query, { 36 | sort: { 37 | created_at: -1, 38 | }, 39 | limit: params.query.limit || 300, 40 | }); 41 | return messages; 42 | } 43 | 44 | async remove(id) { 45 | await twitchChats.update({ id }, { $set: { deleted_at: new Date() } }); 46 | return id; 47 | } 48 | 49 | async patch(id, updates) { 50 | const updated = await twitchChats.findOneAndUpdate( 51 | { 52 | id, 53 | }, 54 | { 55 | $set: updates, 56 | }, 57 | { 58 | upsert: true, 59 | } 60 | ); 61 | return updated; 62 | } 63 | 64 | async create(message) { 65 | let user = { 66 | _id: message.user_id, 67 | name: message.username, 68 | bio: '', 69 | created_at: new Date(), 70 | display_name: message.display_name, 71 | id: message.user_id, 72 | logo: 'https://static-cdn.jtvnw.net/jtv_user_pictures/611cac54-34e0-4c2a-851b-66e5ea2b3f81-profile_image-300x300.png', 73 | type: 'user', 74 | updated_at: new Date(), 75 | follow: false, 76 | subscription: false, 77 | }; 78 | try { 79 | user = await this.app.service('twitch/users').get(message.username); 80 | } catch (error) { 81 | console.error( 82 | 'error requesting user...', 83 | error.message, 84 | message.username 85 | ); 86 | } 87 | const created = await twitchChats.findOneAndUpdate( 88 | { 89 | id: message.id, 90 | }, 91 | { 92 | $set: message, 93 | }, 94 | { 95 | upsert: true, 96 | } 97 | ); 98 | created.user = user; 99 | return created; 100 | } 101 | } 102 | 103 | module.exports = TwitchService; 104 | -------------------------------------------------------------------------------- /src/services/twitch/commands.service.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { 3 | twitchCommands, 4 | } = require('../../db'); 5 | const createCommandsService = require('../../lib/createCommandsService'); 6 | 7 | module.exports = createCommandsService({ 8 | dbCollection: twitchCommands, 9 | getUserService: (app) => app.service('twitch/users'), 10 | getUserQuery: ({ message, user }) => (user ? user.name : message.username), 11 | getIsModOrOwner: ({ message, question }) => !!(message.badges.moderator 12 | || message.badges.broadcaster 13 | || question.user_id === message.user_id) 14 | }); 15 | -------------------------------------------------------------------------------- /src/services/twitch/login.service.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const axios = require('axios'); 3 | const jws = require('jws'); 4 | 5 | const { 6 | getModerators, 7 | } = require('./chat.functions'); 8 | 9 | const { 10 | JWT_SECRET: secret, 11 | TWITCH_MGMT_CLIENT_ID: client_id, 12 | TWITCH_MGMT_CLIENT_SECRET: client_secret, 13 | TWITCH_CHANNEL_NAME: channel_name, 14 | } = process.env; 15 | 16 | const algorithm = { alg: 'HS256' }; 17 | 18 | function createToken(payload = crypto.randomBytes(2).toString('hex')) { 19 | const token = jws.sign({ 20 | header: algorithm, 21 | payload, 22 | secret, 23 | }); 24 | const [, data, signature] = token.split('.'); 25 | return [data, signature].join('.'); 26 | } 27 | 28 | function validateToken(state) { 29 | return jws.verify(`eyJhbGciOiJIUzI1NiJ9.${state}`, algorithm.alg, secret); 30 | } 31 | 32 | class TwitchLoginService { 33 | constructor(app) { 34 | this.app = app; 35 | } 36 | 37 | async get(id, params) { 38 | if (id !== 'mod') throw new Error('Not Found'); 39 | const state = createToken(); 40 | const requestParams = new URLSearchParams({ 41 | client_id, 42 | redirect_uri: 'http://localhost:8080/#/twitch/callback', 43 | response_type: 'code', 44 | state, 45 | force_verify: true 46 | }); 47 | params.res.status(302); 48 | params.res.set('Location', `https://id.twitch.tv/oauth2/authorize?${requestParams}`); 49 | return { 50 | status: 'Logging in...' 51 | }; 52 | } 53 | 54 | async create(info) { 55 | const { code, state } = info; 56 | if (!validateToken(state)) { 57 | throw new Error('Invalid state'); 58 | } 59 | const authParams = new URLSearchParams({ 60 | client_id, 61 | client_secret, 62 | code, 63 | scope: '', 64 | grant_type: 'authorization_code', 65 | redirect_uri: 'http://localhost:8080/#/twitch/callback', 66 | state, 67 | }); 68 | try { 69 | const { 70 | data: { 71 | access_token, 72 | } 73 | } = await axios.post(`https://id.twitch.tv/oauth2/token?${authParams}`); 74 | const { data: { data: [user] } } = await axios.get('https://api.twitch.tv/helix/users', { 75 | headers: { 76 | Authorization: `Bearer ${access_token}`, 77 | 'client-id': client_id, 78 | }, 79 | }); 80 | const moderators = await getModerators(); 81 | if (channel_name !== user.login && !moderators.includes(user.login)) { 82 | throw new Error('You are not a moderator.'); 83 | } 84 | const token = createToken({ 85 | id: user.id, 86 | login: user.login, 87 | display_name: user.display_name, 88 | image: user.profile_image_url, 89 | moderator: true, 90 | }); 91 | return { token }; 92 | } catch (error) { 93 | error.message = error.response ? error.response.data.message : error.message; 94 | throw error; 95 | } 96 | } 97 | } 98 | 99 | module.exports = TwitchLoginService; 100 | -------------------------------------------------------------------------------- /src/services/twitch/rewards.service.js: -------------------------------------------------------------------------------- 1 | const TPS = require('twitchps'); 2 | 3 | const { 4 | twitchRewards, 5 | } = require('../../db'); 6 | 7 | const { 8 | updateRedemption, 9 | } = require('../../lib/twitchAPI'); 10 | 11 | const { 12 | TWITCH_CHANNEL_ID: channelId 13 | } = process.env; 14 | 15 | class TwitchRewardsService { 16 | constructor(app) { 17 | const init_topics = [{ 18 | topic: `channel-points-channel-v1.${channelId}`, 19 | token: process.env.TWITCH_REWARDS_TOKEN, 20 | }]; 21 | 22 | const pubSub = new TPS({ 23 | init_topics, 24 | reconnect: true, 25 | debug: false, 26 | }); 27 | 28 | pubSub.on('channel-points', async (data) => { 29 | app.service('twitch/rewards').create(data); 30 | }); 31 | } 32 | 33 | async find() { 34 | return twitchRewards.find({ 35 | ack: { 36 | $ne: true, 37 | }, 38 | }); 39 | } 40 | 41 | async patch(_id, updates) { 42 | let redemption; 43 | // TODO: this doesn't work because the rewards were created with a different client_id... thanks for wasting my time twitch api. 44 | // if (updates.ack) { 45 | // const existing = await twitchRewards.findOne({ 46 | // _id, 47 | // }); 48 | // redemption = await updateRedemption(existing.redemption); 49 | // } 50 | const updated = await twitchRewards.findOneAndUpdate({ 51 | _id, 52 | }, { 53 | $set: { 54 | ...updates, 55 | redemption, 56 | }, 57 | }, { 58 | upsert: true, 59 | }); 60 | return updated; 61 | } 62 | 63 | async create(data) { 64 | const created = await twitchRewards.insert(data); 65 | return created; 66 | } 67 | } 68 | 69 | module.exports = TwitchRewardsService; 70 | -------------------------------------------------------------------------------- /src/services/twitch/subs.service.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | // { 4 | // created_at": "2020-04-28T11:37:28Z", 5 | // "_id": "e60aaaf329e3a89a1cfb0cf33dd848205893dd20", 6 | // "sub_plan": "3000", 7 | // "sub_plan_name": "Avocado", 8 | // "is_gift": false, 9 | // "user": { 10 | // "display_name": "CodingGarden", 11 | // "type": "user", 12 | // "bio": "", 13 | // "created_at": "2019-02-02T04:46:04Z", 14 | // "updated_at": "2020-04-29T20:05:08Z", 15 | // "name": "codinggarden", 16 | // "_id": "413856795", 17 | // "logo": "https://static-cdn.jtvnw.net/jtv_user_pictures/611cac54-34e0-4c2a-851b-66e5ea2b3f81-profile_image-300x300.png" 18 | // }, 19 | // "sender": null 20 | // }, 21 | 22 | // { 23 | // "id": "2870215", 24 | // "name": "Danny", 25 | // "level": { 26 | // "amount_cents": 100, 27 | // "created_at": "2018-06-02T00:58:14.762+00:00", 28 | // "level_id": "2915602" 29 | // } 30 | // }, 31 | 32 | const cacheTime = 30 * 60 * 1000; 33 | let lastRequest; 34 | 35 | const tiersToCents = { 36 | 1000: 499, 37 | 2000: 999, 38 | 3000: 2499, 39 | }; 40 | 41 | const levels = { 42 | 1000: 'Wall Flower', 43 | 2000: 'Fertilizer', 44 | 3000: 'Avocado', 45 | }; 46 | 47 | const helixAPI = axios.create({ 48 | baseURL: 'https://api.twitch.tv/helix', 49 | headers: { 50 | 'Client-ID': process.env.TWITCH_SUB_CLIENT_ID, 51 | Authorization: `Bearer ${process.env.TWITCH_SUB_OAUTH_TOKEN}`, 52 | }, 53 | }); 54 | 55 | const { 56 | TWITCH_CHANNEL_ID, 57 | } = process.env; 58 | 59 | async function getSubCount() { 60 | const { 61 | data: { 62 | total, 63 | }, 64 | } = await helixAPI.get( 65 | `/subscriptions?broadcaster_id=${TWITCH_CHANNEL_ID}&first=1`, 66 | ); 67 | return { 68 | total, 69 | }; 70 | } 71 | 72 | async function getSubsPage(cursor = '', all = []) { 73 | console.log('Gettting sub cursor', cursor); 74 | const beforeLength = all.length; 75 | const { 76 | data: { 77 | total, 78 | data: subscriptions, 79 | pagination: { cursor: next_cursor }, 80 | }, 81 | } = await helixAPI.get( 82 | `/subscriptions?broadcaster_id=${TWITCH_CHANNEL_ID}&first=100&after=${cursor}`, 83 | ); 84 | all = all.concat(subscriptions); 85 | console.log('Got', all.length, 'of', total, 'subs'); 86 | if (all.length >= total || beforeLength === all.length) return all; 87 | return getSubsPage(next_cursor, all); 88 | } 89 | 90 | async function getSubs() { 91 | console.log('getting subs...'); 92 | const data = await getSubsPage(); 93 | console.log('got all subs!'); 94 | const usersById = {}; 95 | const users = data 96 | .map(({ tier, user_name: name, user_id: id }) => { 97 | const user = { 98 | id, 99 | name, 100 | level: { 101 | amount_cents: tiersToCents[tier], 102 | created_at: new Date(), 103 | level_id: tier, 104 | }, 105 | }; 106 | usersById[id] = user; 107 | return user; 108 | }) 109 | .filter((user) => user.id !== TWITCH_CHANNEL_ID); 110 | return { 111 | users, 112 | levels, 113 | usersById, 114 | }; 115 | } 116 | 117 | class TwitchSubs { 118 | constructor() { 119 | this.data = null; 120 | } 121 | 122 | async get(id) { 123 | if (!this.data) { 124 | this.data = getSubs(); 125 | lastRequest = Date.now(); 126 | } else if (!lastRequest || lastRequest < Date.now() - cacheTime) { 127 | this.data = getSubs(); 128 | lastRequest = Date.now(); 129 | } 130 | this.data = await this.data; 131 | if (Object.prototype.hasOwnProperty.call(this.data.usersById, id)) { 132 | return this.data.usersById[id]; 133 | } 134 | return false; 135 | } 136 | 137 | async find(params) { 138 | if (params.query.count) { 139 | return getSubCount(); 140 | } 141 | if (!this.data) { 142 | this.data = getSubs(); 143 | lastRequest = Date.now(); 144 | } else if (!lastRequest || lastRequest < Date.now() - cacheTime) { 145 | this.data = getSubs(); 146 | lastRequest = Date.now(); 147 | } 148 | this.data = await this.data; 149 | const result = { 150 | users: this.data.users.slice(0), 151 | levels: this.data.levels, 152 | }; 153 | const { sort } = params.query; 154 | result.users = result.users.sort((a, b) => { 155 | if (sort === 'amount') { 156 | const priceDiff = b.level.amount_cents - a.level.amount_cents; 157 | if (priceDiff !== 0) return priceDiff; 158 | } 159 | return new Date(a.level.created_at) - new Date(b.level.created_at); 160 | }); 161 | return result; 162 | } 163 | 164 | async create() { 165 | if (!this.data) return { created: true }; 166 | const latestData = await getSubs(); 167 | const newMember = latestData.users.find( 168 | (user) => !this.data.usersById[user.id] 169 | ); 170 | this.data = latestData; 171 | return newMember; 172 | } 173 | } 174 | 175 | module.exports = TwitchSubs; 176 | -------------------------------------------------------------------------------- /src/services/twitch/users.service.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | const { 3 | getUserFollow, 4 | getUsers, 5 | } = require('../../lib/twitchAPI'); 6 | const { 7 | getModerators, 8 | } = require('./chat.functions'); 9 | const { 10 | twitchUsers, 11 | } = require('../../db'); 12 | 13 | const { 14 | TWITCH_CHANNEL_ID: channelId 15 | } = process.env; 16 | 17 | const cacheTime = 30 * 60 * 1000; 18 | const existingCacheTime = 10 * 60 * 1000; 19 | const cache = new Map(); 20 | 21 | class TwitchUsersService { 22 | constructor(app) { 23 | this.app = app; 24 | } 25 | 26 | async get(name) { 27 | if (name === 'moderator') { 28 | return getModerators(); 29 | } 30 | const cachedUser = cache.get(name); 31 | if (cachedUser && cachedUser.time > Date.now() - cacheTime) { 32 | return cachedUser.user; 33 | } 34 | try { 35 | const [updatedUser] = await getUsers(name); 36 | if (updatedUser) { 37 | const createdUser = await this.create(updatedUser); 38 | cache.set(name, { 39 | time: Date.now(), 40 | user: createdUser, 41 | }); 42 | return createdUser; 43 | } 44 | return { 45 | id: 'not-found', 46 | name: 'not-found', 47 | display_name: 'not-found', 48 | logo: 'https://cdn.discordapp.com/attachments/639685013964849182/716027585594785852/unknown.png', 49 | follow: false, 50 | subscription: false, 51 | }; 52 | } catch (error) { 53 | console.error(error.response ? error.response.data : error); 54 | throw new Error('Not Found', error.message); 55 | } 56 | } 57 | 58 | async find(params) { 59 | const { names = [] } = params.query; 60 | let notFound = []; 61 | const users = []; 62 | names.forEach((name) => { 63 | const cachedUser = cache.get(name); 64 | if (cachedUser && cachedUser.time > Date.now() - cacheTime) { 65 | users.push(cachedUser.user); 66 | } else { 67 | notFound.push(name); 68 | } 69 | }); 70 | try { 71 | let createdUsers = []; 72 | const existingUsers = []; 73 | if (notFound.length) { 74 | while (notFound.length > 0) { 75 | const next100 = notFound.slice(0, 100); 76 | const dbUsers = await twitchUsers.find({ 77 | name: { 78 | $in: next100, 79 | }, 80 | }); 81 | console.log(dbUsers.length, 'already in db...'); 82 | const notInDb = new Set(next100); 83 | // eslint-disable-next-line no-loop-func 84 | dbUsers.forEach((user) => { 85 | notInDb.delete(user.name); 86 | existingUsers.push(user); 87 | cache.set(user.name, { 88 | time: Date.now() - existingCacheTime, 89 | user, 90 | }); 91 | }); 92 | const remaining = [...notInDb]; 93 | if (remaining.length) { 94 | console.log(remaining.length, 'users not in db...'); 95 | const results = await getUsers(...remaining); 96 | createdUsers = createdUsers.concat(results); 97 | } 98 | notFound = notFound.slice(next100.length); 99 | } 100 | createdUsers = await Promise.all( 101 | createdUsers.map((user) => this.create(user)) 102 | ); 103 | createdUsers.forEach((user) => { 104 | cache.set(user.name, { 105 | time: Date.now(), 106 | user, 107 | }); 108 | }); 109 | } 110 | return users.concat(createdUsers).concat(existingUsers); 111 | } catch (error) { 112 | throw new Error('Not Found'); 113 | } 114 | } 115 | 116 | async patch(name, updates) { 117 | await this.get(name); 118 | const updatedUser = await twitchUsers.findOneAndUpdate({ 119 | name, 120 | }, { 121 | $set: updates, 122 | }, { 123 | upsert: true, 124 | }); 125 | 126 | const cachedUser = cache.get(name); 127 | 128 | if (cachedUser) { 129 | cache.set(name, { 130 | time: cachedUser.time, 131 | user: Object.assign(cachedUser.user, updatedUser), 132 | }); 133 | } 134 | 135 | return updatedUser; 136 | } 137 | 138 | async create(user) { 139 | const follow = await getUserFollow(user.id, channelId); 140 | user.follow = follow; 141 | user.subscription = false; 142 | try { 143 | const subscription = await this.app.service('twitch/subs').get(user.id); 144 | if (subscription) { 145 | user.subscription = { 146 | sub_plan: subscription.level.level_id, 147 | created_at: subscription.level.created_at 148 | }; 149 | } 150 | } catch (error) { 151 | console.log('error retrieving subs...', error.message, user); 152 | } 153 | const createdUser = await twitchUsers.findOneAndUpdate({ 154 | name: user.name, 155 | }, { 156 | $set: user, 157 | }, { 158 | upsert: true, 159 | }); 160 | return createdUser; 161 | } 162 | } 163 | 164 | module.exports = TwitchUsersService; 165 | -------------------------------------------------------------------------------- /src/services/vox/populi.service.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable eqeqeq */ 2 | /* eslint-disable class-methods-use-this */ 3 | const { sub } = require('date-fns'); 4 | 5 | const { 6 | twitchCommands, 7 | youtubeCommands, 8 | } = require('../../db'); 9 | 10 | const voxRegex = /^!(ask|idea|submit|comment|upvote)/; 11 | const eventRegex = /^!(ask|idea|submit|comment|upvote|archive)/; 12 | const topLevelRegex = /^!(ask|idea|submit)/; 13 | const commentUpvoteRegex = /^!(comment|upvote)/; 14 | 15 | class VoxPopuliService { 16 | constructor(app) { 17 | this.app = app; 18 | app.service('twitch/commands').on('created', (message) => { 19 | if (message && message.message && message.message.match(eventRegex)) { 20 | app.service('vox/populi').create(message); 21 | } 22 | }); 23 | app.service('youtube/commands').on('created', (message) => { 24 | if (message && message.message && message.message.match(eventRegex)) { 25 | app.service('vox/populi').create(message); 26 | } 27 | }); 28 | } 29 | 30 | async find() { 31 | if (this.data) return this.data; 32 | this.data = await this.getVox(); 33 | this.allByNum = {}; 34 | this.data.questions.forEach((question) => this.allByNum[question.num] = question); 35 | this.data.ideas.forEach((idea) => this.allByNum[idea.num] = idea); 36 | this.data.submissions.forEach((submission) => this.allByNum[submission.num] = submission); 37 | this.usersById = this.data.users.reduce((byId, user) => { 38 | byId[user.id] = user; 39 | return byId; 40 | }, {}); 41 | return this.data; 42 | } 43 | 44 | async getVox() { 45 | const query = { 46 | id: { 47 | $ne: null, 48 | }, 49 | message: { 50 | $regex: voxRegex, 51 | }, 52 | deleted_at: { 53 | $eq: null, 54 | }, 55 | archived: { 56 | $ne: true, 57 | }, 58 | created_at: { 59 | // $gt: new Date('2020-05-20'), 60 | $gte: sub(new Date(), { 61 | days: 1, 62 | }), 63 | }, 64 | }; 65 | // TODO: show youtube commands in vox 66 | const twitchMessages = await twitchCommands.find(query); 67 | const youtubeMessages = await youtubeCommands.find(query); 68 | const messages = twitchMessages.concat(youtubeMessages); 69 | const twitchNames = [...new Set(twitchMessages.map((message) => message.username))]; 70 | const twitchUsers = await this.app.service('twitch/users').find({ 71 | query: { 72 | names: twitchNames, 73 | } 74 | }); 75 | const youtubeIds = [...new Set(youtubeMessages.map((message) => message.author_id))]; 76 | const youtubeUsers = await this.app.service('youtube/users').find({ 77 | query: { 78 | ids: youtubeIds, 79 | } 80 | }); 81 | const users = twitchUsers.concat(youtubeUsers); 82 | const questions = []; 83 | const ideas = []; 84 | const submissions = []; 85 | const upvotes = {}; 86 | const comments = {}; 87 | messages.forEach((message) => { 88 | if (message.author_handle) { 89 | message.platform = 'youtube'; 90 | } 91 | const args = (message.parsedMessage || message.message).split(' '); 92 | const command = args.shift(); 93 | if (command.match(topLevelRegex)) { 94 | if (!message.num) return; 95 | const value = args.join(' '); 96 | if (!value.trim()) return; 97 | message.content = value; 98 | if (command === '!ask') { 99 | questions.push(message); 100 | } else if (command === '!idea') { 101 | ideas.push(message); 102 | } else if (command === '!submit') { 103 | submissions.push(message); 104 | } 105 | } else if (command.match(commentUpvoteRegex)) { 106 | const num = (args.shift() || '').replace('#', ''); 107 | // eslint-disable-next-line no-restricted-globals 108 | if (!num || isNaN(num)) return; 109 | if (command === '!comment') { 110 | message.content = args.join(' '); 111 | comments[num] = comments[num] || []; 112 | comments[num].push(message); 113 | } else if (command === '!upvote') { 114 | upvotes[num] = upvotes[num] || []; 115 | upvotes[num].push(message.author_handle || message.username); 116 | upvotes[num] = [...new Set(upvotes[num])]; 117 | } 118 | } 119 | }); 120 | 121 | const setProps = (item) => { 122 | item.comments = comments[item.num] || []; 123 | item.upvotes = upvotes[item.num] || []; 124 | }; 125 | 126 | questions.forEach(setProps); 127 | ideas.forEach(setProps); 128 | submissions.forEach(setProps); 129 | 130 | return { 131 | questions, 132 | ideas, 133 | submissions, 134 | users, 135 | }; 136 | } 137 | 138 | async remove(_id) { 139 | const twitchMessage = await twitchCommands.findOneAndUpdate({ 140 | _id, 141 | }, { 142 | $set: { 143 | archived: true, 144 | } 145 | }); 146 | const youtubeMessage = await youtubeCommands.findOneAndUpdate({ 147 | _id, 148 | }, { 149 | $set: { 150 | archived: true, 151 | } 152 | }); 153 | const message = twitchMessage || youtubeMessage; 154 | if (message) { 155 | delete this.allByNum[message.num]; 156 | this.data.questions = this.data.questions.filter((item) => item._id.toString() != _id); 157 | this.data.ideas = this.data.ideas.filter((item) => item._id.toString() != _id); 158 | this.data.submissions = this.data.submissions.filter((item) => item._id.toString() != _id); 159 | return message; 160 | } 161 | throw new Error('Not found.'); 162 | } 163 | 164 | async patch(id, updates) { 165 | const twitchPatched = await this.app 166 | .service('twitch/commands') 167 | .patch(id, updates); 168 | const youtubePatched = await this.app 169 | .service('youtube/commands') 170 | .patch(id, updates); 171 | const patched = twitchPatched || youtubePatched; 172 | if (patched.num) { 173 | this.allByNum[patched.num] = patched; 174 | } 175 | return patched; 176 | } 177 | 178 | async create(message) { 179 | if (this.data) { 180 | if (!this.usersById[message.user.id]) { 181 | this.data.users.push(message.user); 182 | } 183 | this.usersById[message.user.id] = message.user; 184 | const args = (message.parsedMessage || message.message).split(' '); 185 | const command = args.shift(); 186 | if (command.match(/^!(ask|idea|submit)/) && message.num) { 187 | if (!this.allByNum[message.num]) { 188 | const value = args.join(' '); 189 | message.content = value; 190 | message.comments = []; 191 | message.upvotes = []; 192 | message.upvote_count = 0; 193 | this.allByNum[message.num] = message; 194 | if (command === '!ask') { 195 | this.data.questions.push(message); 196 | } else if (command === '!idea') { 197 | this.data.ideas.push(message); 198 | } else if (command === '!submit') { 199 | this.data.submissions.push(message); 200 | } 201 | } 202 | } else if (command.match(/^!(comment|upvote)/)) { 203 | const num = (args.shift() || '').replace('#', ''); 204 | // eslint-disable-next-line no-restricted-globals 205 | if (num && !isNaN(num) && this.allByNum[num]) { 206 | if (command === '!comment') { 207 | message.content = args.join(' '); 208 | this.allByNum[num].comments.push(message); 209 | } else if (command === '!upvote') { 210 | this.allByNum[num].upvotes.push(message.author_handle || message.username); 211 | this.allByNum[num].upvotes = [...new Set(this.allByNum[num].upvotes)]; 212 | } 213 | } 214 | } 215 | } 216 | return message; 217 | } 218 | } 219 | 220 | module.exports = VoxPopuliService; 221 | -------------------------------------------------------------------------------- /src/services/youtube/chat.functions.js: -------------------------------------------------------------------------------- 1 | const { getLiveEvents, getLiveStreamDetails, YTLiveChatManager } = require('../../lib/youtubeAPI'); 2 | 3 | let listenTimeout; 4 | 5 | async function listenChatsSpecificVideo(videoId, app) { 6 | const liveEventDetails = await getLiveStreamDetails(videoId); 7 | if (liveEventDetails) { 8 | const youtubeChatService = app.service('youtube/chat'); 9 | const youtubeCommandsService = app.service('youtube/commands'); 10 | YTLiveChatManager.listen( 11 | { 12 | videoId, 13 | id: liveEventDetails.liveStreamingDetails.activeLiveChatId 14 | }, 15 | async (items) => { 16 | await Promise.all(items.map(async (item) => { 17 | if (item.snippet.type === 'textMessageEvent') { 18 | const message = { 19 | id: item.id, 20 | author_id: item.authorDetails.channelId, 21 | author_display_name: item.authorDetails.displayName, 22 | author_handle: null, 23 | message: item.snippet.displayMessage, 24 | created_at: new Date(item.snippet.publishedAt), 25 | deleted_at: null, 26 | live_chat_id: item.snippet.liveChatId, 27 | }; 28 | let user = { 29 | id: item.authorDetails.channelId, 30 | handle: null, 31 | display_name: item.authorDetails.displayName, 32 | logo: item.authorDetails.profileImageUrl, 33 | created_at: new Date(), 34 | updated_at: new Date(), 35 | is_verified: item.authorDetails.isVerified, 36 | is_chat_owner: item.authorDetails.isChatOwner, 37 | is_chat_sponsor: item.authorDetails.isChatSponsor, 38 | is_chat_moderator: item.authorDetails.isChatModerator, 39 | }; 40 | try { 41 | user = await app.service('youtube/users').get(item.authorDetails.channelId); 42 | } catch (error) { 43 | console.error( 44 | 'error requesting user...', 45 | error.message, 46 | item.authorDetails.channelId 47 | ); 48 | } 49 | message.author_handle = user.handle; 50 | if (message.message.match(/^!\w/)) { 51 | message.args = item.snippet.displayMessage.split(' '); 52 | message.command = message.args.shift().slice(1); 53 | message.user = user; 54 | await youtubeCommandsService.create(message); 55 | } else { 56 | await youtubeChatService.create({ message, item, user }); 57 | } 58 | } 59 | })); 60 | } 61 | ); 62 | return ''; 63 | } 64 | return `No live event found for video id: ${videoId}`; 65 | } 66 | 67 | async function listenChats(app) { 68 | clearTimeout(listenTimeout); 69 | try { 70 | if (!YTLiveChatManager.hasListeners()) { 71 | console.log('Detecting YT live events...'); 72 | const events = await getLiveEvents(); 73 | const liveEvent = events.items[0]; 74 | if (liveEvent) { 75 | const result = await listenChatsSpecificVideo(liveEvent.id.videoId, app); 76 | if (result) { 77 | console.log(result); 78 | } 79 | } else { 80 | console.log('No YT live events detected.'); 81 | } 82 | listenTimeout = setTimeout(() => { 83 | listenChats(app); 84 | }, 60 * 1000); 85 | } else { 86 | listenTimeout = setTimeout(() => { 87 | listenChats(app); 88 | }, 60 * 10000); 89 | } 90 | } catch (error) { 91 | console.log(error); 92 | listenTimeout = setTimeout(() => { 93 | listenChats(app); 94 | }, 60 * 1000); 95 | } 96 | } 97 | 98 | module.exports = { 99 | listenChats, 100 | listenChatsSpecificVideo, 101 | }; 102 | -------------------------------------------------------------------------------- /src/services/youtube/chat.service.js: -------------------------------------------------------------------------------- 1 | const { YTLiveChatManager } = require('../../lib/youtubeAPI'); 2 | const { listenChats, listenChatsSpecificVideo } = require('./chat.functions'); 3 | const { youtubeChats } = require('../../db'); 4 | 5 | class YouTubeChatService { 6 | constructor(app) { 7 | this.app = app; 8 | listenChats(app); 9 | } 10 | 11 | async get(id) { 12 | const result = await listenChatsSpecificVideo(id, this.app); 13 | return { 14 | message: result || 'OK', 15 | }; 16 | } 17 | 18 | async find() { 19 | const ids = [...YTLiveChatManager.liveChatListeners.keys()]; 20 | const messages = await youtubeChats.find({ 21 | live_chat_id: { 22 | $in: ids 23 | } 24 | }); 25 | return messages; 26 | } 27 | 28 | async create({ message, item, user }) { 29 | const created = await youtubeChats.findOneAndUpdate( 30 | { 31 | id: item.id, 32 | }, 33 | { 34 | $set: message, 35 | }, 36 | { 37 | upsert: true, 38 | } 39 | ); 40 | created.user = user; 41 | return [created]; 42 | } 43 | 44 | async patch(id, updates) { 45 | const updated = await youtubeChats.findOneAndUpdate( 46 | { 47 | id, 48 | }, 49 | { 50 | $set: updates, 51 | }, 52 | { 53 | upsert: true, 54 | } 55 | ); 56 | return updated; 57 | } 58 | } 59 | 60 | module.exports = YouTubeChatService; 61 | -------------------------------------------------------------------------------- /src/services/youtube/commands.service.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { 3 | youtubeCommands, 4 | } = require('../../db'); 5 | const createCommandsService = require('../../lib/createCommandsService'); 6 | 7 | module.exports = createCommandsService({ 8 | dbCollection: youtubeCommands, 9 | getUserService: (app) => app.service('youtube/users'), 10 | getUserQuery: ({ message, user }) => (user ? user.id : message.author_id), 11 | getIsModOrOwner: ({ message, question, user }) => user.is_chat_owner 12 | || user.is_chat_moderator 13 | || question.author_id === message.author_id 14 | }); 15 | -------------------------------------------------------------------------------- /src/services/youtube/members.config.sample.js: -------------------------------------------------------------------------------- 1 | const key = ''; // FILL ME IN 2 | const externalChannelId = ''; // FILL ME IN 3 | 4 | const cookie = ''; // FILL ME IN 5 | 6 | const referrer = ''; // FILL ME IN 7 | 8 | const headers = { 9 | accept: '*/*', 10 | 'accept-language': 'en-US,en;q=0.5', 11 | authorization: 12 | '', // FILL ME IN 13 | 'cache-control': 'no-cache', 14 | 'content-type': 'application/json', 15 | pragma: 'no-cache', 16 | 'Sec-Fetch-Dest': 'empty', 17 | 'Sec-Fetch-Mode': 'cors', 18 | 'Sec-Fetch-Site': 'same-origin', 19 | 'Sec-GPC': '1', 20 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/110.0', 21 | 'X-Goog-AuthUser': '0', 22 | 'X-Goog-PageId': '109069981838370619794', 23 | 'X-Goog-Visitor-Id': '', // FILL ME IN 24 | 'X-Origin': 'https://studio.youtube.com', 25 | 'X-YouTube-Ad-Signals': '', // FILL ME IN 26 | 'X-YouTube-Client-Name': '62', 27 | 'X-YouTube-Client-Version': '1.20230206.03.00', 28 | 'X-YouTube-Delegation-Context': '', // FILL ME IN 29 | 'X-YouTube-Page-CL': '507507627', 30 | 'X-YouTube-Page-Label': 'youtube.studio.web_20230206_03_RC00', 31 | 'X-YouTube-Time-Zone': 'America/Denver', 32 | 'X-YouTube-Utc-Offset': '-420', 33 | cookie, 34 | origin: 'https://studio.youtube.com', 35 | DNT: '1', 36 | referrer, 37 | }; 38 | 39 | const client = { 40 | clientName: 62, 41 | clientVersion: '1.20230206.03.00', 42 | hl: 'en', 43 | gl: 'US', 44 | experimentsToken: '', 45 | utcOffsetMinutes: -420, 46 | userInterfaceTheme: 'USER_INTERFACE_THEME_DARK', 47 | screenWidthPoints: 991, 48 | screenHeightPoints: 859, 49 | screenPixelDensity: 2, 50 | screenDensityFloat: 2 51 | }; 52 | 53 | const user = { 54 | onBehalfOfUser: '', // FILL ME IN 55 | delegationContext: { 56 | externalChannelId: '', // FILL ME IN 57 | roleType: { channelRoleType: 'CREATOR_CHANNEL_ROLE_TYPE_OWNER' } 58 | }, 59 | serializedDelegationContext: '' // FILL ME IN 60 | }; 61 | 62 | const request = { 63 | returnLogEntry: true, 64 | internalExperimentFlags: [] 65 | }; 66 | 67 | const clientScreenNonce = ''; // FILL ME IN 68 | 69 | const continuationToken = ''; // FILL ME IN 70 | 71 | const context = { 72 | client, 73 | request, 74 | user, 75 | clientScreenNonce, 76 | }; 77 | 78 | module.exports = { 79 | key, 80 | externalChannelId, 81 | context, 82 | headers, 83 | referrer, 84 | continuationToken, 85 | }; 86 | -------------------------------------------------------------------------------- /src/services/youtube/members.functions.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const { 4 | key, 5 | externalChannelId, 6 | context, 7 | headers, 8 | referrer, 9 | continuationToken, 10 | } = require('./members.config'); 11 | 12 | const studioBaseURL = 'https://studio.youtube.com/youtubei/v1'; 13 | 14 | async function getChannelFacts() { 15 | const { 16 | data 17 | } = await axios.post(`${studioBaseURL}/creator/get_channel_dashboard?alt=json&key=${key}`, { 18 | dashboardParams: { 19 | channelId: externalChannelId, 20 | factsAnalyticsParams: { 21 | nodes: [ 22 | { 23 | key: 'DASHBOARD_FACT_ANALYTICS_CURRENT', 24 | value: { 25 | query: { 26 | dimensions: [], 27 | metrics: [ 28 | { type: 'VIEWS' }, 29 | { type: 'WATCH_TIME' }, 30 | { type: 'TOTAL_ESTIMATED_EARNINGS' }, 31 | { type: 'SUBSCRIBERS_NET_CHANGE' } 32 | ], 33 | restricts: [ 34 | { 35 | dimension: { type: 'USER' }, 36 | inValues: [externalChannelId] 37 | } 38 | ], 39 | orders: [], 40 | timeRange: { 41 | dateIdRange: { 42 | inclusiveStart: 20230112, 43 | exclusiveEnd: 20230209 44 | } 45 | }, 46 | currency: 'USD', 47 | returnDataInNewFormat: true, 48 | limitedToBatchedData: false, 49 | useMultiFormatArtistAnalytics: false 50 | } 51 | } 52 | }, 53 | { 54 | key: 'TOP_VIDEOS', 55 | value: { 56 | query: { 57 | dimensions: [{ type: 'VIDEO' }], 58 | metrics: [{ type: 'VIEWS' }], 59 | restricts: [ 60 | { 61 | dimension: { type: 'USER' }, 62 | inValues: [externalChannelId] 63 | } 64 | ], 65 | orders: [ 66 | { 67 | metric: { type: 'VIEWS' }, 68 | direction: 'ANALYTICS_ORDER_DIRECTION_DESC' 69 | } 70 | ], 71 | timeRange: { 72 | unixTimeRange: { 73 | inclusiveStart: '1675818000', 74 | exclusiveEnd: '1675990800' 75 | } 76 | }, 77 | limit: { pageSize: 3, pageOffset: 0 }, 78 | returnDataInNewFormat: true, 79 | limitedToBatchedData: false, 80 | useMultiFormatArtistAnalytics: false 81 | } 82 | } 83 | }, 84 | { 85 | key: 'DASHBOARD_FACT_ANALYTICS_LIFETIME_SUBSCRIBERS', 86 | value: { 87 | query: { 88 | dimensions: [], 89 | metrics: [{ type: 'SUBSCRIBERS_NET_CHANGE' }], 90 | restricts: [ 91 | { 92 | dimension: { type: 'USER' }, 93 | inValues: [externalChannelId] 94 | } 95 | ], 96 | orders: [], 97 | timeRange: { unboundedRange: {} }, 98 | currency: 'USD', 99 | returnDataInNewFormat: true, 100 | limitedToBatchedData: false, 101 | useMultiFormatArtistAnalytics: false 102 | } 103 | } 104 | }, 105 | { 106 | key: 'DASHBOARD_FACT_ANALYTICS_TYPICAL', 107 | value: { 108 | getTypicalPerformance: { 109 | query: { 110 | metrics: [ 111 | { metric: { type: 'VIEWS' } }, 112 | { metric: { type: 'WATCH_TIME' } }, 113 | { metric: { type: 'TOTAL_ESTIMATED_EARNINGS' } } 114 | ], 115 | externalChannelId, 116 | timeRange: { 117 | dateIdRange: { 118 | inclusiveStart: 20230112, 119 | exclusiveEnd: 20230209 120 | } 121 | }, 122 | type: 'TYPICAL_PERFORMANCE_TYPE_NORMAL', 123 | entityType: 'TYPICAL_PERFORMANCE_ENTITY_TYPE_CHANNEL', 124 | currency: 'USD' 125 | } 126 | } 127 | } 128 | }, 129 | { 130 | key: 'TOP_VIDEOS_VIDEO', 131 | value: { 132 | getCreatorVideos: { 133 | mask: { 134 | videoId: true, 135 | title: true, 136 | permissions: { all: true } 137 | } 138 | } 139 | } 140 | } 141 | ], 142 | connectors: [ 143 | { 144 | extractorParams: { 145 | resultKey: 'TOP_VIDEOS', 146 | resultTableExtractorParams: { dimension: { type: 'VIDEO' } } 147 | }, 148 | fillerParams: { 149 | targetKey: 'TOP_VIDEOS_VIDEO', 150 | idFillerParams: {} 151 | } 152 | } 153 | ] 154 | }, 155 | videoSnapshotAnalyticsParams: { 156 | nodes: [ 157 | { 158 | key: 'VIDEO_SNAPSHOT_DATA_QUERY', 159 | value: { 160 | getVideoSnapshotData: { 161 | externalChannelId, 162 | catalystType: 'CATALYST_ANALYSIS_TYPE_RECENT_VIDEO_PERFORMANCE', 163 | showCtr: true 164 | } 165 | } 166 | } 167 | ], 168 | connectors: [] 169 | }, 170 | cardProducerTimeout: 'CARD_PRODUCER_TIMEOUT_SHORT' 171 | }, 172 | context, 173 | }, { 174 | headers, 175 | referrer, 176 | }); 177 | let info = null; 178 | data.cards.forEach((card) => { 179 | if (card.id === 'facts') { 180 | info = card.body.basicCard.item.channelFactsItem.channelFactsData.results.find((result) => result.key === 'DASHBOARD_FACT_ANALYTICS_LIFETIME_SUBSCRIBERS'); 181 | } 182 | }); 183 | const subscribers = info.value.resultTable.metricColumns[0].counts.values[0]; 184 | return { 185 | subscribers, 186 | }; 187 | } 188 | 189 | async function getMemberData() { 190 | const { 191 | data 192 | } = await axios.post(`${studioBaseURL}/sponsors/creator_sponsorships_sponsors?alt=json&key=${key}`, { 193 | context, 194 | externalChannelId, 195 | sponsorsOptions: { 196 | pageSize: 100, 197 | continuationToken, 198 | filter: {}, 199 | order: { 200 | orderFields: [ 201 | { 202 | field: 'SPONSORSHIPS_SPONSORS_ORDER_FIELD_LAST_EVENT_DURATION', 203 | order: 'SPONSORSHIPS_SPONSORS_ORDER_ASC' 204 | } 205 | ] 206 | } 207 | } 208 | }, { 209 | headers, 210 | referrer, 211 | }); 212 | return data.sponsorsData.sponsors; 213 | } 214 | 215 | async function getEmoji() { 216 | const { 217 | data 218 | } = await axios.post(`${studioBaseURL}/sponsors/creator_sponsorships_data?alt=json&key=${key}`, { 219 | context, 220 | externalChannelId, 221 | mask: { 222 | emojiData: { all: true }, 223 | }, 224 | sponsorsOptions: { 225 | pageSize: 100, 226 | filter: {}, 227 | }, 228 | }, { 229 | headers, 230 | referrer, 231 | }); 232 | return data.sponsorshipsData.emojiData; 233 | } 234 | 235 | let moderators = null; 236 | async function getModerators(cache = true) { 237 | return {}; 238 | if (cache && moderators) return moderators; 239 | moderators = new Promise((resolve) => { 240 | (async () => { 241 | const { 242 | data 243 | } = await axios.post(`${studioBaseURL}/creator/get_creator_channels?alt=json&key=${key}`, { 244 | context, 245 | channelIds: [externalChannelId], 246 | mask: { 247 | settings: { all: true }, 248 | } 249 | }, { 250 | headers, 251 | referrer, 252 | }); 253 | resolve(data.channels[0].settings 254 | .comments 255 | .moderators 256 | .reduce((all, mod) => { 257 | all[mod.externalChannelId] = true; 258 | return all; 259 | }, {})); 260 | })(); 261 | }); 262 | return moderators; 263 | } 264 | 265 | const unitMap = { 266 | SPONSORSHIPS_TIME_UNIT_MONTH: 'month', 267 | SPONSORSHIPS_TIME_UNIT_DAY: 'day', 268 | }; 269 | 270 | const plural = (value) => (value > 1 ? 's' : ''); 271 | 272 | async function getMembers() { 273 | return { 274 | users: [], 275 | usersById: {}, 276 | }; 277 | const memberData = await getMemberData(); 278 | const usersById = {}; 279 | const users = memberData.map((member) => { 280 | const user = { 281 | id: member.externalChannelId, 282 | name: member.displayName, 283 | loyaltyBadge: member.loyaltyBadge.thumbnailUrl, 284 | time_as_member: `${member.durationAtCurrentTier.amount} ${unitMap[member.durationAtCurrentTier.timeUnit]}${plural(member.durationAtCurrentTier.amount)}`, 285 | }; 286 | usersById[member.externalChannelId] = user; 287 | return user; 288 | }); 289 | return { 290 | users, 291 | usersById, 292 | }; 293 | } 294 | 295 | module.exports = { 296 | getEmoji, 297 | getMembers, 298 | getModerators, 299 | getChannelFacts, 300 | }; 301 | -------------------------------------------------------------------------------- /src/services/youtube/members.service.js: -------------------------------------------------------------------------------- 1 | const { 2 | getMembers 3 | } = require('./members.functions'); 4 | 5 | class MemberService { 6 | constructor() { 7 | this.data = null; 8 | } 9 | 10 | async find() { 11 | this.data = this.data || await getMembers(); 12 | return this.data.users; 13 | } 14 | 15 | async create() { 16 | if (!this.data) return { created: true }; 17 | const latestData = await getMembers(); 18 | const newMember = latestData.users.find((user) => !this.data.usersById[user.id]); 19 | this.data = latestData; 20 | return newMember; 21 | } 22 | } 23 | 24 | module.exports = MemberService; 25 | -------------------------------------------------------------------------------- /src/services/youtube/stats.service.js: -------------------------------------------------------------------------------- 1 | const { 2 | getChannelFacts, 3 | } = require('./members.functions'); 4 | 5 | class StatsService { 6 | // eslint-disable-next-line 7 | async find() { 8 | return getChannelFacts(); 9 | } 10 | } 11 | 12 | module.exports = StatsService; 13 | -------------------------------------------------------------------------------- /src/services/youtube/youtube.users.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | const { 3 | getUsers, 4 | getChannelMeta, 5 | } = require('../../lib/youtubeAPI'); 6 | const { 7 | getModerators 8 | } = require('./members.functions'); 9 | const { 10 | youtubeUsers, 11 | } = require('../../db'); 12 | 13 | const cacheTime = 30 * 60 * 1000; 14 | const existingCacheTime = 10 * 60 * 1000; 15 | const cache = new Map(); 16 | 17 | class YouTubeUsersService { 18 | constructor(app) { 19 | this.app = app; 20 | } 21 | 22 | async get(id) { 23 | const cachedUser = cache.get(id); 24 | if (cachedUser && cachedUser.time > Date.now() - cacheTime) { 25 | return cachedUser.user; 26 | } 27 | try { 28 | const [updatedUser] = await getUsers(id); 29 | if (updatedUser) { 30 | const createdUser = await this.create(updatedUser); 31 | cache.set(id, { 32 | time: Date.now(), 33 | user: createdUser, 34 | }); 35 | return createdUser; 36 | } 37 | return { 38 | _id: 'not-found', 39 | id: 'not-found', 40 | display_name: 'not-found', 41 | logo: 'https://cdn.discordapp.com/attachments/639685013964849182/716027585594785852/unknown.png', 42 | created_at: new Date(), 43 | updated_at: new Date(), 44 | membership: null, 45 | is_verified: false, 46 | is_chat_owner: false, 47 | is_chat_moderator: false, 48 | }; 49 | } catch (error) { 50 | if (error.response && error.response.data) { 51 | console.error(JSON.stringify(error.response.data, null, 2)); 52 | } 53 | throw new Error('Not Found', error.message); 54 | } 55 | } 56 | 57 | async find(params) { 58 | const { ids = [], moderators, nocache = 'false' } = params.query || {}; 59 | if (moderators) { 60 | return getModerators(nocache === 'false'); 61 | } 62 | let notFound = []; 63 | const users = []; 64 | ids.forEach((id) => { 65 | const cachedUser = cache.get(id); 66 | if (cachedUser && cachedUser.time > Date.now() - cacheTime) { 67 | users.push(cachedUser.user); 68 | } else { 69 | notFound.push(id); 70 | } 71 | }); 72 | try { 73 | let createdUsers = []; 74 | const existingUsers = []; 75 | if (notFound.length) { 76 | while (notFound.length > 0) { 77 | const next50 = notFound.slice(0, 50); 78 | const dbUsers = await youtubeUsers.find({ 79 | id: { 80 | $in: next50, 81 | }, 82 | }); 83 | console.log(dbUsers.length, 'already in db...'); 84 | const notInDb = new Set(next50); 85 | // eslint-disable-next-line no-loop-func 86 | dbUsers.forEach((user) => { 87 | notInDb.delete(user.id); 88 | existingUsers.push(user); 89 | cache.set(user.id, { 90 | time: Date.now() - existingCacheTime, 91 | user, 92 | }); 93 | }); 94 | const remaining = [...notInDb]; 95 | if (remaining.length) { 96 | console.log(remaining.length, 'users not in db...'); 97 | const results = await getUsers(...remaining); 98 | createdUsers = createdUsers.concat(results); 99 | } 100 | notFound = notFound.slice(next50.length); 101 | } 102 | createdUsers = await Promise.all( 103 | createdUsers.map((user) => this.create(user)) 104 | ); 105 | createdUsers.forEach((user) => { 106 | cache.set(user.id, { 107 | time: Date.now(), 108 | user, 109 | }); 110 | }); 111 | } 112 | return users.concat(createdUsers).concat(existingUsers); 113 | } catch (error) { 114 | console.log(error); 115 | throw new Error('Not Found'); 116 | } 117 | } 118 | 119 | async patch(id, updates) { 120 | await this.get(id); 121 | const updatedUser = await youtubeUsers.findOneAndUpdate({ 122 | id, 123 | }, { 124 | $set: updates, 125 | }, { 126 | upsert: true, 127 | }); 128 | 129 | const cachedUser = cache.get(id); 130 | 131 | if (cachedUser) { 132 | cache.set(id, { 133 | time: cachedUser.time, 134 | user: Object.assign(cachedUser.user, updatedUser), 135 | }); 136 | } 137 | 138 | return updatedUser; 139 | } 140 | 141 | async create(user) { 142 | user.is_chat_owner = user.id === process.env.YOUTUBE_CHANNEL_ID; 143 | const members = await this.app.service('youtube/members').find(); 144 | user.membership = members.find((member) => member.id === user.id); 145 | const channel = await getChannelMeta(user.id); 146 | user.is_verified = channel ? channel.approval === 'Verified' : null; 147 | user.handle = channel ? channel.about.handle : null; 148 | const moderatorsById = await this.find({ 149 | query: { 150 | moderators: true 151 | } 152 | }); 153 | user.is_chat_moderator = user.is_chat_owner || !!moderatorsById[user.id]; 154 | const createdUser = await youtubeUsers.findOneAndUpdate({ 155 | id: user.id, 156 | }, { 157 | $set: user, 158 | }, { 159 | upsert: true, 160 | }); 161 | return createdUser; 162 | } 163 | } 164 | 165 | module.exports = YouTubeUsersService; 166 | -------------------------------------------------------------------------------- /src/streamlabs.js: -------------------------------------------------------------------------------- 1 | const io = require('socket.io-client'); 2 | 3 | const streamLabsURL = 'https://sockets.streamlabs.com'; 4 | 5 | const { 6 | STREAMLABS_SOCKET_TOKEN: socketToken 7 | } = process.env; 8 | 9 | function listenStreamlabs(app) { 10 | const streamlabs = io(`${streamLabsURL}?token=${socketToken}`, { 11 | transports: ['websocket'] 12 | }); 13 | streamlabs.on('connect', () => { 14 | console.log('connected to streamlabs'); 15 | }); 16 | streamlabs.on('connect_error', (error) => { 17 | console.log('error connecting to streamlabs'); 18 | console.error(error); 19 | }); 20 | streamlabs.on('event', (eventData) => { 21 | if (eventData.for === 'youtube_account' && eventData.type === 'subscription') { 22 | app.service('youtube/members').create({}); 23 | } else if (eventData.for === 'twitch_account' && eventData.type === 'subscription') { 24 | app.service('twitch/subs').create({}); 25 | } else if (eventData.for === 'patreon' && eventData.type === 'pledge') { 26 | app.service('patreon/pledges').create({}); 27 | } 28 | }); 29 | } 30 | 31 | module.exports = listenStreamlabs; 32 | --------------------------------------------------------------------------------