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