Skip to content
This repository was archived by the owner on Apr 13, 2025. It is now read-only.

Commit 4ce99b7

Browse files
committed
feat: Add sub bomb detection to stream elements
1 parent 0590003 commit 4ce99b7

File tree

4 files changed

+310
-172
lines changed

4 files changed

+310
-172
lines changed

samples/streamelements-events/extension/index.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,32 +32,52 @@ module.exports = function (nodecg: NodeCG) {
3232
});
3333

3434
client.onSubscriber((data) => {
35-
nodecg.log.info(`${data.data.displayName} just subscribed for ${data.data.amount} months (${formatSubTier(data.data.tier)}).`);
35+
nodecg.log.info(
36+
`${data.data.displayName} just subscribed for ${data.data.amount} months (${formatSubTier(
37+
data.data.tier,
38+
)}).`,
39+
);
3640
});
3741

3842
client.onTestSubscriber((data) => {
39-
nodecg.log.info(`${data.event.displayName} just subscribed for ${data.event.amount} months (${formatSubTier(data.event.tier)}).`);
40-
})
43+
nodecg.log.info(
44+
`${data.event.displayName} just subscribed for ${data.event.amount} months (${formatSubTier(
45+
data.event.tier,
46+
)}).`,
47+
);
48+
});
49+
50+
client.onSubscriberBomb((data) => {
51+
nodecg.log.info(`${data.gifterUsername} just gifted ${data.subscribers.length} subs.`);
52+
});
53+
54+
client.onTestSubscriberBomb((data) => {
55+
nodecg.log.info(`${data.gifterUsername} just gifted ${data.subscribers.length} subs.`);
56+
});
4157

4258
client.onGift((data) => {
4359
nodecg.log.info(
44-
`${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.`,
60+
`${data.data.displayName} just got a tier ${formatSubTier(data.data.tier)} subscription from ${
61+
data.data.sender ?? "anonymous"
62+
}! It's ${data.data.displayName}'s ${data.data.amount} month.`,
4563
);
4664
});
4765

4866
client.onTestGift((data) => {
4967
nodecg.log.info(
50-
`${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.`,
68+
`${data.event.displayName} just got a tier ${formatSubTier(data.event.tier)} subscription from ${
69+
data.event.sender ?? "anonymous"
70+
}! It's ${data.event.displayName}'s ${data.event.amount} month.`,
5171
);
52-
})
72+
});
5373

5474
client.onHost((data) => {
5575
nodecg.log.info(`${data.data.displayName} just hosted the stream for ${data.data.amount} viewer(s).`);
5676
});
5777

5878
client.onTestHost((data) => {
5979
nodecg.log.info(`${data.event.displayName} just hosted the stream for ${data.event.amount} viewer(s).`);
60-
})
80+
});
6181

6282
client.onRaid((data) => {
6383
nodecg.log.info(`${data.data.displayName} just raided the stream with ${data.data.amount} viewers.`);
@@ -92,8 +112,7 @@ module.exports = function (nodecg: NodeCG) {
92112
};
93113

94114
function formatSubTier(tier: "1000" | "2000" | "3000" | "prime"): string {
95-
if (tier === "prime")
96-
return "Twitch Prime";
115+
if (tier === "prime") return "Twitch Prime";
97116

98117
// We want to display the tier as 1, 2, 3
99118
// However StreamElements stores the sub tiers as 1000, 2000 and 3000.

samples/streamelements-events/graphics/index.html

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,47 +21,55 @@ <h1>streamelements-events sample bundle</h1>
2121
<p>
2222
Last subscriber:
2323
<span v-if="streamElementsReplicant?.lastSubscriber">
24-
{{ streamElementsReplicant?.lastSubscriber?.data.displayName }} subscribed for {{
25-
streamElementsReplicant?.lastSubscriber?.data.amount }} months ({{ subTier }}).
24+
{{ streamElementsReplicant?.lastSubscriber?.data.displayName }} subscribed for
25+
{{ streamElementsReplicant?.lastSubscriber?.data.amount }} months ({{ subTier }}).
26+
</span>
27+
<span v-else>none</span>
28+
</p>
29+
<p>
30+
Last subbomb:
31+
<span v-if="streamElementsReplicant?.lastSubBomb">
32+
{{ streamElementsReplicant?.lastSubBomb.gifterUsername }} gifted
33+
{{ streamElementsReplicant?.subscribers.subs.length }}.
2634
</span>
2735
<span v-else>none</span>
2836
</p>
2937
<p>
3038
Last tip:
3139
<span v-if="streamElementsReplicant?.lastTip">
32-
{{ streamElementsReplicant?.lastSubscriber?.data.displayName }} subscribed for {{
33-
streamElementsReplicant?.lastSubscriber?.data.amount }} months.
40+
{{ streamElementsReplicant?.lastSubscriber?.data.displayName }} subscribed for
41+
{{ streamElementsReplicant?.lastSubscriber?.data.amount }} months.
3442
</span>
3543
<span v-else>none</span>
3644
</p>
3745
<p>
3846
Last cheer:
3947
<span v-if="streamElementsReplicant?.lastCheer">
40-
{{ streamElementsReplicant?.lastCheer?.data.amount }} bits by {{
41-
streamElementsReplicant?.lastCheer?.data.displayName }}
48+
{{ streamElementsReplicant?.lastCheer?.data.amount }} bits by
49+
{{ streamElementsReplicant?.lastCheer?.data.displayName }}.
4250
</span>
4351
<span v-else>none</span>
4452
</p>
4553
<p>
4654
Last follow:
4755
<span v-if="streamElementsReplicant?.lastFollow">
48-
{{ streamElementsReplicant?.lastFollow?.data.displayName }}
56+
{{ streamElementsReplicant?.lastFollow?.data.displayName }}.
4957
</span>
5058
<span v-else>none</span>
5159
</p>
5260
<p>
5361
Last raid:
5462
<span v-if="streamElementsReplicant?.lastRaid">
55-
{{ streamElementsReplicant?.lastRaid?.data.displayName }} raided with {{
56-
streamElementsReplicant?.lastRaid?.data.amount }} viewers
63+
{{ streamElementsReplicant?.lastRaid?.data.displayName }} raided with
64+
{{ streamElementsReplicant?.lastRaid?.data.amount }} viewers.
5765
</span>
5866
<span v-else>none</span>
5967
</p>
6068
<p>
6169
Last host:
6270
<span v-if="streamElementsReplicant?.lastHost">
63-
{{ streamElementsReplicant?.lastHost?.data.displayName }} hosted with {{
64-
streamElementsReplicant?.lastHost?.data.viewers }} viewers
71+
{{ streamElementsReplicant?.lastHost?.data.displayName }} hosted with
72+
{{ streamElementsReplicant?.lastHost?.data.viewers }} viewers.
6573
</span>
6674
<span v-else>none</span>
6775
</p>

services/nodecg-io-streamelements/extension/StreamElements.ts

Lines changed: 88 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
11
import io = require("socket.io-client");
22
import { Result, emptySuccess, error } from "nodecg-io-core";
33
import {
4-
StreamElementsCheerEvent, StreamElementsEvent,
4+
StreamElementsCheerEvent,
5+
StreamElementsEvent,
56
StreamElementsFollowEvent,
67
StreamElementsHostEvent,
78
StreamElementsRaidEvent,
9+
StreamElementsSubBombEvent,
810
StreamElementsSubscriberEvent,
9-
StreamElementsTestCheerEvent, StreamElementsTestEvent,
11+
StreamElementsTestCheerEvent,
12+
StreamElementsTestEvent,
1013
StreamElementsTestFollowEvent,
11-
StreamElementsTestHostEvent, StreamElementsTestRaidEvent,
12-
StreamElementsTestSubscriberEvent, StreamElementsTestTipEvent,
13-
StreamElementsTipEvent
14+
StreamElementsTestHostEvent,
15+
StreamElementsTestRaidEvent,
16+
StreamElementsTestSubscriberEvent,
17+
StreamElementsTestTipEvent,
18+
StreamElementsTipEvent,
1419
} from "./StreamElementsEvent";
1520
import { EventEmitter } from "events";
1621
import { Replicant } from "nodecg-types/types/server";
1722

1823
export interface StreamElementsReplicant {
1924
lastSubscriber?: StreamElementsSubscriberEvent;
25+
lastSubBomb?: StreamElementsSubBombEvent<StreamElementsSubscriberEvent>;
2026
lastTip?: StreamElementsTipEvent;
2127
lastCheer?: StreamElementsCheerEvent;
2228
lastGift?: StreamElementsSubscriberEvent;
@@ -25,8 +31,17 @@ export interface StreamElementsReplicant {
2531
lastHost?: StreamElementsHostEvent;
2632
}
2733

34+
/**
35+
* Internal utility interface for tracking sub-bombs.
36+
*/
37+
interface SubBomb {
38+
timeout: NodeJS.Timeout;
39+
subs: Array<StreamElementsSubscriberEvent | StreamElementsTestSubscriberEvent>;
40+
}
41+
2842
export class StreamElementsServiceClient extends EventEmitter {
2943
private socket: SocketIOClient.Socket;
44+
private subBombDetectionMap: Map<string, SubBomb> = new Map();
3045

3146
constructor(private jwtToken: string, private handleTestEvents: boolean) {
3247
super();
@@ -47,21 +62,69 @@ export class StreamElementsServiceClient extends EventEmitter {
4762
this.onEvent((data: StreamElementsEvent) => {
4863
if (data.type === "subscriber") {
4964
if (data.data.gifted) {
50-
this.emit("gift", data);
65+
this.handleSubGift(data.data.sender, data,
66+
(subBomb) => this.emit("subbomb", subBomb),
67+
(gift) => this.emit("gift", gift)
68+
);
5169
}
5270
}
5371
this.emit(data.type, data);
5472
});
73+
5574
if (this.handleTestEvents) {
5675
this.onTestEvent((data: StreamElementsTestEvent) => {
5776
if (data.listener) {
5877
this.emit("test", data);
5978
this.emit("test:" + data.listener, data);
6079
}
6180
});
81+
82+
this.onTestSubscriber((data) => {
83+
if (data.event.gifted) {
84+
this.handleSubGift(data.event.sender, data,
85+
(subBomb) => this.emit("test:subbomb", subBomb),
86+
(gift) => this.emit("test:gift", gift)
87+
);
88+
}
89+
});
6290
}
6391
}
6492

93+
private handleSubGift<T extends StreamElementsSubscriberEvent | StreamElementsTestSubscriberEvent>(
94+
subGifter: string | undefined,
95+
gift: T,
96+
handlerSubBomb: (data: StreamElementsSubBombEvent<T>) => void,
97+
handlerGift: (data: T) => void,
98+
) {
99+
const gifter = subGifter ?? "anonymous";
100+
101+
const subBomb = this.subBombDetectionMap.get(gifter) ?? {
102+
subs: [],
103+
timeout: setTimeout(() => {
104+
this.subBombDetectionMap.delete(gifter);
105+
106+
// Only fire sub bomb event if more than one sub were gifted.
107+
// Otherwise, this is just a single gifted sub.
108+
if (subBomb.subs.length > 1) {
109+
const subBombEvent = {
110+
gifterUsername: gifter,
111+
subscribers: subBomb.subs as T[],
112+
};
113+
handlerSubBomb(subBombEvent);
114+
}
115+
116+
subBomb.subs.forEach(handlerGift);
117+
}, 1000),
118+
};
119+
120+
subBomb.subs.push(gift);
121+
122+
// New subs in this sub bomb. Refresh timeout in case another one follows.
123+
subBomb.timeout.refresh();
124+
125+
this.subBombDetectionMap.set(gifter, subBomb);
126+
}
127+
65128
async connect(): Promise<void> {
66129
return new Promise((resolve, _reject) => {
67130
this.createSocket();
@@ -127,6 +190,10 @@ export class StreamElementsServiceClient extends EventEmitter {
127190
this.on("subscriber", handler);
128191
}
129192

193+
public onSubscriberBomb(handler: (data: StreamElementsSubBombEvent<StreamElementsSubscriberEvent>) => void): void {
194+
this.on("subbomb", handler);
195+
}
196+
130197
public onTip(handler: (data: StreamElementsTipEvent) => void): void {
131198
this.on("tip", handler);
132199
}
@@ -159,12 +226,14 @@ export class StreamElementsServiceClient extends EventEmitter {
159226
this.on("test:subscriber-latest", handler);
160227
}
161228

229+
public onTestSubscriberBomb(
230+
handler: (data: StreamElementsSubBombEvent<StreamElementsTestSubscriberEvent>) => void,
231+
): void {
232+
this.on("test:subbomb", handler);
233+
}
234+
162235
public onTestGift(handler: (data: StreamElementsTestSubscriberEvent) => void): void {
163-
this.on("test:subscriber-latest", d => {
164-
if(d.data.gifted) {
165-
handler(d);
166-
}
167-
});
236+
this.on("test:gift", handler);
168237
}
169238

170239
public onTestCheer(handler: (data: StreamElementsTestCheerEvent) => void): void {
@@ -192,12 +261,13 @@ export class StreamElementsServiceClient extends EventEmitter {
192261
rep.value = {};
193262
}
194263

195-
this.on("subscriber", (data) => (rep.value.lastSubscriber = data));
196-
this.on("tip", (data) => (rep.value.lastTip = data));
197-
this.on("cheer", (data) => (rep.value.lastCheer = data));
198-
this.on("gift", (data) => (rep.value.lastGift = data));
199-
this.on("follow", (data) => (rep.value.lastFollow = data));
200-
this.on("raid", (data) => (rep.value.lastRaid = data));
201-
this.on("host", (data) => (rep.value.lastHost = data));
264+
this.onSubscriber(data => rep.value.lastSubscriber = data);
265+
this.onSubscriberBomb(data => rep.value.lastSubBomb = data);
266+
this.onTip(data => rep.value.lastTip = data);
267+
this.onCheer(data => rep.value.lastCheer = data);
268+
this.onGift(data => rep.value.lastGift = data);
269+
this.onFollow(data => rep.value.lastFollow = data);
270+
this.onRaid(data => rep.value.lastRaid = data);
271+
this.onHost(data => rep.value.lastHost = data);
202272
}
203273
}

0 commit comments

Comments
 (0)