twitchdropsminer-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -0
- package/dist/auth/cookieImport.js +7 -0
- package/dist/auth/deviceAuth.js +61 -0
- package/dist/auth/sessionManager.js +30 -0
- package/dist/auth/tokenImport.js +21 -0
- package/dist/auth/validate.js +29 -0
- package/dist/cli/commands/auth.js +172 -0
- package/dist/cli/commands/config.js +51 -0
- package/dist/cli/commands/doctor.js +49 -0
- package/dist/cli/commands/games.js +79 -0
- package/dist/cli/commands/healthcheck.js +25 -0
- package/dist/cli/commands/logs.js +14 -0
- package/dist/cli/commands/run.js +20 -0
- package/dist/cli/commands/service.js +85 -0
- package/dist/cli/commands/status.js +29 -0
- package/dist/cli/contracts/exitCodes.js +7 -0
- package/dist/cli/index.js +36 -0
- package/dist/config/schema.js +16 -0
- package/dist/config/store.js +29 -0
- package/dist/core/channelService.js +105 -0
- package/dist/core/constants.js +12 -0
- package/dist/core/maintenance.js +15 -0
- package/dist/core/miner.js +366 -0
- package/dist/core/runtime.js +34 -0
- package/dist/core/stateMachine.js +9 -0
- package/dist/core/watchLoop.js +26 -0
- package/dist/domain/channel.js +31 -0
- package/dist/domain/inventory.js +370 -0
- package/dist/integrations/gqlClient.js +13 -0
- package/dist/integrations/gqlOperations.js +42 -0
- package/dist/integrations/httpClient.js +37 -0
- package/dist/integrations/twitchPubSub.js +126 -0
- package/dist/integrations/twitchSpade.js +112 -0
- package/dist/ops/systemd.js +63 -0
- package/dist/state/authStore.js +38 -0
- package/dist/state/cookieStore.js +21 -0
- package/dist/state/sessionState.js +26 -0
- package/dist/tests/index.js +7 -0
- package/dist/tests/integration/configStore.test.js +8 -0
- package/dist/tests/parity/stateMachineFlow.test.js +14 -0
- package/dist/tests/unit/channel.test.js +73 -0
- package/dist/tests/unit/channelService.test.js +41 -0
- package/dist/tests/unit/dropsDomain.test.js +57 -0
- package/dist/tests/unit/tokenImport.test.js +13 -0
- package/dist/tests/unit/twitchSpade.test.js +24 -0
- package/docs/ops/authentication.md +32 -0
- package/docs/ops/drops-validation.md +73 -0
- package/docs/ops/linux-install.md +15 -0
- package/docs/ops/service-management.md +23 -0
- package/docs/ops/systemd-hardening.md +13 -0
- package/package.json +41 -0
- package/resources/systemd/tdm.service.tpl +17 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
const MAX_EXTRA_MINUTES = 15;
|
|
2
|
+
export var BenefitType;
|
|
3
|
+
(function (BenefitType) {
|
|
4
|
+
BenefitType["UNKNOWN"] = "UNKNOWN";
|
|
5
|
+
BenefitType["BADGE"] = "BADGE";
|
|
6
|
+
BenefitType["EMOTE"] = "EMOTE";
|
|
7
|
+
BenefitType["DIRECT_ENTITLEMENT"] = "DIRECT_ENTITLEMENT";
|
|
8
|
+
})(BenefitType || (BenefitType = {}));
|
|
9
|
+
export class Benefit {
|
|
10
|
+
id;
|
|
11
|
+
name;
|
|
12
|
+
type;
|
|
13
|
+
imageUrl;
|
|
14
|
+
constructor(data) {
|
|
15
|
+
const benefit = data.benefit;
|
|
16
|
+
this.id = String(benefit.id);
|
|
17
|
+
this.name = String(benefit.name);
|
|
18
|
+
const distType = String(benefit.distributionType ?? "UNKNOWN");
|
|
19
|
+
this.type =
|
|
20
|
+
Object.values(BenefitType).includes(distType) && distType in BenefitType
|
|
21
|
+
? distType
|
|
22
|
+
: BenefitType.UNKNOWN;
|
|
23
|
+
this.imageUrl = String(benefit.imageAssetURL ?? "");
|
|
24
|
+
}
|
|
25
|
+
isBadgeOrEmote() {
|
|
26
|
+
return this.type === BenefitType.BADGE || this.type === BenefitType.EMOTE;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export class TimedDrop {
|
|
30
|
+
id;
|
|
31
|
+
name;
|
|
32
|
+
campaign;
|
|
33
|
+
benefits;
|
|
34
|
+
startsAt;
|
|
35
|
+
endsAt;
|
|
36
|
+
requiredMinutes;
|
|
37
|
+
preconditionDropIds;
|
|
38
|
+
claimId;
|
|
39
|
+
claimed;
|
|
40
|
+
realCurrentMinutes;
|
|
41
|
+
extraCurrentMinutes;
|
|
42
|
+
constructor(campaign, data, claimedBenefits) {
|
|
43
|
+
this.campaign = campaign;
|
|
44
|
+
this.id = String(data.id);
|
|
45
|
+
this.name = String(data.name);
|
|
46
|
+
const benefitEdges = data.benefitEdges || [];
|
|
47
|
+
this.benefits = benefitEdges.map((b) => new Benefit(b));
|
|
48
|
+
this.startsAt = new Date(String(data.startAt));
|
|
49
|
+
this.endsAt = new Date(String(data.endAt));
|
|
50
|
+
const selfEdge = data.self ?? undefined;
|
|
51
|
+
this.claimId =
|
|
52
|
+
selfEdge && typeof selfEdge.dropInstanceID === "string"
|
|
53
|
+
? selfEdge.dropInstanceID
|
|
54
|
+
: null;
|
|
55
|
+
this.claimed = !!(selfEdge && selfEdge.isClaimed);
|
|
56
|
+
const required = Number(data.requiredMinutesWatched ?? 0);
|
|
57
|
+
this.requiredMinutes = Number.isFinite(required) ? required : 0;
|
|
58
|
+
const currentFromSelf = Number(selfEdge?.currentMinutesWatched ?? 0);
|
|
59
|
+
this.realCurrentMinutes = Number.isFinite(currentFromSelf) ? currentFromSelf : 0;
|
|
60
|
+
this.extraCurrentMinutes = 0;
|
|
61
|
+
// If not explicitly claimed, infer from claimed benefits window.
|
|
62
|
+
if (!this.claimed && this.benefits.length > 0) {
|
|
63
|
+
const timestamps = [];
|
|
64
|
+
for (const benefit of this.benefits) {
|
|
65
|
+
const ts = claimedBenefits[benefit.id];
|
|
66
|
+
if (ts) {
|
|
67
|
+
timestamps.push(ts);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (timestamps.length > 0 && timestamps.every((dt) => this.startsAt <= dt && dt < this.endsAt)) {
|
|
71
|
+
this.claimed = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (this.claimed) {
|
|
75
|
+
this.realCurrentMinutes = this.requiredMinutes;
|
|
76
|
+
}
|
|
77
|
+
const preconditions = data.preconditionDrops ?? [];
|
|
78
|
+
this.preconditionDropIds = preconditions.map((d) => String(d.id));
|
|
79
|
+
}
|
|
80
|
+
get isClaimed() {
|
|
81
|
+
return this.claimed;
|
|
82
|
+
}
|
|
83
|
+
get dropInstanceId() {
|
|
84
|
+
return this.claimId;
|
|
85
|
+
}
|
|
86
|
+
get currentMinutes() {
|
|
87
|
+
return this.realCurrentMinutes + this.extraCurrentMinutes;
|
|
88
|
+
}
|
|
89
|
+
get remainingMinutes() {
|
|
90
|
+
return Math.max(0, this.requiredMinutes - this.currentMinutes);
|
|
91
|
+
}
|
|
92
|
+
get totalRequiredMinutes() {
|
|
93
|
+
const chainTotal = this.preconditionDropIds
|
|
94
|
+
.map((id) => this.campaign.timedDrops.get(id))
|
|
95
|
+
.filter((d) => !!d)
|
|
96
|
+
.reduce((acc, d) => acc + d.totalRequiredMinutes, 0);
|
|
97
|
+
return this.requiredMinutes + chainTotal;
|
|
98
|
+
}
|
|
99
|
+
get totalRemainingMinutes() {
|
|
100
|
+
const chainRemaining = this.preconditionDropIds
|
|
101
|
+
.map((id) => this.campaign.timedDrops.get(id))
|
|
102
|
+
.filter((d) => !!d)
|
|
103
|
+
.reduce((acc, d) => acc + d.totalRemainingMinutes, 0);
|
|
104
|
+
return this.remainingMinutes + chainRemaining;
|
|
105
|
+
}
|
|
106
|
+
get progress() {
|
|
107
|
+
if (this.currentMinutes <= 0 || this.requiredMinutes <= 0) {
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
if (this.currentMinutes >= this.requiredMinutes) {
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
return this.currentMinutes / this.requiredMinutes;
|
|
114
|
+
}
|
|
115
|
+
get availability() {
|
|
116
|
+
const now = new Date();
|
|
117
|
+
if (this.requiredMinutes > 0 &&
|
|
118
|
+
this.totalRemainingMinutes > 0 &&
|
|
119
|
+
now.getTime() < this.endsAt.getTime()) {
|
|
120
|
+
const minutesLeft = (this.endsAt.getTime() - now.getTime()) / 60000;
|
|
121
|
+
return minutesLeft / this.totalRemainingMinutes;
|
|
122
|
+
}
|
|
123
|
+
return Number.POSITIVE_INFINITY;
|
|
124
|
+
}
|
|
125
|
+
baseEarnConditions() {
|
|
126
|
+
return (!this.claimed &&
|
|
127
|
+
(this.benefits.length > 0 || this.campaign.preconditionsChain().has(this.id)) &&
|
|
128
|
+
this.requiredMinutes > 0 &&
|
|
129
|
+
this.extraCurrentMinutes < MAX_EXTRA_MINUTES);
|
|
130
|
+
}
|
|
131
|
+
canEarn(now = new Date()) {
|
|
132
|
+
return (this.baseEarnConditions() &&
|
|
133
|
+
this.startsAt.getTime() <= now.getTime() &&
|
|
134
|
+
now.getTime() < this.endsAt.getTime() &&
|
|
135
|
+
this.campaign.baseCanEarn());
|
|
136
|
+
}
|
|
137
|
+
canEarnWithin(stamp) {
|
|
138
|
+
return (this.baseEarnConditions() &&
|
|
139
|
+
this.endsAt.getTime() > Date.now() &&
|
|
140
|
+
this.startsAt.getTime() < stamp.getTime());
|
|
141
|
+
}
|
|
142
|
+
get canClaim() {
|
|
143
|
+
if (!this.claimId || this.claimed) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
const now = new Date();
|
|
147
|
+
const cutoff = new Date(this.campaign.endsAt.getTime() + 24 * 60 * 60 * 1000);
|
|
148
|
+
return now.getTime() < cutoff.getTime();
|
|
149
|
+
}
|
|
150
|
+
markClaimed() {
|
|
151
|
+
this.claimed = true;
|
|
152
|
+
this.realCurrentMinutes = this.requiredMinutes;
|
|
153
|
+
this.extraCurrentMinutes = 0;
|
|
154
|
+
}
|
|
155
|
+
/** Apply a delta to this drop's real minutes only (no campaign-wide propagation). */
|
|
156
|
+
addRealMinutes(delta) {
|
|
157
|
+
if (delta === 0)
|
|
158
|
+
return;
|
|
159
|
+
let next = this.realCurrentMinutes + delta;
|
|
160
|
+
if (next < 0)
|
|
161
|
+
next = 0;
|
|
162
|
+
if (next > this.requiredMinutes)
|
|
163
|
+
next = this.requiredMinutes;
|
|
164
|
+
this.realCurrentMinutes = next;
|
|
165
|
+
this.extraCurrentMinutes = 0;
|
|
166
|
+
}
|
|
167
|
+
updateMinutes(newMinutes) {
|
|
168
|
+
const delta = newMinutes - this.realCurrentMinutes;
|
|
169
|
+
if (delta === 0)
|
|
170
|
+
return;
|
|
171
|
+
this.addRealMinutes(delta);
|
|
172
|
+
}
|
|
173
|
+
bumpMinutes() {
|
|
174
|
+
if (this.canEarn()) {
|
|
175
|
+
this.extraCurrentMinutes += 1;
|
|
176
|
+
if (this.extraCurrentMinutes >= MAX_EXTRA_MINUTES) {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export class DropsCampaign {
|
|
184
|
+
id;
|
|
185
|
+
name;
|
|
186
|
+
gameName;
|
|
187
|
+
gameSlug;
|
|
188
|
+
startsAt;
|
|
189
|
+
endsAt;
|
|
190
|
+
linked;
|
|
191
|
+
valid;
|
|
192
|
+
timedDrops;
|
|
193
|
+
enableBadgesEmotes;
|
|
194
|
+
constructor(raw, claimedBenefits, enableBadgesEmotes) {
|
|
195
|
+
this.id = String(raw.id);
|
|
196
|
+
this.name = String(raw.name);
|
|
197
|
+
const game = raw.game || {};
|
|
198
|
+
this.gameName = String(game.name ?? game.displayName ?? "Unknown Game");
|
|
199
|
+
this.gameSlug = String(game.slug ?? this.gameName.toLowerCase().replace(/\s+/g, "-"));
|
|
200
|
+
this.linked = !!(raw.self?.isAccountConnected);
|
|
201
|
+
this.startsAt = new Date(String(raw.startAt));
|
|
202
|
+
this.endsAt = new Date(String(raw.endAt));
|
|
203
|
+
this.valid = raw.status !== "EXPIRED";
|
|
204
|
+
this.enableBadgesEmotes = enableBadgesEmotes;
|
|
205
|
+
const drops = raw.timeBasedDrops || [];
|
|
206
|
+
this.timedDrops = new Map(drops.map((drop) => {
|
|
207
|
+
const td = new TimedDrop(this, drop, claimedBenefits);
|
|
208
|
+
return [td.id, td];
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
get drops() {
|
|
212
|
+
return Array.from(this.timedDrops.values());
|
|
213
|
+
}
|
|
214
|
+
get timeTriggers() {
|
|
215
|
+
const triggers = new Set();
|
|
216
|
+
triggers.add(this.startsAt.getTime());
|
|
217
|
+
triggers.add(this.endsAt.getTime());
|
|
218
|
+
for (const drop of this.drops) {
|
|
219
|
+
triggers.add(drop.startsAt.getTime());
|
|
220
|
+
triggers.add(drop.endsAt.getTime());
|
|
221
|
+
}
|
|
222
|
+
return Array.from(triggers)
|
|
223
|
+
.sort((a, b) => a - b)
|
|
224
|
+
.map((t) => new Date(t));
|
|
225
|
+
}
|
|
226
|
+
get active() {
|
|
227
|
+
const now = new Date();
|
|
228
|
+
return (this.valid &&
|
|
229
|
+
this.startsAt.getTime() <= now.getTime() &&
|
|
230
|
+
now.getTime() < this.endsAt.getTime());
|
|
231
|
+
}
|
|
232
|
+
get upcoming() {
|
|
233
|
+
const now = new Date();
|
|
234
|
+
return this.valid && now.getTime() < this.startsAt.getTime();
|
|
235
|
+
}
|
|
236
|
+
get expired() {
|
|
237
|
+
const now = new Date();
|
|
238
|
+
return !this.valid || this.endsAt.getTime() <= now.getTime();
|
|
239
|
+
}
|
|
240
|
+
get eligible() {
|
|
241
|
+
if (this.hasBadgeOrEmote) {
|
|
242
|
+
return this.enableBadgesEmotes;
|
|
243
|
+
}
|
|
244
|
+
return this.linked;
|
|
245
|
+
}
|
|
246
|
+
get hasBadgeOrEmote() {
|
|
247
|
+
return this.drops.some((drop) => drop.benefits.some((b) => b.isBadgeOrEmote()));
|
|
248
|
+
}
|
|
249
|
+
get finished() {
|
|
250
|
+
return this.drops.every((d) => d.isClaimed || d.requiredMinutes <= 0);
|
|
251
|
+
}
|
|
252
|
+
get claimedDrops() {
|
|
253
|
+
return this.drops.filter((d) => d.isClaimed).length;
|
|
254
|
+
}
|
|
255
|
+
get remainingDrops() {
|
|
256
|
+
return this.drops.filter((d) => !d.isClaimed).length;
|
|
257
|
+
}
|
|
258
|
+
get requiredMinutes() {
|
|
259
|
+
return this.drops.reduce((acc, d) => Math.max(acc, d.totalRequiredMinutes), 0);
|
|
260
|
+
}
|
|
261
|
+
get remainingMinutes() {
|
|
262
|
+
return this.drops.reduce((acc, d) => Math.max(acc, d.totalRemainingMinutes), 0);
|
|
263
|
+
}
|
|
264
|
+
get progress() {
|
|
265
|
+
if (!this.drops.length) {
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
return this.drops.reduce((acc, d) => acc + d.progress, 0) / this.drops.length;
|
|
269
|
+
}
|
|
270
|
+
get availability() {
|
|
271
|
+
return this.drops.reduce((acc, d) => Math.min(acc, d.availability), Number.POSITIVE_INFINITY);
|
|
272
|
+
}
|
|
273
|
+
get firstDrop() {
|
|
274
|
+
const candidates = this.drops.filter((d) => d.canEarn());
|
|
275
|
+
if (!candidates.length) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
candidates.sort((a, b) => a.remainingMinutes - b.remainingMinutes);
|
|
279
|
+
return candidates[0] ?? null;
|
|
280
|
+
}
|
|
281
|
+
updateRealMinutes(delta) {
|
|
282
|
+
if (delta === 0)
|
|
283
|
+
return;
|
|
284
|
+
for (const drop of this.drops) {
|
|
285
|
+
drop.addRealMinutes(delta);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
baseCanEarn() {
|
|
289
|
+
return this.eligible && this.active;
|
|
290
|
+
}
|
|
291
|
+
preconditionsChain() {
|
|
292
|
+
const chain = new Set();
|
|
293
|
+
for (const drop of this.drops) {
|
|
294
|
+
if (!drop.isClaimed) {
|
|
295
|
+
for (const id of drop.preconditionDropIds) {
|
|
296
|
+
chain.add(id);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return chain;
|
|
301
|
+
}
|
|
302
|
+
canEarnWithin(stamp) {
|
|
303
|
+
const now = new Date();
|
|
304
|
+
return (this.eligible &&
|
|
305
|
+
this.valid &&
|
|
306
|
+
this.endsAt.getTime() > now.getTime() &&
|
|
307
|
+
this.startsAt.getTime() < stamp.getTime() &&
|
|
308
|
+
this.drops.some((d) => d.canEarnWithin(stamp)));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
export function buildInventoryFromGqlResponses(inventoryResponse, campaignsResponse, ctx) {
|
|
312
|
+
const inventoryRoot = inventoryResponse.data?.currentUser;
|
|
313
|
+
const inv = inventoryRoot?.inventory ?? {};
|
|
314
|
+
const ongoing = inv.dropCampaignsInProgress ?? [];
|
|
315
|
+
const gameEventDrops = inv.gameEventDrops ?? [];
|
|
316
|
+
const claimedBenefits = {};
|
|
317
|
+
for (const b of gameEventDrops) {
|
|
318
|
+
const id = String(b.id);
|
|
319
|
+
const lastAwardedAt = b.lastAwardedAt ? new Date(String(b.lastAwardedAt)) : null;
|
|
320
|
+
if (lastAwardedAt) {
|
|
321
|
+
claimedBenefits[id] = lastAwardedAt;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const inventoryData = {};
|
|
325
|
+
for (const c of ongoing) {
|
|
326
|
+
inventoryData[String(c.id)] = c;
|
|
327
|
+
}
|
|
328
|
+
const campaignsRoot = campaignsResponse.data?.currentUser;
|
|
329
|
+
const campaignsList = campaignsRoot?.dropCampaigns ?? [];
|
|
330
|
+
for (const c of campaignsList) {
|
|
331
|
+
const status = String(c.status ?? "");
|
|
332
|
+
if (status !== "ACTIVE" && status !== "UPCOMING") {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const id = String(c.id);
|
|
336
|
+
if (!inventoryData[id]) {
|
|
337
|
+
inventoryData[id] = c;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const campaigns = [];
|
|
341
|
+
const dropsById = new Map();
|
|
342
|
+
const triggerSet = new Set();
|
|
343
|
+
for (const data of Object.values(inventoryData)) {
|
|
344
|
+
const game = data.game ?? undefined;
|
|
345
|
+
if (!game) {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const campaign = new DropsCampaign(data, claimedBenefits, ctx.enableBadgesEmotes);
|
|
349
|
+
campaigns.push(campaign);
|
|
350
|
+
for (const drop of campaign.drops) {
|
|
351
|
+
dropsById.set(drop.id, drop);
|
|
352
|
+
}
|
|
353
|
+
for (const t of campaign.timeTriggers) {
|
|
354
|
+
triggerSet.add(t.getTime());
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
campaigns.sort((a, b) => {
|
|
358
|
+
if (a.eligible !== b.eligible) {
|
|
359
|
+
return a.eligible ? -1 : 1;
|
|
360
|
+
}
|
|
361
|
+
if (a.upcoming !== b.upcoming) {
|
|
362
|
+
return a.upcoming ? 1 : -1;
|
|
363
|
+
}
|
|
364
|
+
return a.startsAt.getTime() - b.startsAt.getTime();
|
|
365
|
+
});
|
|
366
|
+
const timeTriggers = Array.from(triggerSet)
|
|
367
|
+
.sort((a, b) => a - b)
|
|
368
|
+
.map((t) => new Date(t));
|
|
369
|
+
return { campaigns, dropsById, timeTriggers };
|
|
370
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { httpJson } from "./httpClient.js";
|
|
2
|
+
import { TWITCH_GQL_URL, TWITCH_ANDROID_CLIENT_ID, TWITCH_ANDROID_USER_AGENT } from "../core/constants.js";
|
|
3
|
+
import { gqlPayload } from "./gqlOperations.js";
|
|
4
|
+
export async function gqlRequest(operation, accessToken, variables) {
|
|
5
|
+
return httpJson("POST", TWITCH_GQL_URL, gqlPayload(operation, variables), {
|
|
6
|
+
retries: 3,
|
|
7
|
+
headers: {
|
|
8
|
+
"Client-Id": TWITCH_ANDROID_CLIENT_ID,
|
|
9
|
+
"User-Agent": TWITCH_ANDROID_USER_AGENT,
|
|
10
|
+
Authorization: `OAuth ${accessToken}`
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const GQL_OPERATIONS = {
|
|
2
|
+
Inventory: {
|
|
3
|
+
operationName: "Inventory",
|
|
4
|
+
sha256Hash: "d86775d0ef16a63a33ad52e80eaff963b2d5b72fada7c991504a57496e1d8e4b",
|
|
5
|
+
variables: { fetchRewardCampaigns: false }
|
|
6
|
+
},
|
|
7
|
+
Campaigns: {
|
|
8
|
+
operationName: "ViewerDropsDashboard",
|
|
9
|
+
sha256Hash: "5a4da2ab3d5b47c9f9ce864e727b2cb346af1e3ea8b897fe8f704a97ff017619",
|
|
10
|
+
variables: { fetchRewardCampaigns: false }
|
|
11
|
+
},
|
|
12
|
+
CurrentDrop: {
|
|
13
|
+
operationName: "DropCurrentSessionContext",
|
|
14
|
+
sha256Hash: "4d06b702d25d652afb9ef835d2a550031f1cf762b193523a92166f40ea3d142b",
|
|
15
|
+
variables: { channelID: "", channelLogin: "" }
|
|
16
|
+
},
|
|
17
|
+
GameDirectory: {
|
|
18
|
+
operationName: "DirectoryPage_Game",
|
|
19
|
+
sha256Hash: "76cb069d835b8a02914c08dc42c421d0dafda8af5b113a3f19141824b901402f",
|
|
20
|
+
variables: { limit: 30, slug: "" }
|
|
21
|
+
},
|
|
22
|
+
ClaimDrop: {
|
|
23
|
+
operationName: "DropsPage_ClaimDropRewards",
|
|
24
|
+
sha256Hash: "a455deea71bdc9015b78eb49f4acfbce8baa7ccbedd28e549bb025bd0f751930",
|
|
25
|
+
variables: { input: { dropInstanceID: "" } }
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
export function gqlPayload(operation, variables) {
|
|
29
|
+
return {
|
|
30
|
+
operationName: operation.operationName,
|
|
31
|
+
variables: {
|
|
32
|
+
...operation.variables,
|
|
33
|
+
...(variables ?? {})
|
|
34
|
+
},
|
|
35
|
+
extensions: {
|
|
36
|
+
persistedQuery: {
|
|
37
|
+
version: 1,
|
|
38
|
+
sha256Hash: operation.sha256Hash
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { request } from "undici";
|
|
2
|
+
function sleep(ms) {
|
|
3
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
|
+
}
|
|
5
|
+
export async function httpJson(method, url, body, options) {
|
|
6
|
+
const retries = options?.retries ?? 3;
|
|
7
|
+
const retryDelayMs = options?.retryDelayMs ?? 1_000;
|
|
8
|
+
let lastError;
|
|
9
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
10
|
+
try {
|
|
11
|
+
const response = await request(url, {
|
|
12
|
+
method: method,
|
|
13
|
+
headers: {
|
|
14
|
+
"content-type": "application/json",
|
|
15
|
+
...(options?.headers ?? {})
|
|
16
|
+
},
|
|
17
|
+
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
18
|
+
});
|
|
19
|
+
const text = await response.body.text();
|
|
20
|
+
if (response.statusCode >= 500) {
|
|
21
|
+
throw new Error(`HTTP ${response.statusCode}: ${text}`);
|
|
22
|
+
}
|
|
23
|
+
if (!text) {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
return JSON.parse(text);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
lastError = err;
|
|
30
|
+
if (attempt === retries) {
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
await sleep(retryDelayMs * (attempt + 1));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
throw lastError instanceof Error ? lastError : new Error("HTTP request failed.");
|
|
37
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { PING_INTERVAL_MS, TWITCH_PUBSUB_URL, WS_TOPICS_LIMIT } from "../core/constants.js";
|
|
3
|
+
export class TwitchPubSub {
|
|
4
|
+
ws = null;
|
|
5
|
+
pingTimer = null;
|
|
6
|
+
handlers = new Map();
|
|
7
|
+
subscribedTopics = new Set();
|
|
8
|
+
authToken = null;
|
|
9
|
+
async start() {
|
|
10
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
await new Promise((resolve, reject) => {
|
|
14
|
+
const ws = new WebSocket(TWITCH_PUBSUB_URL);
|
|
15
|
+
this.ws = ws;
|
|
16
|
+
ws.on("open", () => {
|
|
17
|
+
this.startPing();
|
|
18
|
+
resolve();
|
|
19
|
+
});
|
|
20
|
+
ws.on("error", (err) => reject(err));
|
|
21
|
+
ws.on("message", (data) => this.onMessage(data.toString()));
|
|
22
|
+
ws.on("close", () => this.stopPing());
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
async stop() {
|
|
26
|
+
this.stopPing();
|
|
27
|
+
this.subscribedTopics.clear();
|
|
28
|
+
this.authToken = null;
|
|
29
|
+
const ws = this.ws;
|
|
30
|
+
this.ws = null;
|
|
31
|
+
if (!ws) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await new Promise((resolve) => {
|
|
35
|
+
ws.once("close", () => resolve());
|
|
36
|
+
ws.close();
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
registerTopic(topic, handler) {
|
|
40
|
+
this.handlers.set(topic, handler);
|
|
41
|
+
}
|
|
42
|
+
/** Subscribe to topics; batches and enforces WS_TOPICS_LIMIT. Stores token for reconnect. */
|
|
43
|
+
listen(topics, authToken) {
|
|
44
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
45
|
+
throw new Error("PubSub socket is not connected.");
|
|
46
|
+
}
|
|
47
|
+
this.authToken = authToken;
|
|
48
|
+
const toAdd = topics.filter((t) => !this.subscribedTopics.has(t));
|
|
49
|
+
if (toAdd.length === 0)
|
|
50
|
+
return;
|
|
51
|
+
const capacity = WS_TOPICS_LIMIT - this.subscribedTopics.size;
|
|
52
|
+
const batch = toAdd.slice(0, Math.max(0, capacity));
|
|
53
|
+
if (batch.length === 0)
|
|
54
|
+
return;
|
|
55
|
+
for (const t of batch) {
|
|
56
|
+
this.subscribedTopics.add(t);
|
|
57
|
+
}
|
|
58
|
+
this.ws.send(JSON.stringify({
|
|
59
|
+
type: "LISTEN",
|
|
60
|
+
data: { topics: batch, auth_token: authToken }
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
/** Unsubscribe from topics and send UNLISTEN. */
|
|
64
|
+
unlisten(topics, authToken) {
|
|
65
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const toRemove = topics.filter((t) => this.subscribedTopics.has(t));
|
|
69
|
+
if (toRemove.length === 0)
|
|
70
|
+
return;
|
|
71
|
+
for (const t of toRemove) {
|
|
72
|
+
this.subscribedTopics.delete(t);
|
|
73
|
+
}
|
|
74
|
+
this.ws.send(JSON.stringify({
|
|
75
|
+
type: "UNLISTEN",
|
|
76
|
+
data: { topics: toRemove, auth_token: authToken }
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
getSubscribedTopics() {
|
|
80
|
+
return Array.from(this.subscribedTopics);
|
|
81
|
+
}
|
|
82
|
+
startPing() {
|
|
83
|
+
this.stopPing();
|
|
84
|
+
this.pingTimer = setInterval(() => {
|
|
85
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
86
|
+
this.ws.send(JSON.stringify({ type: "PING" }));
|
|
87
|
+
}
|
|
88
|
+
}, PING_INTERVAL_MS);
|
|
89
|
+
}
|
|
90
|
+
stopPing() {
|
|
91
|
+
if (this.pingTimer) {
|
|
92
|
+
clearInterval(this.pingTimer);
|
|
93
|
+
this.pingTimer = null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async onMessage(raw) {
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(raw);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (parsed.type === "PONG") {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (parsed.type === "MESSAGE" && typeof parsed.data === "object" && parsed.data) {
|
|
108
|
+
const data = parsed.data;
|
|
109
|
+
const topic = data.topic;
|
|
110
|
+
const msg = data.message;
|
|
111
|
+
if (!topic || !msg) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const handler = this.handlers.get(topic);
|
|
115
|
+
if (!handler) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
await handler(JSON.parse(msg));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// swallow callback errors to keep listener alive
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { request } from "undici";
|
|
2
|
+
import { TWITCH_ANDROID_CLIENT_ID, TWITCH_ANDROID_USER_AGENT } from "../core/constants.js";
|
|
3
|
+
const SPADE_PATTERN = /"(?:beacon|spade)_?url":\s*"(https:\/\/[.\w\-/]+\.ts(?:\?allow_stream=true)?)"/i;
|
|
4
|
+
const SETTINGS_PATTERN = /src="(https:\/\/[\w.]+\/config\/settings\.[0-9a-f]{32}\.js)"/i;
|
|
5
|
+
const DEFAULT_SPADE_BASE = "https://spade.twitch.tv";
|
|
6
|
+
const CHANNEL_PAGE_BASE = "https://www.twitch.tv";
|
|
7
|
+
/**
|
|
8
|
+
* Build minute-watched payload and return { data: base64(json) } as sent by Twitch web.
|
|
9
|
+
*/
|
|
10
|
+
export function buildSpadePayload(broadcastId, channelId, channelLogin, userId) {
|
|
11
|
+
const payload = [
|
|
12
|
+
{
|
|
13
|
+
event: "minute-watched",
|
|
14
|
+
properties: {
|
|
15
|
+
broadcast_id: broadcastId,
|
|
16
|
+
channel_id: channelId,
|
|
17
|
+
channel: channelLogin,
|
|
18
|
+
hidden: false,
|
|
19
|
+
live: true,
|
|
20
|
+
location: "channel",
|
|
21
|
+
logged_in: true,
|
|
22
|
+
muted: false,
|
|
23
|
+
player: "site",
|
|
24
|
+
user_id: userId
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
];
|
|
28
|
+
const json = JSON.stringify(payload);
|
|
29
|
+
const data = Buffer.from(json, "utf8").toString("base64");
|
|
30
|
+
return { data };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve spade/beacon URL from channel page (or settings JS). Caches nothing; caller may cache.
|
|
34
|
+
*/
|
|
35
|
+
export async function getSpadeUrl(channelLogin, accessToken) {
|
|
36
|
+
const url = `${CHANNEL_PAGE_BASE}/${channelLogin}`;
|
|
37
|
+
const res = await request(url, {
|
|
38
|
+
method: "GET",
|
|
39
|
+
headers: {
|
|
40
|
+
"Client-Id": TWITCH_ANDROID_CLIENT_ID,
|
|
41
|
+
"User-Agent": TWITCH_ANDROID_USER_AGENT,
|
|
42
|
+
Authorization: `OAuth ${accessToken}`
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
const html = await res.body.text();
|
|
46
|
+
let match = html.match(SPADE_PATTERN);
|
|
47
|
+
if (match) {
|
|
48
|
+
return match[1];
|
|
49
|
+
}
|
|
50
|
+
match = html.match(SETTINGS_PATTERN);
|
|
51
|
+
if (!match) {
|
|
52
|
+
return `${DEFAULT_SPADE_BASE}/`;
|
|
53
|
+
}
|
|
54
|
+
const settingsUrl = match[1];
|
|
55
|
+
const settingsRes = await request(settingsUrl, {
|
|
56
|
+
method: "GET",
|
|
57
|
+
headers: {
|
|
58
|
+
"Client-Id": TWITCH_ANDROID_CLIENT_ID,
|
|
59
|
+
"User-Agent": TWITCH_ANDROID_USER_AGENT,
|
|
60
|
+
Authorization: `OAuth ${accessToken}`
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
const js = await settingsRes.body.text();
|
|
64
|
+
const spadeMatch = js.match(SPADE_PATTERN);
|
|
65
|
+
if (!spadeMatch) {
|
|
66
|
+
return `${DEFAULT_SPADE_BASE}/`;
|
|
67
|
+
}
|
|
68
|
+
return spadeMatch[1];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* POST spade payload to the given URL. Body is application/x-www-form-urlencoded with key "data".
|
|
72
|
+
* Returns true on 204, false otherwise.
|
|
73
|
+
*/
|
|
74
|
+
export async function sendSpadePost(spadeUrl, payload, accessToken) {
|
|
75
|
+
const body = new URLSearchParams({ data: payload.data }).toString();
|
|
76
|
+
const res = await request(spadeUrl, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: {
|
|
79
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
80
|
+
"Client-Id": TWITCH_ANDROID_CLIENT_ID,
|
|
81
|
+
"User-Agent": TWITCH_ANDROID_USER_AGENT,
|
|
82
|
+
Authorization: `OAuth ${accessToken}`
|
|
83
|
+
},
|
|
84
|
+
body
|
|
85
|
+
});
|
|
86
|
+
return res.statusCode === 204;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Send a single "minute-watched" beacon for the channel. Resolves spade URL (use cache to avoid repeated fetches)
|
|
90
|
+
* and POSTs. Returns true on success (204).
|
|
91
|
+
*/
|
|
92
|
+
export async function sendChannelWatch(channel, userId, accessToken, options) {
|
|
93
|
+
const broadcastId = channel.streamId ?? channel.id;
|
|
94
|
+
const payload = buildSpadePayload(broadcastId, channel.id, channel.login, userId);
|
|
95
|
+
const cache = options?.spadeUrlCache;
|
|
96
|
+
let url = cache?.get(channel.login);
|
|
97
|
+
if (!url) {
|
|
98
|
+
try {
|
|
99
|
+
url = await getSpadeUrl(channel.login, accessToken);
|
|
100
|
+
cache?.set(channel.login, url);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
url = `${DEFAULT_SPADE_BASE}/`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return await sendSpadePost(url, payload, accessToken);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|