multichat-ts 0.0.82 → 0.0.84

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.82",
3
+ "version": "0.0.84",
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",
@@ -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
+ };