From 4ce99b7b5a0df356b3a4d5cc80454ca6e30c7350 Mon Sep 17 00:00:00 2001 From: SteffoSpieler Date: Tue, 2 Aug 2022 22:14:17 +0200 Subject: [PATCH 1/2] feat: Add sub bomb detection to stream elements --- .../streamelements-events/extension/index.ts | 37 ++- .../streamelements-events/graphics/index.html | 30 +- .../extension/StreamElements.ts | 106 +++++- .../extension/StreamElementsEvent.ts | 309 ++++++++++-------- 4 files changed, 310 insertions(+), 172 deletions(-) diff --git a/samples/streamelements-events/extension/index.ts b/samples/streamelements-events/extension/index.ts index 2c39b11c5..0ec33eb89 100644 --- a/samples/streamelements-events/extension/index.ts +++ b/samples/streamelements-events/extension/index.ts @@ -32,24 +32,44 @@ module.exports = function (nodecg: NodeCG) { }); client.onSubscriber((data) => { - nodecg.log.info(`${data.data.displayName} just subscribed for ${data.data.amount} months (${formatSubTier(data.data.tier)}).`); + nodecg.log.info( + `${data.data.displayName} just subscribed for ${data.data.amount} months (${formatSubTier( + data.data.tier, + )}).`, + ); }); client.onTestSubscriber((data) => { - nodecg.log.info(`${data.event.displayName} just subscribed for ${data.event.amount} months (${formatSubTier(data.event.tier)}).`); - }) + nodecg.log.info( + `${data.event.displayName} just subscribed for ${data.event.amount} months (${formatSubTier( + data.event.tier, + )}).`, + ); + }); + + client.onSubscriberBomb((data) => { + nodecg.log.info(`${data.gifterUsername} just gifted ${data.subscribers.length} subs.`); + }); + + client.onTestSubscriberBomb((data) => { + nodecg.log.info(`${data.gifterUsername} just gifted ${data.subscribers.length} subs.`); + }); client.onGift((data) => { nodecg.log.info( - `${data.data.displayName} just got a tier ${formatSubTier(data.data.tier)} subscription from ${data.data.sender ?? "anonymous"}! It's ${data.data.displayName}'s ${data.data.amount} month.`, + `${data.data.displayName} just got a tier ${formatSubTier(data.data.tier)} subscription from ${ + data.data.sender ?? "anonymous" + }! It's ${data.data.displayName}'s ${data.data.amount} month.`, ); }); client.onTestGift((data) => { nodecg.log.info( - `${data.event.displayName} just got a tier ${formatSubTier(data.event.tier)} subscription from ${data.event.sender ?? "anonymous"}! It's ${data.event.displayName}'s ${data.event.amount} month.`, + `${data.event.displayName} just got a tier ${formatSubTier(data.event.tier)} subscription from ${ + data.event.sender ?? "anonymous" + }! It's ${data.event.displayName}'s ${data.event.amount} month.`, ); - }) + }); client.onHost((data) => { nodecg.log.info(`${data.data.displayName} just hosted the stream for ${data.data.amount} viewer(s).`); @@ -57,7 +77,7 @@ module.exports = function (nodecg: NodeCG) { client.onTestHost((data) => { nodecg.log.info(`${data.event.displayName} just hosted the stream for ${data.event.amount} viewer(s).`); - }) + }); client.onRaid((data) => { nodecg.log.info(`${data.data.displayName} just raided the stream with ${data.data.amount} viewers.`); @@ -92,8 +112,7 @@ module.exports = function (nodecg: NodeCG) { }; function formatSubTier(tier: "1000" | "2000" | "3000" | "prime"): string { - if (tier === "prime") - return "Twitch Prime"; + if (tier === "prime") return "Twitch Prime"; // We want to display the tier as 1, 2, 3 // However StreamElements stores the sub tiers as 1000, 2000 and 3000. diff --git a/samples/streamelements-events/graphics/index.html b/samples/streamelements-events/graphics/index.html index 6deecc874..aa9417aa6 100644 --- a/samples/streamelements-events/graphics/index.html +++ b/samples/streamelements-events/graphics/index.html @@ -21,47 +21,55 @@

streamelements-events sample bundle

Last subscriber: - {{ streamElementsReplicant?.lastSubscriber?.data.displayName }} subscribed for {{ - streamElementsReplicant?.lastSubscriber?.data.amount }} months ({{ subTier }}). + {{ streamElementsReplicant?.lastSubscriber?.data.displayName }} subscribed for + {{ streamElementsReplicant?.lastSubscriber?.data.amount }} months ({{ subTier }}). + + none +

+

+ Last subbomb: + + {{ streamElementsReplicant?.lastSubBomb.gifterUsername }} gifted + {{ streamElementsReplicant?.subscribers.subs.length }}. none

Last tip: - {{ streamElementsReplicant?.lastSubscriber?.data.displayName }} subscribed for {{ - streamElementsReplicant?.lastSubscriber?.data.amount }} months. + {{ streamElementsReplicant?.lastSubscriber?.data.displayName }} subscribed for + {{ streamElementsReplicant?.lastSubscriber?.data.amount }} months. none

Last cheer: - {{ streamElementsReplicant?.lastCheer?.data.amount }} bits by {{ - streamElementsReplicant?.lastCheer?.data.displayName }} + {{ streamElementsReplicant?.lastCheer?.data.amount }} bits by + {{ streamElementsReplicant?.lastCheer?.data.displayName }}. none

Last follow: - {{ streamElementsReplicant?.lastFollow?.data.displayName }} + {{ streamElementsReplicant?.lastFollow?.data.displayName }}. none

Last raid: - {{ streamElementsReplicant?.lastRaid?.data.displayName }} raided with {{ - streamElementsReplicant?.lastRaid?.data.amount }} viewers + {{ streamElementsReplicant?.lastRaid?.data.displayName }} raided with + {{ streamElementsReplicant?.lastRaid?.data.amount }} viewers. none

Last host: - {{ streamElementsReplicant?.lastHost?.data.displayName }} hosted with {{ - streamElementsReplicant?.lastHost?.data.viewers }} viewers + {{ streamElementsReplicant?.lastHost?.data.displayName }} hosted with + {{ streamElementsReplicant?.lastHost?.data.viewers }} viewers. none

diff --git a/services/nodecg-io-streamelements/extension/StreamElements.ts b/services/nodecg-io-streamelements/extension/StreamElements.ts index 168418fdb..2f0960ccb 100644 --- a/services/nodecg-io-streamelements/extension/StreamElements.ts +++ b/services/nodecg-io-streamelements/extension/StreamElements.ts @@ -1,22 +1,28 @@ import io = require("socket.io-client"); import { Result, emptySuccess, error } from "nodecg-io-core"; import { - StreamElementsCheerEvent, StreamElementsEvent, + StreamElementsCheerEvent, + StreamElementsEvent, StreamElementsFollowEvent, StreamElementsHostEvent, StreamElementsRaidEvent, + StreamElementsSubBombEvent, StreamElementsSubscriberEvent, - StreamElementsTestCheerEvent, StreamElementsTestEvent, + StreamElementsTestCheerEvent, + StreamElementsTestEvent, StreamElementsTestFollowEvent, - StreamElementsTestHostEvent, StreamElementsTestRaidEvent, - StreamElementsTestSubscriberEvent, StreamElementsTestTipEvent, - StreamElementsTipEvent + StreamElementsTestHostEvent, + StreamElementsTestRaidEvent, + StreamElementsTestSubscriberEvent, + StreamElementsTestTipEvent, + StreamElementsTipEvent, } from "./StreamElementsEvent"; import { EventEmitter } from "events"; import { Replicant } from "nodecg-types/types/server"; export interface StreamElementsReplicant { lastSubscriber?: StreamElementsSubscriberEvent; + lastSubBomb?: StreamElementsSubBombEvent; lastTip?: StreamElementsTipEvent; lastCheer?: StreamElementsCheerEvent; lastGift?: StreamElementsSubscriberEvent; @@ -25,8 +31,17 @@ export interface StreamElementsReplicant { lastHost?: StreamElementsHostEvent; } +/** + * Internal utility interface for tracking sub-bombs. + */ +interface SubBomb { + timeout: NodeJS.Timeout; + subs: Array; +} + export class StreamElementsServiceClient extends EventEmitter { private socket: SocketIOClient.Socket; + private subBombDetectionMap: Map = new Map(); constructor(private jwtToken: string, private handleTestEvents: boolean) { super(); @@ -47,11 +62,15 @@ export class StreamElementsServiceClient extends EventEmitter { this.onEvent((data: StreamElementsEvent) => { if (data.type === "subscriber") { if (data.data.gifted) { - this.emit("gift", data); + this.handleSubGift(data.data.sender, data, + (subBomb) => this.emit("subbomb", subBomb), + (gift) => this.emit("gift", gift) + ); } } this.emit(data.type, data); }); + if (this.handleTestEvents) { this.onTestEvent((data: StreamElementsTestEvent) => { if (data.listener) { @@ -59,9 +78,53 @@ export class StreamElementsServiceClient extends EventEmitter { this.emit("test:" + data.listener, data); } }); + + this.onTestSubscriber((data) => { + if (data.event.gifted) { + this.handleSubGift(data.event.sender, data, + (subBomb) => this.emit("test:subbomb", subBomb), + (gift) => this.emit("test:gift", gift) + ); + } + }); } } + private handleSubGift( + subGifter: string | undefined, + gift: T, + handlerSubBomb: (data: StreamElementsSubBombEvent) => void, + handlerGift: (data: T) => void, + ) { + const gifter = subGifter ?? "anonymous"; + + const subBomb = this.subBombDetectionMap.get(gifter) ?? { + subs: [], + timeout: setTimeout(() => { + this.subBombDetectionMap.delete(gifter); + + // Only fire sub bomb event if more than one sub were gifted. + // Otherwise, this is just a single gifted sub. + if (subBomb.subs.length > 1) { + const subBombEvent = { + gifterUsername: gifter, + subscribers: subBomb.subs as T[], + }; + handlerSubBomb(subBombEvent); + } + + subBomb.subs.forEach(handlerGift); + }, 1000), + }; + + subBomb.subs.push(gift); + + // New subs in this sub bomb. Refresh timeout in case another one follows. + subBomb.timeout.refresh(); + + this.subBombDetectionMap.set(gifter, subBomb); + } + async connect(): Promise { return new Promise((resolve, _reject) => { this.createSocket(); @@ -127,6 +190,10 @@ export class StreamElementsServiceClient extends EventEmitter { this.on("subscriber", handler); } + public onSubscriberBomb(handler: (data: StreamElementsSubBombEvent) => void): void { + this.on("subbomb", handler); + } + public onTip(handler: (data: StreamElementsTipEvent) => void): void { this.on("tip", handler); } @@ -159,12 +226,14 @@ export class StreamElementsServiceClient extends EventEmitter { this.on("test:subscriber-latest", handler); } + public onTestSubscriberBomb( + handler: (data: StreamElementsSubBombEvent) => void, + ): void { + this.on("test:subbomb", handler); + } + public onTestGift(handler: (data: StreamElementsTestSubscriberEvent) => void): void { - this.on("test:subscriber-latest", d => { - if(d.data.gifted) { - handler(d); - } - }); + this.on("test:gift", handler); } public onTestCheer(handler: (data: StreamElementsTestCheerEvent) => void): void { @@ -192,12 +261,13 @@ export class StreamElementsServiceClient extends EventEmitter { rep.value = {}; } - this.on("subscriber", (data) => (rep.value.lastSubscriber = data)); - this.on("tip", (data) => (rep.value.lastTip = data)); - this.on("cheer", (data) => (rep.value.lastCheer = data)); - this.on("gift", (data) => (rep.value.lastGift = data)); - this.on("follow", (data) => (rep.value.lastFollow = data)); - this.on("raid", (data) => (rep.value.lastRaid = data)); - this.on("host", (data) => (rep.value.lastHost = data)); + this.onSubscriber(data => rep.value.lastSubscriber = data); + this.onSubscriberBomb(data => rep.value.lastSubBomb = data); + this.onTip(data => rep.value.lastTip = data); + this.onCheer(data => rep.value.lastCheer = data); + this.onGift(data => rep.value.lastGift = data); + this.onFollow(data => rep.value.lastFollow = data); + this.onRaid(data => rep.value.lastRaid = data); + this.onHost(data => rep.value.lastHost = data); } } diff --git a/services/nodecg-io-streamelements/extension/StreamElementsEvent.ts b/services/nodecg-io-streamelements/extension/StreamElementsEvent.ts index f8ca0c3b6..2a9c55d4f 100644 --- a/services/nodecg-io-streamelements/extension/StreamElementsEvent.ts +++ b/services/nodecg-io-streamelements/extension/StreamElementsEvent.ts @@ -55,84 +55,110 @@ interface StreamElementsDataBase { export type StreamElementsFollowEvent = StreamElementsBaseEvent<"follow", unknown>; -export type StreamElementsCheerEvent = StreamElementsBaseEvent<"cheer", { - /** - * The count of bits that were cheered. - */ - amount: number; - /** - * The message contained in the cheer. - */ - message: string; -}>; +export type StreamElementsCheerEvent = StreamElementsBaseEvent< + "cheer", + { + /** + * The count of bits that were cheered. + */ + amount: number; + /** + * The message contained in the cheer. + */ + message: string; + } +>; -export type StreamElementsHostEvent = StreamElementsBaseEvent<"host", { - /** - * Number of viewers that are watching through this host. - */ - amount: number; -}>; +export type StreamElementsHostEvent = StreamElementsBaseEvent< + "host", + { + /** + * Number of viewers that are watching through this host. + */ + amount: number; + } +>; -export type StreamElementsRaidEvent = StreamElementsBaseEvent<"raid", { - /** - * Number of viewers raiding this channel. - */ - amount: number; -}>; +export type StreamElementsRaidEvent = StreamElementsBaseEvent< + "raid", + { + /** + * Number of viewers raiding this channel. + */ + amount: number; + } +>; -export type StreamElementsSubscriberEvent = StreamElementsBaseEvent<"subscriber", { - /** - * The total amount of months that this user has already subscribed. - */ - amount: number; - /** - * True if this sub was gifted by someone else. - */ - gifted?: boolean; - /** - * The username of the user that has gifted this sub. - */ - sender?: string; - /** - * Subscription message by user - */ - message: string; - /** - * Amount of consequent months this user already has subscribed. - */ - streak: number; - /** - * The tier of the subscription. - */ - tier: "1000" | "2000" | "3000" | "prime"; -}>; +export type StreamElementsSubscriberEvent = StreamElementsBaseEvent< + "subscriber", + { + /** + * The total amount of months that this user has already subscribed. + */ + amount: number; + /** + * True if this sub was gifted by someone else. + */ + gifted?: boolean; + /** + * The username of the user that has gifted this sub. + */ + sender?: string; + /** + * Subscription message by user + */ + message: string; + /** + * Amount of consequent months this user already has subscribed. + */ + streak: number; + /** + * The tier of the subscription. + */ + tier: "1000" | "2000" | "3000" | "prime"; + } +>; -export type StreamElementsTipEvent = StreamElementsBaseEvent<"tip", { +export interface StreamElementsSubBombEvent { /** - * The amount of money in the given currency that was tipped. + * The username of the gifter. */ - amount: number; + gifterUsername: string; /** - * The user provided message for this tip. + * All gifted subs. */ - message: string; - /** - * The currency symbol. - */ - currency: string; - /** - * StreamElements's hexadecimal tip ID. - */ - tipId: string; -}> + subscribers: ReadonlyArray; +} + +export type StreamElementsTipEvent = StreamElementsBaseEvent< + "tip", + { + /** + * The amount of money in the given currency that was tipped. + */ + amount: number; + /** + * The user provided message for this tip. + */ + message: string; + /** + * The currency symbol. + */ + currency: string; + /** + * StreamElements's hexadecimal tip ID. + */ + tipId: string; + } +>; interface StreamElementsBaseTestEvent { /** * Event provider */ provider: "twitch" | "youtube" | "facebook"; - listener: TListener, - event: TEvent & StreamElementsTestDataBase + listener: TListener; + event: TEvent & StreamElementsTestDataBase; } interface StreamElementsTestDataBase { @@ -154,81 +180,96 @@ interface StreamElementsTestDataBase { providerId?: string; } -export type StreamElementsTestFollowEvent = StreamElementsBaseTestEvent<"follower-latest", unknown> +export type StreamElementsTestFollowEvent = StreamElementsBaseTestEvent<"follower-latest", unknown>; -export type StreamElementsTestCheerEvent = StreamElementsBaseTestEvent<"cheer-latest", { - /** - * The count of bits that were cheered. - */ - amount: number; - /** - * The message contained in the cheer. - */ - message: string; -}> +export type StreamElementsTestCheerEvent = StreamElementsBaseTestEvent< + "cheer-latest", + { + /** + * The count of bits that were cheered. + */ + amount: number; + /** + * The message contained in the cheer. + */ + message: string; + } +>; -export type StreamElementsTestHostEvent = StreamElementsBaseTestEvent<"host-latest", { - /** - * Number of viewers that are watching through this host. - */ - amount: number; -}> +export type StreamElementsTestHostEvent = StreamElementsBaseTestEvent< + "host-latest", + { + /** + * Number of viewers that are watching through this host. + */ + amount: number; + } +>; -export type StreamElementsTestRaidEvent = StreamElementsBaseTestEvent<"raid-latest", { - /** - * Number of viewers raiding this channel. - */ - amount: number; -}> +export type StreamElementsTestRaidEvent = StreamElementsBaseTestEvent< + "raid-latest", + { + /** + * Number of viewers raiding this channel. + */ + amount: number; + } +>; -export type StreamElementsTestSubscriberEvent = StreamElementsBaseTestEvent<"subscriber-latest", { - /** - * The total amount of months that this user has already subscribed. - */ - amount: number; - /** - * True if this sub was gifted by someone else. - */ - gifted?: boolean; - /** - * The username of the user that has gifted this sub. - */ - sender?: string; - /** - * Subscription message by user - */ - message: string; - /** - * Amount of consequent months this user already has subscribed. - */ - streak: number; - /** - * The tier of the subscription. - */ - tier: "1000" | "2000" | "3000" | "prime"; -}> +export type StreamElementsTestSubscriberEvent = StreamElementsBaseTestEvent< + "subscriber-latest", + { + /** + * The total amount of months that this user has already subscribed. + */ + amount: number; + /** + * True if this sub was gifted by someone else. + */ + gifted?: boolean; + /** + * The username of the user that has gifted this sub. + */ + sender?: string; + /** + * Subscription message by user + */ + message: string; + /** + * Amount of consequent months this user already has subscribed. + */ + streak: number; + /** + * The tier of the subscription. + */ + tier: "1000" | "2000" | "3000" | "prime"; + } +>; -export type StreamElementsTestTipEvent = StreamElementsBaseTestEvent<"tip-latest", { - /** - * The amount of money in the given currency that was tipped. - */ - amount: number; - /** - * The user provided message for this tip. - */ - message: string; - /** - * The currency symbol. - */ - currency: string; - /** - * StreamElements's hexadecimal tip ID. - */ - tipId: string; -}> +export type StreamElementsTestTipEvent = StreamElementsBaseTestEvent< + "tip-latest", + { + /** + * The amount of money in the given currency that was tipped. + */ + amount: number; + /** + * The user provided message for this tip. + */ + message: string; + /** + * The currency symbol. + */ + currency: string; + /** + * StreamElements's hexadecimal tip ID. + */ + tipId: string; + } +>; export type StreamElementsEvent = - StreamElementsFollowEvent + | StreamElementsFollowEvent | StreamElementsCheerEvent | StreamElementsHostEvent | StreamElementsRaidEvent @@ -236,9 +277,9 @@ export type StreamElementsEvent = | StreamElementsTipEvent; export type StreamElementsTestEvent = - StreamElementsTestFollowEvent + | StreamElementsTestFollowEvent | StreamElementsTestCheerEvent | StreamElementsTestHostEvent | StreamElementsTestRaidEvent | StreamElementsTestSubscriberEvent - | StreamElementsTestTipEvent; \ No newline at end of file + | StreamElementsTestTipEvent; From 889b7104af3d685ceba527ec7db807f89124383c Mon Sep 17 00:00:00 2001 From: SteffoSpieler Date: Tue, 2 Aug 2022 22:30:16 +0200 Subject: [PATCH 2/2] feat: Allow filtering of gifted subs in onSubscriber and onTestSubscriber in stream elements --- .../extension/StreamElements.ts | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/services/nodecg-io-streamelements/extension/StreamElements.ts b/services/nodecg-io-streamelements/extension/StreamElements.ts index 2f0960ccb..2a50a053c 100644 --- a/services/nodecg-io-streamelements/extension/StreamElements.ts +++ b/services/nodecg-io-streamelements/extension/StreamElements.ts @@ -62,9 +62,11 @@ export class StreamElementsServiceClient extends EventEmitter { this.onEvent((data: StreamElementsEvent) => { if (data.type === "subscriber") { if (data.data.gifted) { - this.handleSubGift(data.data.sender, data, + this.handleSubGift( + data.data.sender, + data, (subBomb) => this.emit("subbomb", subBomb), - (gift) => this.emit("gift", gift) + (gift) => this.emit("gift", gift), ); } } @@ -81,9 +83,11 @@ export class StreamElementsServiceClient extends EventEmitter { this.onTestSubscriber((data) => { if (data.event.gifted) { - this.handleSubGift(data.event.sender, data, + this.handleSubGift( + data.event.sender, + data, (subBomb) => this.emit("test:subbomb", subBomb), - (gift) => this.emit("test:gift", gift) + (gift) => this.emit("test:gift", gift), ); } }); @@ -186,8 +190,11 @@ export class StreamElementsServiceClient extends EventEmitter { }); } - public onSubscriber(handler: (data: StreamElementsSubscriberEvent) => void): void { - this.on("subscriber", handler); + public onSubscriber(handler: (data: StreamElementsSubscriberEvent) => void, includeSubGifts = true): void { + this.on("subscriber", (data) => { + if (data.data.gifted && !includeSubGifts) return; + handler(data); + }); } public onSubscriberBomb(handler: (data: StreamElementsSubBombEvent) => void): void { @@ -222,8 +229,11 @@ export class StreamElementsServiceClient extends EventEmitter { this.on("test", handler); } - public onTestSubscriber(handler: (data: StreamElementsTestSubscriberEvent) => void): void { - this.on("test:subscriber-latest", handler); + public onTestSubscriber(handler: (data: StreamElementsTestSubscriberEvent) => void, includeSubGifts = true): void { + this.on("test:subscriber-latest", (data) => { + if (data.event.gifted && !includeSubGifts) return; + handler(data); + }); } public onTestSubscriberBomb( @@ -261,13 +271,13 @@ export class StreamElementsServiceClient extends EventEmitter { rep.value = {}; } - this.onSubscriber(data => rep.value.lastSubscriber = data); - this.onSubscriberBomb(data => rep.value.lastSubBomb = data); - this.onTip(data => rep.value.lastTip = data); - this.onCheer(data => rep.value.lastCheer = data); - this.onGift(data => rep.value.lastGift = data); - this.onFollow(data => rep.value.lastFollow = data); - this.onRaid(data => rep.value.lastRaid = data); - this.onHost(data => rep.value.lastHost = data); + this.onSubscriber((data) => (rep.value.lastSubscriber = data)); + this.onSubscriberBomb((data) => (rep.value.lastSubBomb = data)); + this.onTip((data) => (rep.value.lastTip = data)); + this.onCheer((data) => (rep.value.lastCheer = data)); + this.onGift((data) => (rep.value.lastGift = data)); + this.onFollow((data) => (rep.value.lastFollow = data)); + this.onRaid((data) => (rep.value.lastRaid = data)); + this.onHost((data) => (rep.value.lastHost = data)); } }