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.
package/dist/twitch.js CHANGED
@@ -1,10 +1,14 @@
1
1
  import { WebSocket } from 'partysocket';
2
+ import { buildMessageBody, runSafely } from './safety.js';
2
3
  const ANONYMOUS_IRC_PASS = 'SCHMOOPIIE';
3
4
  const ANONYMOUS_IRC_LOGIN = 'justinfan1234';
4
5
  const PING_INTERVAL_MS = 30_000;
5
6
  const PING_TIMEOUT_MS = 10_000;
6
7
  export class TwitchIRC {
7
8
  channel_name;
9
+ auth;
10
+ bot_name;
11
+ bot_token;
8
12
  assets = {
9
13
  external_emotes: {},
10
14
  badges: {},
@@ -13,15 +17,19 @@ export class TwitchIRC {
13
17
  ping = {};
14
18
  public_listeners = {};
15
19
  socket;
16
- ws;
17
- constructor(ws) {
18
- this.ws = ws;
20
+ wsCustom;
21
+ constructor(options = {}) {
22
+ this.wsCustom = options.ws;
23
+ this.auth = options.auth;
19
24
  }
20
25
  setBadges(badges) {
21
- this.assets.badges = { ...badges, ...this.assets.badges };
26
+ this.assets.badges = { ...this.assets.badges, ...badges };
22
27
  }
23
- setExternalEmotes(external_emotes) {
24
- this.assets.external_emotes = { ...external_emotes, ...this.assets.external_emotes };
28
+ setExternalEmotes(externalEmotes) {
29
+ this.assets.external_emotes = {
30
+ ...this.assets.external_emotes,
31
+ ...externalEmotes,
32
+ };
25
33
  }
26
34
  getStoredBadges() {
27
35
  return this.assets.badges;
@@ -29,22 +37,78 @@ export class TwitchIRC {
29
37
  getStoredExternalEmotes() {
30
38
  return this.assets.external_emotes;
31
39
  }
40
+ authenticate(username, token) {
41
+ this.auth = { username, token };
42
+ this.bot_name = username;
43
+ this.bot_token = token;
44
+ }
45
+ clearPing() {
46
+ clearInterval(this.ping.interval);
47
+ clearTimeout(this.ping.timeout);
48
+ this.ping = {};
49
+ }
32
50
  connect(channel) {
33
- if (channel?.channelName)
51
+ if (channel?.channelName) {
34
52
  this.channel_name = channel.channelName;
35
- if (!this.channel_name)
36
- return console.error('channel_name not specified');
37
- console.log(`connecting to ${this.channel_name}...`);
38
- this.socket?.close();
39
- this.socket = new WebSocket('wss://irc-ws.chat.twitch.tv', null, {
40
- WebSocket: this.ws,
53
+ }
54
+ if (!this.channel_name) {
55
+ throw new Error('Twitch channel_name not specified');
56
+ }
57
+ this.disconnect();
58
+ const socket = new WebSocket('wss://irc-ws.chat.twitch.tv', null, {
59
+ WebSocket: this.wsCustom,
41
60
  });
42
- this.socket.onopen = () => this.onOpen();
43
- this.socket.onclose = () => this.onClose();
44
- this.socket.onmessage = (event) => this.onMessage(event);
61
+ this.socket = socket;
62
+ socket.onopen = () => {
63
+ if (this.socket !== socket)
64
+ return;
65
+ runSafely('twitch.open', () => this.onOpen());
66
+ };
67
+ socket.onclose = () => {
68
+ if (this.socket !== socket)
69
+ return;
70
+ this.clearPing();
71
+ };
72
+ socket.onerror = () => {
73
+ if (this.socket !== socket)
74
+ return;
75
+ console.error('Twitch WebSocket error', {
76
+ channel: this.channel_name,
77
+ });
78
+ };
79
+ socket.onmessage = (event) => {
80
+ if (this.socket !== socket)
81
+ return;
82
+ runSafely('twitch.frame', () => this.onMessage(event));
83
+ };
84
+ }
85
+ send(message, replyParentMessageId) {
86
+ if (!this.auth) {
87
+ console.error('No Twitch auth information');
88
+ return;
89
+ }
90
+ const text = message.replace(/[\r\n]/g, ' ');
91
+ if (replyParentMessageId !== undefined && !/^[A-Za-z0-9-]+$/.test(replyParentMessageId)) {
92
+ throw new Error('Invalid reply parent message ID');
93
+ }
94
+ const prefix = replyParentMessageId ? `@reply-parent-msg-id=${replyParentMessageId} ` : '';
95
+ this.sendIRC(`${prefix}PRIVMSG #${this.channel_name} :${text}`);
45
96
  }
46
97
  disconnect() {
47
- this.socket?.close();
98
+ this.clearPing();
99
+ const socket = this.socket;
100
+ this.socket = undefined;
101
+ if (!socket)
102
+ return;
103
+ socket.onopen = null;
104
+ socket.onclose = null;
105
+ socket.onmessage = null;
106
+ socket.onerror = null;
107
+ runSafely('twitch.disconnect', () => socket.close());
108
+ }
109
+ restartConnection() {
110
+ this.clearPing();
111
+ runSafely('twitch.reconnect', () => this.socket?.reconnect());
48
112
  }
49
113
  on(event_name, callback_fn) {
50
114
  this.public_listeners[event_name] = callback_fn;
@@ -53,220 +117,267 @@ export class TwitchIRC {
53
117
  return !!this.socket && this.socket.readyState === WebSocket.OPEN;
54
118
  }
55
119
  onOpen() {
56
- this.send('CAP REQ :twitch.tv/commands twitch.tv/tags');
57
- this.send(`PASS ${ANONYMOUS_IRC_PASS}`);
58
- this.send(`NICK ${ANONYMOUS_IRC_LOGIN}`);
59
- this.send(`JOIN #${this.channel_name}`);
60
- console.log(`Connected to Twitch IRC as Anonymous (${this.channel_name})`);
61
- if (this.ping.interval)
62
- clearInterval(this.ping.interval);
120
+ this.clearPing();
121
+ const token = this.auth?.token.replace(/^oauth:/, '');
122
+ const commands = [
123
+ 'CAP REQ :twitch.tv/commands twitch.tv/tags',
124
+ this.auth ? `PASS oauth:${token}` : `PASS ${ANONYMOUS_IRC_PASS}`,
125
+ `NICK ${this.auth?.username ?? ANONYMOUS_IRC_LOGIN}`,
126
+ `JOIN #${this.channel_name}`,
127
+ ];
128
+ for (const command of commands) {
129
+ if (!this.sendIRC(command))
130
+ return;
131
+ }
63
132
  this.sendPing();
64
- this.ping.interval = setInterval(() => this.sendPing(), PING_INTERVAL_MS);
65
- }
66
- onClose() {
67
- clearInterval(this.ping.interval);
68
- clearTimeout(this.ping.timeout);
133
+ if (!this.isConnected())
134
+ return;
135
+ this.ping.interval = setInterval(() => {
136
+ runSafely('twitch.ping', () => this.sendPing());
137
+ }, PING_INTERVAL_MS);
69
138
  }
70
139
  sendPing() {
71
- this.send('PING');
140
+ if (this.ping.lastSentTimestamp !== undefined)
141
+ return;
142
+ if (!this.sendIRC('PING :multichat'))
143
+ return;
72
144
  this.ping.lastSentTimestamp = Date.now();
73
- if (this.ping.timeout)
74
- clearTimeout(this.ping.timeout);
75
145
  this.ping.timeout = setTimeout(() => {
76
- console.error('PING Timeout, reconnecting...');
77
- this.connect();
146
+ console.error('Twitch PING timeout');
147
+ this.restartConnection();
78
148
  }, PING_TIMEOUT_MS);
79
149
  }
80
- send(irc_message) {
81
- if (!this.isConnected()) {
82
- throw new Error('Not connected');
150
+ sendIRC(message) {
151
+ if (!this.isConnected())
152
+ return false;
153
+ try {
154
+ this.socket.send(message);
155
+ return true;
156
+ }
157
+ catch (error) {
158
+ console.error('Twitch send failed', error);
159
+ this.restartConnection();
160
+ return false;
83
161
  }
84
- this.socket?.send(irc_message);
85
162
  }
86
163
  onMessage(event) {
87
- if (!event.data)
164
+ if (typeof event.data !== 'string') {
165
+ console.warn('Ignoring non-text Twitch WebSocket frame', JSON.stringify(event));
88
166
  return;
89
- const lines = event.data.trim().split('\r\n');
90
- const messages = lines.map(parseIRCLine);
91
- messages.forEach((message) => {
92
- this.public_listeners.raw_message?.(message);
93
- switch (message.command) {
94
- case 'PONG': {
95
- if (!this.ping.lastSentTimestamp)
96
- return console.error('got PONG without sending PING');
97
- clearInterval(this.ping.timeout);
98
- this.latency = Date.now() - this.ping.lastSentTimestamp;
99
- this.ping.lastSentTimestamp = undefined;
100
- break;
167
+ }
168
+ const socket = this.socket;
169
+ for (const line of event.data.split('\r\n')) {
170
+ if (!line)
171
+ continue;
172
+ if (this.socket !== socket || !this.isConnected())
173
+ break;
174
+ runSafely('twitch.irc_line', () => {
175
+ const message = parseIRCLine(line);
176
+ if (!message) {
177
+ console.warn('Ignoring malformed Twitch IRC line', JSON.stringify(message));
178
+ return;
101
179
  }
102
- case 'PING': {
103
- this.send('PONG');
180
+ this.handleIRCMessage(message);
181
+ });
182
+ }
183
+ }
184
+ handleIRCMessage(message) {
185
+ runSafely('twitch.raw_message', () => this.public_listeners.raw_message?.(message));
186
+ switch (message.command) {
187
+ case 'PONG': {
188
+ const sentAt = this.ping.lastSentTimestamp;
189
+ if (sentAt === undefined)
104
190
  break;
191
+ clearTimeout(this.ping.timeout);
192
+ this.latency = Date.now() - sentAt;
193
+ this.ping.lastSentTimestamp = undefined;
194
+ break;
195
+ }
196
+ case 'PING': {
197
+ const token = message.params.at(-1);
198
+ this.sendIRC(token === undefined ? 'PONG' : `PONG :${token}`);
199
+ break;
200
+ }
201
+ case '001': {
202
+ console.log(`Connected to Twitch IRC (${this.channel_name})`);
203
+ runSafely('twitch.connected', () => this.public_listeners.connected?.());
204
+ break;
205
+ }
206
+ case 'RECONNECT': {
207
+ this.restartConnection();
208
+ break;
209
+ }
210
+ case 'NOTICE': {
211
+ const text = message.params.at(-1);
212
+ if (text === 'Login authentication failed' || text === 'Improperly formatted auth') {
213
+ this.disconnect();
214
+ runSafely('twitch.auth_error', () => this.public_listeners.auth_error?.());
105
215
  }
106
- case 'CLEARCHAT': {
107
- const { channel, tags } = message;
108
- if (!tags)
109
- return;
110
- this.public_listeners.clear_messages?.({
111
- channel: {
112
- name: channel,
113
- room_id: tags['room-id'] ?? 'unknown',
114
- },
115
- timestamp_sent: Number(tags['tmi-sent-ts']),
116
- timeout_duration_seconds: tags['ban-duration']
117
- ? Number(tags['ban-duration'])
118
- : undefined,
119
- });
120
- break;
216
+ break;
217
+ }
218
+ case 'CLEARCHAT': {
219
+ const { channel, tags } = message;
220
+ if (!tags)
221
+ return;
222
+ const data = {
223
+ channel: {
224
+ name: channel,
225
+ room_id: tags['room-id'] ?? 'unknown',
226
+ },
227
+ timestamp_sent: Number(tags['tmi-sent-ts']),
228
+ timeout_duration_seconds: tags['ban-duration'] ? Number(tags['ban-duration']) : undefined,
229
+ };
230
+ runSafely('twitch.clear_messages', () => {
231
+ this.public_listeners.clear_messages?.(data);
232
+ });
233
+ break;
234
+ }
235
+ case 'PRIVMSG': {
236
+ const { channel, params, tags, source } = message;
237
+ if (!tags || !tags['user-id'] || !tags['id'] || !tags['room-id'])
238
+ return;
239
+ const rawText = params[0];
240
+ if (!rawText)
241
+ return;
242
+ const actionPrefix = '\u0001ACTION ';
243
+ const isAction = rawText.startsWith(actionPrefix) && rawText.endsWith('\u0001');
244
+ const text = isAction ? rawText.slice(actionPrefix.length, -1) : rawText;
245
+ const offset = isAction ? actionPrefix.length : 0;
246
+ const boundaries = [0];
247
+ let utf16Offset = 0;
248
+ for (const character of rawText) {
249
+ utf16Offset += character.length;
250
+ boundaries.push(utf16Offset);
121
251
  }
122
- case 'PRIVMSG': {
123
- const { channel, params, tags, source } = message;
124
- if (!tags || !tags['user-id'] || !tags['id'] || !tags['room-id'])
125
- return;
126
- const text = params[0];
127
- if (!text)
128
- return;
129
- const body = [];
130
- tags.emotes?.split('/').forEach((raw_emote_string) => {
131
- const [emote_id, raw_emote_positions_string] = raw_emote_string.split(':');
132
- if (!emote_id || !raw_emote_positions_string)
133
- return;
134
- const raw_emote_positions = raw_emote_positions_string.split(',');
135
- raw_emote_positions.forEach((raw_emote_position) => {
136
- const [emote_start, emote_end] = raw_emote_position.split('-');
137
- if (!emote_start || !emote_end)
138
- return;
139
- body.push({
140
- type: 'emote',
141
- start_inclusive: +emote_start,
142
- end_exclusive: +emote_end + 1,
143
- url: `https://static-cdn.jtvnw.net/emoticons/v2/${emote_id}/default/dark/1.0`,
144
- });
145
- });
146
- });
147
- body.sort((a, b) => a.start_inclusive - b.start_inclusive);
148
- const old_body_length = body.length;
149
- if (old_body_length > 0) {
150
- body.forEach((segment, index) => {
151
- const previous_segment = body[index - 1];
152
- const text_start_inclusive = previous_segment?.end_exclusive !== undefined
153
- ? previous_segment.end_exclusive + 1
154
- : 0;
155
- const text_end_exclusive = Math.max(0, segment.start_inclusive);
156
- if (text_end_exclusive - text_start_inclusive > 0) {
157
- body.push({
158
- type: 'text',
159
- text: text.slice(text_start_inclusive, text_end_exclusive),
160
- start_inclusive: text_start_inclusive,
161
- end_exclusive: text_end_exclusive,
162
- });
163
- }
164
- if (index === old_body_length - 1 && segment.end_exclusive < text.length - 1) {
165
- body.push({
166
- type: 'text',
167
- text: text.slice(segment.end_exclusive),
168
- start_inclusive: segment.end_exclusive,
169
- end_exclusive: text.length,
170
- });
171
- }
172
- });
173
- }
174
- else {
175
- body.push({
176
- type: 'text',
177
- text,
178
- start_inclusive: 0,
179
- end_exclusive: text.length,
252
+ const emotes = [];
253
+ for (const encoded of tags['emotes']?.split('/') ?? []) {
254
+ const colon = encoded.indexOf(':');
255
+ if (colon === -1)
256
+ continue;
257
+ const id = encoded.slice(0, colon);
258
+ if (!id)
259
+ continue;
260
+ for (const range of encoded.slice(colon + 1).split(',')) {
261
+ const match = /^(\d+)-(\d+)$/.exec(range);
262
+ if (!match)
263
+ continue;
264
+ const first = Number(match[1]);
265
+ const last = Number(match[2]);
266
+ if (last < first)
267
+ continue;
268
+ const rawStart = boundaries[first];
269
+ const rawEnd = boundaries[last + 1];
270
+ if (rawStart === undefined || rawEnd === undefined)
271
+ continue;
272
+ emotes.push({
273
+ type: 'emote',
274
+ start_inclusive: rawStart - offset,
275
+ end_exclusive: rawEnd - offset,
276
+ url: 'https://static-cdn.jtvnw.net/emoticons/v2/' +
277
+ `${encodeURIComponent(id)}/default/dark/1.0`,
180
278
  });
181
279
  }
182
- body.sort((a, b) => a.start_inclusive - b.start_inclusive);
183
- const badge_info = {};
184
- tags['badge-info']?.split(',').forEach((badge) => {
185
- const [set_id, info] = badge.split('/');
186
- if (!set_id || !info)
187
- return;
188
- badge_info[set_id] = info;
189
- });
190
- this.public_listeners.message?.({
191
- body: body,
192
- channel: {
193
- room_id: tags['room-id'],
194
- name: channel,
195
- },
196
- id: tags.id,
197
- raw_text: text,
198
- timestamp_sent: Number(tags['tmi-sent-ts']),
199
- user: {
200
- badges: tags.badges?.split(',').flatMap((badge) => {
201
- const [set_id, version] = badge.split('/');
202
- if (!set_id || !version)
203
- return [];
204
- const storedBadge = this.assets.badges[set_id];
205
- if (!storedBadge)
206
- return [];
207
- const url = typeof storedBadge === 'object' ? storedBadge[version] : storedBadge;
208
- if (!url)
209
- return [];
210
- return {
211
- info: badge_info[set_id],
212
- set_id,
213
- url,
214
- };
215
- }) ?? [],
216
- color: tags.color ?? '#FFFFFF',
217
- id: tags['user-id'],
218
- username: source?.user ?? 'Unknown',
219
- display_name: tags['display-name'] ?? 'Unknown',
220
- roles: {
221
- admin: tags['user-type'] === 'admin',
222
- global_moderator: tags['user-type'] === 'global_mod',
223
- staff: tags['user-type'] === 'staff',
224
- turbo: tags.turbo === '1',
225
- vip: tags.vip === '1',
226
- moderator: tags.mod === '1',
227
- },
228
- },
229
- });
230
- break;
231
280
  }
281
+ const body = buildMessageBody(text, emotes);
282
+ const badge_info = {};
283
+ tags['badge-info']?.split(',').forEach((badge) => {
284
+ const [set_id, info] = badge.split('/');
285
+ if (!set_id || !info)
286
+ return;
287
+ badge_info[set_id] = info;
288
+ });
289
+ const timestamp = Number(tags['tmi-sent-ts']);
290
+ const data = {
291
+ body: body,
292
+ channel: {
293
+ room_id: tags['room-id'],
294
+ name: channel,
295
+ },
296
+ id: tags['id'],
297
+ raw_text: text,
298
+ timestamp_sent: Number.isFinite(timestamp) ? timestamp : Date.now(),
299
+ user: {
300
+ badges: tags['badges']?.split(',').flatMap((badge) => {
301
+ const [set_id, version] = badge.split('/');
302
+ if (!set_id || !version)
303
+ return [];
304
+ const storedBadge = this.assets.badges[set_id];
305
+ if (!storedBadge)
306
+ return [];
307
+ const url = typeof storedBadge === 'object' ? storedBadge[version] : storedBadge;
308
+ if (typeof url !== 'string' || !url)
309
+ return [];
310
+ return {
311
+ info: badge_info[set_id],
312
+ set_id,
313
+ url,
314
+ };
315
+ }) ?? [],
316
+ color: tags['color'] ?? '#FFFFFF',
317
+ id: tags['user-id'],
318
+ username: source?.user ?? 'Unknown',
319
+ display_name: tags['display-name'] ?? 'Unknown',
320
+ roles: {
321
+ admin: tags['user-type'] === 'admin',
322
+ global_moderator: tags['user-type'] === 'global_mod',
323
+ staff: tags['user-type'] === 'staff',
324
+ turbo: tags['turbo'] === '1',
325
+ vip: tags['vip'] === '1',
326
+ moderator: tags['mod'] === '1',
327
+ },
328
+ },
329
+ };
330
+ runSafely('twitch.message', () => {
331
+ this.public_listeners.message?.(data);
332
+ });
333
+ break;
232
334
  }
233
- });
335
+ }
234
336
  }
235
337
  }
236
- function parseIRCLine(line) {
237
- const components = line.split(' ');
238
- let componentIndex = 0;
239
- let raw_tags_component;
338
+ export function parseIRCLine(line) {
339
+ let rest = line.replace(/[\r\n]+$/, '').trimStart();
340
+ if (!rest)
341
+ return undefined;
342
+ const takeToken = () => {
343
+ const space = rest.indexOf(' ');
344
+ if (space === -1) {
345
+ const token = rest;
346
+ rest = '';
347
+ return token;
348
+ }
349
+ const token = rest.slice(0, space);
350
+ rest = rest.slice(space + 1).trimStart();
351
+ return token;
352
+ };
353
+ let tags;
240
354
  let source;
241
- if (components[componentIndex].startsWith('@')) {
242
- raw_tags_component = components[componentIndex].slice(1);
243
- componentIndex++;
355
+ if (rest.startsWith('@')) {
356
+ tags = parseTags(takeToken().slice(1));
357
+ if (!rest)
358
+ return undefined;
244
359
  }
245
- if (components[componentIndex].startsWith(':')) {
246
- source = parseSource(components[componentIndex].slice(1));
247
- componentIndex++;
360
+ if (rest.startsWith(':')) {
361
+ source = parseSource(takeToken().slice(1));
362
+ if (!rest)
363
+ return undefined;
364
+ }
365
+ const command = takeToken();
366
+ if (!/^(?:[A-Za-z]+|\d{3})$/.test(command)) {
367
+ return undefined;
248
368
  }
249
- const command = components[componentIndex];
250
- componentIndex++;
251
- let channel = '';
252
- if (components[componentIndex].startsWith('#'))
253
- channel = components[componentIndex].slice(1);
254
- componentIndex++;
255
369
  const params = [];
256
- while (components[componentIndex] !== undefined) {
257
- const param = components[componentIndex];
258
- if (param.startsWith(':')) {
259
- params.push(components.slice(componentIndex).join(' ').slice(1));
260
- componentIndex = -1;
261
- }
262
- else {
263
- params.push(param);
264
- componentIndex++;
370
+ while (rest) {
371
+ if (rest.startsWith(':')) {
372
+ params.push(rest.slice(1));
373
+ break;
265
374
  }
375
+ params.push(takeToken());
376
+ }
377
+ let channel = '';
378
+ if (params[0]?.startsWith('#')) {
379
+ channel = params.shift().slice(1);
266
380
  }
267
- if (params[0])
268
- params[0] = String.raw `${params[0]}`.replaceAll(/.+ACTION (.*).+/g, (_original, group) => group);
269
- const tags = raw_tags_component ? parseTags(raw_tags_component, command) : undefined;
270
381
  return {
271
382
  channel,
272
383
  command,
@@ -275,158 +386,44 @@ function parseIRCLine(line) {
275
386
  tags,
276
387
  };
277
388
  }
278
- function parseTags(component, command) {
279
- const tags = {};
280
- component.split(';').forEach((raw_tag) => {
281
- const [key, value] = raw_tag.split('=');
282
- if (RAW_TAGS[command].findIndex((el) => el === key) === -1)
283
- console.warn(`[${command}] Unknown Tag: ${raw_tag}`);
284
- tags[key] = value;
285
- });
389
+ function parseTags(component) {
390
+ const tags = Object.create(null);
391
+ for (const rawTag of component.split(';')) {
392
+ const equals = rawTag.indexOf('=');
393
+ const key = equals === -1 ? rawTag : rawTag.slice(0, equals);
394
+ const value = equals === -1 ? '' : rawTag.slice(equals + 1);
395
+ if (!key)
396
+ continue;
397
+ tags[key] = value.replace(/\\(.)/g, (_, escaped) => {
398
+ switch (escaped) {
399
+ case 's':
400
+ return ' ';
401
+ case ':':
402
+ return ';';
403
+ case 'r':
404
+ return '\r';
405
+ case 'n':
406
+ return '\n';
407
+ case '\\':
408
+ return '\\';
409
+ default:
410
+ return escaped;
411
+ }
412
+ });
413
+ }
286
414
  return tags;
287
415
  }
288
416
  function parseSource(component) {
289
- let user = undefined;
290
- let host = component;
291
- let nick = undefined;
292
- if (component.includes('!'))
293
- [nick, host] = component.split('!');
294
- if (host?.includes('@'))
295
- [user, host] = host.split('@');
417
+ const bang = component.indexOf('!');
418
+ if (bang === -1) {
419
+ return { host: component || 'unknown' };
420
+ }
421
+ const nick = component.slice(0, bang);
422
+ const remainder = component.slice(bang + 1);
423
+ const at = remainder.indexOf('@');
296
424
  return {
297
- host: host ?? 'unknown',
298
425
  nick,
299
- user,
426
+ user: at === -1 ? remainder : remainder.slice(0, at),
427
+ host: at === -1 ? 'unknown' : remainder.slice(at + 1),
300
428
  };
301
429
  }
302
- const RAW_TAGS = {
303
- CLEARCHAT: ['ban-duration', 'room-id', 'target-user-id', 'tmi-sent-ts'],
304
- CLEARMSG: ['login', 'room-id', 'target-msg-id', 'tmi-sent-ts'],
305
- GLOBALUSERSTATE: [
306
- 'badge-info',
307
- 'badges',
308
- 'color',
309
- 'display-name',
310
- 'emote-sets',
311
- 'turbo',
312
- 'user-id',
313
- 'user-type',
314
- ],
315
- HOSTTARGET: [],
316
- NOTICE: ['msg-id', 'target-user-id'],
317
- PART: [],
318
- PING: [],
319
- PONG: [],
320
- PRIVMSG: [
321
- 'badge-info',
322
- 'badges',
323
- 'bits',
324
- 'color',
325
- 'display-name',
326
- 'emotes',
327
- 'emote-only',
328
- 'id',
329
- 'mod',
330
- 'custom-reward-id',
331
- 'reply-thread-parent-display-name',
332
- 'reply-thread-parent-user-id',
333
- 'pinned-chat-paid-amount',
334
- 'pinned-chat-paid-currency',
335
- 'pinned-chat-paid-exponent',
336
- 'pinned-chat-paid-level',
337
- 'pinned-chat-paid-is-system-message',
338
- 'reply-parent-msg-id',
339
- 'reply-parent-user-id',
340
- 'reply-parent-user-login',
341
- 'reply-parent-display-name',
342
- 'reply-parent-msg-body',
343
- 'reply-thread-parent-msg-id',
344
- 'reply-thread-parent-user-login',
345
- 'room-id',
346
- 'subscriber',
347
- 'tmi-sent-ts',
348
- 'turbo',
349
- 'user-id',
350
- 'user-type',
351
- 'vip',
352
- ...[
353
- 'client-nonce',
354
- 'first-msg',
355
- 'flags',
356
- 'returning-chatter',
357
- ],
358
- ],
359
- RECONNECT: [],
360
- ROOMSTATE: ['emote-only', 'followers-only', 'r9k', 'room-id', 'slow', 'subs-only'],
361
- USERNOTICE: [
362
- 'badge-info',
363
- 'badges',
364
- 'color',
365
- 'display-name',
366
- 'emotes',
367
- 'id',
368
- 'login',
369
- 'mod',
370
- 'msg-id',
371
- 'room-id',
372
- 'subscriber',
373
- 'system-msg',
374
- 'tmi-sent-ts',
375
- 'turbo',
376
- 'user-id',
377
- 'user-type',
378
- 'vip',
379
- 'flags',
380
- ...[
381
- 'msg-param-cumulative-months',
382
- 'msg-param-displayName',
383
- 'msg-param-login',
384
- 'msg-param-multimonth-duration',
385
- 'msg-param-multimonth-tenure',
386
- 'msg-param-was-gifted=false',
387
- 'msg-param-months',
388
- 'msg-param-promo-gift-total',
389
- 'msg-param-promo-name',
390
- 'msg-param-recipient-display-name',
391
- 'msg-param-recipient-id',
392
- 'msg-param-recipient-user-name',
393
- 'msg-param-sender-login',
394
- 'msg-param-sender-name',
395
- 'msg-param-should-share-streak',
396
- 'msg-param-streak-months',
397
- 'msg-param-sub-plan',
398
- 'msg-param-sub-plan-name',
399
- 'msg-param-viewerCount',
400
- 'msg-param-ritual-name',
401
- 'msg-param-threshold',
402
- 'msg-param-gift-months',
403
- 'msg-param-was-gifted',
404
- 'msg-param-community-gift-id',
405
- 'msg-param-mass-gift-count',
406
- 'msg-param-origin-id',
407
- ],
408
- ],
409
- USERSTATE: [
410
- 'badge-info',
411
- 'badges',
412
- 'color',
413
- 'display-name',
414
- 'emote-sets',
415
- 'id',
416
- 'mod',
417
- 'subscriber',
418
- 'turbo',
419
- 'user-type',
420
- ],
421
- WHISPER: [
422
- 'badges',
423
- 'color',
424
- 'display-name',
425
- 'emotes',
426
- 'message-id',
427
- 'thread-id',
428
- 'turbo',
429
- 'user-id',
430
- 'user-type',
431
- ],
432
- };