mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-12-22 21:01:16 +00:00
585 lines
18 KiB
JavaScript
585 lines
18 KiB
JavaScript
/**
|
|
* @typedef {Object} Event
|
|
* @property {number} eventId ID of the event as returned by the server
|
|
* @property {Object|undefined} extraData Any additional data specific to this event type
|
|
* @property {string} filterKey Event-Type key
|
|
* @property {string|undefined} originId ID from the Twitch server for de-duplication
|
|
* @property {string|function|undefined} subtext Additional text, usually user-message
|
|
* @property {string|undefined} text Descriptive text of the event
|
|
* @property {Date} time The moment the event occurred
|
|
* @property {string} title The title of the event
|
|
* @property {boolean} hasReplay Whether the replay button should be shown
|
|
* @property {boolean} isMeta Whether not to display event in frontend
|
|
*/
|
|
|
|
import { customFilters, customHandler } from './eventfeed.custom.js'
|
|
import { createApp } from 'https://cdn.jsdelivr.net/npm/vue@3.4/dist/vue.esm-browser.prod.js'
|
|
import dayjs from 'https://cdn.jsdelivr.net/npm/dayjs@1.11/+esm'
|
|
import dayjsLocalizedFormat from 'https://cdn.jsdelivr.net/npm/dayjs@1.11/plugin/localizedFormat.js/+esm'
|
|
import dayjsRelativeTime from 'https://cdn.jsdelivr.net/npm/dayjs@1.11/plugin/relativeTime.js/+esm'
|
|
import EventClient from './eventclient.mjs'
|
|
|
|
const STORAGE_KEY = 'io.luzifer.eventfeed'
|
|
|
|
const defaultFilters = {
|
|
adbreak: { name: 'Adbreaks', visible: true },
|
|
ban: { name: 'Bans / Timeouts', visible: true },
|
|
bits: { name: 'Bits', visible: true },
|
|
channelpoint: { name: 'Channel-Points', visible: true },
|
|
donation: { name: 'Donations', visible: true },
|
|
follow: { name: 'Follows', visible: true },
|
|
hypetrain: { name: 'Hypetrains', visible: true },
|
|
pollEnd: { name: 'Poll-Summary', visible: true },
|
|
raid: { name: 'Raids', visible: true },
|
|
shoutout: { name: 'Shoutouts', visible: true },
|
|
streamOffline: { name: 'Stream-Offline', visible: true },
|
|
streamUpdate: { name: 'Stream-Update', visible: true },
|
|
subs: { name: 'Subs', visible: true },
|
|
watchStreak: { name: 'Watchstreaks', visible: true },
|
|
}
|
|
|
|
const userAnonSubgifter = 'ananonymousgifter'
|
|
const userAnonCheerer = 'ananonymouscheerer'
|
|
|
|
const app = createApp({
|
|
computed: {
|
|
filterCount() {
|
|
const filters = Object.values(this.filters)
|
|
return `${filters.filter(f => f.visible).length} / ${filters.length}`
|
|
},
|
|
|
|
filters() {
|
|
return Object.fromEntries(Object.entries({
|
|
...defaultFilters,
|
|
...customFilters(),
|
|
...this.storedData.filters || {},
|
|
})
|
|
.filter(e => Object.keys(defaultFilters).includes(e[0]) || Object.keys(customFilters()).includes(e[0]))
|
|
.sort((a, b) => a[1].name.localeCompare(b[1].name)))
|
|
},
|
|
|
|
hypetrain() {
|
|
const evts = [...this.events]
|
|
.filter(evt => evt.filterKey === 'hypetrain')
|
|
.sort((b, a) => a.time.getTime() - b.time.getTime())
|
|
|
|
if (evts.length < 1) {
|
|
return {
|
|
active: false,
|
|
}
|
|
}
|
|
|
|
return evts[0].extraData
|
|
},
|
|
|
|
recentEvents() {
|
|
return [...this.events]
|
|
.filter(evt => !evt.isMeta)
|
|
.filter(evt => this.filters[evt.filterKey]?.visible !== false)
|
|
.filter(evt => !this.knownMultiGiftIDs.includes(evt.originId))
|
|
.sort((b, a) => a.time.getTime() - b.time.getTime())
|
|
},
|
|
|
|
sortedStats() {
|
|
const evts = [...this.events]
|
|
.filter(evt => evt.time.getTime() > this.streamOfflineTime.getTime())
|
|
|
|
|
|
return [
|
|
{
|
|
icon: 'fas fa-gem',
|
|
key: 'bits',
|
|
value: evts
|
|
.filter(evt => evt.filterKey === 'bits')
|
|
.reduce((sum, evt) => sum + evt.extraData.bits, 0),
|
|
},
|
|
{
|
|
icon: 'fas fa-circle-dollar-to-slot',
|
|
key: 'donation',
|
|
value: evts
|
|
.filter(evt => evt.filterKey === 'donation')
|
|
.reduce((sum, evt) => sum + evt.extraData.amount, 0)
|
|
.toFixed(2),
|
|
},
|
|
{
|
|
icon: 'fas fa-heart',
|
|
key: 'follow',
|
|
value: evts
|
|
.filter(evt => evt.filterKey === 'follow')
|
|
.length,
|
|
},
|
|
{
|
|
icon: 'fas fa-parachute-box',
|
|
key: 'raid',
|
|
value: evts
|
|
.filter(evt => evt.filterKey === 'raid')
|
|
.length,
|
|
},
|
|
{
|
|
icon: 'fas fa-star',
|
|
key: 'sub',
|
|
value: evts
|
|
.filter(evt => evt.filterKey === 'subs')
|
|
.filter(evt => !this.knownMultiGiftIDs.includes(evt.originId))
|
|
.reduce((sum, evt) => sum + evt.extraData.count, 0),
|
|
},
|
|
]
|
|
},
|
|
},
|
|
|
|
created() {
|
|
window.setInterval(() => {
|
|
this.now = new Date()
|
|
}, 60000)
|
|
|
|
this.eventClient = new EventClient({
|
|
handlers: {
|
|
adbreak_begin: ({ event_id, fields, time }) => this.handleAdBreak(event_id, fields, time),
|
|
ban: ({ event_id, fields, time }) => this.handleBan(event_id, fields, time),
|
|
bits: ({ event_id, fields, time }) => this.handleBits(event_id, fields, time),
|
|
category_update: ({ event_id, fields, time }) => this.handleCategoryUpdate(event_id, fields, time),
|
|
channelpoint_redeem: ({ event_id, fields, time }) => this.handleChannelPoints(event_id, fields, time),
|
|
custom: eventobj => this.handleCustom(eventobj),
|
|
follow: ({ event_id, fields, time }) => this.handleFollow(event_id, fields, time),
|
|
hypetrain_begin: ({ event_id, fields, time }) => this.handleHypetrain(event_id, fields, time, 'start'),
|
|
hypetrain_end: ({ event_id, fields, time }) => this.handleHypetrain(event_id, fields, time, 'end'),
|
|
hypetrain_progress: ({ event_id, fields, time }) => this.handleHypetrain(event_id, fields, time, 'progress'),
|
|
kofi_donation: ({ event_id, fields, time }) => this.handleKoFiDonation(event_id, fields, time),
|
|
poll_end: ({ event_id, fields, time }) => this.handlePollEnd(event_id, fields, time),
|
|
raid: ({ event_id, fields, time }) => this.handleRaid(event_id, fields, time),
|
|
resub: ({ event_id, fields, reason, time, type }) => this.handleSub(type, event_id, fields, time, reason),
|
|
shoutout_created: ({ event_id, fields, time }) => this.handleShoutoutCreated(event_id, fields, time),
|
|
shoutout_received: ({ event_id, fields, time }) => this.handleShoutoutReceived(event_id, fields, time),
|
|
stream_offline: ({ event_id, time }) => this.handleStreamOffline(event_id, time),
|
|
sub: ({ event_id, fields, time, type }) => this.handleSub(type, event_id, fields, time),
|
|
subgift: ({ event_id, fields, time, type }) => this.handleSubgift(type, event_id, fields, time),
|
|
submysterygift: ({ event_id, fields, time, type }) => this.handleSubgift(type, event_id, fields, time),
|
|
timeout: ({ event_id, fields, time }) => this.handleTimeout(event_id, fields, time),
|
|
title_update: ({ event_id, fields, time }) => this.handleTitleUpdate(event_id, fields, time),
|
|
watch_streak: ({ event_id, fields, time }) => this.handleWatchStreak(event_id, fields, time),
|
|
},
|
|
|
|
maxReplayAge: 168,
|
|
replay: true,
|
|
})
|
|
|
|
this.storageLoad()
|
|
window.addEventListener('storage', ev => {
|
|
if (ev.key !== this.storageKey()) {
|
|
return
|
|
}
|
|
|
|
// Our key has been changed, reload stored data
|
|
this.storageLoad()
|
|
})
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
eventClient: null,
|
|
events: [],
|
|
now: new Date(),
|
|
storedData: {},
|
|
|
|
// Workaround for Twitch not sending hypetrain progress in end-event
|
|
// eslint-disable-next-line sort-keys
|
|
hypetrainProgress: 0,
|
|
knownMultiGiftIDs: [],
|
|
streamOfflineTime: new Date(0),
|
|
subgiftRecipients: {},
|
|
}
|
|
},
|
|
|
|
methods: {
|
|
/**
|
|
* @param {Event} event
|
|
*/
|
|
addEvent(event) {
|
|
if (!event.eventId || !event.filterKey || !event.time || !event.title) {
|
|
throw new Error(`Event missing fields: ${event}`)
|
|
}
|
|
|
|
this.events = [
|
|
...this.events.filter(evt => evt.eventId !== event.eventId),
|
|
event,
|
|
]
|
|
},
|
|
|
|
eventClass(event) {
|
|
const classes = ['border-event', 'list-group-item']
|
|
|
|
if (this.storedData.readDate && this.storedData.readDate > event.time.getTime()) {
|
|
classes.push('disabled')
|
|
}
|
|
|
|
if (event.filterKey) {
|
|
classes.push(`event-${event.filterKey}`)
|
|
}
|
|
|
|
return classes.join(' ')
|
|
},
|
|
|
|
handleAdBreak(eventId, data, time) {
|
|
this.addEvent({
|
|
eventId,
|
|
filterKey: 'adbreak',
|
|
icon: 'fas fa-rectangle-ad text-warning',
|
|
text: `${data.duration}s ad-break is now running`,
|
|
time: time ? new Date(time) : null,
|
|
title: 'Ad-Break started',
|
|
})
|
|
},
|
|
|
|
handleBan(eventId, data, time) {
|
|
this.addEvent({
|
|
eventId,
|
|
filterKey: 'ban',
|
|
icon: 'fas fa-ban',
|
|
time: new Date(time),
|
|
title: `${data.target_name} has been banned`,
|
|
})
|
|
},
|
|
|
|
handleBits(eventId, data, time) {
|
|
const from = data.user === userAnonCheerer ? 'Someone' : data.user
|
|
|
|
this.addEvent({
|
|
eventId,
|
|
extraData: { bits: data.bits },
|
|
filterKey: 'bits',
|
|
hasReplay: true,
|
|
icon: 'fas fa-gem',
|
|
subtext: data.message,
|
|
text: `${from} just spent ${data.bits} Bits`,
|
|
time: time ? new Date(time) : null,
|
|
title: 'Bits donated',
|
|
})
|
|
},
|
|
|
|
handleCategoryUpdate(eventId, data, time) {
|
|
this.addEvent({
|
|
eventId,
|
|
filterKey: 'streamUpdate',
|
|
icon: 'fas fa-gamepad',
|
|
text: data.category,
|
|
time: new Date(time),
|
|
title: 'Category updated',
|
|
})
|
|
},
|
|
|
|
handleChannelPoints(eventId, data, time) {
|
|
this.addEvent({
|
|
eventId,
|
|
filterKey: 'channelpoint',
|
|
hasReplay: true,
|
|
icon: 'fas fa-diamond',
|
|
subtext: data.user_input,
|
|
text: `${data.user} redeemed "${data.reward_title}"`,
|
|
time: new Date(time),
|
|
title: 'Reward Redeemed',
|
|
})
|
|
},
|
|
|
|
handleCustom(eventObj) {
|
|
const evt = customHandler(eventObj)
|
|
if (evt !== null) {
|
|
this.addEvent(evt)
|
|
}
|
|
},
|
|
|
|
handleFollow(eventId, data, time) {
|
|
this.addEvent({
|
|
eventId,
|
|
filterKey: 'follow',
|
|
hasReplay: true,
|
|
icon: 'fas fa-user',
|
|
text: `${data.user} just followed`,
|
|
time: new Date(time),
|
|
title: 'New Follower',
|
|
})
|
|
},
|
|
|
|
handleHypetrain(eventId, data, time, phase) {
|
|
const evt = {
|
|
eventId,
|
|
extraData: {
|
|
active: phase !== 'end',
|
|
level: data.level,
|
|
progress: data.levelProgress || this.hypetrainProgress,
|
|
},
|
|
|
|
filterKey: 'hypetrain',
|
|
icon: 'fas fa-train',
|
|
time: new Date(time),
|
|
}
|
|
|
|
this.hypetrainProgress = evt.extraData.progress
|
|
|
|
switch (phase) {
|
|
case 'start':
|
|
this.addEvent({
|
|
...evt,
|
|
text: `A hypetrain started on ${(data.levelProgress * 100).toFixed(0)}% towards level ${data.level}`,
|
|
title: 'Hypetrain started',
|
|
})
|
|
break
|
|
|
|
case 'progress':
|
|
this.addEvent({
|
|
...evt,
|
|
isMeta: true,
|
|
title: 'Hypetrain progressed',
|
|
})
|
|
break
|
|
|
|
case 'end':
|
|
this.addEvent({
|
|
...evt,
|
|
text: `A hypetrain ended on ${(this.hypetrainProgress * 100).toFixed(0)}% towards level ${data.level}`,
|
|
title: 'Hypetrain ended',
|
|
})
|
|
break
|
|
}
|
|
},
|
|
|
|
handleKoFiDonation(eventId, data, time) {
|
|
let text
|
|
if (data.isSubscription && data.isFirstSubPayment) {
|
|
text = `${data.from} just started a monthly subscription of ${Number(data.amount).toFixed(2)} ${data.currency}`
|
|
} else if (data.isSubscription && !data.isFirstSubPayment) {
|
|
text = `${data.from} continued their monthly subscription of ${Number(data.amount).toFixed(2)} ${data.currency}`
|
|
} else {
|
|
text = `${data.from} just donated ${Number(data.amount).toFixed(2)} ${data.currency}`
|
|
}
|
|
|
|
this.addEvent({
|
|
eventId,
|
|
extraData: { amount: Number(data.amount) },
|
|
filterKey: 'donation',
|
|
icon: 'fas fa-circle-dollar-to-slot',
|
|
subtext: data.message ? data.message : undefined,
|
|
text,
|
|
time: new Date(time),
|
|
title: 'Ko-fi Donation received',
|
|
})
|
|
},
|
|
|
|
handlePollEnd(eventId, data, time) {
|
|
if (data.poll.status === 'archived') {
|
|
return
|
|
}
|
|
|
|
this.addEvent({
|
|
eventId,
|
|
filterKey: 'pollEnd',
|
|
icon: 'fas fa-square-poll-vertical',
|
|
subtext: data.poll.choices.map(choice => `${choice.title} (${choice.votes})`).join(' | '),
|
|
text: data.poll.title,
|
|
time: new Date(time),
|
|
title: `Poll Ended (${data.poll.status})`,
|
|
})
|
|
},
|
|
|
|
handleRaid(eventId, data, time) {
|
|
this.addEvent({
|
|
eventId,
|
|
filterKey: 'raid',
|
|
hasReplay: true,
|
|
icon: 'fas fa-parachute-box',
|
|
soundUrl: '/public/fanfare.webm',
|
|
text: `${data.from} just raided with ${data.viewercount} raiders`,
|
|
time: new Date(time),
|
|
title: 'Incoming raid',
|
|
})
|
|
},
|
|
|
|
handleShoutoutCreated(eventId, data, time) {
|
|
this.addEvent({
|
|
eventId,
|
|
filterKey: 'shoutout',
|
|
icon: 'fas fa-bullhorn',
|
|
text: `We gave a shoutout for ${data.to} to ${data.viewers} viewers`,
|
|
time: new Date(time),
|
|
title: 'Shoutout created',
|
|
})
|
|
},
|
|
|
|
handleShoutoutReceived(eventId, data, time) {
|
|
this.addEvent({
|
|
eventId,
|
|
filterKey: 'shoutout',
|
|
icon: 'fas fa-bullhorn',
|
|
text: `${data.from} just gave us a shoutout to ${data.viewers} viewers`,
|
|
time: new Date(time),
|
|
title: 'Shoutout received',
|
|
})
|
|
},
|
|
|
|
handleStreamOffline(eventId, time) {
|
|
this.addEvent({
|
|
eventId,
|
|
filterKey: 'streamOffline',
|
|
icon: 'fas fa-clapperboard text-danger',
|
|
time: new Date(time),
|
|
title: 'Stream Offline',
|
|
})
|
|
|
|
this.streamOfflineTime = new Date(time)
|
|
},
|
|
|
|
handleSub(evt, eventId, data, time) {
|
|
const text = evt === 'resub' ? `resubscribed for the ${data.subscribed_months}. time` : 'subscribed'
|
|
const tier = data.plan === 'Prime' ? 'P' : `T${Number(data.plan) / 1000}`
|
|
const title = evt === 'resub' ? `Resub (${tier})` : `New Sub (${tier})`
|
|
this.addEvent({
|
|
eventId,
|
|
extraData: { count: 1 },
|
|
filterKey: 'subs',
|
|
hasReplay: true,
|
|
icon: 'fas fa-star',
|
|
subtext: data.message,
|
|
text: `${data.user} just ${text} (${tier})`,
|
|
time: new Date(time),
|
|
title,
|
|
})
|
|
},
|
|
|
|
handleSubgift(evt, eventId, data, time) {
|
|
const from = data.user === userAnonSubgifter ? 'ANON' : data.from
|
|
|
|
const tier = data.plan === 'Prime' ? 'Prime' : `Tier ${Number(data.plan) / 1000}`
|
|
|
|
if (evt === 'submysterygift') {
|
|
this.addEvent({
|
|
eventId,
|
|
extraData: { count: data.number },
|
|
filterKey: 'subs',
|
|
hasReplay: true,
|
|
icon: 'fas fa-gift',
|
|
subtext: () => this.subgiftRecipients[data.origin_id] ? `To: ${this.subgiftRecipients[data.origin_id].join(', ')}` : undefined,
|
|
text: `${from} just gifted ${data.number} subs`,
|
|
time: time ? new Date(time) : null,
|
|
title: `Subs gifted (${tier})`,
|
|
variant: 'warning',
|
|
})
|
|
|
|
this.knownMultiGiftIDs.push(data.origin_id)
|
|
return
|
|
}
|
|
|
|
if (data.origin_id) {
|
|
this.subgiftRecipients[data.origin_id] = [
|
|
...this.subgiftRecipients[data.origin_id] || [],
|
|
data.to,
|
|
].sort((a, b) => a.localeCompare(b))
|
|
}
|
|
|
|
this.addEvent({
|
|
eventId,
|
|
extraData: { count: 1 },
|
|
filterKey: 'subs',
|
|
hasReplay: true,
|
|
icon: 'fas fa-gift',
|
|
originId: data.origin_id,
|
|
text: `${from} just gifted ${data.to} a sub`,
|
|
time: time ? new Date(time) : null,
|
|
title: `Sub gifted (${tier})`,
|
|
variant: 'warning',
|
|
})
|
|
},
|
|
|
|
handleTimeout(eventId, data, time) {
|
|
this.addEvent({
|
|
eventId,
|
|
filterKey: 'ban',
|
|
icon: 'fas fa-ban',
|
|
time: new Date(time),
|
|
title: `${data.target_name} has been timed out for ${data.seconds}s`,
|
|
})
|
|
},
|
|
|
|
handleTitleUpdate(eventId, data, time) {
|
|
this.addEvent({
|
|
eventId,
|
|
filterKey: 'streamUpdate',
|
|
icon: 'fas fa-heading',
|
|
text: data.title,
|
|
time: new Date(time),
|
|
title: 'Title updated',
|
|
})
|
|
},
|
|
|
|
handleWatchStreak(eventId, data, time) {
|
|
this.addEvent({
|
|
eventId,
|
|
filterKey: 'watchStreak',
|
|
icon: 'fas fa-circle-info',
|
|
subtext: data.message,
|
|
text: `${data.user} watched ${data.streak} consecutive streams`,
|
|
time: new Date(time),
|
|
title: 'Watch-Streak shared',
|
|
})
|
|
},
|
|
|
|
markRead() {
|
|
this.storedData.readDate = new Date().getTime()
|
|
this.storageSave()
|
|
},
|
|
|
|
repeatEvent(eventId) {
|
|
return this.eventClient.replayEvent(eventId)
|
|
},
|
|
|
|
resolveSubtext(subtext) {
|
|
if (typeof subtext === 'function') {
|
|
return subtext()
|
|
}
|
|
|
|
return subtext
|
|
},
|
|
|
|
storageKey() {
|
|
const channel = this.eventClient.paramOptionFallback('channel').replace(/^#*/, '')
|
|
return [STORAGE_KEY, channel].join('.')
|
|
},
|
|
|
|
storageLoad() {
|
|
this.storedData = {
|
|
// Default values
|
|
filters: {},
|
|
readDate: 0,
|
|
|
|
// Stored data
|
|
...JSON.parse(window.localStorage.getItem(this.storageKey()) || '{}'),
|
|
}
|
|
},
|
|
|
|
storageSave() {
|
|
window.localStorage.setItem(this.storageKey(), JSON.stringify(this.storedData))
|
|
},
|
|
|
|
timeDisplay(time) {
|
|
return dayjs(time).format('llll')
|
|
},
|
|
|
|
timeSince(time) {
|
|
return dayjs(time).from(this.now)
|
|
},
|
|
|
|
toggleFilterVisibility(filter) {
|
|
if (!this.storedData.filters[filter]) {
|
|
this.storedData.filters[filter] = this.filters[filter]
|
|
}
|
|
|
|
this.storedData.filters[filter].visible = !this.storedData.filters[filter].visible
|
|
this.storageSave()
|
|
},
|
|
},
|
|
|
|
name: 'EventFeed',
|
|
})
|
|
|
|
dayjs.extend(dayjsLocalizedFormat)
|
|
dayjs.extend(dayjsRelativeTime)
|
|
|
|
app.mount('#app')
|