multichat-ts 0.0.91 → 0.0.93
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/.prettierrc +7 -7
- package/dist/twitch.js +10 -3
- package/package.json +1 -1
- package/src/default/index.ts +173 -173
- package/src/default/kick.ts +676 -676
- package/src/default/twitch.ts +547 -540
package/src/default/twitch.ts
CHANGED
|
@@ -1,540 +1,547 @@
|
|
|
1
|
-
import { WebSocket } from 'partysocket';
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
type BodyComponent,
|
|
5
|
-
type ClearMessages,
|
|
6
|
-
type DeleteMessage,
|
|
7
|
-
type Message,
|
|
8
|
-
type Event,
|
|
9
|
-
type EmoteURLsByName,
|
|
10
|
-
type BadgeURLsBySetIDOrSetIDAndVersion,
|
|
11
|
-
} from './index.js';
|
|
12
|
-
|
|
13
|
-
const ANONYMOUS_IRC_PASS = 'SCHMOOPIIE';
|
|
14
|
-
const ANONYMOUS_IRC_LOGIN = 'justinfan1234';
|
|
15
|
-
const PING_INTERVAL_MS = 30_000;
|
|
16
|
-
const PING_TIMEOUT_MS = 10_000;
|
|
17
|
-
|
|
18
|
-
type EventCallbackFunctions = {
|
|
19
|
-
message: (message: Message) => unknown;
|
|
20
|
-
clear_messages: (data: ClearMessages) => unknown;
|
|
21
|
-
delete_message: (data: DeleteMessage) => unknown;
|
|
22
|
-
event: (event: Event) => unknown;
|
|
23
|
-
raw_message: (message: IRC_Message) => unknown;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
type EventNames = keyof EventCallbackFunctions;
|
|
27
|
-
|
|
28
|
-
export class TwitchIRC {
|
|
29
|
-
public channel_name?: string;
|
|
30
|
-
private assets: {
|
|
31
|
-
external_emotes: EmoteURLsByName;
|
|
32
|
-
badges: BadgeURLsBySetIDOrSetIDAndVersion;
|
|
33
|
-
} = {
|
|
34
|
-
external_emotes: {},
|
|
35
|
-
badges: {},
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
public latency = 0;
|
|
39
|
-
private ping: {
|
|
40
|
-
interval?: ReturnType<typeof setInterval>;
|
|
41
|
-
lastSentTimestamp?: number | undefined;
|
|
42
|
-
timeout?: ReturnType<typeof setTimeout>;
|
|
43
|
-
} = {};
|
|
44
|
-
|
|
45
|
-
private public_listeners: Partial<EventCallbackFunctions> = {};
|
|
46
|
-
|
|
47
|
-
public socket?: WebSocket;
|
|
48
|
-
public ws?: WebSocket | undefined;
|
|
49
|
-
|
|
50
|
-
constructor(ws?: WebSocket) {
|
|
51
|
-
this.ws = ws;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
public setBadges(badges: BadgeURLsBySetIDOrSetIDAndVersion) {
|
|
55
|
-
this.assets.badges = { ...badges, ...this.assets.badges };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
public setExternalEmotes(external_emotes: EmoteURLsByName) {
|
|
59
|
-
this.assets.external_emotes = { ...external_emotes, ...this.assets.external_emotes };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
public getStoredBadges() {
|
|
63
|
-
return this.assets.badges;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
public getStoredExternalEmotes() {
|
|
67
|
-
return this.assets.external_emotes;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
public connect(channel?: { channelName?: string }) {
|
|
71
|
-
if (channel?.channelName) this.channel_name = channel.channelName;
|
|
72
|
-
if (!this.channel_name) return console.error('channel_name not specified');
|
|
73
|
-
|
|
74
|
-
console.log(`connecting to ${this.channel_name}...`);
|
|
75
|
-
|
|
76
|
-
this.socket?.close();
|
|
77
|
-
|
|
78
|
-
this.socket = new WebSocket('wss://irc-ws.chat.twitch.tv', null, {
|
|
79
|
-
WebSocket: this.ws,
|
|
80
|
-
});
|
|
81
|
-
this.socket.onopen = () => this.onOpen();
|
|
82
|
-
this.socket.onclose = () => this.onClose();
|
|
83
|
-
this.socket.onmessage = (event) => this.onMessage(event);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
public disconnect() {
|
|
87
|
-
this.socket?.close();
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
public on<EventName extends EventNames>(
|
|
91
|
-
event_name: EventName,
|
|
92
|
-
callback_fn: EventCallbackFunctions[EventName],
|
|
93
|
-
) {
|
|
94
|
-
this.public_listeners[event_name] = callback_fn;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
public isConnected(): this is { socket: { readyState: typeof WebSocket.OPEN } & WebSocket } {
|
|
98
|
-
return !!this.socket && this.socket.readyState === WebSocket.OPEN;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
private onOpen() {
|
|
102
|
-
this.send('CAP REQ :twitch.tv/commands twitch.tv/tags');
|
|
103
|
-
this.send(`PASS ${ANONYMOUS_IRC_PASS}`);
|
|
104
|
-
this.send(`NICK ${ANONYMOUS_IRC_LOGIN}`);
|
|
105
|
-
this.send(`JOIN #${this.channel_name}`);
|
|
106
|
-
|
|
107
|
-
console.log(`Connected to Twitch IRC as Anonymous (${this.channel_name})`);
|
|
108
|
-
|
|
109
|
-
if (this.ping.interval) clearInterval(this.ping.interval);
|
|
110
|
-
this.sendPing();
|
|
111
|
-
this.ping.interval = setInterval(() => this.sendPing(), PING_INTERVAL_MS);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
private onClose() {
|
|
115
|
-
clearInterval(this.ping.interval);
|
|
116
|
-
clearTimeout(this.ping.timeout);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
private sendPing() {
|
|
120
|
-
this.send('PING');
|
|
121
|
-
this.ping.lastSentTimestamp = Date.now();
|
|
122
|
-
|
|
123
|
-
if (this.ping.timeout) clearTimeout(this.ping.timeout);
|
|
124
|
-
this.ping.timeout = setTimeout(() => {
|
|
125
|
-
console.error('PING Timeout, reconnecting...');
|
|
126
|
-
this.connect();
|
|
127
|
-
}, PING_TIMEOUT_MS);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
private send(irc_message: string) {
|
|
131
|
-
if (!this.isConnected()) {
|
|
132
|
-
throw new Error('Not connected');
|
|
133
|
-
}
|
|
134
|
-
this.socket?.send(irc_message);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
private onMessage(event: MessageEvent) {
|
|
138
|
-
if (!event.data) return;
|
|
139
|
-
const lines = (event.data as string).trim().split('\r\n');
|
|
140
|
-
const messages = lines.map(parseIRCLine);
|
|
141
|
-
messages.forEach((message) => {
|
|
142
|
-
this.public_listeners.raw_message?.(message);
|
|
143
|
-
switch (message.command) {
|
|
144
|
-
case 'PONG': {
|
|
145
|
-
if (!this.ping.lastSentTimestamp) return console.error('got PONG without sending PING');
|
|
146
|
-
clearInterval(this.ping.timeout);
|
|
147
|
-
this.latency = Date.now() - this.ping.lastSentTimestamp;
|
|
148
|
-
this.ping.lastSentTimestamp = undefined;
|
|
149
|
-
break;
|
|
150
|
-
}
|
|
151
|
-
case 'PING': {
|
|
152
|
-
this.send('PONG');
|
|
153
|
-
break;
|
|
154
|
-
}
|
|
155
|
-
case 'CLEARCHAT': {
|
|
156
|
-
const { channel, tags } = message;
|
|
157
|
-
if (!tags) return;
|
|
158
|
-
|
|
159
|
-
this.public_listeners.clear_messages?.({
|
|
160
|
-
channel: {
|
|
161
|
-
name: channel,
|
|
162
|
-
room_id: tags['room-id'] ?? 'unknown',
|
|
163
|
-
},
|
|
164
|
-
timestamp_sent: Number(tags['tmi-sent-ts']),
|
|
165
|
-
timeout_duration_seconds: tags['ban-duration']
|
|
166
|
-
? Number(tags['ban-duration'])
|
|
167
|
-
: undefined,
|
|
168
|
-
});
|
|
169
|
-
break;
|
|
170
|
-
}
|
|
171
|
-
case 'PRIVMSG': {
|
|
172
|
-
const { channel, params, tags } = message;
|
|
173
|
-
if (!tags || !tags['user-id'] || !tags['id'] || !tags['room-id']) return;
|
|
174
|
-
|
|
175
|
-
const text = params[0];
|
|
176
|
-
if (!text) return;
|
|
177
|
-
|
|
178
|
-
const body: BodyComponent[] = [];
|
|
179
|
-
|
|
180
|
-
tags.emotes?.split('/').forEach((raw_emote_string) => {
|
|
181
|
-
const [emote_id, raw_emote_positions_string] = raw_emote_string.split(':');
|
|
182
|
-
if (!emote_id || !raw_emote_positions_string) return;
|
|
183
|
-
|
|
184
|
-
const raw_emote_positions = raw_emote_positions_string.split(',');
|
|
185
|
-
raw_emote_positions.forEach((raw_emote_position) => {
|
|
186
|
-
const [emote_start, emote_end] = raw_emote_position.split('-');
|
|
187
|
-
if (!emote_start || !emote_end) return;
|
|
188
|
-
|
|
189
|
-
body.push({
|
|
190
|
-
type: 'emote',
|
|
191
|
-
start_inclusive: +emote_start,
|
|
192
|
-
end_exclusive: +emote_end + 1,
|
|
193
|
-
url: `https://static-cdn.jtvnw.net/emoticons/v2/${emote_id}/default/dark/1.0`,
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
body.sort((a, b) => a.start_inclusive - b.start_inclusive);
|
|
199
|
-
|
|
200
|
-
const old_body_length = body.length;
|
|
201
|
-
|
|
202
|
-
if (old_body_length > 0) {
|
|
203
|
-
body.forEach((segment, index) => {
|
|
204
|
-
const previous_segment = body[index - 1];
|
|
205
|
-
|
|
206
|
-
const text_start_inclusive =
|
|
207
|
-
previous_segment?.end_exclusive !== undefined
|
|
208
|
-
? previous_segment.end_exclusive + 1
|
|
209
|
-
: 0;
|
|
210
|
-
const text_end_exclusive = Math.max(0, segment.start_inclusive);
|
|
211
|
-
|
|
212
|
-
if (text_end_exclusive - text_start_inclusive > 0) {
|
|
213
|
-
body.push({
|
|
214
|
-
type: 'text',
|
|
215
|
-
text: text.slice(text_start_inclusive, text_end_exclusive),
|
|
216
|
-
start_inclusive: text_start_inclusive,
|
|
217
|
-
end_exclusive: text_end_exclusive,
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
if (index === old_body_length - 1 && segment.end_exclusive < text.length - 1) {
|
|
221
|
-
body.push({
|
|
222
|
-
type: 'text',
|
|
223
|
-
text: text.slice(segment.end_exclusive),
|
|
224
|
-
start_inclusive: segment.end_exclusive,
|
|
225
|
-
end_exclusive: text.length,
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
});
|
|
229
|
-
} else {
|
|
230
|
-
body.push({
|
|
231
|
-
type: 'text',
|
|
232
|
-
text,
|
|
233
|
-
start_inclusive: 0,
|
|
234
|
-
end_exclusive: text.length,
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
body.sort((a, b) => a.start_inclusive - b.start_inclusive);
|
|
239
|
-
|
|
240
|
-
const badge_info: {
|
|
241
|
-
[set_id: string]: string;
|
|
242
|
-
} = {};
|
|
243
|
-
tags['badge-info']?.split(',').forEach((badge) => {
|
|
244
|
-
const [set_id, info] = badge.split('/');
|
|
245
|
-
if (!set_id || !info) return;
|
|
246
|
-
|
|
247
|
-
badge_info[set_id] = info;
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
this.public_listeners.message?.({
|
|
251
|
-
body: body,
|
|
252
|
-
channel: {
|
|
253
|
-
room_id: tags['room-id'],
|
|
254
|
-
name: channel,
|
|
255
|
-
},
|
|
256
|
-
id: tags.id,
|
|
257
|
-
raw_text: text,
|
|
258
|
-
timestamp_sent: Number(tags['tmi-sent-ts']),
|
|
259
|
-
user: {
|
|
260
|
-
badges:
|
|
261
|
-
tags.badges?.split(',').flatMap((badge) => {
|
|
262
|
-
const [set_id, version] = badge.split('/');
|
|
263
|
-
if (!set_id || !version) return [];
|
|
264
|
-
|
|
265
|
-
const storedBadge = this.assets.badges[set_id];
|
|
266
|
-
if (!storedBadge) return [];
|
|
267
|
-
|
|
268
|
-
const url = typeof storedBadge === 'object' ? storedBadge[version] : storedBadge;
|
|
269
|
-
if (!url) return [];
|
|
270
|
-
|
|
271
|
-
return {
|
|
272
|
-
info: badge_info[set_id],
|
|
273
|
-
set_id,
|
|
274
|
-
url,
|
|
275
|
-
};
|
|
276
|
-
}) ?? [],
|
|
277
|
-
color: tags.color ?? '#FFFFFF',
|
|
278
|
-
id: tags['user-id'],
|
|
279
|
-
username:
|
|
280
|
-
display_name: tags['display-name'] ?? 'Unknown',
|
|
281
|
-
roles: {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
type
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
'
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
'
|
|
435
|
-
'
|
|
436
|
-
'
|
|
437
|
-
'
|
|
438
|
-
'
|
|
439
|
-
'
|
|
440
|
-
'
|
|
441
|
-
'
|
|
442
|
-
'
|
|
443
|
-
'
|
|
444
|
-
'reply-parent-
|
|
445
|
-
'reply-parent-user-id',
|
|
446
|
-
'
|
|
447
|
-
'
|
|
448
|
-
'
|
|
449
|
-
'
|
|
450
|
-
'
|
|
451
|
-
'
|
|
452
|
-
'
|
|
453
|
-
'
|
|
454
|
-
'
|
|
455
|
-
'
|
|
456
|
-
'
|
|
457
|
-
'
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
'
|
|
477
|
-
'
|
|
478
|
-
'
|
|
479
|
-
'
|
|
480
|
-
'
|
|
481
|
-
'
|
|
482
|
-
'
|
|
483
|
-
'
|
|
484
|
-
'
|
|
485
|
-
'
|
|
486
|
-
'
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
'msg-param-
|
|
497
|
-
'msg-param-
|
|
498
|
-
'msg-param-
|
|
499
|
-
'msg-param-
|
|
500
|
-
'msg-param-
|
|
501
|
-
'msg-param-
|
|
502
|
-
'msg-param-
|
|
503
|
-
'msg-param-
|
|
504
|
-
'msg-param-
|
|
505
|
-
'msg-param-
|
|
506
|
-
'msg-param-
|
|
507
|
-
'msg-param-
|
|
508
|
-
'msg-param-
|
|
509
|
-
'msg-param-
|
|
510
|
-
'msg-param-
|
|
511
|
-
'msg-param-
|
|
512
|
-
'msg-param-
|
|
513
|
-
'msg-param-
|
|
514
|
-
'msg-param-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
'
|
|
526
|
-
'
|
|
527
|
-
'
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
'
|
|
531
|
-
'
|
|
532
|
-
'
|
|
533
|
-
'
|
|
534
|
-
'
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
'
|
|
538
|
-
'
|
|
539
|
-
|
|
540
|
-
|
|
1
|
+
import { WebSocket } from 'partysocket';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type BodyComponent,
|
|
5
|
+
type ClearMessages,
|
|
6
|
+
type DeleteMessage,
|
|
7
|
+
type Message,
|
|
8
|
+
type Event,
|
|
9
|
+
type EmoteURLsByName,
|
|
10
|
+
type BadgeURLsBySetIDOrSetIDAndVersion,
|
|
11
|
+
} from './index.js';
|
|
12
|
+
|
|
13
|
+
const ANONYMOUS_IRC_PASS = 'SCHMOOPIIE';
|
|
14
|
+
const ANONYMOUS_IRC_LOGIN = 'justinfan1234';
|
|
15
|
+
const PING_INTERVAL_MS = 30_000;
|
|
16
|
+
const PING_TIMEOUT_MS = 10_000;
|
|
17
|
+
|
|
18
|
+
type EventCallbackFunctions = {
|
|
19
|
+
message: (message: Message) => unknown;
|
|
20
|
+
clear_messages: (data: ClearMessages) => unknown;
|
|
21
|
+
delete_message: (data: DeleteMessage) => unknown;
|
|
22
|
+
event: (event: Event) => unknown;
|
|
23
|
+
raw_message: (message: IRC_Message) => unknown;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type EventNames = keyof EventCallbackFunctions;
|
|
27
|
+
|
|
28
|
+
export class TwitchIRC {
|
|
29
|
+
public channel_name?: string;
|
|
30
|
+
private assets: {
|
|
31
|
+
external_emotes: EmoteURLsByName;
|
|
32
|
+
badges: BadgeURLsBySetIDOrSetIDAndVersion;
|
|
33
|
+
} = {
|
|
34
|
+
external_emotes: {},
|
|
35
|
+
badges: {},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
public latency = 0;
|
|
39
|
+
private ping: {
|
|
40
|
+
interval?: ReturnType<typeof setInterval>;
|
|
41
|
+
lastSentTimestamp?: number | undefined;
|
|
42
|
+
timeout?: ReturnType<typeof setTimeout>;
|
|
43
|
+
} = {};
|
|
44
|
+
|
|
45
|
+
private public_listeners: Partial<EventCallbackFunctions> = {};
|
|
46
|
+
|
|
47
|
+
public socket?: WebSocket;
|
|
48
|
+
public ws?: WebSocket | undefined;
|
|
49
|
+
|
|
50
|
+
constructor(ws?: WebSocket) {
|
|
51
|
+
this.ws = ws;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public setBadges(badges: BadgeURLsBySetIDOrSetIDAndVersion) {
|
|
55
|
+
this.assets.badges = { ...badges, ...this.assets.badges };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public setExternalEmotes(external_emotes: EmoteURLsByName) {
|
|
59
|
+
this.assets.external_emotes = { ...external_emotes, ...this.assets.external_emotes };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public getStoredBadges() {
|
|
63
|
+
return this.assets.badges;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public getStoredExternalEmotes() {
|
|
67
|
+
return this.assets.external_emotes;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public connect(channel?: { channelName?: string }) {
|
|
71
|
+
if (channel?.channelName) this.channel_name = channel.channelName;
|
|
72
|
+
if (!this.channel_name) return console.error('channel_name not specified');
|
|
73
|
+
|
|
74
|
+
console.log(`connecting to ${this.channel_name}...`);
|
|
75
|
+
|
|
76
|
+
this.socket?.close();
|
|
77
|
+
|
|
78
|
+
this.socket = new WebSocket('wss://irc-ws.chat.twitch.tv', null, {
|
|
79
|
+
WebSocket: this.ws,
|
|
80
|
+
});
|
|
81
|
+
this.socket.onopen = () => this.onOpen();
|
|
82
|
+
this.socket.onclose = () => this.onClose();
|
|
83
|
+
this.socket.onmessage = (event) => this.onMessage(event);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
public disconnect() {
|
|
87
|
+
this.socket?.close();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
public on<EventName extends EventNames>(
|
|
91
|
+
event_name: EventName,
|
|
92
|
+
callback_fn: EventCallbackFunctions[EventName],
|
|
93
|
+
) {
|
|
94
|
+
this.public_listeners[event_name] = callback_fn;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
public isConnected(): this is { socket: { readyState: typeof WebSocket.OPEN } & WebSocket } {
|
|
98
|
+
return !!this.socket && this.socket.readyState === WebSocket.OPEN;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private onOpen() {
|
|
102
|
+
this.send('CAP REQ :twitch.tv/commands twitch.tv/tags');
|
|
103
|
+
this.send(`PASS ${ANONYMOUS_IRC_PASS}`);
|
|
104
|
+
this.send(`NICK ${ANONYMOUS_IRC_LOGIN}`);
|
|
105
|
+
this.send(`JOIN #${this.channel_name}`);
|
|
106
|
+
|
|
107
|
+
console.log(`Connected to Twitch IRC as Anonymous (${this.channel_name})`);
|
|
108
|
+
|
|
109
|
+
if (this.ping.interval) clearInterval(this.ping.interval);
|
|
110
|
+
this.sendPing();
|
|
111
|
+
this.ping.interval = setInterval(() => this.sendPing(), PING_INTERVAL_MS);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private onClose() {
|
|
115
|
+
clearInterval(this.ping.interval);
|
|
116
|
+
clearTimeout(this.ping.timeout);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private sendPing() {
|
|
120
|
+
this.send('PING');
|
|
121
|
+
this.ping.lastSentTimestamp = Date.now();
|
|
122
|
+
|
|
123
|
+
if (this.ping.timeout) clearTimeout(this.ping.timeout);
|
|
124
|
+
this.ping.timeout = setTimeout(() => {
|
|
125
|
+
console.error('PING Timeout, reconnecting...');
|
|
126
|
+
this.connect();
|
|
127
|
+
}, PING_TIMEOUT_MS);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private send(irc_message: string) {
|
|
131
|
+
if (!this.isConnected()) {
|
|
132
|
+
throw new Error('Not connected');
|
|
133
|
+
}
|
|
134
|
+
this.socket?.send(irc_message);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private onMessage(event: MessageEvent) {
|
|
138
|
+
if (!event.data) return;
|
|
139
|
+
const lines = (event.data as string).trim().split('\r\n');
|
|
140
|
+
const messages = lines.map(parseIRCLine);
|
|
141
|
+
messages.forEach((message) => {
|
|
142
|
+
this.public_listeners.raw_message?.(message);
|
|
143
|
+
switch (message.command) {
|
|
144
|
+
case 'PONG': {
|
|
145
|
+
if (!this.ping.lastSentTimestamp) return console.error('got PONG without sending PING');
|
|
146
|
+
clearInterval(this.ping.timeout);
|
|
147
|
+
this.latency = Date.now() - this.ping.lastSentTimestamp;
|
|
148
|
+
this.ping.lastSentTimestamp = undefined;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
case 'PING': {
|
|
152
|
+
this.send('PONG');
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case 'CLEARCHAT': {
|
|
156
|
+
const { channel, tags } = message;
|
|
157
|
+
if (!tags) return;
|
|
158
|
+
|
|
159
|
+
this.public_listeners.clear_messages?.({
|
|
160
|
+
channel: {
|
|
161
|
+
name: channel,
|
|
162
|
+
room_id: tags['room-id'] ?? 'unknown',
|
|
163
|
+
},
|
|
164
|
+
timestamp_sent: Number(tags['tmi-sent-ts']),
|
|
165
|
+
timeout_duration_seconds: tags['ban-duration']
|
|
166
|
+
? Number(tags['ban-duration'])
|
|
167
|
+
: undefined,
|
|
168
|
+
});
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case 'PRIVMSG': {
|
|
172
|
+
const { channel, params, tags, source } = message;
|
|
173
|
+
if (!tags || !tags['user-id'] || !tags['id'] || !tags['room-id']) return;
|
|
174
|
+
|
|
175
|
+
const text = params[0];
|
|
176
|
+
if (!text) return;
|
|
177
|
+
|
|
178
|
+
const body: BodyComponent[] = [];
|
|
179
|
+
|
|
180
|
+
tags.emotes?.split('/').forEach((raw_emote_string) => {
|
|
181
|
+
const [emote_id, raw_emote_positions_string] = raw_emote_string.split(':');
|
|
182
|
+
if (!emote_id || !raw_emote_positions_string) return;
|
|
183
|
+
|
|
184
|
+
const raw_emote_positions = raw_emote_positions_string.split(',');
|
|
185
|
+
raw_emote_positions.forEach((raw_emote_position) => {
|
|
186
|
+
const [emote_start, emote_end] = raw_emote_position.split('-');
|
|
187
|
+
if (!emote_start || !emote_end) return;
|
|
188
|
+
|
|
189
|
+
body.push({
|
|
190
|
+
type: 'emote',
|
|
191
|
+
start_inclusive: +emote_start,
|
|
192
|
+
end_exclusive: +emote_end + 1,
|
|
193
|
+
url: `https://static-cdn.jtvnw.net/emoticons/v2/${emote_id}/default/dark/1.0`,
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
body.sort((a, b) => a.start_inclusive - b.start_inclusive);
|
|
199
|
+
|
|
200
|
+
const old_body_length = body.length;
|
|
201
|
+
|
|
202
|
+
if (old_body_length > 0) {
|
|
203
|
+
body.forEach((segment, index) => {
|
|
204
|
+
const previous_segment = body[index - 1];
|
|
205
|
+
|
|
206
|
+
const text_start_inclusive =
|
|
207
|
+
previous_segment?.end_exclusive !== undefined
|
|
208
|
+
? previous_segment.end_exclusive + 1
|
|
209
|
+
: 0;
|
|
210
|
+
const text_end_exclusive = Math.max(0, segment.start_inclusive);
|
|
211
|
+
|
|
212
|
+
if (text_end_exclusive - text_start_inclusive > 0) {
|
|
213
|
+
body.push({
|
|
214
|
+
type: 'text',
|
|
215
|
+
text: text.slice(text_start_inclusive, text_end_exclusive),
|
|
216
|
+
start_inclusive: text_start_inclusive,
|
|
217
|
+
end_exclusive: text_end_exclusive,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (index === old_body_length - 1 && segment.end_exclusive < text.length - 1) {
|
|
221
|
+
body.push({
|
|
222
|
+
type: 'text',
|
|
223
|
+
text: text.slice(segment.end_exclusive),
|
|
224
|
+
start_inclusive: segment.end_exclusive,
|
|
225
|
+
end_exclusive: text.length,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
} else {
|
|
230
|
+
body.push({
|
|
231
|
+
type: 'text',
|
|
232
|
+
text,
|
|
233
|
+
start_inclusive: 0,
|
|
234
|
+
end_exclusive: text.length,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
body.sort((a, b) => a.start_inclusive - b.start_inclusive);
|
|
239
|
+
|
|
240
|
+
const badge_info: {
|
|
241
|
+
[set_id: string]: string;
|
|
242
|
+
} = {};
|
|
243
|
+
tags['badge-info']?.split(',').forEach((badge) => {
|
|
244
|
+
const [set_id, info] = badge.split('/');
|
|
245
|
+
if (!set_id || !info) return;
|
|
246
|
+
|
|
247
|
+
badge_info[set_id] = info;
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
this.public_listeners.message?.({
|
|
251
|
+
body: body,
|
|
252
|
+
channel: {
|
|
253
|
+
room_id: tags['room-id'],
|
|
254
|
+
name: channel,
|
|
255
|
+
},
|
|
256
|
+
id: tags.id,
|
|
257
|
+
raw_text: text,
|
|
258
|
+
timestamp_sent: Number(tags['tmi-sent-ts']),
|
|
259
|
+
user: {
|
|
260
|
+
badges:
|
|
261
|
+
tags.badges?.split(',').flatMap((badge) => {
|
|
262
|
+
const [set_id, version] = badge.split('/');
|
|
263
|
+
if (!set_id || !version) return [];
|
|
264
|
+
|
|
265
|
+
const storedBadge = this.assets.badges[set_id];
|
|
266
|
+
if (!storedBadge) return [];
|
|
267
|
+
|
|
268
|
+
const url = typeof storedBadge === 'object' ? storedBadge[version] : storedBadge;
|
|
269
|
+
if (!url) return [];
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
info: badge_info[set_id],
|
|
273
|
+
set_id,
|
|
274
|
+
url,
|
|
275
|
+
};
|
|
276
|
+
}) ?? [],
|
|
277
|
+
color: tags.color ?? '#FFFFFF',
|
|
278
|
+
id: tags['user-id'],
|
|
279
|
+
username: source?.user ?? 'Unknown',
|
|
280
|
+
display_name: tags['display-name'] ?? 'Unknown',
|
|
281
|
+
roles: {
|
|
282
|
+
admin: tags['user-type'] === 'admin',
|
|
283
|
+
global_moderator: tags['user-type'] === 'global_mod',
|
|
284
|
+
staff: tags['user-type'] === 'staff',
|
|
285
|
+
turbo: tags.turbo === '1',
|
|
286
|
+
vip: tags.vip === '1',
|
|
287
|
+
moderator: tags.mod === '1',
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function parseIRCLine(line: string): IRC_Message {
|
|
299
|
+
const components = line.split(' ');
|
|
300
|
+
let componentIndex = 0;
|
|
301
|
+
|
|
302
|
+
let raw_tags_component: string | undefined;
|
|
303
|
+
let source: RawSource | undefined;
|
|
304
|
+
|
|
305
|
+
if (components[componentIndex]!.startsWith('@')) {
|
|
306
|
+
raw_tags_component = components[componentIndex]!.slice(1);
|
|
307
|
+
componentIndex++;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (components[componentIndex]!.startsWith(':')) {
|
|
311
|
+
source = parseSource(components[componentIndex]!.slice(1));
|
|
312
|
+
componentIndex++;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const command = components[componentIndex]! as CommandType;
|
|
316
|
+
componentIndex++;
|
|
317
|
+
|
|
318
|
+
let channel: string = '';
|
|
319
|
+
if (components[componentIndex]!.startsWith('#')) channel = components[componentIndex]!.slice(1);
|
|
320
|
+
componentIndex++;
|
|
321
|
+
|
|
322
|
+
const params: string[] = [];
|
|
323
|
+
while (components[componentIndex] !== undefined) {
|
|
324
|
+
const param = components[componentIndex]!;
|
|
325
|
+
if (param.startsWith(':')) {
|
|
326
|
+
params.push(components.slice(componentIndex).join(' ').slice(1));
|
|
327
|
+
componentIndex = -1;
|
|
328
|
+
} else {
|
|
329
|
+
params.push(param);
|
|
330
|
+
componentIndex++;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// TODO: handle message actions like "/me"
|
|
335
|
+
if (params[0])
|
|
336
|
+
params[0] = String.raw`${params[0]}`.replaceAll(
|
|
337
|
+
/.+ACTION (.*).+/g,
|
|
338
|
+
(_original, group) => group,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const tags = raw_tags_component ? parseTags(raw_tags_component, command) : undefined;
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
channel,
|
|
345
|
+
command,
|
|
346
|
+
params,
|
|
347
|
+
source,
|
|
348
|
+
tags,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function parseTags(component: string, command: CommandType): AllRawTags {
|
|
353
|
+
// const tags = Object.fromEntries(RAW_TAGS[command].map((key) => [key, undefined])) as AllRawTags
|
|
354
|
+
const tags = {} as AllRawTags;
|
|
355
|
+
|
|
356
|
+
component.split(';').forEach((raw_tag) => {
|
|
357
|
+
const [key, value] = raw_tag.split('=') as [AllRawTagsKeys, string];
|
|
358
|
+
// console.log([key, value])
|
|
359
|
+
if (RAW_TAGS[command].findIndex((el) => el === key) === -1)
|
|
360
|
+
console.warn(`[${command}] Unknown Tag: ${raw_tag}`);
|
|
361
|
+
// if (value.length)
|
|
362
|
+
tags[key] = value;
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
return tags;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function parseSource(component: string): RawSource {
|
|
369
|
+
let user: string | undefined = undefined;
|
|
370
|
+
let host: string | undefined = component;
|
|
371
|
+
let nick: string | undefined = undefined;
|
|
372
|
+
|
|
373
|
+
if (component.includes('!')) [nick, host] = component.split('!');
|
|
374
|
+
if (host?.includes('@')) [user, host] = host.split('@');
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
host: host ?? 'unknown',
|
|
378
|
+
nick,
|
|
379
|
+
user,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
type IRC_Message = {
|
|
384
|
+
[C in CommandType]: {
|
|
385
|
+
channel: string;
|
|
386
|
+
command: C;
|
|
387
|
+
params: string[];
|
|
388
|
+
source: RawSource | undefined;
|
|
389
|
+
tags: SpecificRawTags<C> | undefined;
|
|
390
|
+
};
|
|
391
|
+
}[CommandType];
|
|
392
|
+
|
|
393
|
+
type RAW_TAGS = typeof RAW_TAGS;
|
|
394
|
+
type CommandType = keyof RAW_TAGS;
|
|
395
|
+
type AllRawTagsKeys = RAW_TAGS[CommandType][number];
|
|
396
|
+
type AllRawTags = Record<AllRawTagsKeys, string | undefined>;
|
|
397
|
+
type SpecificRawTags<Command extends CommandType> = Record<
|
|
398
|
+
RAW_TAGS[Command][number],
|
|
399
|
+
string | undefined
|
|
400
|
+
>;
|
|
401
|
+
|
|
402
|
+
type RawSource = {
|
|
403
|
+
host: string;
|
|
404
|
+
nick?: string | undefined;
|
|
405
|
+
user?: string | undefined;
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const RAW_TAGS = {
|
|
409
|
+
CLEARCHAT: ['ban-duration', 'room-id', 'target-user-id', 'tmi-sent-ts'],
|
|
410
|
+
CLEARMSG: ['login', 'room-id', 'target-msg-id', 'tmi-sent-ts'],
|
|
411
|
+
|
|
412
|
+
GLOBALUSERSTATE: [
|
|
413
|
+
'badge-info',
|
|
414
|
+
'badges',
|
|
415
|
+
'color',
|
|
416
|
+
'display-name',
|
|
417
|
+
'emote-sets',
|
|
418
|
+
'turbo',
|
|
419
|
+
'user-id',
|
|
420
|
+
'user-type',
|
|
421
|
+
],
|
|
422
|
+
|
|
423
|
+
HOSTTARGET: [],
|
|
424
|
+
|
|
425
|
+
NOTICE: ['msg-id', 'target-user-id'],
|
|
426
|
+
|
|
427
|
+
PART: [],
|
|
428
|
+
|
|
429
|
+
PING: [],
|
|
430
|
+
|
|
431
|
+
PONG: [],
|
|
432
|
+
|
|
433
|
+
PRIVMSG: [
|
|
434
|
+
'badge-info',
|
|
435
|
+
'badges',
|
|
436
|
+
'bits',
|
|
437
|
+
'color',
|
|
438
|
+
'display-name',
|
|
439
|
+
'emotes',
|
|
440
|
+
'emote-only',
|
|
441
|
+
'id',
|
|
442
|
+
'mod',
|
|
443
|
+
'custom-reward-id',
|
|
444
|
+
'reply-thread-parent-display-name',
|
|
445
|
+
'reply-thread-parent-user-id',
|
|
446
|
+
'pinned-chat-paid-amount',
|
|
447
|
+
'pinned-chat-paid-currency',
|
|
448
|
+
'pinned-chat-paid-exponent',
|
|
449
|
+
'pinned-chat-paid-level',
|
|
450
|
+
'pinned-chat-paid-is-system-message',
|
|
451
|
+
'reply-parent-msg-id',
|
|
452
|
+
'reply-parent-user-id',
|
|
453
|
+
'reply-parent-user-login',
|
|
454
|
+
'reply-parent-display-name',
|
|
455
|
+
'reply-parent-msg-body',
|
|
456
|
+
'reply-thread-parent-msg-id',
|
|
457
|
+
'reply-thread-parent-user-login',
|
|
458
|
+
'room-id',
|
|
459
|
+
'subscriber',
|
|
460
|
+
'tmi-sent-ts',
|
|
461
|
+
'turbo',
|
|
462
|
+
'user-id',
|
|
463
|
+
'user-type',
|
|
464
|
+
'vip',
|
|
465
|
+
...[
|
|
466
|
+
// undocumented
|
|
467
|
+
'client-nonce',
|
|
468
|
+
'first-msg',
|
|
469
|
+
'flags',
|
|
470
|
+
'returning-chatter',
|
|
471
|
+
],
|
|
472
|
+
],
|
|
473
|
+
RECONNECT: [],
|
|
474
|
+
ROOMSTATE: ['emote-only', 'followers-only', 'r9k', 'room-id', 'slow', 'subs-only'],
|
|
475
|
+
USERNOTICE: [
|
|
476
|
+
'badge-info',
|
|
477
|
+
'badges',
|
|
478
|
+
'color',
|
|
479
|
+
'display-name',
|
|
480
|
+
'emotes',
|
|
481
|
+
'id',
|
|
482
|
+
'login',
|
|
483
|
+
'mod',
|
|
484
|
+
'msg-id',
|
|
485
|
+
'room-id',
|
|
486
|
+
'subscriber',
|
|
487
|
+
'system-msg',
|
|
488
|
+
'tmi-sent-ts',
|
|
489
|
+
'turbo',
|
|
490
|
+
'user-id',
|
|
491
|
+
'user-type',
|
|
492
|
+
'vip',
|
|
493
|
+
'flags',
|
|
494
|
+
...[
|
|
495
|
+
// Only subscription/raid related notices
|
|
496
|
+
'msg-param-cumulative-months',
|
|
497
|
+
'msg-param-displayName',
|
|
498
|
+
'msg-param-login',
|
|
499
|
+
'msg-param-multimonth-duration',
|
|
500
|
+
'msg-param-multimonth-tenure',
|
|
501
|
+
'msg-param-was-gifted=false',
|
|
502
|
+
'msg-param-months',
|
|
503
|
+
'msg-param-promo-gift-total',
|
|
504
|
+
'msg-param-promo-name',
|
|
505
|
+
'msg-param-recipient-display-name',
|
|
506
|
+
'msg-param-recipient-id',
|
|
507
|
+
'msg-param-recipient-user-name',
|
|
508
|
+
'msg-param-sender-login',
|
|
509
|
+
'msg-param-sender-name',
|
|
510
|
+
'msg-param-should-share-streak',
|
|
511
|
+
'msg-param-streak-months',
|
|
512
|
+
'msg-param-sub-plan',
|
|
513
|
+
'msg-param-sub-plan-name',
|
|
514
|
+
'msg-param-viewerCount',
|
|
515
|
+
'msg-param-ritual-name',
|
|
516
|
+
'msg-param-threshold',
|
|
517
|
+
'msg-param-gift-months',
|
|
518
|
+
'msg-param-was-gifted',
|
|
519
|
+
'msg-param-community-gift-id',
|
|
520
|
+
'msg-param-mass-gift-count',
|
|
521
|
+
'msg-param-origin-id',
|
|
522
|
+
],
|
|
523
|
+
],
|
|
524
|
+
USERSTATE: [
|
|
525
|
+
'badge-info',
|
|
526
|
+
'badges',
|
|
527
|
+
'color',
|
|
528
|
+
'display-name',
|
|
529
|
+
'emote-sets',
|
|
530
|
+
'id',
|
|
531
|
+
'mod',
|
|
532
|
+
'subscriber',
|
|
533
|
+
'turbo',
|
|
534
|
+
'user-type',
|
|
535
|
+
],
|
|
536
|
+
WHISPER: [
|
|
537
|
+
'badges',
|
|
538
|
+
'color',
|
|
539
|
+
'display-name',
|
|
540
|
+
'emotes',
|
|
541
|
+
'message-id',
|
|
542
|
+
'thread-id',
|
|
543
|
+
'turbo',
|
|
544
|
+
'user-id',
|
|
545
|
+
'user-type',
|
|
546
|
+
],
|
|
547
|
+
} as const;
|