twitch-bot/internal/apimodules/overlays/default/eventfeed.js
Knut Ahlers c4be936c63
[overlays] Add eventfeed as default-overlay
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-07-26 14:42:57 +02:00

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)}`
} else if (data.isSubscription && !data.isFirstSubPayment) {
text = `${data.from} continued their monthly subscription of ${Number(data.amount).toFixed(2)}`
} else {
text = `${data.from} just donated ${Number(data.amount).toFixed(2)}`
}
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')