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