mirror of
https://github.com/streamwall/streamwall.git
synced 2026-01-24 14:12:48 -05:00
Add back TwitchBot
This commit is contained in:
@@ -25,6 +25,7 @@
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"@preact/preset-vite": "^2.10.1",
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/ejs": "^3.1.5",
|
||||
"@types/electron-squirrel-startup": "^1.0.2",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/ws": "^8.5.13",
|
||||
@@ -48,6 +49,8 @@
|
||||
"bufferutil": "^4.0.9",
|
||||
"chokidar": "^4.0.3",
|
||||
"color": "^5.0.0",
|
||||
"dank-twitch-irc": "^4.3.0",
|
||||
"ejs": "^3.1.10",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"esbuild-register": "^3.6.0",
|
||||
"hls.js": "^1.5.18",
|
||||
|
||||
202
packages/streamwall/src/main/TwitchBot.ts
Normal file
202
packages/streamwall/src/main/TwitchBot.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import Color from 'color'
|
||||
import {
|
||||
ChatClient,
|
||||
LoginError,
|
||||
PrivmsgMessage,
|
||||
SlowModeRateLimiter,
|
||||
} from 'dank-twitch-irc'
|
||||
import ejs from 'ejs'
|
||||
import EventEmitter from 'events'
|
||||
import { StreamList, StreamwallState } from 'streamwall-shared'
|
||||
import { matchesState } from 'xstate'
|
||||
import { StreamwallConfig } from '.'
|
||||
|
||||
const VOTE_RE = /^!(\d+)$/
|
||||
|
||||
type TwitchBotConfig = StreamwallConfig['twitch'] & {
|
||||
username: string
|
||||
token: string
|
||||
channel: string
|
||||
}
|
||||
|
||||
export default class TwitchBot extends EventEmitter {
|
||||
config: TwitchBotConfig
|
||||
announceTemplate: ejs.TemplateFunction
|
||||
voteTemplate: ejs.TemplateFunction
|
||||
client: ChatClient
|
||||
streams: StreamList
|
||||
listeningURL: string | null
|
||||
dwellTimeout: NodeJS.Timeout | undefined
|
||||
announceTimeouts: Map<string, NodeJS.Timeout>
|
||||
votes: Map<number, number>
|
||||
|
||||
constructor(config: TwitchBotConfig) {
|
||||
super()
|
||||
const { username, token, vote } = 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 = []
|
||||
this.listeningURL = null
|
||||
this.dwellTimeout = undefined
|
||||
this.announceTimeouts = new Map()
|
||||
|
||||
if (vote.interval) {
|
||||
this.voteTemplate = ejs.compile(config.vote.template)
|
||||
this.votes = new Map()
|
||||
setInterval(this.tallyVotes.bind(this), vote.interval * 1000)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
client.on('PRIVMSG', (msg) => {
|
||||
this.onMsg(msg)
|
||||
})
|
||||
}
|
||||
|
||||
connect() {
|
||||
const { client } = this
|
||||
client.connect()
|
||||
}
|
||||
|
||||
async onReady() {
|
||||
const { client } = this
|
||||
const { channel, color: colorText } = this.config
|
||||
const color = Color(colorText)
|
||||
await client.setColor({
|
||||
r: color.red(),
|
||||
g: color.green(),
|
||||
b: color.blue(),
|
||||
})
|
||||
await client.join(channel)
|
||||
this.emit('connected')
|
||||
}
|
||||
|
||||
onState({ views, streams }: StreamwallState) {
|
||||
this.streams = streams
|
||||
|
||||
const listeningView = views.find(({ state }) =>
|
||||
matchesState(state, 'displaying.running.audio.listening'),
|
||||
)
|
||||
if (!listeningView) {
|
||||
return
|
||||
}
|
||||
|
||||
const listeningURL = listeningView.context.content?.url ?? null
|
||||
if (listeningURL === this.listeningURL) {
|
||||
return
|
||||
}
|
||||
this.listeningURL = listeningURL
|
||||
this.onListeningURLChange(listeningURL)
|
||||
}
|
||||
|
||||
onListeningURLChange(listeningURL: string | null) {
|
||||
if (!listeningURL) {
|
||||
return
|
||||
}
|
||||
|
||||
const { announce } = this.config
|
||||
clearTimeout(this.dwellTimeout)
|
||||
this.dwellTimeout = setTimeout(() => {
|
||||
if (!this.announceTimeouts.has(listeningURL)) {
|
||||
this.announce()
|
||||
}
|
||||
}, announce.delay * 1000)
|
||||
}
|
||||
|
||||
async announce() {
|
||||
const { client, listeningURL, streams } = this
|
||||
const { channel, announce } = this.config
|
||||
|
||||
if (!client.ready || !listeningURL) {
|
||||
return
|
||||
}
|
||||
|
||||
const stream = streams.find((s) => s.link === listeningURL)
|
||||
if (!stream) {
|
||||
return
|
||||
}
|
||||
|
||||
const msg = this.announceTemplate({ stream })
|
||||
await client.say(channel, msg)
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.announceTimeouts.delete(listeningURL)
|
||||
if (this.listeningURL === listeningURL) {
|
||||
this.announce()
|
||||
}
|
||||
}, announce.interval * 1000)
|
||||
this.announceTimeouts.set(listeningURL, timeout)
|
||||
}
|
||||
|
||||
async tallyVotes() {
|
||||
const { client } = this
|
||||
const { channel } = this.config
|
||||
if (this.votes.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let voteCount = 0
|
||||
let selectedIdx = null
|
||||
for (const [idx, value] of this.votes) {
|
||||
if (value > voteCount) {
|
||||
voteCount = value
|
||||
selectedIdx = idx
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedIdx === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const msg = this.voteTemplate({ selectedIdx, voteCount })
|
||||
await client.say(channel, msg)
|
||||
|
||||
// Index spaces starting with 1
|
||||
this.emit('setListeningView', selectedIdx - 1)
|
||||
|
||||
this.votes = new Map()
|
||||
}
|
||||
|
||||
onMsg(msg: PrivmsgMessage) {
|
||||
const { vote } = this.config
|
||||
if (!vote.interval) {
|
||||
return
|
||||
}
|
||||
|
||||
const match = msg.messageText.match(VOTE_RE)
|
||||
if (!match) {
|
||||
return
|
||||
}
|
||||
|
||||
let idx
|
||||
try {
|
||||
idx = Number(match[1])
|
||||
} catch (err) {
|
||||
return
|
||||
}
|
||||
|
||||
this.votes.set(idx, (this.votes.get(idx) || 0) + 1)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from './data'
|
||||
import StreamdelayClient from './StreamdelayClient'
|
||||
import StreamWindow from './StreamWindow'
|
||||
import TwitchBot from './TwitchBot'
|
||||
|
||||
const SENTRY_DSN =
|
||||
'https://e630a21dcf854d1a9eb2a7a8584cbd0b@o459879.ingest.sentry.io/5459505'
|
||||
@@ -54,6 +55,21 @@ export interface StreamwallConfig {
|
||||
control: {
|
||||
endpoint: string
|
||||
}
|
||||
twitch: {
|
||||
channel: string | null
|
||||
username: string | null
|
||||
token: string | null
|
||||
color: string
|
||||
announce: {
|
||||
template: string
|
||||
interval: number
|
||||
delay: number
|
||||
}
|
||||
vote: {
|
||||
template: string
|
||||
interval: number
|
||||
}
|
||||
}
|
||||
telemetry: {
|
||||
sentry: boolean
|
||||
}
|
||||
@@ -157,6 +173,61 @@ function parseArgs(): StreamwallConfig {
|
||||
describe: 'URL of control server endpoint',
|
||||
default: null,
|
||||
})
|
||||
.group(
|
||||
[
|
||||
'twitch.channel',
|
||||
'twitch.username',
|
||||
'twitch.token',
|
||||
'twitch.color',
|
||||
'twitch.announce.template',
|
||||
'twitch.announce.interval',
|
||||
'twitch.vote.template',
|
||||
'twitch.vote.interval',
|
||||
],
|
||||
'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.token', {
|
||||
describe: 'Password of Twitch bot account',
|
||||
default: null,
|
||||
})
|
||||
.option('twitch.color', {
|
||||
describe: 'Color of Twitch bot username',
|
||||
default: '#ff0000',
|
||||
})
|
||||
.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,
|
||||
})
|
||||
.option('twitch.announce.delay', {
|
||||
describe: 'Time to dwell on a stream before its details are announced',
|
||||
number: true,
|
||||
default: 30,
|
||||
})
|
||||
.option('twitch.vote.template', {
|
||||
describe: 'Message template for vote result announcements',
|
||||
default:
|
||||
'Switching to #<%- selectedIdx %> (with <%- voteCount %> votes)',
|
||||
})
|
||||
.option('twitch.vote.interval', {
|
||||
describe: 'Time interval (in seconds) between votes (0 to disable)',
|
||||
number: true,
|
||||
default: 0,
|
||||
})
|
||||
.group(['telemetry.sentry'], 'Telemetry')
|
||||
.option('telemetry.sentry', {
|
||||
describe: 'Enable error reporting to Sentry',
|
||||
@@ -402,6 +473,26 @@ async function main(argv: ReturnType<typeof parseArgs>) {
|
||||
streamdelayClient.connect()
|
||||
}
|
||||
|
||||
const {
|
||||
username: twitchUsername,
|
||||
token: twitchToken,
|
||||
channel: twitchChannel,
|
||||
} = argv.twitch
|
||||
if (twitchUsername && twitchToken && twitchChannel) {
|
||||
console.debug('Setting up Twitch bot...')
|
||||
const twitchBot = new TwitchBot({
|
||||
...argv.twitch,
|
||||
username: twitchUsername,
|
||||
token: twitchToken,
|
||||
channel: twitchChannel,
|
||||
})
|
||||
twitchBot.on('setListeningView', (idx) => {
|
||||
streamWindow.setListeningView(idx)
|
||||
})
|
||||
stateEmitter.on('state', () => twitchBot.onState(clientState))
|
||||
twitchBot.connect()
|
||||
}
|
||||
|
||||
const dataSources = [
|
||||
...argv.data['json-url'].map((url) => {
|
||||
console.debug('Setting data source from json-url:', url)
|
||||
|
||||
Reference in New Issue
Block a user