From 48c1954c79efe63b86b65859e3818540f90aa2b6 Mon Sep 17 00:00:00 2001 From: Max Goodhart Date: Thu, 2 Jul 2020 17:56:13 -0700 Subject: [PATCH] Add Twitch stream focus announcement bot support --- README.md | 4 + example.config.toml | 12 +++ package-lock.json | 217 +++++++++++++++++++++++++++++++++++++++++- package.json | 2 + src/node/TwitchBot.js | 104 ++++++++++++++++++++ src/node/index.js | 50 ++++++++++ 6 files changed, 386 insertions(+), 3 deletions(-) create mode 100644 src/node/TwitchBot.js diff --git a/README.md b/README.md index b966357..118e417 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,10 @@ Streamwall can load stream data from both JSON APIs and TOML files. Data sources npm start -- --data.json-url="https://your-site/api/streams.json" --data.toml-file="./streams.toml" ``` +## Twitch bot + +Streamwall can announce the name and URL of streams to your Twitch channel as you focus their audio. Use [twitchtokengenerator.com](https://twitchtokengenerator.com/?scope=chat:read+chat:edit) to generate an OAuth token. See `example.config.toml` for all available options. + ## Hotkeys The following hotkeys are available with the "control" webpage focused: diff --git a/example.config.toml b/example.config.toml index 9ec2c25..26809d6 100644 --- a/example.config.toml +++ b/example.config.toml @@ -35,6 +35,18 @@ json-url = ["https://woke.net/api/streams.json"] # See example.streams.toml for a sample. #toml-file = ["./example.streams.toml"] +[twitch] +#channel = "woke" +#username = "bot-username" +#color = "#ff0000" + +# Use https://twitchtokengenerator.com/?scope=chat:read+chat:edit to generate an OAuth token: +#token = "twitch-oauth-token" + + [twitch.announce] + #template = "SingsMic <%- stream.source %> <%- stream.city && stream.state ? `(${stream.city} ${stream.state})` : '' %> <%- stream.link %>" + #interval = 60 + [cert] # SSL certificates (optional) # If you specify an https:// URL for the "webserver" option, a certificate will be automatically generated and signed by Let's Encrypt. diff --git a/package-lock.json b/package-lock.json index a0d63a1..3c7e84d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2102,6 +2102,19 @@ "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==" }, + "@types/debug": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.5.tgz", + "integrity": "sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ==" + }, + "@types/duplexify": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@types/duplexify/-/duplexify-3.6.0.tgz", + "integrity": "sha512-5zOA53RUlzN74bvrSGwjudssD9F3a797sDZQkiYpUOxW+WHaXTCPz4/d5Dgi6FKnOqZ2CpaTo0DhgIfsXAOE/A==", + "requires": { + "@types/node": "*" + } + }, "@types/graceful-fs": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.3.tgz", @@ -2566,6 +2579,11 @@ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true }, + "array-uniq": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.2.tgz", + "integrity": "sha1-X8w3OSB3VyPP1k1lxkvvU7+eum0=" + }, "array-unique": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", @@ -3517,6 +3535,15 @@ "object-visit": "^1.0.0" } }, + "color": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/color/-/color-3.1.2.tgz", + "integrity": "sha512-vXTJhHebByxZn3lDvDJYw4lR5+uB3vuoHsuYA5AKuxRVn5wzzIfQKGLBmgdVRHKTJYeK5rvJcHnrd0Li49CFpg==", + "requires": { + "color-convert": "^1.9.1", + "color-string": "^1.5.2" + } + }, "color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -3530,6 +3557,15 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, + "color-string": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.3.tgz", + "integrity": "sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==", + "requires": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -4155,6 +4191,50 @@ "integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=", "dev": true }, + "dank-twitch-irc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dank-twitch-irc/-/dank-twitch-irc-3.3.0.tgz", + "integrity": "sha512-EIGr6Q9LNRR+r274rvv2RrgHHI7vIY0bePY5TR0yxV0dN+36ObKSToFg2BQ8fEMkUotB/0tECwHoK1i1ixCcTQ==", + "requires": { + "@types/debug": "^4.1.5", + "@types/duplexify": "^3.6.0", + "debug-logger": "^0.4.1", + "duplexify": "^4.1.1", + "eventemitter3": "^4.0.4", + "lodash.camelcase": "^4.3.0", + "lodash.pickby": "^4.6.0", + "make-error-cause": "^2.3.0", + "ms": "^2.1.2", + "randomstring": "^1.1.5", + "semaphore-async-await": "^1.5.1", + "simple-websocket": "^9.0.0", + "split2": "^3.1.1", + "ts-toolbelt": "^6.9.9" + }, + "dependencies": { + "duplexify": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.1.tgz", + "integrity": "sha512-DY3xVEmVHTv1wSzKNbwoU6nVjzI369Y6sPoqfYr0/xlx3IdX2n94xIszTcjPO8W8ZIv0Wb0PXNcjuZyT4wiICA==", + "requires": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.0" + } + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -4183,6 +4263,29 @@ "ms": "^2.1.1" } }, + "debug-logger": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/debug-logger/-/debug-logger-0.4.1.tgz", + "integrity": "sha1-4zhJw2njzTYbULKZ1xylIkuqGuE=", + "requires": { + "debug": "^2.1.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, "decamelize": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", @@ -4712,6 +4815,11 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "eventemitter3": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.4.tgz", + "integrity": "sha512-rlaVLnVxtxvoyLsQQFBx53YmXHDxRIzzTLbdfxqi4yocpSjAxXwkU0cScM5JgSKMqEhrZpnvQ2D9gjylR0AimQ==" + }, "events": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/events/-/events-3.1.0.tgz", @@ -8349,6 +8457,16 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" }, + "lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=" + }, + "lodash.pickby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz", + "integrity": "sha1-feoh2MGNdwOifHBMFdO4SmfjOv8=" + }, "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -8402,6 +8520,19 @@ } } }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" + }, + "make-error-cause": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/make-error-cause/-/make-error-cause-2.3.0.tgz", + "integrity": "sha512-etgt+n4LlOkGSJbBTV9VROHA5R7ekIPS4vfh+bCAoJgRrJWdqJCBbpS3osRJ/HrT7R68MzMiY3L3sDJ/Fd8aBg==", + "requires": { + "make-error": "^1.3.5" + } + }, "makeerror": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.11.tgz", @@ -9795,11 +9926,15 @@ "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=", "dev": true }, + "queue-microtask": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.1.3.tgz", + "integrity": "sha512-zC1ZDLKFhZSa8vAdFbkOGouHcOUMgUAI/2/3on/KktpY+BaVqABkzDSsCSvJfmLbICOnrEuF9VIMezZf+T0mBA==" + }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, "requires": { "safe-buffer": "^5.1.0" } @@ -9814,6 +9949,14 @@ "safe-buffer": "^5.1.0" } }, + "randomstring": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/randomstring/-/randomstring-1.1.5.tgz", + "integrity": "sha1-bfBij3XL1ZMpMNn+OrTpVqGFGMM=", + "requires": { + "array-uniq": "1.0.2" + } + }, "react-hotkeys-hook": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-2.1.3.tgz", @@ -10362,6 +10505,11 @@ "ajv-keywords": "^3.1.0" } }, + "semaphore-async-await": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/semaphore-async-await/-/semaphore-async-await-1.5.1.tgz", + "integrity": "sha1-hXvvXjZEYBykuVcLh+nfXKEpdPo=" + }, "semver": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", @@ -10507,6 +10655,45 @@ "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", "dev": true }, + "simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", + "requires": { + "is-arrayish": "^0.3.1" + }, + "dependencies": { + "is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + } + } + }, + "simple-websocket": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/simple-websocket/-/simple-websocket-9.0.0.tgz", + "integrity": "sha512-Q+u1BJ06/FR30xS1Sf6zDuL+vAdAA7VFqZ0TdKpmQKB2uNTAPKWQFFhUDV4YD7TDi7gSRJXoxv21WprNPR0ykQ==", + "requires": { + "debug": "^4.1.1", + "queue-microtask": "^1.1.0", + "randombytes": "^2.0.3", + "readable-stream": "^3.1.1", + "ws": "^7.0.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -10730,6 +10917,26 @@ "extend-shallow": "^3.0.0" } }, + "split2": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/split2/-/split2-3.1.1.tgz", + "integrity": "sha512-emNzr1s7ruq4N+1993yht631/JH+jaj0NYBosuKmLcq+JkGQ9MmTw1RB1fGaTCzUuseRIClrlSLHRNYGwWQ58Q==", + "requires": { + "readable-stream": "^3.0.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "sprintf-js": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", @@ -10853,8 +11060,7 @@ "stream-shift": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", - "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==", - "dev": true + "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" }, "string-length": { "version": "4.0.1", @@ -11336,6 +11542,11 @@ "utf8-byte-length": "^1.0.1" } }, + "ts-toolbelt": { + "version": "6.9.9", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-6.9.9.tgz", + "integrity": "sha512-5a8k6qfbrL54N4Dw+i7M6kldrbjgDWb5Vit8DnT+gwThhvqMg8KtxLE5Vmnft+geIgaSOfNJyAcnmmlflS+Vdg==" + }, "tslib": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz", diff --git a/package.json b/package.json index 038557f..ee9e7dd 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "@iarna/toml": "^2.2.5", "@repeaterjs/repeater": "^3.0.1", "chokidar": "^3.4.0", + "color": "^3.1.2", + "dank-twitch-irc": "^3.3.0", "ejs": "^3.1.3", "electron": "^9.0.4", "koa": "^2.12.1", diff --git a/src/node/TwitchBot.js b/src/node/TwitchBot.js new file mode 100644 index 0000000..89adf70 --- /dev/null +++ b/src/node/TwitchBot.js @@ -0,0 +1,104 @@ +import EventEmitter from 'events' + +import ejs from 'ejs' +import { State } from 'xstate' +import { ChatClient, SlowModeRateLimiter, LoginError } from 'dank-twitch-irc' + +export default class TwitchBot extends EventEmitter { + constructor(config) { + super() + const { username, token } = config + this.config = config + this.announceTemplate = ejs.compile(config.announce.template) + const client = new ChatClient({ + username, + password: `oauth:${token}`, + rateLimits: 'default', + }) + client.use(new SlowModeRateLimiter(client, 0)) + this.client = client + + this.streams = null + this.listeningURL = null + this.announceTimeouts = new Map() + + client.on('ready', () => { + this.onReady() + }) + client.on('error', (err) => { + console.error('Twitch connection error:', err) + if (err instanceof LoginError) { + client.close() + } + }) + client.on('close', (err) => { + console.log('Twitch bot disconnected.') + if (err != null) { + console.error('Twitch bot disconnected due to error:', err) + } + }) + } + + connect() { + const { client } = this + client.connect() + } + + async onReady() { + const { client } = this + const { channel, color } = this.config + await client.setColor(color) + await client.join(channel) + this.emit('connected') + } + + onState({ views, streams }) { + this.streams = streams + + const listeningView = views.find(({ state, context }) => + State.from(state, context).matches('displaying.running.audio.listening'), + ) + if (!listeningView) { + return + } + + const listeningURL = listeningView.context.content.url + if (listeningURL === this.listeningURL) { + return + } + this.listeningURL = listeningURL + this.onListeningURLChange(listeningURL) + } + + async onListeningURLChange(listeningURL) { + if (!this.announceTimeouts.has(listeningURL)) { + await this.announce() + } + } + + async announce() { + const { client, listeningURL, streams } = this + const { channel, announce } = this.config + + if (!client.ready) { + return + } + + const stream = streams.find((s) => s.link === listeningURL) + if (!stream) { + return + } + + const msg = this.announceTemplate({ stream }) + await client.say(channel, msg) + this.emit('sent', msg) + + const timeout = setTimeout(() => { + this.announceTimeouts.delete(listeningURL) + if (this.listeningURL === listeningURL) { + this.announce() + } + }, announce.interval * 1000) + this.announceTimeouts.set(listeningURL, timeout) + } +} diff --git a/src/node/index.js b/src/node/index.js index 1f5b09b..190a8d6 100644 --- a/src/node/index.js +++ b/src/node/index.js @@ -1,6 +1,7 @@ import fs from 'fs' import yargs from 'yargs' import TOML from '@iarna/toml' +import Color from 'color' import { Repeater } from '@repeaterjs/repeater' import { app, shell, session, BrowserWindow } from 'electron' @@ -13,6 +14,7 @@ import { combineDataSources, } from './data' import StreamWindow from './StreamWindow' +import TwitchBot from './TwitchBot' import StreamdelayClient from './StreamdelayClient' import initWebServer from './server' @@ -71,6 +73,45 @@ function parseArgs() { array: true, default: [], }) + .group( + [ + 'twitch.channel', + 'twitch.username', + 'twitch.password', + 'twitch.color', + 'twitch.announce.template', + 'twitch.announce.interval-seconds', + ], + 'Twitch Chat', + ) + .option('twitch.channel', { + describe: 'Name of Twitch channel', + default: null, + }) + .option('twitch.username', { + describe: 'Username of Twitch bot account', + default: null, + }) + .option('twitch.password', { + describe: 'Password of Twitch bot account', + default: null, + }) + .option('twitch.color', { + describe: 'Color of Twitch bot username', + coerce: (text) => Color(text).object(), + default: { r: 255, g: 0, b: 0 }, + }) + .option('twitch.announce.template', { + describe: 'Message template for stream announcements', + default: + 'SingsMic <%- stream.source %> <%- stream.city && stream.state ? `(${stream.city} ${stream.state})` : `` %> <%- stream.link %>', + }) + .option('twitch.announce.interval', { + describe: + 'Minimum time interval (in seconds) between re-announcing the same stream', + number: true, + default: 60, + }) .group( [ 'control.username', @@ -163,6 +204,7 @@ async function main() { streamWindow.init() let browseWindow = null + let twitchBot = null let streamdelayClient = null const clientState = { @@ -250,10 +292,18 @@ async function main() { streamdelayClient.connect() } + if (argv.twitch.token) { + twitchBot = new TwitchBot(argv.twitch) + twitchBot.connect() + } + streamWindow.on('state', (viewStates) => { clientState.views = viewStates streamWindow.send('state', clientState) broadcastState(clientState) + if (twitchBot) { + twitchBot.onState(clientState) + } }) const dataSources = [