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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multichat-ts",
3
- "version": "0.0.94",
3
+ "version": "0.0.96",
4
4
  "type": "module",
5
5
  "description": "Receive type-safe realtime events for chat-related messages on multiple platforms (Twitch, Kick)",
6
6
  "repository": {
@@ -6,8 +6,15 @@ import {
6
6
  type EmoteURLsByName,
7
7
  type Message,
8
8
  } from './index.js';
9
-
10
- type ConnectionState = 'initialized' | 'connecting' | 'connected' | 'unavailable' | 'failed';
9
+ import { buildMessageBody, isFiniteNumber, isRecord, runSafely } from './safety.js';
10
+
11
+ type ConnectionState =
12
+ | 'initialized'
13
+ | 'connecting'
14
+ | 'connected'
15
+ | 'unavailable'
16
+ | 'failed'
17
+ | 'disconnected';
11
18
  type ConnectionStateEvent = { previous: ConnectionState; current: ConnectionState };
12
19
 
13
20
  type EventCallbackFunctions = {
@@ -50,7 +57,7 @@ export class KickPusher {
50
57
 
51
58
  private public_listeners: Partial<EventCallbackFunctions> = {};
52
59
 
53
- public socket?: Pusher;
60
+ public socket?: Pusher | undefined;
54
61
  public isConnected = false;
55
62
 
56
63
  public setBadges(badges: BadgeURLsByNameOrCount) {
@@ -69,109 +76,143 @@ export class KickPusher {
69
76
  return this.assets.external_emotes;
70
77
  }
71
78
 
79
+ private connectionVersion = 0;
80
+
81
+ private closeSocket() {
82
+ const socket = this.socket;
83
+
84
+ this.socket = undefined;
85
+ this.isConnected = false;
86
+
87
+ if (!socket) return;
88
+
89
+ runSafely('kick.cleanup.connection', () => socket.connection.unbind_all());
90
+
91
+ runSafely('kick.cleanup.listeners', () => socket.unbind_all());
92
+
93
+ runSafely('kick.cleanup.disconnect', () => socket.disconnect());
94
+ }
95
+
72
96
  public async connect(
73
97
  channel?: { channelName?: string },
74
- get_channel: (channelName: string) => Promise<GetChannelResponse | undefined> = async (
75
- channelName,
76
- ) => {
77
- const res = await fetch(`https://kick.com/api/v2/channels/${channelName}`, {
78
- headers: {
79
- accept: 'aplication/json',
80
- 'user-agent':
81
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
82
- },
83
- });
84
- const json = await res.json();
85
- return json as GetChannelResponse | undefined;
86
- },
87
- ) {
88
- if (channel?.channelName) this.channel_name = channel.channelName;
89
- if (!this.channel_name) return console.error('channel_name not specified');
98
+ getChannel: (
99
+ channelName: string,
100
+ ) => Promise<GetChannelResponse | undefined> = defaultGetChannel,
101
+ ): Promise<void> {
102
+ if (channel?.channelName) {
103
+ this.channel_name = channel.channelName;
104
+ }
90
105
 
91
- console.log(`connecting to ${this.channel_name}...`);
106
+ const channelName = this.channel_name;
92
107
 
93
- const channel_response = await get_channel(this.channel_name);
108
+ if (!channelName) {
109
+ throw new Error('Kick channel_name not specified');
110
+ }
94
111
 
95
- if (!channel_response) return console.error('Failed to connect to Kick.com chat');
112
+ const version = ++this.connectionVersion;
96
113
 
97
- channel_response.subscriber_badges.forEach((subscriber_badge) => {
98
- this.assets.badges['subscriber'] = {
99
- ...(this.assets.badges['subscriber'] ?? {}),
100
- [subscriber_badge.months]: subscriber_badge.badge_image.src,
101
- };
102
- });
114
+ this.closeSocket();
103
115
 
104
- this.disconnect();
116
+ const response = await getChannel(channelName);
105
117
 
106
- this.socket = new Pusher(this.kick_pusher_key, {
107
- cluster: 'us2',
108
- });
109
- /*
110
- | 'App\\Events\\ChatMessageEvent'
111
- | 'App\\Events\\ChatroomClearEvent'
112
- | 'App\\Events\\ChatroomUpdatedEvent'
113
- | 'App\\Events\\GiftedSubscriptionsEvent'
114
- | 'App\\Events\\MessageDeletedEvent'
115
- | 'App\\Events\\PinnedMessageCreatedEvent'
116
- | 'App\\Events\\PinnedMessageDeletedEvent'
117
- | 'App\\Events\\PollDeleteEvent'
118
- | 'App\\Events\\PollUpdateEvent'
119
- | 'App\\Events\\StreamHostEvent'
120
- | 'App\\Events\\SubscriptionEvent'
121
- | 'App\\Events\\UserBannedEvent'
122
- | 'App\\Events\\UserUnbannedEvent'
123
- */
124
- this.socket.subscribe(`chatroom_${channel_response.chatroom.id}`);
125
- this.socket.subscribe(`chatrooms.${channel_response.chatroom.id}.v2`);
126
-
127
- this.socket
128
- .bind('pusher:subscription_succeeded', () =>
129
- this.onSubscriptionSuccess('pusher:subscription_succeeded'),
130
- )
131
- .bind('App\\Events\\ChatMessageEvent', (data: ChatroomsV2Events['ChatMessageEvent']) =>
132
- this.onChatMessage(data),
133
- )
134
- .bind('App\\Events\\SubscriptionEvent', (data: ChatroomsV2Events['SubscriptionEvent']) =>
135
- this.onChatSubscription(data),
136
- )
137
- .bind('GiftedSubscriptionsEvent', (data: ChatroomV1Events['GiftedSubscriptionsEvent']) =>
138
- this.onChatGifted(data),
139
- );
140
-
141
- this.socket.connection.bind('state_change', (state: ConnectionStateEvent) => {
142
- switch (state.current) {
143
- case 'connected': {
144
- this.isConnected = true;
145
- console.log(`Connected to Kick Pusher (${this.channel_name})!`);
146
- break;
147
- }
148
- case 'connecting': {
149
- this.isConnected = false;
150
- console.log(`Connecting to Kick Pusher (${this.channel_name})...`);
151
- break;
152
- }
153
- case 'failed': {
154
- this.isConnected = false;
155
- console.log(`Failed to connect to Kick Pusher (${this.channel_name})`);
156
- break;
157
- }
158
- case 'unavailable': {
159
- this.isConnected = false;
160
- console.log(`Disconnected from Kick Pusher (${this.channel_name})`);
161
- break;
162
- }
163
- default: {
164
- this.isConnected = false;
165
- break;
118
+ // ignore a lookup superseded by connect() or disconnect().
119
+ if (version !== this.connectionVersion) return;
120
+
121
+ if (!response || !isFiniteNumber(response.chatroom?.id) || response.chatroom.id <= 0) {
122
+ throw new Error(`Invalid Kick channel response: ${channelName}`);
123
+ }
124
+
125
+ const subscriberBadges: Record<string, string> = {};
126
+
127
+ if (Array.isArray(response.subscriber_badges)) {
128
+ for (const badge of response.subscriber_badges) {
129
+ if (badge && isFiniteNumber(badge.months) && typeof badge.badge_image?.src === 'string') {
130
+ subscriberBadges[badge.months] = badge.badge_image.src;
166
131
  }
167
132
  }
168
- this.public_listeners.connection_state_changed?.(state);
169
- });
133
+ }
134
+
135
+ // Replace channel-specific badges rather than retaining old ones.
136
+ this.assets.badges['subscriber'] = subscriberBadges;
137
+
138
+ try {
139
+ const socket = new Pusher(this.kick_pusher_key, {
140
+ cluster: 'us2',
141
+ });
142
+
143
+ this.socket = socket;
144
+
145
+ const bind = (event: string, handler: (data: unknown) => unknown) => {
146
+ socket.bind(event, (data: unknown) => {
147
+ if (this.socket !== socket) return;
148
+
149
+ runSafely(`kick.${event}`, () => handler(data));
150
+ });
151
+ };
152
+
153
+ bind('App\\Events\\ChatMessageEvent', (data) => this.onChatMessage(data));
154
+
155
+ bind('App\\Events\\SubscriptionEvent', (data) => this.onChatSubscription(data));
156
+
157
+ bind('GiftedSubscriptionsEvent', (data) => this.onChatGifted(data));
158
+
159
+ socket.connection.bind('state_change', (state: ConnectionStateEvent) => {
160
+ if (this.socket !== socket) return;
161
+
162
+ runSafely('kick.state_change', () => {
163
+ if (!state || typeof state.current !== 'string') {
164
+ throw new Error('Invalid Pusher state-change payload');
165
+ }
166
+
167
+ this.isConnected = state.current === 'connected';
168
+
169
+ runSafely('kick.connection_state_changed', () =>
170
+ this.public_listeners.connection_state_changed?.(state),
171
+ );
172
+ });
173
+ });
174
+
175
+ socket.connection.bind('error', (error: unknown) => {
176
+ if (this.socket !== socket) return;
177
+
178
+ console.error('Kick Pusher connection error', {
179
+ channelName,
180
+ error,
181
+ });
182
+ });
183
+
184
+ const names = [`chatroom_${response.chatroom.id}`, `chatrooms.${response.chatroom.id}.v2`];
185
+
186
+ for (const name of names) {
187
+ const subscription = socket.subscribe(name);
188
+
189
+ subscription.bind('pusher:subscription_succeeded', () => {
190
+ if (this.socket !== socket) return;
191
+
192
+ runSafely('kick.subscription_succeeded', () => this.onSubscriptionSuccess(name));
193
+ });
194
+
195
+ subscription.bind('pusher:subscription_error', (error: unknown) => {
196
+ if (this.socket !== socket) return;
197
+
198
+ console.error('Kick Pusher subscription error', {
199
+ channelName,
200
+ subscription: name,
201
+ error,
202
+ });
203
+ });
204
+ }
205
+
206
+ this.isConnected = socket.connection.state === 'connected';
207
+ } catch (error) {
208
+ this.closeSocket();
209
+ throw error;
210
+ }
170
211
  }
171
212
 
172
- public disconnect() {
173
- this.socket?.disconnect();
174
- this.socket?.unbind_all();
213
+ public disconnect(): void {
214
+ ++this.connectionVersion;
215
+ this.closeSocket();
175
216
  }
176
217
 
177
218
  public on<EventName extends EventNames>(
@@ -185,74 +226,56 @@ export class KickPusher {
185
226
  console.log(`Subscribed to Channel on Kick Pusher (${channel})`);
186
227
  }
187
228
 
188
- private onChatSubscription(data: ChatroomsV2Events['SubscriptionEvent']) {
189
- this.public_listeners.subscription?.(data);
190
- }
229
+ private onChatSubscription(data: unknown) {
230
+ if (!isSubscription(data)) {
231
+ console.warn('Ignoring invalid Kick subscription payload', JSON.stringify(data));
232
+ return;
233
+ }
191
234
 
192
- private onChatGifted(data: ChatroomV1Events['GiftedSubscriptionsEvent']) {
193
- this.public_listeners.gifted?.(data);
235
+ runSafely('kick.subscription', () => {
236
+ this.public_listeners.subscription?.(data);
237
+ });
194
238
  }
195
239
 
196
- private onChatMessage(data: ChatroomsV2Events['ChatMessageEvent']) {
197
- this.public_listeners.raw_message?.(data);
198
- const text = data.content;
199
- const emote_matches = [...data.content.matchAll(/\[emote:\d+:.+\]/g)];
200
-
201
- const body: BodyComponent[] = [];
240
+ private onChatGifted(data: unknown) {
241
+ if (!isGifted(data)) {
242
+ console.warn('Ignoring invalid Kick gifted payload', JSON.stringify(data));
243
+ return;
244
+ }
245
+ runSafely('kick.gifted', () => {
246
+ this.public_listeners.gifted?.(data);
247
+ });
248
+ }
202
249
 
203
- emote_matches.forEach((match) => {
204
- const emote_string = match[0];
205
- const emote_parts = emote_string.slice(1, emote_string.length - 1).split(':');
206
- const emote_id = emote_parts[1];
207
- if (!emote_id) return;
250
+ private onChatMessage(data: unknown) {
251
+ if (!isChatMessage(data)) {
252
+ console.warn('Ignoring invalid Kick chat payload: ', JSON.stringify(data));
253
+ return;
254
+ }
208
255
 
209
- body.push({
210
- type: 'emote',
211
- start_inclusive: match.index,
212
- end_exclusive: match.index + emote_string.length,
213
- url: `https://files.kick.com/emotes/${emote_id}/fullsize`,
214
- });
256
+ runSafely('kick.raw_message', () => {
257
+ this.public_listeners.raw_message?.(data);
215
258
  });
216
259
 
217
- body.sort((a, b) => a.start_inclusive - b.start_inclusive);
218
-
219
- const old_body_length = body.length;
260
+ const text = data.content;
261
+ const emotes: BodyComponent[] = [];
220
262
 
221
- if (old_body_length > 0) {
222
- body.forEach((segment, index) => {
223
- const previous_segment = body[index - 1];
263
+ for (const match of text.matchAll(/\[emote:(\d+):[^\]]+\]/g)) {
264
+ const id = match[1];
265
+ const start = match.index;
224
266
 
225
- const text_start_inclusive =
226
- previous_segment?.end_exclusive !== undefined ? previous_segment.end_exclusive + 1 : 0;
227
- const text_end_exclusive = Math.max(0, segment.start_inclusive);
267
+ if (!id || start === undefined) continue;
228
268
 
229
- if (text_end_exclusive - text_start_inclusive > 0) {
230
- body.push({
231
- type: 'text',
232
- text: text.slice(text_start_inclusive, text_end_exclusive),
233
- start_inclusive: text_start_inclusive,
234
- end_exclusive: text_end_exclusive,
235
- });
236
- }
237
- if (index === old_body_length - 1 && segment.end_exclusive < text.length - 1) {
238
- body.push({
239
- type: 'text',
240
- text: text.slice(segment.end_exclusive),
241
- start_inclusive: segment.end_exclusive,
242
- end_exclusive: text.length,
243
- });
244
- }
245
- });
246
- } else {
247
- body.push({
248
- type: 'text',
249
- text,
250
- start_inclusive: 0,
251
- end_exclusive: text.length,
269
+ emotes.push({
270
+ type: 'emote',
271
+ start_inclusive: start,
272
+ end_exclusive: start + match[0].length,
273
+ url: `https://files.kick.com/emotes/${id}/fullsize`,
252
274
  });
253
275
  }
254
276
 
255
- body.sort((a, b) => a.start_inclusive - b.start_inclusive);
277
+ const body = buildMessageBody(text, emotes);
278
+
256
279
  const message: Message = {
257
280
  id: data.id,
258
281
  user: {
@@ -268,11 +291,19 @@ export class KickPusher {
268
291
  const badge_count = badge.count;
269
292
 
270
293
  if (typeof badge_url_or_counts === 'string') badge_url = badge_url_or_counts;
271
- else if (typeof badge_url_or_counts === 'object' && badge_count !== undefined) {
272
- const badge_entry_by_count = Object.entries(badge_url_or_counts)
273
- .sort(([a_min_count], [b_min_count]) => Number(a_min_count) - Number(b_min_count))
274
- .find(([min_count]) => badge_count >= Number(min_count));
275
- if (badge_entry_by_count) badge_url = badge_entry_by_count[1];
294
+ else if (
295
+ badge_url_or_counts !== null &&
296
+ typeof badge_url_or_counts === 'object' &&
297
+ isFiniteNumber(badge_count)
298
+ ) {
299
+ const entry = Object.entries(badge_url_or_counts)
300
+ .filter(
301
+ ([minimum, url]) => Number.isFinite(Number(minimum)) && typeof url === 'string',
302
+ )
303
+ .sort(([a], [b]) => Number(b) - Number(a))
304
+ .find(([minimum]) => badge_count >= Number(minimum));
305
+
306
+ badge_url = entry?.[1];
276
307
  }
277
308
 
278
309
  if (!badge_url) return [];
@@ -292,17 +323,104 @@ export class KickPusher {
292
323
  timestamp_sent: Date.parse(data.created_at),
293
324
  };
294
325
 
295
- if (data.type === 'celebration' && data.metadata?.celebration) {
326
+ const metadata: unknown = data.metadata;
327
+ const celebration = isRecord(metadata) ? metadata['celebration'] : undefined;
328
+
329
+ if (
330
+ data.type === 'celebration' &&
331
+ isRecord(celebration) &&
332
+ typeof celebration['id'] === 'string' &&
333
+ isFiniteNumber(celebration['total_months']) &&
334
+ celebration['total_months'] > 0 &&
335
+ typeof celebration['created_at'] === 'string' &&
336
+ Number.isFinite(Date.parse(celebration['created_at']))
337
+ ) {
296
338
  message.resubscription = {
297
- id: data.metadata.celebration.id,
298
- months: data.metadata.celebration.total_months,
299
- subscribed_since_timestamp: data.metadata.celebration.created_at,
339
+ id: celebration['id'],
340
+ months: celebration['total_months'],
341
+ subscribed_since_timestamp: celebration['created_at'],
300
342
  };
301
343
  }
302
- this.public_listeners.message?.(message);
344
+ runSafely('kick.message', () => {
345
+ this.public_listeners.message?.(message);
346
+ });
303
347
  }
304
348
  }
305
349
 
350
+ async function defaultGetChannel(channelName: string): Promise<GetChannelResponse | undefined> {
351
+ const url = 'https://kick.com/api/v2/channels/' + encodeURIComponent(channelName);
352
+
353
+ const response = await fetch(url, {
354
+ headers: {
355
+ accept: 'application/json',
356
+ },
357
+ signal: AbortSignal.timeout(15_000),
358
+ });
359
+
360
+ if (!response.ok) {
361
+ throw new Error(`Kick channel lookup HTTP ${response.status}`);
362
+ }
363
+
364
+ return (await response.json()) as GetChannelResponse;
365
+ }
366
+
367
+ function isChatMessage(value: unknown): value is ChatMessageEvent {
368
+ if (!isRecord(value)) return false;
369
+
370
+ const sender = value['sender'];
371
+
372
+ if (
373
+ typeof value['id'] !== 'string' ||
374
+ !isFiniteNumber(value['chatroom_id']) ||
375
+ typeof value['content'] !== 'string' ||
376
+ typeof value['created_at'] !== 'string' ||
377
+ !Number.isFinite(Date.parse(value['created_at'])) ||
378
+ !['message', 'celebration', 'reply'].includes(String(value['type'])) ||
379
+ !isRecord(sender) ||
380
+ !isFiniteNumber(sender['id']) ||
381
+ typeof sender['username'] !== 'string' ||
382
+ (sender['slug'] !== undefined && typeof sender['slug'] !== 'string')
383
+ ) {
384
+ return false;
385
+ }
386
+
387
+ const identity = sender['identity'];
388
+
389
+ return (
390
+ isRecord(identity) &&
391
+ typeof identity['color'] === 'string' &&
392
+ Array.isArray(identity['badges']) &&
393
+ identity['badges'].every(
394
+ (badge) =>
395
+ isRecord(badge) &&
396
+ typeof badge['type'] === 'string' &&
397
+ typeof badge['text'] === 'string' &&
398
+ (badge['count'] === undefined || isFiniteNumber(badge['count'])),
399
+ )
400
+ );
401
+ }
402
+
403
+ function isSubscription(value: unknown): value is ChatroomsV2Events['SubscriptionEvent'] {
404
+ return (
405
+ isRecord(value) &&
406
+ isFiniteNumber(value['chatroom_id']) &&
407
+ typeof value['username'] === 'string' &&
408
+ isFiniteNumber(value['months']) &&
409
+ value['months'] > 0
410
+ );
411
+ }
412
+
413
+ function isGifted(value: unknown): value is ChatroomV1Events['GiftedSubscriptionsEvent'] {
414
+ return (
415
+ isRecord(value) &&
416
+ isFiniteNumber(value['chatroom_id']) &&
417
+ typeof value['gifter_username'] === 'string' &&
418
+ isFiniteNumber(value['gifter_total']) &&
419
+ Array.isArray(value['gifted_usernames']) &&
420
+ value['gifted_usernames'].every((name) => typeof name === 'string')
421
+ );
422
+ }
423
+
306
424
  export interface GetChannelResponse {
307
425
  id: number;
308
426
  user_id: number;
@@ -0,0 +1,72 @@
1
+ import { BodyComponent } from '.';
2
+
3
+ export function runSafely(context: string, callback: () => unknown) {
4
+ try {
5
+ const result = callback();
6
+
7
+ if (
8
+ result !== null &&
9
+ result !== undefined &&
10
+ typeof (result as PromiseLike<unknown>).then === 'function'
11
+ ) {
12
+ void Promise.resolve(result).catch((error) => {
13
+ console.error(`[${context}] Async failure`, error);
14
+ });
15
+ }
16
+ } catch (error) {
17
+ console.error(`[${context}] Failure`, error);
18
+ }
19
+ }
20
+
21
+ export function isRecord(value: unknown): value is Record<string, unknown> {
22
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
23
+ }
24
+
25
+ export function isFiniteNumber(value: unknown): value is number {
26
+ return typeof value === 'number' && Number.isFinite(value);
27
+ }
28
+
29
+ export function buildMessageBody(text: string, emotes: BodyComponent[]): BodyComponent[] {
30
+ const sorted = [...emotes].sort((a, b) => a.start_inclusive - b.start_inclusive);
31
+
32
+ const body: BodyComponent[] = [];
33
+ let cursor = 0;
34
+
35
+ for (const emote of sorted) {
36
+ const start = emote.start_inclusive;
37
+ const end = emote.end_exclusive;
38
+
39
+ if (
40
+ !Number.isInteger(start) ||
41
+ !Number.isInteger(end) ||
42
+ start < cursor ||
43
+ end <= start ||
44
+ end > text.length
45
+ ) {
46
+ continue;
47
+ }
48
+
49
+ if (start > cursor) {
50
+ body.push({
51
+ type: 'text',
52
+ text: text.slice(cursor, start),
53
+ start_inclusive: cursor,
54
+ end_exclusive: start,
55
+ });
56
+ }
57
+
58
+ body.push(emote);
59
+ cursor = end;
60
+ }
61
+
62
+ if (cursor < text.length || body.length === 0) {
63
+ body.push({
64
+ type: 'text',
65
+ text: text.slice(cursor),
66
+ start_inclusive: cursor,
67
+ end_exclusive: text.length,
68
+ });
69
+ }
70
+
71
+ return body;
72
+ }
@@ -0,0 +1,32 @@
1
+ import { format } from 'util';
2
+ import { KickPusher } from './kick.js';
3
+
4
+ const originalError = console.error.bind(console);
5
+ const originalWarn = console.warn.bind(console);
6
+
7
+ console.warn = (...args) => {
8
+ originalWarn(`\x1b[33m${format(...args)}\x1b[0m`);
9
+ };
10
+
11
+ console.error = (...args) => {
12
+ originalError(`\x1b[31m${format(...args)}\x1b[0m`);
13
+ };
14
+
15
+ const client = new KickPusher();
16
+ client.on('subscription', (data) => {
17
+ console.log(JSON.stringify(data));
18
+ });
19
+ client.on('connection_state_changed', (data) => {
20
+ console.log(JSON.stringify(data));
21
+ });
22
+ client.on('gifted', (data) => {
23
+ console.log(JSON.stringify(data));
24
+ });
25
+ client.on('message', (data) => {
26
+ console.log(JSON.stringify(data));
27
+ });
28
+ // client.on('raw_message', data => {
29
+ // console.log(JSON.stringify(data))
30
+ // })
31
+
32
+ client.connect({ channelName: 'drb7h' });