multichat-ts 0.0.81 → 0.0.83

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.
@@ -0,0 +1,425 @@
1
+ import { WebSocket } from 'partysocket';
2
+ const ANONYMOUS_IRC_PASS = 'SCHMOOPIIE';
3
+ const ANONYMOUS_IRC_LOGIN = 'justinfan1234';
4
+ const PING_INTERVAL_MS = 30_000;
5
+ const PING_TIMEOUT_MS = 10_000;
6
+ export class TwitchIRC {
7
+ channel_name;
8
+ assets = {
9
+ external_emotes: {},
10
+ badges: {},
11
+ };
12
+ latency = 0;
13
+ ping = {};
14
+ public_listeners = {};
15
+ socket;
16
+ ws;
17
+ constructor(ws) {
18
+ this.ws = ws;
19
+ }
20
+ setBadges(badges) {
21
+ this.assets.badges = { ...badges, ...this.assets.badges };
22
+ }
23
+ setExternalEmotes(external_emotes) {
24
+ this.assets.external_emotes = { ...external_emotes, ...this.assets.external_emotes };
25
+ }
26
+ getStoredBadges() {
27
+ return this.assets.badges;
28
+ }
29
+ getStoredExternalEmotes() {
30
+ return this.assets.external_emotes;
31
+ }
32
+ connect(channel) {
33
+ if (channel?.channelName)
34
+ 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,
41
+ });
42
+ this.socket.onopen = () => this.onOpen();
43
+ this.socket.onclose = () => this.onClose();
44
+ this.socket.onmessage = (event) => this.onMessage(event);
45
+ }
46
+ disconnect() {
47
+ this.socket?.close();
48
+ }
49
+ on(event_name, callback_fn) {
50
+ this.public_listeners[event_name] = callback_fn;
51
+ }
52
+ isConnected() {
53
+ return !!this.socket && this.socket.readyState === WebSocket.OPEN;
54
+ }
55
+ 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);
63
+ 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);
69
+ }
70
+ sendPing() {
71
+ this.send('PING');
72
+ this.ping.lastSentTimestamp = Date.now();
73
+ if (this.ping.timeout)
74
+ clearTimeout(this.ping.timeout);
75
+ this.ping.timeout = setTimeout(() => {
76
+ console.error('PING Timeout, reconnecting...');
77
+ this.connect();
78
+ }, PING_TIMEOUT_MS);
79
+ }
80
+ send(irc_message) {
81
+ if (!this.isConnected()) {
82
+ throw new Error('Not connected');
83
+ }
84
+ this.socket?.send(irc_message);
85
+ }
86
+ onMessage(event) {
87
+ if (!event.data)
88
+ 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;
101
+ }
102
+ case 'PING': {
103
+ this.send('PONG');
104
+ break;
105
+ }
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;
121
+ }
122
+ case 'PRIVMSG': {
123
+ const { channel, params, tags } = 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,
180
+ });
181
+ }
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: (tags['display-name'] ?? 'Unknown').toLowerCase(),
219
+ display_name: tags['display-name'] ?? 'Unknown',
220
+ roles: {},
221
+ },
222
+ });
223
+ break;
224
+ }
225
+ }
226
+ });
227
+ }
228
+ }
229
+ function parseIRCLine(line) {
230
+ const components = line.split(' ');
231
+ let componentIndex = 0;
232
+ let raw_tags_component;
233
+ let source;
234
+ if (components[componentIndex].startsWith('@')) {
235
+ raw_tags_component = components[componentIndex].slice(1);
236
+ componentIndex++;
237
+ }
238
+ if (components[componentIndex].startsWith(':')) {
239
+ source = parseSource(components[componentIndex].slice(1));
240
+ componentIndex++;
241
+ }
242
+ const command = components[componentIndex];
243
+ componentIndex++;
244
+ let channel = '';
245
+ if (components[componentIndex].startsWith('#'))
246
+ channel = components[componentIndex].slice(1);
247
+ componentIndex++;
248
+ const params = [];
249
+ while (components[componentIndex] !== undefined) {
250
+ const param = components[componentIndex];
251
+ if (param.startsWith(':')) {
252
+ params.push(components.slice(componentIndex).join(' ').slice(1));
253
+ componentIndex = -1;
254
+ }
255
+ else {
256
+ params.push(param);
257
+ componentIndex++;
258
+ }
259
+ }
260
+ if (params[0])
261
+ params[0] = String.raw `${params[0]}`.replaceAll(/.+ACTION (.*).+/g, (_original, group) => group);
262
+ const tags = raw_tags_component ? parseTags(raw_tags_component, command) : undefined;
263
+ return {
264
+ channel,
265
+ command,
266
+ params,
267
+ source,
268
+ tags,
269
+ };
270
+ }
271
+ function parseTags(component, command) {
272
+ const tags = {};
273
+ component.split(';').forEach((raw_tag) => {
274
+ const [key, value] = raw_tag.split('=');
275
+ if (RAW_TAGS[command].findIndex((el) => el === key) === -1)
276
+ console.warn(`[${command}] Unknown Tag: ${raw_tag}`);
277
+ tags[key] = value;
278
+ });
279
+ return tags;
280
+ }
281
+ function parseSource(component) {
282
+ let user = undefined;
283
+ let host = component;
284
+ let nick = undefined;
285
+ if (component.includes('!'))
286
+ [nick, host] = component.split('!');
287
+ if (host?.includes('@'))
288
+ [user, host] = host.split('@');
289
+ return {
290
+ host: host ?? 'unknown',
291
+ nick,
292
+ user,
293
+ };
294
+ }
295
+ const RAW_TAGS = {
296
+ CLEARCHAT: ['ban-duration', 'room-id', 'target-user-id', 'tmi-sent-ts'],
297
+ CLEARMSG: ['login', 'room-id', 'target-msg-id', 'tmi-sent-ts'],
298
+ GLOBALUSERSTATE: [
299
+ 'badge-info',
300
+ 'badges',
301
+ 'color',
302
+ 'display-name',
303
+ 'emote-sets',
304
+ 'turbo',
305
+ 'user-id',
306
+ 'user-type',
307
+ ],
308
+ HOSTTARGET: [],
309
+ NOTICE: ['msg-id', 'target-user-id'],
310
+ PART: [],
311
+ PING: [],
312
+ PONG: [],
313
+ PRIVMSG: [
314
+ 'badge-info',
315
+ 'badges',
316
+ 'bits',
317
+ 'color',
318
+ 'display-name',
319
+ 'emotes',
320
+ 'emote-only',
321
+ 'id',
322
+ 'mod',
323
+ 'custom-reward-id',
324
+ 'reply-thread-parent-display-name',
325
+ 'reply-thread-parent-user-id',
326
+ 'pinned-chat-paid-amount',
327
+ 'pinned-chat-paid-currency',
328
+ 'pinned-chat-paid-exponent',
329
+ 'pinned-chat-paid-level',
330
+ 'pinned-chat-paid-is-system-message',
331
+ 'reply-parent-msg-id',
332
+ 'reply-parent-user-id',
333
+ 'reply-parent-user-login',
334
+ 'reply-parent-display-name',
335
+ 'reply-parent-msg-body',
336
+ 'reply-thread-parent-msg-id',
337
+ 'reply-thread-parent-user-login',
338
+ 'room-id',
339
+ 'subscriber',
340
+ 'tmi-sent-ts',
341
+ 'turbo',
342
+ 'user-id',
343
+ 'user-type',
344
+ 'vip',
345
+ ...[
346
+ 'client-nonce',
347
+ 'first-msg',
348
+ 'flags',
349
+ 'returning-chatter',
350
+ ],
351
+ ],
352
+ RECONNECT: [],
353
+ ROOMSTATE: ['emote-only', 'followers-only', 'r9k', 'room-id', 'slow', 'subs-only'],
354
+ USERNOTICE: [
355
+ 'badge-info',
356
+ 'badges',
357
+ 'color',
358
+ 'display-name',
359
+ 'emotes',
360
+ 'id',
361
+ 'login',
362
+ 'mod',
363
+ 'msg-id',
364
+ 'room-id',
365
+ 'subscriber',
366
+ 'system-msg',
367
+ 'tmi-sent-ts',
368
+ 'turbo',
369
+ 'user-id',
370
+ 'user-type',
371
+ 'vip',
372
+ 'flags',
373
+ ...[
374
+ 'msg-param-cumulative-months',
375
+ 'msg-param-displayName',
376
+ 'msg-param-login',
377
+ 'msg-param-multimonth-duration',
378
+ 'msg-param-multimonth-tenure',
379
+ 'msg-param-was-gifted=false',
380
+ 'msg-param-months',
381
+ 'msg-param-promo-gift-total',
382
+ 'msg-param-promo-name',
383
+ 'msg-param-recipient-display-name',
384
+ 'msg-param-recipient-id',
385
+ 'msg-param-recipient-user-name',
386
+ 'msg-param-sender-login',
387
+ 'msg-param-sender-name',
388
+ 'msg-param-should-share-streak',
389
+ 'msg-param-streak-months',
390
+ 'msg-param-sub-plan',
391
+ 'msg-param-sub-plan-name',
392
+ 'msg-param-viewerCount',
393
+ 'msg-param-ritual-name',
394
+ 'msg-param-threshold',
395
+ 'msg-param-gift-months',
396
+ 'msg-param-was-gifted',
397
+ 'msg-param-community-gift-id',
398
+ 'msg-param-mass-gift-count',
399
+ 'msg-param-origin-id',
400
+ ],
401
+ ],
402
+ USERSTATE: [
403
+ 'badge-info',
404
+ 'badges',
405
+ 'color',
406
+ 'display-name',
407
+ 'emote-sets',
408
+ 'id',
409
+ 'mod',
410
+ 'subscriber',
411
+ 'turbo',
412
+ 'user-type',
413
+ ],
414
+ WHISPER: [
415
+ 'badges',
416
+ 'color',
417
+ 'display-name',
418
+ 'emotes',
419
+ 'message-id',
420
+ 'thread-id',
421
+ 'turbo',
422
+ 'user-id',
423
+ 'user-type',
424
+ ],
425
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multichat-ts",
3
- "version": "0.0.81",
3
+ "version": "0.0.83",
4
4
  "type": "module",
5
5
  "description": "Receive type-safe realtime events for chat-related messages on multiple platforms (Twitch, Kick)",
6
6
  "repository": {
@@ -11,7 +11,8 @@
11
11
  "main": "dist/index.js",
12
12
  "types": "dist/index.d.ts",
13
13
  "exports": {
14
- ".": "./dist/index.js"
14
+ ".": "./dist/default/index.js",
15
+ "./worker": "./dist/worker/index.js"
15
16
  },
16
17
  "scripts": {
17
18
  "build": "tsc",
@@ -50,11 +50,11 @@ export class KickPusher {
50
50
  public isConnected = false;
51
51
 
52
52
  public setBadges(badges: BadgeURLsByNameOrCount) {
53
- this.assets.badges = { ...badges, ...this.assets.badges };
53
+ this.assets.badges = { ...this.assets.badges, ...badges };
54
54
  }
55
55
 
56
56
  public setExternalEmotes(external_emotes: EmoteURLsByName) {
57
- this.assets.external_emotes = { ...external_emotes, ...this.assets.external_emotes };
57
+ this.assets.external_emotes = { ...this.assets.external_emotes, ...external_emotes };
58
58
  }
59
59
 
60
60
  public getStoredBadges() {
@@ -0,0 +1,168 @@
1
+ export * from './kick.js';
2
+ export * from './twitch.js';
3
+
4
+ export type Message = {
5
+ body: BodyComponent[];
6
+ channel: {
7
+ room_id: string;
8
+ name: string;
9
+ };
10
+ id: string;
11
+ raw_text: string;
12
+ timestamp_sent: number;
13
+ user: {
14
+ badges: Badge[];
15
+ color: string;
16
+ id: string;
17
+ username: string;
18
+ display_name: string;
19
+ roles: {
20
+ [role in 'vip' | 'moderator' | 'turbo' | 'admin' | 'global_moderator' | 'staff']?: boolean;
21
+ };
22
+ };
23
+ };
24
+
25
+ export type ClearMessages = {
26
+ channel: {
27
+ room_id: string;
28
+ name: string;
29
+ };
30
+ user?: {
31
+ id: string;
32
+ username: string;
33
+ };
34
+ timeout_duration_seconds?: number | undefined;
35
+ timestamp_sent: number;
36
+ };
37
+
38
+ export type DeleteMessage = {
39
+ channel: {
40
+ room_id?: string;
41
+ name: string;
42
+ };
43
+ user?: {
44
+ username: string;
45
+ };
46
+ id: string;
47
+ raw_text: string;
48
+ timestamp_sent: number;
49
+ };
50
+
51
+ type EventData = {
52
+ subscription: {
53
+ months: number;
54
+ streak?: number;
55
+ subscription_plan: {
56
+ type: 'prime' | 1000 | 2000 | 3000;
57
+ name: string;
58
+ };
59
+ gift_upgrade?: {
60
+ gift_total: number;
61
+ promo_name: string;
62
+ sender: {
63
+ username: string;
64
+ display_name: string;
65
+ };
66
+ };
67
+ };
68
+ gift: {
69
+ months: number;
70
+ recipient: {
71
+ id: string;
72
+ username: string;
73
+ display_name: string;
74
+ };
75
+ subscription_plan: {
76
+ type: 'prime' | 1000 | 2000 | 3000;
77
+ name: string;
78
+ };
79
+ };
80
+ raid: {
81
+ sender: {
82
+ username: string;
83
+ display_name: string;
84
+ viewer_count: number;
85
+ };
86
+ };
87
+ };
88
+
89
+ export type Event = {
90
+ [E in keyof EventData]: {
91
+ type: E;
92
+ body: BodyComponent[];
93
+ channel: {
94
+ room_id: string;
95
+ name: string;
96
+ };
97
+ id: string;
98
+ raw_text: string;
99
+ system_message: string;
100
+ timestamp_sent: number;
101
+ user: {
102
+ badges: Badge[];
103
+ color: string;
104
+ id: string;
105
+ username: string;
106
+ display_name: string;
107
+ roles: {
108
+ [role in
109
+ | 'moderator'
110
+ | 'subscriber'
111
+ | 'turbo'
112
+ | 'admin'
113
+ | 'global_moderator'
114
+ | 'staff']?: boolean;
115
+ };
116
+ };
117
+ data: EventData[E];
118
+ };
119
+ }[keyof EventData];
120
+
121
+ export type BodyComponentEmote = {
122
+ end_exclusive: number;
123
+ start_inclusive: number;
124
+ type: 'emote';
125
+ url: string;
126
+ };
127
+
128
+ export type BodyComponent =
129
+ | {
130
+ end_exclusive: number;
131
+ label: string;
132
+ start_inclusive: number;
133
+ type: 'link';
134
+ url: string;
135
+ }
136
+ | {
137
+ end_exclusive: number;
138
+ start_inclusive: number;
139
+ text: string;
140
+ type: 'text';
141
+ }
142
+ | BodyComponentEmote;
143
+
144
+ type Badge = {
145
+ info?: string | undefined;
146
+ set_id: string;
147
+ url: string;
148
+ };
149
+
150
+ export type BadgeURLsByNameOrCount = {
151
+ [badge_name: string]:
152
+ | string
153
+ | {
154
+ [badge_count: number]: string;
155
+ };
156
+ };
157
+
158
+ export type BadgeURLsBySetIDOrSetIDAndVersion = {
159
+ [set_id: string]:
160
+ | {
161
+ [version: string]: string;
162
+ }
163
+ | string;
164
+ };
165
+
166
+ export type EmoteURLsByName = {
167
+ [emote_name: string]: string;
168
+ };