multichat-ts 0.0.94 → 0.0.96

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.
@@ -9,6 +9,7 @@ import {
9
9
  type EmoteURLsByName,
10
10
  type BadgeURLsBySetIDOrSetIDAndVersion,
11
11
  } from './index.js';
12
+ import { buildMessageBody, runSafely } from './safety.js';
12
13
 
13
14
  const ANONYMOUS_IRC_PASS = 'SCHMOOPIIE';
14
15
  const ANONYMOUS_IRC_LOGIN = 'justinfan1234';
@@ -21,12 +22,20 @@ type EventCallbackFunctions = {
21
22
  delete_message: (data: DeleteMessage) => unknown;
22
23
  event: (event: Event) => unknown;
23
24
  raw_message: (message: IRC_Message) => unknown;
25
+ connected: () => unknown;
26
+ auth_error: () => unknown;
24
27
  };
25
28
 
26
29
  type EventNames = keyof EventCallbackFunctions;
27
30
 
31
+ type SocketOptions = NonNullable<ConstructorParameters<typeof WebSocket>[2]>;
32
+
28
33
  export class TwitchIRC {
29
34
  public channel_name?: string;
35
+ public auth?: { username: string; token: string } | undefined;
36
+ public bot_name?: string;
37
+ public bot_token?: string;
38
+
30
39
  private assets: {
31
40
  external_emotes: EmoteURLsByName;
32
41
  badges: BadgeURLsBySetIDOrSetIDAndVersion;
@@ -44,19 +53,28 @@ export class TwitchIRC {
44
53
 
45
54
  private public_listeners: Partial<EventCallbackFunctions> = {};
46
55
 
47
- public socket?: WebSocket;
48
- public ws?: WebSocket | undefined;
56
+ public socket?: WebSocket | undefined;
57
+ public wsCustom?: SocketOptions['WebSocket'];
49
58
 
50
- constructor(ws?: WebSocket) {
51
- this.ws = ws;
59
+ constructor(
60
+ options: {
61
+ ws?: SocketOptions['WebSocket'];
62
+ auth?: { username: string; token: string };
63
+ } = {},
64
+ ) {
65
+ this.wsCustom = options.ws;
66
+ this.auth = options.auth;
52
67
  }
53
68
 
54
69
  public setBadges(badges: BadgeURLsBySetIDOrSetIDAndVersion) {
55
- this.assets.badges = { ...badges, ...this.assets.badges };
70
+ this.assets.badges = { ...this.assets.badges, ...badges };
56
71
  }
57
72
 
58
- public setExternalEmotes(external_emotes: EmoteURLsByName) {
59
- this.assets.external_emotes = { ...external_emotes, ...this.assets.external_emotes };
73
+ public setExternalEmotes(externalEmotes: EmoteURLsByName) {
74
+ this.assets.external_emotes = {
75
+ ...this.assets.external_emotes,
76
+ ...externalEmotes,
77
+ };
60
78
  }
61
79
 
62
80
  public getStoredBadges() {
@@ -67,24 +85,96 @@ export class TwitchIRC {
67
85
  return this.assets.external_emotes;
68
86
  }
69
87
 
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');
88
+ public authenticate(username: string, token: string): void {
89
+ this.auth = { username, token };
90
+ this.bot_name = username;
91
+ this.bot_token = token;
92
+ }
73
93
 
74
- console.log(`connecting to ${this.channel_name}...`);
94
+ private clearPing(): void {
95
+ clearInterval(this.ping.interval);
96
+ clearTimeout(this.ping.timeout);
97
+ this.ping = {};
98
+ }
75
99
 
76
- this.socket?.close();
100
+ public connect(channel?: { channelName?: string }): void {
101
+ if (channel?.channelName) {
102
+ this.channel_name = channel.channelName;
103
+ }
77
104
 
78
- this.socket = new WebSocket('wss://irc-ws.chat.twitch.tv', null, {
79
- WebSocket: this.ws,
105
+ if (!this.channel_name) {
106
+ throw new Error('Twitch channel_name not specified');
107
+ }
108
+
109
+ this.disconnect();
110
+
111
+ const socket = new WebSocket('wss://irc-ws.chat.twitch.tv', null, {
112
+ WebSocket: this.wsCustom,
80
113
  });
81
- this.socket.onopen = () => this.onOpen();
82
- this.socket.onclose = () => this.onClose();
83
- this.socket.onmessage = (event) => this.onMessage(event);
114
+
115
+ this.socket = socket;
116
+
117
+ socket.onopen = () => {
118
+ if (this.socket !== socket) return;
119
+ runSafely('twitch.open', () => this.onOpen());
120
+ };
121
+
122
+ socket.onclose = () => {
123
+ if (this.socket !== socket) return;
124
+ this.clearPing();
125
+ };
126
+
127
+ socket.onerror = () => {
128
+ if (this.socket !== socket) return;
129
+
130
+ console.error('Twitch WebSocket error', {
131
+ channel: this.channel_name,
132
+ });
133
+ };
134
+
135
+ socket.onmessage = (event) => {
136
+ if (this.socket !== socket) return;
137
+ runSafely('twitch.frame', () => this.onMessage(event));
138
+ };
139
+ }
140
+
141
+ public send(message: string, replyParentMessageId?: string): void {
142
+ if (!this.auth) {
143
+ console.error('No Twitch auth information');
144
+ return;
145
+ }
146
+
147
+ const text = message.replace(/[\r\n]/g, ' ');
148
+
149
+ if (replyParentMessageId !== undefined && !/^[A-Za-z0-9-]+$/.test(replyParentMessageId)) {
150
+ throw new Error('Invalid reply parent message ID');
151
+ }
152
+
153
+ const prefix = replyParentMessageId ? `@reply-parent-msg-id=${replyParentMessageId} ` : '';
154
+
155
+ this.sendIRC(`${prefix}PRIVMSG #${this.channel_name} :${text}`);
84
156
  }
85
157
 
86
- public disconnect() {
87
- this.socket?.close();
158
+ public disconnect(): void {
159
+ this.clearPing();
160
+
161
+ const socket = this.socket;
162
+ this.socket = undefined;
163
+
164
+ if (!socket) return;
165
+
166
+ socket.onopen = null;
167
+ socket.onclose = null;
168
+ socket.onmessage = null;
169
+ socket.onerror = null;
170
+
171
+ runSafely('twitch.disconnect', () => socket.close());
172
+ }
173
+
174
+ private restartConnection(): void {
175
+ this.clearPing();
176
+
177
+ runSafely('twitch.reconnect', () => this.socket?.reconnect());
88
178
  }
89
179
 
90
180
  public on<EventName extends EventNames>(
@@ -98,247 +188,321 @@ export class TwitchIRC {
98
188
  return !!this.socket && this.socket.readyState === WebSocket.OPEN;
99
189
  }
100
190
 
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}`);
191
+ // private onClose() {
192
+ // clearInterval(this.ping.interval);
193
+ // clearTimeout(this.ping.timeout);
194
+ // }
195
+
196
+ private onOpen(): void {
197
+ this.clearPing();
106
198
 
107
- console.log(`Connected to Twitch IRC as Anonymous (${this.channel_name})`);
199
+ const token = this.auth?.token.replace(/^oauth:/, '');
200
+
201
+ const commands = [
202
+ 'CAP REQ :twitch.tv/commands twitch.tv/tags',
203
+ this.auth ? `PASS oauth:${token}` : `PASS ${ANONYMOUS_IRC_PASS}`,
204
+ `NICK ${this.auth?.username ?? ANONYMOUS_IRC_LOGIN}`,
205
+ `JOIN #${this.channel_name}`,
206
+ ];
207
+
208
+ for (const command of commands) {
209
+ if (!this.sendIRC(command)) return;
210
+ }
108
211
 
109
- if (this.ping.interval) clearInterval(this.ping.interval);
110
212
  this.sendPing();
111
- this.ping.interval = setInterval(() => this.sendPing(), PING_INTERVAL_MS);
112
- }
113
213
 
114
- private onClose() {
115
- clearInterval(this.ping.interval);
116
- clearTimeout(this.ping.timeout);
214
+ if (!this.isConnected()) return;
215
+
216
+ this.ping.interval = setInterval(() => {
217
+ runSafely('twitch.ping', () => this.sendPing());
218
+ }, PING_INTERVAL_MS);
117
219
  }
118
220
 
119
- private sendPing() {
120
- this.send('PING');
221
+ private sendPing(): void {
222
+ if (this.ping.lastSentTimestamp !== undefined) return;
223
+ if (!this.sendIRC('PING :multichat')) return;
224
+
121
225
  this.ping.lastSentTimestamp = Date.now();
122
226
 
123
- if (this.ping.timeout) clearTimeout(this.ping.timeout);
124
227
  this.ping.timeout = setTimeout(() => {
125
- console.error('PING Timeout, reconnecting...');
126
- this.connect();
228
+ console.error('Twitch PING timeout');
229
+ this.restartConnection();
127
230
  }, PING_TIMEOUT_MS);
128
231
  }
129
232
 
130
- private send(irc_message: string) {
131
- if (!this.isConnected()) {
132
- throw new Error('Not connected');
233
+ private sendIRC(message: string): boolean {
234
+ if (!this.isConnected()) return false;
235
+
236
+ try {
237
+ this.socket.send(message);
238
+ return true;
239
+ } catch (error) {
240
+ console.error('Twitch send failed', error);
241
+ this.restartConnection();
242
+ return false;
133
243
  }
134
- this.socket?.send(irc_message);
135
244
  }
136
245
 
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;
246
+ private onMessage(event: MessageEvent): void {
247
+ if (typeof event.data !== 'string') {
248
+ console.warn('Ignoring non-text Twitch WebSocket frame', JSON.stringify(event));
249
+ return;
250
+ }
251
+
252
+ const socket = this.socket;
253
+
254
+ for (const line of event.data.split('\r\n')) {
255
+ if (!line) continue;
256
+
257
+ if (this.socket !== socket || !this.isConnected()) break;
258
+
259
+ runSafely('twitch.irc_line', () => {
260
+ const message = parseIRCLine(line);
261
+
262
+ if (!message) {
263
+ console.warn('Ignoring malformed Twitch IRC line', JSON.stringify(message));
264
+ return;
150
265
  }
151
- case 'PING': {
152
- this.send('PONG');
153
- break;
266
+
267
+ this.handleIRCMessage(message);
268
+ });
269
+ }
270
+ }
271
+
272
+ private handleIRCMessage(message: IRC_Message): void {
273
+ runSafely('twitch.raw_message', () => this.public_listeners.raw_message?.(message));
274
+
275
+ switch (message.command) {
276
+ case 'PONG': {
277
+ const sentAt = this.ping.lastSentTimestamp;
278
+
279
+ if (sentAt === undefined) break;
280
+
281
+ clearTimeout(this.ping.timeout);
282
+ this.latency = Date.now() - sentAt;
283
+ this.ping.lastSentTimestamp = undefined;
284
+ break;
285
+ }
286
+ case 'PING': {
287
+ const token = message.params.at(-1);
288
+
289
+ this.sendIRC(token === undefined ? 'PONG' : `PONG :${token}`);
290
+ break;
291
+ }
292
+ case '001': {
293
+ console.log(`Connected to Twitch IRC (${this.channel_name})`);
294
+
295
+ runSafely('twitch.connected', () => this.public_listeners.connected?.());
296
+ break;
297
+ }
298
+ case 'RECONNECT': {
299
+ this.restartConnection();
300
+ break;
301
+ }
302
+ case 'NOTICE': {
303
+ const text = message.params.at(-1);
304
+
305
+ if (text === 'Login authentication failed' || text === 'Improperly formatted auth') {
306
+ this.disconnect();
307
+
308
+ runSafely('twitch.auth_error', () => this.public_listeners.auth_error?.());
154
309
  }
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;
310
+
311
+ break;
312
+ }
313
+ case 'CLEARCHAT': {
314
+ const { channel, tags } = message;
315
+ if (!tags) return;
316
+ const data = {
317
+ channel: {
318
+ name: channel,
319
+ room_id: tags['room-id'] ?? 'unknown',
320
+ },
321
+ timestamp_sent: Number(tags['tmi-sent-ts']),
322
+ timeout_duration_seconds: tags['ban-duration'] ? Number(tags['ban-duration']) : undefined,
323
+ };
324
+ runSafely('twitch.clear_messages', () => {
325
+ this.public_listeners.clear_messages?.(data);
326
+ });
327
+ break;
328
+ }
329
+ case 'PRIVMSG': {
330
+ const { channel, params, tags, source } = message;
331
+ if (!tags || !tags['user-id'] || !tags['id'] || !tags['room-id']) return;
332
+
333
+ const rawText = params[0];
334
+ if (!rawText) return;
335
+
336
+ const actionPrefix = '\u0001ACTION ';
337
+ const isAction = rawText.startsWith(actionPrefix) && rawText.endsWith('\u0001');
338
+
339
+ const text = isAction ? rawText.slice(actionPrefix.length, -1) : rawText;
340
+
341
+ const offset = isAction ? actionPrefix.length : 0;
342
+
343
+ const boundaries = [0];
344
+ let utf16Offset = 0;
345
+
346
+ for (const character of rawText) {
347
+ utf16Offset += character.length;
348
+ boundaries.push(utf16Offset);
170
349
  }
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
350
 
238
- body.sort((a, b) => a.start_inclusive - b.start_inclusive);
351
+ const emotes: BodyComponent[] = [];
239
352
 
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;
353
+ for (const encoded of tags['emotes']?.split('/') ?? []) {
354
+ const colon = encoded.indexOf(':');
355
+ if (colon === -1) continue;
246
356
 
247
- badge_info[set_id] = info;
248
- });
357
+ const id = encoded.slice(0, colon);
358
+ if (!id) continue;
249
359
 
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;
360
+ for (const range of encoded.slice(colon + 1).split(',')) {
361
+ const match = /^(\d+)-(\d+)$/.exec(range);
362
+ if (!match) continue;
363
+
364
+ const first = Number(match[1]);
365
+ const last = Number(match[2]);
366
+
367
+ if (last < first) continue;
368
+
369
+ const rawStart = boundaries[first];
370
+ const rawEnd = boundaries[last + 1];
371
+
372
+ if (rawStart === undefined || rawEnd === undefined) continue;
373
+
374
+ emotes.push({
375
+ type: 'emote',
376
+ start_inclusive: rawStart - offset,
377
+ end_exclusive: rawEnd - offset,
378
+ url:
379
+ 'https://static-cdn.jtvnw.net/emoticons/v2/' +
380
+ `${encodeURIComponent(id)}/default/dark/1.0`,
381
+ });
382
+ }
292
383
  }
384
+
385
+ const body = buildMessageBody(text, emotes);
386
+
387
+ const badge_info: {
388
+ [set_id: string]: string;
389
+ } = {};
390
+ tags['badge-info']?.split(',').forEach((badge) => {
391
+ const [set_id, info] = badge.split('/');
392
+ if (!set_id || !info) return;
393
+
394
+ badge_info[set_id] = info;
395
+ });
396
+
397
+ const timestamp = Number(tags['tmi-sent-ts']);
398
+
399
+ const data = {
400
+ body: body,
401
+ channel: {
402
+ room_id: tags['room-id'],
403
+ name: channel,
404
+ },
405
+ id: tags['id'],
406
+ raw_text: text,
407
+ timestamp_sent: Number.isFinite(timestamp) ? timestamp : Date.now(),
408
+ user: {
409
+ badges:
410
+ tags['badges']?.split(',').flatMap((badge) => {
411
+ const [set_id, version] = badge.split('/');
412
+ if (!set_id || !version) return [];
413
+
414
+ const storedBadge = this.assets.badges[set_id];
415
+ if (!storedBadge) return [];
416
+
417
+ const url = typeof storedBadge === 'object' ? storedBadge[version] : storedBadge;
418
+ if (typeof url !== 'string' || !url) return [];
419
+
420
+ return {
421
+ info: badge_info[set_id],
422
+ set_id,
423
+ url,
424
+ };
425
+ }) ?? [],
426
+ color: tags['color'] ?? '#FFFFFF',
427
+ id: tags['user-id'],
428
+ username: source?.user ?? 'Unknown',
429
+ display_name: tags['display-name'] ?? 'Unknown',
430
+ roles: {
431
+ admin: tags['user-type'] === 'admin',
432
+ global_moderator: tags['user-type'] === 'global_mod',
433
+ staff: tags['user-type'] === 'staff',
434
+ turbo: tags['turbo'] === '1',
435
+ vip: tags['vip'] === '1',
436
+ moderator: tags['mod'] === '1',
437
+ },
438
+ },
439
+ };
440
+
441
+ runSafely('twitch.message', () => {
442
+ this.public_listeners.message?.(data);
443
+ });
444
+ break;
293
445
  }
294
- });
446
+ }
295
447
  }
296
448
  }
297
449
 
298
- function parseIRCLine(line: string): IRC_Message {
299
- const components = line.split(' ');
300
- let componentIndex = 0;
450
+ export function parseIRCLine(line: string): IRC_Message | undefined {
451
+ let rest = line.replace(/[\r\n]+$/, '').trimStart();
452
+
453
+ if (!rest) return undefined;
301
454
 
302
- let raw_tags_component: string | undefined;
455
+ const takeToken = (): string => {
456
+ const space = rest.indexOf(' ');
457
+
458
+ if (space === -1) {
459
+ const token = rest;
460
+ rest = '';
461
+ return token;
462
+ }
463
+
464
+ const token = rest.slice(0, space);
465
+ rest = rest.slice(space + 1).trimStart();
466
+ return token;
467
+ };
468
+
469
+ let tags: IRC_Message['tags'];
303
470
  let source: RawSource | undefined;
304
471
 
305
- if (components[componentIndex]!.startsWith('@')) {
306
- raw_tags_component = components[componentIndex]!.slice(1);
307
- componentIndex++;
472
+ if (rest.startsWith('@')) {
473
+ tags = parseTags(takeToken().slice(1));
474
+
475
+ if (!rest) return undefined;
308
476
  }
309
477
 
310
- if (components[componentIndex]!.startsWith(':')) {
311
- source = parseSource(components[componentIndex]!.slice(1));
312
- componentIndex++;
478
+ if (rest.startsWith(':')) {
479
+ source = parseSource(takeToken().slice(1));
480
+
481
+ if (!rest) return undefined;
313
482
  }
314
483
 
315
- const command = components[componentIndex]! as CommandType;
316
- componentIndex++;
484
+ const command = takeToken();
317
485
 
318
- let channel: string = '';
319
- if (components[componentIndex]!.startsWith('#')) channel = components[componentIndex]!.slice(1);
320
- componentIndex++;
486
+ if (!/^(?:[A-Za-z]+|\d{3})$/.test(command)) {
487
+ return undefined;
488
+ }
321
489
 
322
490
  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++;
491
+
492
+ while (rest) {
493
+ if (rest.startsWith(':')) {
494
+ params.push(rest.slice(1));
495
+ break;
331
496
  }
497
+
498
+ params.push(takeToken());
332
499
  }
333
500
 
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
- );
501
+ let channel = '';
340
502
 
341
- const tags = raw_tags_component ? parseTags(raw_tags_component, command) : undefined;
503
+ if (params[0]?.startsWith('#')) {
504
+ channel = params.shift()!.slice(1);
505
+ }
342
506
 
343
507
  return {
344
508
  channel,
@@ -349,199 +513,66 @@ function parseIRCLine(line: string): IRC_Message {
349
513
  };
350
514
  }
351
515
 
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
- });
516
+ function parseTags(component: string): Record<string, string | undefined> {
517
+ const tags: Record<string, string | undefined> = Object.create(null);
518
+
519
+ for (const rawTag of component.split(';')) {
520
+ const equals = rawTag.indexOf('=');
521
+
522
+ const key = equals === -1 ? rawTag : rawTag.slice(0, equals);
523
+ const value = equals === -1 ? '' : rawTag.slice(equals + 1);
524
+
525
+ if (!key) continue;
526
+
527
+ tags[key] = value.replace(/\\(.)/g, (_, escaped: string) => {
528
+ switch (escaped) {
529
+ case 's':
530
+ return ' ';
531
+ case ':':
532
+ return ';';
533
+ case 'r':
534
+ return '\r';
535
+ case 'n':
536
+ return '\n';
537
+ case '\\':
538
+ return '\\';
539
+ default:
540
+ return escaped;
541
+ }
542
+ });
543
+ }
364
544
 
365
545
  return tags;
366
546
  }
367
547
 
368
548
  function parseSource(component: string): RawSource {
369
- let user: string | undefined = undefined;
370
- let host: string | undefined = component;
371
- let nick: string | undefined = undefined;
549
+ const bang = component.indexOf('!');
550
+
551
+ if (bang === -1) {
552
+ return { host: component || 'unknown' };
553
+ }
372
554
 
373
- if (component.includes('!')) [nick, host] = component.split('!');
374
- if (host?.includes('@')) [user, host] = host.split('@');
555
+ const nick = component.slice(0, bang);
556
+ const remainder = component.slice(bang + 1);
557
+ const at = remainder.indexOf('@');
375
558
 
376
559
  return {
377
- host: host ?? 'unknown',
378
560
  nick,
379
- user,
561
+ user: at === -1 ? remainder : remainder.slice(0, at),
562
+ host: at === -1 ? 'unknown' : remainder.slice(at + 1),
380
563
  };
381
564
  }
382
565
 
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
- >;
566
+ export type IRC_Message = {
567
+ channel: string;
568
+ command: string;
569
+ params: string[];
570
+ source: RawSource | undefined;
571
+ tags: Record<string, string | undefined> | undefined;
572
+ };
401
573
 
402
574
  type RawSource = {
403
575
  host: string;
404
576
  nick?: string | undefined;
405
577
  user?: string | undefined;
406
578
  };
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;