multichat-ts 0.0.92 → 0.0.94

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/dist/kick.js CHANGED
@@ -121,7 +121,7 @@ export class KickPusher {
121
121
  onChatMessage(data) {
122
122
  this.public_listeners.raw_message?.(data);
123
123
  const text = data.content;
124
- const emote_matches = [...data.content.matchAll(/\[emote:\d+:[a-zA-Z0-9]*\]/g)];
124
+ const emote_matches = [...data.content.matchAll(/\[emote:\d+:.+\]/g)];
125
125
  const body = [];
126
126
  emote_matches.forEach((match) => {
127
127
  const emote_string = match[0];
package/dist/twitch.js CHANGED
@@ -120,7 +120,7 @@ export class TwitchIRC {
120
120
  break;
121
121
  }
122
122
  case 'PRIVMSG': {
123
- const { channel, params, tags } = message;
123
+ const { channel, params, tags, source } = message;
124
124
  if (!tags || !tags['user-id'] || !tags['id'] || !tags['room-id'])
125
125
  return;
126
126
  const text = params[0];
@@ -215,7 +215,7 @@ export class TwitchIRC {
215
215
  }) ?? [],
216
216
  color: tags.color ?? '#FFFFFF',
217
217
  id: tags['user-id'],
218
- username: (tags['display-name'] ?? 'Unknown').toLowerCase(),
218
+ username: source?.user ?? 'Unknown',
219
219
  display_name: tags['display-name'] ?? 'Unknown',
220
220
  roles: {
221
221
  admin: tags['user-type'] === 'admin',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multichat-ts",
3
- "version": "0.0.92",
3
+ "version": "0.0.94",
4
4
  "type": "module",
5
5
  "description": "Receive type-safe realtime events for chat-related messages on multiple platforms (Twitch, Kick)",
6
6
  "repository": {
@@ -196,7 +196,7 @@ export class KickPusher {
196
196
  private onChatMessage(data: ChatroomsV2Events['ChatMessageEvent']) {
197
197
  this.public_listeners.raw_message?.(data);
198
198
  const text = data.content;
199
- const emote_matches = [...data.content.matchAll(/\[emote:\d+:[a-zA-Z0-9]*\]/g)];
199
+ const emote_matches = [...data.content.matchAll(/\[emote:\d+:.+\]/g)];
200
200
 
201
201
  const body: BodyComponent[] = [];
202
202
 
@@ -1,547 +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: (tags['display-name'] ?? 'Unknown').toLowerCase(),
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;
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;