multichat-ts 0.0.1

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 ADDED
@@ -0,0 +1,403 @@
1
+ import { WebSocket } from 'partysocket';
2
+ import {} from './index';
3
+ const ANONYMOUS_IRC_PASS = 'SCHMOOPIIE';
4
+ const ANONYMOUS_IRC_LOGIN = 'justinfan1234';
5
+ const PING_INTERVAL_MS = 30_000;
6
+ const PING_TIMEOUT_MS = 10_000;
7
+ export class TwitchIRC {
8
+ channel_name;
9
+ assets = {
10
+ external_emotes: {},
11
+ badges: {},
12
+ };
13
+ latency = 0;
14
+ ping = {};
15
+ public_listeners = {};
16
+ socket;
17
+ connect(channel) {
18
+ if (channel?.channelName)
19
+ this.channel_name = channel.channelName;
20
+ if (!this.channel_name)
21
+ return console.error('channel_name not specified');
22
+ console.log(`connecting to ${this.channel_name}...`);
23
+ this.socket?.close();
24
+ this.socket = new WebSocket('wss://irc-ws.chat.twitch.tv');
25
+ this.socket.onopen = () => this.onOpen();
26
+ this.socket.onclose = () => this.onClose();
27
+ this.socket.onmessage = (event) => this.onMessage(event);
28
+ console.log(parseIRCLine(`@badge-info=;badges=turbo/1;color=#9ACD32;display-name=TestChannel;emotes=;id=3d830f12-795c-447d-af3c-ea05e40fbddb;login=testchannel;mod=0;msg-id=raid;msg-param-displayName=TestChannel;msg-param-login=testchannel;msg-param-viewerCount=15;room-id=33332222;subscriber=0;system-msg=15\sraiders\sfrom\sTestChannel\shave\sjoined\n!;tmi-sent-ts=1507246572675;turbo=1;user-id=123456;user-type= :tmi.twitch.tv USERNOTICE #othertestchannel`));
29
+ }
30
+ disconnect() {
31
+ this.socket?.close();
32
+ }
33
+ on(event_name, callback_fn) {
34
+ this.public_listeners[event_name] = callback_fn;
35
+ }
36
+ isConnected() {
37
+ return !!this.socket && this.socket.readyState === WebSocket.OPEN;
38
+ }
39
+ onOpen() {
40
+ this.send('CAP REQ :twitch.tv/commands twitch.tv/tags');
41
+ this.send(`PASS ${ANONYMOUS_IRC_PASS}`);
42
+ this.send(`NICK ${ANONYMOUS_IRC_LOGIN}`);
43
+ this.send(`JOIN #${this.channel_name}`);
44
+ console.log(`Connected to Twitch IRC as Anonymous (${this.channel_name})`);
45
+ if (this.ping.interval)
46
+ clearInterval(this.ping.interval);
47
+ this.sendPing();
48
+ this.ping.interval = setInterval(() => this.sendPing(), PING_INTERVAL_MS);
49
+ }
50
+ onClose() {
51
+ clearInterval(this.ping.interval);
52
+ clearTimeout(this.ping.timeout);
53
+ }
54
+ sendPing() {
55
+ this.send('PING');
56
+ this.ping.lastSentTimestamp = Date.now();
57
+ if (this.ping.timeout)
58
+ clearTimeout(this.ping.timeout);
59
+ this.ping.timeout = setTimeout(() => {
60
+ console.error('PING Timeout, reconnecting...');
61
+ this.connect();
62
+ }, PING_TIMEOUT_MS);
63
+ }
64
+ send(irc_message) {
65
+ if (!this.isConnected()) {
66
+ throw new Error('Not connected');
67
+ }
68
+ this.socket?.send(irc_message);
69
+ }
70
+ onMessage(event) {
71
+ if (!event.data)
72
+ return;
73
+ const lines = String(event.data).trim().split('\r\n');
74
+ const messages = lines.map(parseIRCLine);
75
+ messages.forEach((message) => {
76
+ switch (message.command) {
77
+ case 'PONG': {
78
+ if (!this.ping.lastSentTimestamp)
79
+ return console.error('got PONG without sending PING');
80
+ clearInterval(this.ping.timeout);
81
+ this.latency = Date.now() - this.ping.lastSentTimestamp;
82
+ this.ping.lastSentTimestamp = undefined;
83
+ break;
84
+ }
85
+ case 'PING': {
86
+ this.send('PONG');
87
+ break;
88
+ }
89
+ case 'CLEARCHAT': {
90
+ const { channel, tags } = message;
91
+ if (!tags)
92
+ return;
93
+ this.public_listeners.clear_messages?.({
94
+ channel: {
95
+ name: channel,
96
+ room_id: tags['room-id'] ?? 'unknown',
97
+ },
98
+ timestamp_sent: Number(tags['tmi-sent-ts']),
99
+ timeout_duration_seconds: tags['ban-duration']
100
+ ? Number(tags['ban-duration'])
101
+ : undefined,
102
+ });
103
+ break;
104
+ }
105
+ case 'PRIVMSG': {
106
+ const { channel, params, tags } = message;
107
+ if (!tags || !tags['user-id'] || !tags['id'] || !tags['room-id'])
108
+ return;
109
+ const text = params[0];
110
+ if (!text)
111
+ return;
112
+ const body = [];
113
+ tags.emotes?.split('/').forEach((raw_emote_string) => {
114
+ const [emote_id, raw_emote_positions_string] = raw_emote_string.split(':');
115
+ if (!emote_id || !raw_emote_positions_string)
116
+ return;
117
+ const raw_emote_positions = raw_emote_positions_string.split(',');
118
+ raw_emote_positions.forEach((raw_emote_position) => {
119
+ const [emote_start, emote_end] = raw_emote_position.split('-');
120
+ if (!emote_start || !emote_end)
121
+ return;
122
+ body.push({
123
+ type: 'emote',
124
+ start_inclusive: +emote_start,
125
+ end_exclusive: +emote_end + 1,
126
+ url: `https://static-cdn.jtvnw.net/emoticons/v2/${emote_id}/default/dark/1.0`,
127
+ });
128
+ });
129
+ });
130
+ body.sort((a, b) => a.start_inclusive - b.start_inclusive);
131
+ const old_body_length = body.length;
132
+ if (old_body_length > 0) {
133
+ body.forEach((segment, index) => {
134
+ const previous_segment = body[index - 1];
135
+ const text_start_inclusive = previous_segment?.end_exclusive !== undefined
136
+ ? previous_segment.end_exclusive + 1
137
+ : 0;
138
+ const text_end_exclusive = Math.max(0, segment.start_inclusive);
139
+ if (text_end_exclusive - text_start_inclusive > 0) {
140
+ body.push({
141
+ type: 'text',
142
+ text: text.slice(text_start_inclusive, text_end_exclusive),
143
+ start_inclusive: text_start_inclusive,
144
+ end_exclusive: text_end_exclusive,
145
+ });
146
+ }
147
+ if (index === old_body_length - 1 && segment.end_exclusive < text.length - 1) {
148
+ body.push({
149
+ type: 'text',
150
+ text: text.slice(segment.end_exclusive),
151
+ start_inclusive: segment.end_exclusive,
152
+ end_exclusive: text.length,
153
+ });
154
+ }
155
+ });
156
+ }
157
+ else {
158
+ body.push({
159
+ type: 'text',
160
+ text,
161
+ start_inclusive: 0,
162
+ end_exclusive: text.length,
163
+ });
164
+ }
165
+ body.sort((a, b) => a.start_inclusive - b.start_inclusive);
166
+ const badge_info = {};
167
+ tags['badge-info']?.split(',').forEach((badge) => {
168
+ const [set_id, info] = badge.split('/');
169
+ if (!set_id || !info)
170
+ return;
171
+ badge_info[set_id] = info;
172
+ });
173
+ this.public_listeners.message?.({
174
+ body: body,
175
+ channel: {
176
+ room_id: tags['room-id'],
177
+ name: channel,
178
+ },
179
+ id: tags.id,
180
+ raw_text: text,
181
+ timestamp_sent: Number(tags['tmi-sent-ts']),
182
+ user: {
183
+ badges: tags.badges?.split(',').flatMap((badge) => {
184
+ const [set_id, version] = badge.split('/');
185
+ if (!set_id || !version)
186
+ return [];
187
+ const url = this.assets.badges[set_id]?.[version];
188
+ if (!url)
189
+ return [];
190
+ return {
191
+ info: badge_info[set_id],
192
+ set_id,
193
+ url,
194
+ };
195
+ }) ?? [],
196
+ color: tags.color ?? '#FFFFFF',
197
+ id: tags['user-id'],
198
+ username: 'IDK',
199
+ display_name: tags['display-name'] ?? 'Unknown',
200
+ roles: {},
201
+ },
202
+ });
203
+ break;
204
+ }
205
+ }
206
+ });
207
+ }
208
+ }
209
+ function parseIRCLine(line) {
210
+ const components = line.split(' ');
211
+ let componentIndex = 0;
212
+ let raw_tags_component;
213
+ let source;
214
+ if (components[componentIndex].startsWith('@')) {
215
+ raw_tags_component = components[componentIndex].slice(1);
216
+ componentIndex++;
217
+ }
218
+ if (components[componentIndex].startsWith(':')) {
219
+ source = parseSource(components[componentIndex].slice(1));
220
+ componentIndex++;
221
+ }
222
+ const command = components[componentIndex];
223
+ componentIndex++;
224
+ let channel = '';
225
+ if (components[componentIndex].startsWith('#'))
226
+ channel = components[componentIndex].slice(1);
227
+ componentIndex++;
228
+ const params = [];
229
+ while (components[componentIndex] !== undefined) {
230
+ const param = components[componentIndex];
231
+ if (param.startsWith(':')) {
232
+ params.push(components.slice(componentIndex).join(' ').slice(1));
233
+ componentIndex = -1;
234
+ }
235
+ else {
236
+ params.push(param);
237
+ componentIndex++;
238
+ }
239
+ }
240
+ const tags = raw_tags_component ? parseTags(raw_tags_component, command) : undefined;
241
+ return {
242
+ channel,
243
+ command,
244
+ params,
245
+ source,
246
+ tags,
247
+ };
248
+ }
249
+ function parseTags(component, command) {
250
+ const tags = {};
251
+ component.split(';').forEach((raw_tag) => {
252
+ const [key, value] = raw_tag.split('=');
253
+ if (RAW_TAGS[command].findIndex((el) => el === key) === -1)
254
+ console.warn(`[${command}] Unknown Tag: ${raw_tag}`);
255
+ tags[key] = value;
256
+ });
257
+ return tags;
258
+ }
259
+ function parseSource(component) {
260
+ let user = undefined;
261
+ let host = component;
262
+ let nick = undefined;
263
+ if (component.includes('!'))
264
+ [nick, host] = component.split('!');
265
+ if (host?.includes('@'))
266
+ [user, host] = host.split('@');
267
+ return {
268
+ host: host ?? 'unknown',
269
+ nick,
270
+ user,
271
+ };
272
+ }
273
+ const RAW_TAGS = {
274
+ CLEARCHAT: ['ban-duration', 'room-id', 'target-user-id', 'tmi-sent-ts'],
275
+ CLEARMSG: ['login', 'room-id', 'target-msg-id', 'tmi-sent-ts'],
276
+ GLOBALUSERSTATE: [
277
+ 'badge-info',
278
+ 'badges',
279
+ 'color',
280
+ 'display-name',
281
+ 'emote-sets',
282
+ 'turbo',
283
+ 'user-id',
284
+ 'user-type',
285
+ ],
286
+ HOSTTARGET: [],
287
+ NOTICE: ['msg-id', 'target-user-id'],
288
+ PART: [],
289
+ PING: [],
290
+ PONG: [],
291
+ PRIVMSG: [
292
+ 'badge-info',
293
+ 'badges',
294
+ 'bits',
295
+ 'color',
296
+ 'display-name',
297
+ 'emotes',
298
+ 'emote-only',
299
+ 'id',
300
+ 'mod',
301
+ 'custom-reward-id',
302
+ 'reply-thread-parent-display-name',
303
+ 'reply-thread-parent-user-id',
304
+ 'pinned-chat-paid-amount',
305
+ 'pinned-chat-paid-currency',
306
+ 'pinned-chat-paid-exponent',
307
+ 'pinned-chat-paid-level',
308
+ 'pinned-chat-paid-is-system-message',
309
+ 'reply-parent-msg-id',
310
+ 'reply-parent-user-id',
311
+ 'reply-parent-user-login',
312
+ 'reply-parent-display-name',
313
+ 'reply-parent-msg-body',
314
+ 'reply-thread-parent-msg-id',
315
+ 'reply-thread-parent-user-login',
316
+ 'room-id',
317
+ 'subscriber',
318
+ 'tmi-sent-ts',
319
+ 'turbo',
320
+ 'user-id',
321
+ 'user-type',
322
+ 'vip',
323
+ ...[
324
+ 'client-nonce',
325
+ 'first-msg',
326
+ 'flags',
327
+ 'returning-chatter',
328
+ ],
329
+ ],
330
+ RECONNECT: [],
331
+ ROOMSTATE: ['emote-only', 'followers-only', 'r9k', 'room-id', 'slow', 'subs-only'],
332
+ USERNOTICE: [
333
+ 'badge-info',
334
+ 'badges',
335
+ 'color',
336
+ 'display-name',
337
+ 'emotes',
338
+ 'id',
339
+ 'login',
340
+ 'mod',
341
+ 'msg-id',
342
+ 'room-id',
343
+ 'subscriber',
344
+ 'system-msg',
345
+ 'tmi-sent-ts',
346
+ 'turbo',
347
+ 'user-id',
348
+ 'user-type',
349
+ 'vip',
350
+ 'flags',
351
+ ...[
352
+ 'msg-param-cumulative-months',
353
+ 'msg-param-displayName',
354
+ 'msg-param-login',
355
+ 'msg-param-multimonth-duration',
356
+ 'msg-param-multimonth-tenure',
357
+ 'msg-param-was-gifted=false',
358
+ 'msg-param-months',
359
+ 'msg-param-promo-gift-total',
360
+ 'msg-param-promo-name',
361
+ 'msg-param-recipient-display-name',
362
+ 'msg-param-recipient-id',
363
+ 'msg-param-recipient-user-name',
364
+ 'msg-param-sender-login',
365
+ 'msg-param-sender-name',
366
+ 'msg-param-should-share-streak',
367
+ 'msg-param-streak-months',
368
+ 'msg-param-sub-plan',
369
+ 'msg-param-sub-plan-name',
370
+ 'msg-param-viewerCount',
371
+ 'msg-param-ritual-name',
372
+ 'msg-param-threshold',
373
+ 'msg-param-gift-months',
374
+ 'msg-param-was-gifted',
375
+ 'msg-param-community-gift-id',
376
+ 'msg-param-mass-gift-count',
377
+ 'msg-param-origin-id',
378
+ ],
379
+ ],
380
+ USERSTATE: [
381
+ 'badge-info',
382
+ 'badges',
383
+ 'color',
384
+ 'display-name',
385
+ 'emote-sets',
386
+ 'id',
387
+ 'mod',
388
+ 'subscriber',
389
+ 'turbo',
390
+ 'user-type',
391
+ ],
392
+ WHISPER: [
393
+ 'badges',
394
+ 'color',
395
+ 'display-name',
396
+ 'emotes',
397
+ 'message-id',
398
+ 'thread-id',
399
+ 'turbo',
400
+ 'user-id',
401
+ 'user-type',
402
+ ],
403
+ };
@@ -0,0 +1,12 @@
1
+ import js from '@eslint/js'
2
+ import prettier from 'eslint-config-prettier'
3
+ import ts from 'typescript-eslint'
4
+
5
+ export default [
6
+ js.configs.recommended,
7
+ ...ts.configs.recommended,
8
+ prettier,
9
+ {
10
+ ignores: ['dist/'],
11
+ }
12
+ ]
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "multichat-ts",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "Receive real-time events for chat-related messages and events on multiple platforms (Twitch, Kick)",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/0Abdullah/multichat-ts.git"
9
+ },
10
+ "homepage": "https://github.com/0Abdullah/multichat-ts",
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "exports": {
14
+ ".": "./dist/index.js"
15
+ },
16
+ "keywords": [
17
+ "chat",
18
+ "multichat",
19
+ "twitch",
20
+ "kick",
21
+ "realtime",
22
+ "irc",
23
+ "pusher",
24
+ "typescript",
25
+ "ts"
26
+ ],
27
+ "author": {
28
+ "name": "Abdullah",
29
+ "url": "https://github.com/0Abdullah"
30
+ },
31
+ "license": "MIT",
32
+ "devDependencies": {
33
+ "@eslint/js": "^9.10.0",
34
+ "@types/node": "^22.5.4",
35
+ "eslint": "^9.10.0",
36
+ "eslint-config-prettier": "^9.1.0",
37
+ "prettier": "^3.3.3",
38
+ "typescript": "^5.5.4",
39
+ "typescript-eslint": "^8.4.0"
40
+ },
41
+ "dependencies": {
42
+ "partysocket": "^1.0.2",
43
+ "pusher-js": "8.4.0-rc2"
44
+ },
45
+ "scripts": {
46
+ "build": "tsc",
47
+ "lint": "eslint .",
48
+ "lint:fix": "eslint . --fix",
49
+ "format": "prettier --check src",
50
+ "format:fix": "prettier --write src"
51
+ }
52
+ }
package/src/index.ts ADDED
@@ -0,0 +1,166 @@
1
+ export * from './kick';
2
+ export * from './twitch';
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 BadgeURLsBySetIDAndVersionID = {
159
+ [badge_set_id: string]: {
160
+ [badge_version_id: string]: string;
161
+ };
162
+ };
163
+
164
+ export type EmoteURLsByName = {
165
+ [emote_name: string]: string;
166
+ };