multichat-ts 0.0.1

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