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/dist/kick.d.ts +5 -3
- package/dist/kick.js +202 -123
- package/dist/safety.d.ts +5 -0
- package/dist/safety.js +56 -0
- package/dist/test.d.ts +1 -0
- package/dist/test.js +24 -0
- package/dist/twitch.d.ts +33 -34
- package/dist/twitch.js +349 -352
- package/package.json +1 -1
- package/src/default/kick.ts +277 -159
- package/src/default/safety.ts +72 -0
- package/src/default/test.ts +32 -0
- package/src/default/twitch.ts +428 -397
package/src/default/twitch.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type EmoteURLsByName,
|
|
10
10
|
type BadgeURLsBySetIDOrSetIDAndVersion,
|
|
11
11
|
} from './index.js';
|
|
12
|
+
import { buildMessageBody, runSafely } from './safety.js';
|
|
12
13
|
|
|
13
14
|
const ANONYMOUS_IRC_PASS = 'SCHMOOPIIE';
|
|
14
15
|
const ANONYMOUS_IRC_LOGIN = 'justinfan1234';
|
|
@@ -21,12 +22,20 @@ type EventCallbackFunctions = {
|
|
|
21
22
|
delete_message: (data: DeleteMessage) => unknown;
|
|
22
23
|
event: (event: Event) => unknown;
|
|
23
24
|
raw_message: (message: IRC_Message) => unknown;
|
|
25
|
+
connected: () => unknown;
|
|
26
|
+
auth_error: () => unknown;
|
|
24
27
|
};
|
|
25
28
|
|
|
26
29
|
type EventNames = keyof EventCallbackFunctions;
|
|
27
30
|
|
|
31
|
+
type SocketOptions = NonNullable<ConstructorParameters<typeof WebSocket>[2]>;
|
|
32
|
+
|
|
28
33
|
export class TwitchIRC {
|
|
29
34
|
public channel_name?: string;
|
|
35
|
+
public auth?: { username: string; token: string } | undefined;
|
|
36
|
+
public bot_name?: string;
|
|
37
|
+
public bot_token?: string;
|
|
38
|
+
|
|
30
39
|
private assets: {
|
|
31
40
|
external_emotes: EmoteURLsByName;
|
|
32
41
|
badges: BadgeURLsBySetIDOrSetIDAndVersion;
|
|
@@ -44,19 +53,28 @@ export class TwitchIRC {
|
|
|
44
53
|
|
|
45
54
|
private public_listeners: Partial<EventCallbackFunctions> = {};
|
|
46
55
|
|
|
47
|
-
public socket?: WebSocket;
|
|
48
|
-
public
|
|
56
|
+
public socket?: WebSocket | undefined;
|
|
57
|
+
public wsCustom?: SocketOptions['WebSocket'];
|
|
49
58
|
|
|
50
|
-
constructor(
|
|
51
|
-
|
|
59
|
+
constructor(
|
|
60
|
+
options: {
|
|
61
|
+
ws?: SocketOptions['WebSocket'];
|
|
62
|
+
auth?: { username: string; token: string };
|
|
63
|
+
} = {},
|
|
64
|
+
) {
|
|
65
|
+
this.wsCustom = options.ws;
|
|
66
|
+
this.auth = options.auth;
|
|
52
67
|
}
|
|
53
68
|
|
|
54
69
|
public setBadges(badges: BadgeURLsBySetIDOrSetIDAndVersion) {
|
|
55
|
-
this.assets.badges = { ...
|
|
70
|
+
this.assets.badges = { ...this.assets.badges, ...badges };
|
|
56
71
|
}
|
|
57
72
|
|
|
58
|
-
public setExternalEmotes(
|
|
59
|
-
this.assets.external_emotes = {
|
|
73
|
+
public setExternalEmotes(externalEmotes: EmoteURLsByName) {
|
|
74
|
+
this.assets.external_emotes = {
|
|
75
|
+
...this.assets.external_emotes,
|
|
76
|
+
...externalEmotes,
|
|
77
|
+
};
|
|
60
78
|
}
|
|
61
79
|
|
|
62
80
|
public getStoredBadges() {
|
|
@@ -67,24 +85,96 @@ export class TwitchIRC {
|
|
|
67
85
|
return this.assets.external_emotes;
|
|
68
86
|
}
|
|
69
87
|
|
|
70
|
-
public
|
|
71
|
-
|
|
72
|
-
|
|
88
|
+
public authenticate(username: string, token: string): void {
|
|
89
|
+
this.auth = { username, token };
|
|
90
|
+
this.bot_name = username;
|
|
91
|
+
this.bot_token = token;
|
|
92
|
+
}
|
|
73
93
|
|
|
74
|
-
|
|
94
|
+
private clearPing(): void {
|
|
95
|
+
clearInterval(this.ping.interval);
|
|
96
|
+
clearTimeout(this.ping.timeout);
|
|
97
|
+
this.ping = {};
|
|
98
|
+
}
|
|
75
99
|
|
|
76
|
-
|
|
100
|
+
public connect(channel?: { channelName?: string }): void {
|
|
101
|
+
if (channel?.channelName) {
|
|
102
|
+
this.channel_name = channel.channelName;
|
|
103
|
+
}
|
|
77
104
|
|
|
78
|
-
|
|
79
|
-
|
|
105
|
+
if (!this.channel_name) {
|
|
106
|
+
throw new Error('Twitch channel_name not specified');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.disconnect();
|
|
110
|
+
|
|
111
|
+
const socket = new WebSocket('wss://irc-ws.chat.twitch.tv', null, {
|
|
112
|
+
WebSocket: this.wsCustom,
|
|
80
113
|
});
|
|
81
|
-
|
|
82
|
-
this.socket
|
|
83
|
-
|
|
114
|
+
|
|
115
|
+
this.socket = socket;
|
|
116
|
+
|
|
117
|
+
socket.onopen = () => {
|
|
118
|
+
if (this.socket !== socket) return;
|
|
119
|
+
runSafely('twitch.open', () => this.onOpen());
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
socket.onclose = () => {
|
|
123
|
+
if (this.socket !== socket) return;
|
|
124
|
+
this.clearPing();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
socket.onerror = () => {
|
|
128
|
+
if (this.socket !== socket) return;
|
|
129
|
+
|
|
130
|
+
console.error('Twitch WebSocket error', {
|
|
131
|
+
channel: this.channel_name,
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
socket.onmessage = (event) => {
|
|
136
|
+
if (this.socket !== socket) return;
|
|
137
|
+
runSafely('twitch.frame', () => this.onMessage(event));
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
public send(message: string, replyParentMessageId?: string): void {
|
|
142
|
+
if (!this.auth) {
|
|
143
|
+
console.error('No Twitch auth information');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const text = message.replace(/[\r\n]/g, ' ');
|
|
148
|
+
|
|
149
|
+
if (replyParentMessageId !== undefined && !/^[A-Za-z0-9-]+$/.test(replyParentMessageId)) {
|
|
150
|
+
throw new Error('Invalid reply parent message ID');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const prefix = replyParentMessageId ? `@reply-parent-msg-id=${replyParentMessageId} ` : '';
|
|
154
|
+
|
|
155
|
+
this.sendIRC(`${prefix}PRIVMSG #${this.channel_name} :${text}`);
|
|
84
156
|
}
|
|
85
157
|
|
|
86
|
-
public disconnect() {
|
|
87
|
-
this.
|
|
158
|
+
public disconnect(): void {
|
|
159
|
+
this.clearPing();
|
|
160
|
+
|
|
161
|
+
const socket = this.socket;
|
|
162
|
+
this.socket = undefined;
|
|
163
|
+
|
|
164
|
+
if (!socket) return;
|
|
165
|
+
|
|
166
|
+
socket.onopen = null;
|
|
167
|
+
socket.onclose = null;
|
|
168
|
+
socket.onmessage = null;
|
|
169
|
+
socket.onerror = null;
|
|
170
|
+
|
|
171
|
+
runSafely('twitch.disconnect', () => socket.close());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private restartConnection(): void {
|
|
175
|
+
this.clearPing();
|
|
176
|
+
|
|
177
|
+
runSafely('twitch.reconnect', () => this.socket?.reconnect());
|
|
88
178
|
}
|
|
89
179
|
|
|
90
180
|
public on<EventName extends EventNames>(
|
|
@@ -98,247 +188,321 @@ export class TwitchIRC {
|
|
|
98
188
|
return !!this.socket && this.socket.readyState === WebSocket.OPEN;
|
|
99
189
|
}
|
|
100
190
|
|
|
101
|
-
private
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
191
|
+
// private onClose() {
|
|
192
|
+
// clearInterval(this.ping.interval);
|
|
193
|
+
// clearTimeout(this.ping.timeout);
|
|
194
|
+
// }
|
|
195
|
+
|
|
196
|
+
private onOpen(): void {
|
|
197
|
+
this.clearPing();
|
|
106
198
|
|
|
107
|
-
|
|
199
|
+
const token = this.auth?.token.replace(/^oauth:/, '');
|
|
200
|
+
|
|
201
|
+
const commands = [
|
|
202
|
+
'CAP REQ :twitch.tv/commands twitch.tv/tags',
|
|
203
|
+
this.auth ? `PASS oauth:${token}` : `PASS ${ANONYMOUS_IRC_PASS}`,
|
|
204
|
+
`NICK ${this.auth?.username ?? ANONYMOUS_IRC_LOGIN}`,
|
|
205
|
+
`JOIN #${this.channel_name}`,
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
for (const command of commands) {
|
|
209
|
+
if (!this.sendIRC(command)) return;
|
|
210
|
+
}
|
|
108
211
|
|
|
109
|
-
if (this.ping.interval) clearInterval(this.ping.interval);
|
|
110
212
|
this.sendPing();
|
|
111
|
-
this.ping.interval = setInterval(() => this.sendPing(), PING_INTERVAL_MS);
|
|
112
|
-
}
|
|
113
213
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
214
|
+
if (!this.isConnected()) return;
|
|
215
|
+
|
|
216
|
+
this.ping.interval = setInterval(() => {
|
|
217
|
+
runSafely('twitch.ping', () => this.sendPing());
|
|
218
|
+
}, PING_INTERVAL_MS);
|
|
117
219
|
}
|
|
118
220
|
|
|
119
|
-
private sendPing() {
|
|
120
|
-
this.
|
|
221
|
+
private sendPing(): void {
|
|
222
|
+
if (this.ping.lastSentTimestamp !== undefined) return;
|
|
223
|
+
if (!this.sendIRC('PING :multichat')) return;
|
|
224
|
+
|
|
121
225
|
this.ping.lastSentTimestamp = Date.now();
|
|
122
226
|
|
|
123
|
-
if (this.ping.timeout) clearTimeout(this.ping.timeout);
|
|
124
227
|
this.ping.timeout = setTimeout(() => {
|
|
125
|
-
console.error('PING
|
|
126
|
-
this.
|
|
228
|
+
console.error('Twitch PING timeout');
|
|
229
|
+
this.restartConnection();
|
|
127
230
|
}, PING_TIMEOUT_MS);
|
|
128
231
|
}
|
|
129
232
|
|
|
130
|
-
private
|
|
131
|
-
if (!this.isConnected())
|
|
132
|
-
|
|
233
|
+
private sendIRC(message: string): boolean {
|
|
234
|
+
if (!this.isConnected()) return false;
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
this.socket.send(message);
|
|
238
|
+
return true;
|
|
239
|
+
} catch (error) {
|
|
240
|
+
console.error('Twitch send failed', error);
|
|
241
|
+
this.restartConnection();
|
|
242
|
+
return false;
|
|
133
243
|
}
|
|
134
|
-
this.socket?.send(irc_message);
|
|
135
244
|
}
|
|
136
245
|
|
|
137
|
-
private onMessage(event: MessageEvent) {
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
246
|
+
private onMessage(event: MessageEvent): void {
|
|
247
|
+
if (typeof event.data !== 'string') {
|
|
248
|
+
console.warn('Ignoring non-text Twitch WebSocket frame', JSON.stringify(event));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const socket = this.socket;
|
|
253
|
+
|
|
254
|
+
for (const line of event.data.split('\r\n')) {
|
|
255
|
+
if (!line) continue;
|
|
256
|
+
|
|
257
|
+
if (this.socket !== socket || !this.isConnected()) break;
|
|
258
|
+
|
|
259
|
+
runSafely('twitch.irc_line', () => {
|
|
260
|
+
const message = parseIRCLine(line);
|
|
261
|
+
|
|
262
|
+
if (!message) {
|
|
263
|
+
console.warn('Ignoring malformed Twitch IRC line', JSON.stringify(message));
|
|
264
|
+
return;
|
|
150
265
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
266
|
+
|
|
267
|
+
this.handleIRCMessage(message);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private handleIRCMessage(message: IRC_Message): void {
|
|
273
|
+
runSafely('twitch.raw_message', () => this.public_listeners.raw_message?.(message));
|
|
274
|
+
|
|
275
|
+
switch (message.command) {
|
|
276
|
+
case 'PONG': {
|
|
277
|
+
const sentAt = this.ping.lastSentTimestamp;
|
|
278
|
+
|
|
279
|
+
if (sentAt === undefined) break;
|
|
280
|
+
|
|
281
|
+
clearTimeout(this.ping.timeout);
|
|
282
|
+
this.latency = Date.now() - sentAt;
|
|
283
|
+
this.ping.lastSentTimestamp = undefined;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
case 'PING': {
|
|
287
|
+
const token = message.params.at(-1);
|
|
288
|
+
|
|
289
|
+
this.sendIRC(token === undefined ? 'PONG' : `PONG :${token}`);
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
case '001': {
|
|
293
|
+
console.log(`Connected to Twitch IRC (${this.channel_name})`);
|
|
294
|
+
|
|
295
|
+
runSafely('twitch.connected', () => this.public_listeners.connected?.());
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
case 'RECONNECT': {
|
|
299
|
+
this.restartConnection();
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
case 'NOTICE': {
|
|
303
|
+
const text = message.params.at(-1);
|
|
304
|
+
|
|
305
|
+
if (text === 'Login authentication failed' || text === 'Improperly formatted auth') {
|
|
306
|
+
this.disconnect();
|
|
307
|
+
|
|
308
|
+
runSafely('twitch.auth_error', () => this.public_listeners.auth_error?.());
|
|
154
309
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
310
|
+
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
case 'CLEARCHAT': {
|
|
314
|
+
const { channel, tags } = message;
|
|
315
|
+
if (!tags) return;
|
|
316
|
+
const data = {
|
|
317
|
+
channel: {
|
|
318
|
+
name: channel,
|
|
319
|
+
room_id: tags['room-id'] ?? 'unknown',
|
|
320
|
+
},
|
|
321
|
+
timestamp_sent: Number(tags['tmi-sent-ts']),
|
|
322
|
+
timeout_duration_seconds: tags['ban-duration'] ? Number(tags['ban-duration']) : undefined,
|
|
323
|
+
};
|
|
324
|
+
runSafely('twitch.clear_messages', () => {
|
|
325
|
+
this.public_listeners.clear_messages?.(data);
|
|
326
|
+
});
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
case 'PRIVMSG': {
|
|
330
|
+
const { channel, params, tags, source } = message;
|
|
331
|
+
if (!tags || !tags['user-id'] || !tags['id'] || !tags['room-id']) return;
|
|
332
|
+
|
|
333
|
+
const rawText = params[0];
|
|
334
|
+
if (!rawText) return;
|
|
335
|
+
|
|
336
|
+
const actionPrefix = '\u0001ACTION ';
|
|
337
|
+
const isAction = rawText.startsWith(actionPrefix) && rawText.endsWith('\u0001');
|
|
338
|
+
|
|
339
|
+
const text = isAction ? rawText.slice(actionPrefix.length, -1) : rawText;
|
|
340
|
+
|
|
341
|
+
const offset = isAction ? actionPrefix.length : 0;
|
|
342
|
+
|
|
343
|
+
const boundaries = [0];
|
|
344
|
+
let utf16Offset = 0;
|
|
345
|
+
|
|
346
|
+
for (const character of rawText) {
|
|
347
|
+
utf16Offset += character.length;
|
|
348
|
+
boundaries.push(utf16Offset);
|
|
170
349
|
}
|
|
171
|
-
case 'PRIVMSG': {
|
|
172
|
-
const { channel, params, tags, source } = message;
|
|
173
|
-
if (!tags || !tags['user-id'] || !tags['id'] || !tags['room-id']) return;
|
|
174
|
-
|
|
175
|
-
const text = params[0];
|
|
176
|
-
if (!text) return;
|
|
177
|
-
|
|
178
|
-
const body: BodyComponent[] = [];
|
|
179
|
-
|
|
180
|
-
tags.emotes?.split('/').forEach((raw_emote_string) => {
|
|
181
|
-
const [emote_id, raw_emote_positions_string] = raw_emote_string.split(':');
|
|
182
|
-
if (!emote_id || !raw_emote_positions_string) return;
|
|
183
|
-
|
|
184
|
-
const raw_emote_positions = raw_emote_positions_string.split(',');
|
|
185
|
-
raw_emote_positions.forEach((raw_emote_position) => {
|
|
186
|
-
const [emote_start, emote_end] = raw_emote_position.split('-');
|
|
187
|
-
if (!emote_start || !emote_end) return;
|
|
188
|
-
|
|
189
|
-
body.push({
|
|
190
|
-
type: 'emote',
|
|
191
|
-
start_inclusive: +emote_start,
|
|
192
|
-
end_exclusive: +emote_end + 1,
|
|
193
|
-
url: `https://static-cdn.jtvnw.net/emoticons/v2/${emote_id}/default/dark/1.0`,
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
body.sort((a, b) => a.start_inclusive - b.start_inclusive);
|
|
199
|
-
|
|
200
|
-
const old_body_length = body.length;
|
|
201
|
-
|
|
202
|
-
if (old_body_length > 0) {
|
|
203
|
-
body.forEach((segment, index) => {
|
|
204
|
-
const previous_segment = body[index - 1];
|
|
205
|
-
|
|
206
|
-
const text_start_inclusive =
|
|
207
|
-
previous_segment?.end_exclusive !== undefined
|
|
208
|
-
? previous_segment.end_exclusive + 1
|
|
209
|
-
: 0;
|
|
210
|
-
const text_end_exclusive = Math.max(0, segment.start_inclusive);
|
|
211
|
-
|
|
212
|
-
if (text_end_exclusive - text_start_inclusive > 0) {
|
|
213
|
-
body.push({
|
|
214
|
-
type: 'text',
|
|
215
|
-
text: text.slice(text_start_inclusive, text_end_exclusive),
|
|
216
|
-
start_inclusive: text_start_inclusive,
|
|
217
|
-
end_exclusive: text_end_exclusive,
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
if (index === old_body_length - 1 && segment.end_exclusive < text.length - 1) {
|
|
221
|
-
body.push({
|
|
222
|
-
type: 'text',
|
|
223
|
-
text: text.slice(segment.end_exclusive),
|
|
224
|
-
start_inclusive: segment.end_exclusive,
|
|
225
|
-
end_exclusive: text.length,
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
});
|
|
229
|
-
} else {
|
|
230
|
-
body.push({
|
|
231
|
-
type: 'text',
|
|
232
|
-
text,
|
|
233
|
-
start_inclusive: 0,
|
|
234
|
-
end_exclusive: text.length,
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
350
|
|
|
238
|
-
|
|
351
|
+
const emotes: BodyComponent[] = [];
|
|
239
352
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
tags['badge-info']?.split(',').forEach((badge) => {
|
|
244
|
-
const [set_id, info] = badge.split('/');
|
|
245
|
-
if (!set_id || !info) return;
|
|
353
|
+
for (const encoded of tags['emotes']?.split('/') ?? []) {
|
|
354
|
+
const colon = encoded.indexOf(':');
|
|
355
|
+
if (colon === -1) continue;
|
|
246
356
|
|
|
247
|
-
|
|
248
|
-
|
|
357
|
+
const id = encoded.slice(0, colon);
|
|
358
|
+
if (!id) continue;
|
|
249
359
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
set_id,
|
|
274
|
-
url,
|
|
275
|
-
};
|
|
276
|
-
}) ?? [],
|
|
277
|
-
color: tags.color ?? '#FFFFFF',
|
|
278
|
-
id: tags['user-id'],
|
|
279
|
-
username: source?.user ?? 'Unknown',
|
|
280
|
-
display_name: tags['display-name'] ?? 'Unknown',
|
|
281
|
-
roles: {
|
|
282
|
-
admin: tags['user-type'] === 'admin',
|
|
283
|
-
global_moderator: tags['user-type'] === 'global_mod',
|
|
284
|
-
staff: tags['user-type'] === 'staff',
|
|
285
|
-
turbo: tags.turbo === '1',
|
|
286
|
-
vip: tags.vip === '1',
|
|
287
|
-
moderator: tags.mod === '1',
|
|
288
|
-
},
|
|
289
|
-
},
|
|
290
|
-
});
|
|
291
|
-
break;
|
|
360
|
+
for (const range of encoded.slice(colon + 1).split(',')) {
|
|
361
|
+
const match = /^(\d+)-(\d+)$/.exec(range);
|
|
362
|
+
if (!match) continue;
|
|
363
|
+
|
|
364
|
+
const first = Number(match[1]);
|
|
365
|
+
const last = Number(match[2]);
|
|
366
|
+
|
|
367
|
+
if (last < first) continue;
|
|
368
|
+
|
|
369
|
+
const rawStart = boundaries[first];
|
|
370
|
+
const rawEnd = boundaries[last + 1];
|
|
371
|
+
|
|
372
|
+
if (rawStart === undefined || rawEnd === undefined) continue;
|
|
373
|
+
|
|
374
|
+
emotes.push({
|
|
375
|
+
type: 'emote',
|
|
376
|
+
start_inclusive: rawStart - offset,
|
|
377
|
+
end_exclusive: rawEnd - offset,
|
|
378
|
+
url:
|
|
379
|
+
'https://static-cdn.jtvnw.net/emoticons/v2/' +
|
|
380
|
+
`${encodeURIComponent(id)}/default/dark/1.0`,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
292
383
|
}
|
|
384
|
+
|
|
385
|
+
const body = buildMessageBody(text, emotes);
|
|
386
|
+
|
|
387
|
+
const badge_info: {
|
|
388
|
+
[set_id: string]: string;
|
|
389
|
+
} = {};
|
|
390
|
+
tags['badge-info']?.split(',').forEach((badge) => {
|
|
391
|
+
const [set_id, info] = badge.split('/');
|
|
392
|
+
if (!set_id || !info) return;
|
|
393
|
+
|
|
394
|
+
badge_info[set_id] = info;
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const timestamp = Number(tags['tmi-sent-ts']);
|
|
398
|
+
|
|
399
|
+
const data = {
|
|
400
|
+
body: body,
|
|
401
|
+
channel: {
|
|
402
|
+
room_id: tags['room-id'],
|
|
403
|
+
name: channel,
|
|
404
|
+
},
|
|
405
|
+
id: tags['id'],
|
|
406
|
+
raw_text: text,
|
|
407
|
+
timestamp_sent: Number.isFinite(timestamp) ? timestamp : Date.now(),
|
|
408
|
+
user: {
|
|
409
|
+
badges:
|
|
410
|
+
tags['badges']?.split(',').flatMap((badge) => {
|
|
411
|
+
const [set_id, version] = badge.split('/');
|
|
412
|
+
if (!set_id || !version) return [];
|
|
413
|
+
|
|
414
|
+
const storedBadge = this.assets.badges[set_id];
|
|
415
|
+
if (!storedBadge) return [];
|
|
416
|
+
|
|
417
|
+
const url = typeof storedBadge === 'object' ? storedBadge[version] : storedBadge;
|
|
418
|
+
if (typeof url !== 'string' || !url) return [];
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
info: badge_info[set_id],
|
|
422
|
+
set_id,
|
|
423
|
+
url,
|
|
424
|
+
};
|
|
425
|
+
}) ?? [],
|
|
426
|
+
color: tags['color'] ?? '#FFFFFF',
|
|
427
|
+
id: tags['user-id'],
|
|
428
|
+
username: source?.user ?? 'Unknown',
|
|
429
|
+
display_name: tags['display-name'] ?? 'Unknown',
|
|
430
|
+
roles: {
|
|
431
|
+
admin: tags['user-type'] === 'admin',
|
|
432
|
+
global_moderator: tags['user-type'] === 'global_mod',
|
|
433
|
+
staff: tags['user-type'] === 'staff',
|
|
434
|
+
turbo: tags['turbo'] === '1',
|
|
435
|
+
vip: tags['vip'] === '1',
|
|
436
|
+
moderator: tags['mod'] === '1',
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
runSafely('twitch.message', () => {
|
|
442
|
+
this.public_listeners.message?.(data);
|
|
443
|
+
});
|
|
444
|
+
break;
|
|
293
445
|
}
|
|
294
|
-
}
|
|
446
|
+
}
|
|
295
447
|
}
|
|
296
448
|
}
|
|
297
449
|
|
|
298
|
-
function parseIRCLine(line: string): IRC_Message {
|
|
299
|
-
|
|
300
|
-
|
|
450
|
+
export function parseIRCLine(line: string): IRC_Message | undefined {
|
|
451
|
+
let rest = line.replace(/[\r\n]+$/, '').trimStart();
|
|
452
|
+
|
|
453
|
+
if (!rest) return undefined;
|
|
301
454
|
|
|
302
|
-
|
|
455
|
+
const takeToken = (): string => {
|
|
456
|
+
const space = rest.indexOf(' ');
|
|
457
|
+
|
|
458
|
+
if (space === -1) {
|
|
459
|
+
const token = rest;
|
|
460
|
+
rest = '';
|
|
461
|
+
return token;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const token = rest.slice(0, space);
|
|
465
|
+
rest = rest.slice(space + 1).trimStart();
|
|
466
|
+
return token;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
let tags: IRC_Message['tags'];
|
|
303
470
|
let source: RawSource | undefined;
|
|
304
471
|
|
|
305
|
-
if (
|
|
306
|
-
|
|
307
|
-
|
|
472
|
+
if (rest.startsWith('@')) {
|
|
473
|
+
tags = parseTags(takeToken().slice(1));
|
|
474
|
+
|
|
475
|
+
if (!rest) return undefined;
|
|
308
476
|
}
|
|
309
477
|
|
|
310
|
-
if (
|
|
311
|
-
source = parseSource(
|
|
312
|
-
|
|
478
|
+
if (rest.startsWith(':')) {
|
|
479
|
+
source = parseSource(takeToken().slice(1));
|
|
480
|
+
|
|
481
|
+
if (!rest) return undefined;
|
|
313
482
|
}
|
|
314
483
|
|
|
315
|
-
const command =
|
|
316
|
-
componentIndex++;
|
|
484
|
+
const command = takeToken();
|
|
317
485
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
486
|
+
if (!/^(?:[A-Za-z]+|\d{3})$/.test(command)) {
|
|
487
|
+
return undefined;
|
|
488
|
+
}
|
|
321
489
|
|
|
322
490
|
const params: string[] = [];
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if (
|
|
326
|
-
params.push(
|
|
327
|
-
|
|
328
|
-
} else {
|
|
329
|
-
params.push(param);
|
|
330
|
-
componentIndex++;
|
|
491
|
+
|
|
492
|
+
while (rest) {
|
|
493
|
+
if (rest.startsWith(':')) {
|
|
494
|
+
params.push(rest.slice(1));
|
|
495
|
+
break;
|
|
331
496
|
}
|
|
497
|
+
|
|
498
|
+
params.push(takeToken());
|
|
332
499
|
}
|
|
333
500
|
|
|
334
|
-
|
|
335
|
-
if (params[0])
|
|
336
|
-
params[0] = String.raw`${params[0]}`.replaceAll(
|
|
337
|
-
/.+ACTION (.*).+/g,
|
|
338
|
-
(_original, group) => group,
|
|
339
|
-
);
|
|
501
|
+
let channel = '';
|
|
340
502
|
|
|
341
|
-
|
|
503
|
+
if (params[0]?.startsWith('#')) {
|
|
504
|
+
channel = params.shift()!.slice(1);
|
|
505
|
+
}
|
|
342
506
|
|
|
343
507
|
return {
|
|
344
508
|
channel,
|
|
@@ -349,199 +513,66 @@ function parseIRCLine(line: string): IRC_Message {
|
|
|
349
513
|
};
|
|
350
514
|
}
|
|
351
515
|
|
|
352
|
-
function parseTags(component: string,
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
516
|
+
function parseTags(component: string): Record<string, string | undefined> {
|
|
517
|
+
const tags: Record<string, string | undefined> = Object.create(null);
|
|
518
|
+
|
|
519
|
+
for (const rawTag of component.split(';')) {
|
|
520
|
+
const equals = rawTag.indexOf('=');
|
|
521
|
+
|
|
522
|
+
const key = equals === -1 ? rawTag : rawTag.slice(0, equals);
|
|
523
|
+
const value = equals === -1 ? '' : rawTag.slice(equals + 1);
|
|
524
|
+
|
|
525
|
+
if (!key) continue;
|
|
526
|
+
|
|
527
|
+
tags[key] = value.replace(/\\(.)/g, (_, escaped: string) => {
|
|
528
|
+
switch (escaped) {
|
|
529
|
+
case 's':
|
|
530
|
+
return ' ';
|
|
531
|
+
case ':':
|
|
532
|
+
return ';';
|
|
533
|
+
case 'r':
|
|
534
|
+
return '\r';
|
|
535
|
+
case 'n':
|
|
536
|
+
return '\n';
|
|
537
|
+
case '\\':
|
|
538
|
+
return '\\';
|
|
539
|
+
default:
|
|
540
|
+
return escaped;
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
}
|
|
364
544
|
|
|
365
545
|
return tags;
|
|
366
546
|
}
|
|
367
547
|
|
|
368
548
|
function parseSource(component: string): RawSource {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
549
|
+
const bang = component.indexOf('!');
|
|
550
|
+
|
|
551
|
+
if (bang === -1) {
|
|
552
|
+
return { host: component || 'unknown' };
|
|
553
|
+
}
|
|
372
554
|
|
|
373
|
-
|
|
374
|
-
|
|
555
|
+
const nick = component.slice(0, bang);
|
|
556
|
+
const remainder = component.slice(bang + 1);
|
|
557
|
+
const at = remainder.indexOf('@');
|
|
375
558
|
|
|
376
559
|
return {
|
|
377
|
-
host: host ?? 'unknown',
|
|
378
560
|
nick,
|
|
379
|
-
user,
|
|
561
|
+
user: at === -1 ? remainder : remainder.slice(0, at),
|
|
562
|
+
host: at === -1 ? 'unknown' : remainder.slice(at + 1),
|
|
380
563
|
};
|
|
381
564
|
}
|
|
382
565
|
|
|
383
|
-
type IRC_Message = {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
};
|
|
391
|
-
}[CommandType];
|
|
392
|
-
|
|
393
|
-
type RAW_TAGS = typeof RAW_TAGS;
|
|
394
|
-
type CommandType = keyof RAW_TAGS;
|
|
395
|
-
type AllRawTagsKeys = RAW_TAGS[CommandType][number];
|
|
396
|
-
type AllRawTags = Record<AllRawTagsKeys, string | undefined>;
|
|
397
|
-
type SpecificRawTags<Command extends CommandType> = Record<
|
|
398
|
-
RAW_TAGS[Command][number],
|
|
399
|
-
string | undefined
|
|
400
|
-
>;
|
|
566
|
+
export type IRC_Message = {
|
|
567
|
+
channel: string;
|
|
568
|
+
command: string;
|
|
569
|
+
params: string[];
|
|
570
|
+
source: RawSource | undefined;
|
|
571
|
+
tags: Record<string, string | undefined> | undefined;
|
|
572
|
+
};
|
|
401
573
|
|
|
402
574
|
type RawSource = {
|
|
403
575
|
host: string;
|
|
404
576
|
nick?: string | undefined;
|
|
405
577
|
user?: string | undefined;
|
|
406
578
|
};
|
|
407
|
-
|
|
408
|
-
const RAW_TAGS = {
|
|
409
|
-
CLEARCHAT: ['ban-duration', 'room-id', 'target-user-id', 'tmi-sent-ts'],
|
|
410
|
-
CLEARMSG: ['login', 'room-id', 'target-msg-id', 'tmi-sent-ts'],
|
|
411
|
-
|
|
412
|
-
GLOBALUSERSTATE: [
|
|
413
|
-
'badge-info',
|
|
414
|
-
'badges',
|
|
415
|
-
'color',
|
|
416
|
-
'display-name',
|
|
417
|
-
'emote-sets',
|
|
418
|
-
'turbo',
|
|
419
|
-
'user-id',
|
|
420
|
-
'user-type',
|
|
421
|
-
],
|
|
422
|
-
|
|
423
|
-
HOSTTARGET: [],
|
|
424
|
-
|
|
425
|
-
NOTICE: ['msg-id', 'target-user-id'],
|
|
426
|
-
|
|
427
|
-
PART: [],
|
|
428
|
-
|
|
429
|
-
PING: [],
|
|
430
|
-
|
|
431
|
-
PONG: [],
|
|
432
|
-
|
|
433
|
-
PRIVMSG: [
|
|
434
|
-
'badge-info',
|
|
435
|
-
'badges',
|
|
436
|
-
'bits',
|
|
437
|
-
'color',
|
|
438
|
-
'display-name',
|
|
439
|
-
'emotes',
|
|
440
|
-
'emote-only',
|
|
441
|
-
'id',
|
|
442
|
-
'mod',
|
|
443
|
-
'custom-reward-id',
|
|
444
|
-
'reply-thread-parent-display-name',
|
|
445
|
-
'reply-thread-parent-user-id',
|
|
446
|
-
'pinned-chat-paid-amount',
|
|
447
|
-
'pinned-chat-paid-currency',
|
|
448
|
-
'pinned-chat-paid-exponent',
|
|
449
|
-
'pinned-chat-paid-level',
|
|
450
|
-
'pinned-chat-paid-is-system-message',
|
|
451
|
-
'reply-parent-msg-id',
|
|
452
|
-
'reply-parent-user-id',
|
|
453
|
-
'reply-parent-user-login',
|
|
454
|
-
'reply-parent-display-name',
|
|
455
|
-
'reply-parent-msg-body',
|
|
456
|
-
'reply-thread-parent-msg-id',
|
|
457
|
-
'reply-thread-parent-user-login',
|
|
458
|
-
'room-id',
|
|
459
|
-
'subscriber',
|
|
460
|
-
'tmi-sent-ts',
|
|
461
|
-
'turbo',
|
|
462
|
-
'user-id',
|
|
463
|
-
'user-type',
|
|
464
|
-
'vip',
|
|
465
|
-
...[
|
|
466
|
-
// undocumented
|
|
467
|
-
'client-nonce',
|
|
468
|
-
'first-msg',
|
|
469
|
-
'flags',
|
|
470
|
-
'returning-chatter',
|
|
471
|
-
],
|
|
472
|
-
],
|
|
473
|
-
RECONNECT: [],
|
|
474
|
-
ROOMSTATE: ['emote-only', 'followers-only', 'r9k', 'room-id', 'slow', 'subs-only'],
|
|
475
|
-
USERNOTICE: [
|
|
476
|
-
'badge-info',
|
|
477
|
-
'badges',
|
|
478
|
-
'color',
|
|
479
|
-
'display-name',
|
|
480
|
-
'emotes',
|
|
481
|
-
'id',
|
|
482
|
-
'login',
|
|
483
|
-
'mod',
|
|
484
|
-
'msg-id',
|
|
485
|
-
'room-id',
|
|
486
|
-
'subscriber',
|
|
487
|
-
'system-msg',
|
|
488
|
-
'tmi-sent-ts',
|
|
489
|
-
'turbo',
|
|
490
|
-
'user-id',
|
|
491
|
-
'user-type',
|
|
492
|
-
'vip',
|
|
493
|
-
'flags',
|
|
494
|
-
...[
|
|
495
|
-
// Only subscription/raid related notices
|
|
496
|
-
'msg-param-cumulative-months',
|
|
497
|
-
'msg-param-displayName',
|
|
498
|
-
'msg-param-login',
|
|
499
|
-
'msg-param-multimonth-duration',
|
|
500
|
-
'msg-param-multimonth-tenure',
|
|
501
|
-
'msg-param-was-gifted=false',
|
|
502
|
-
'msg-param-months',
|
|
503
|
-
'msg-param-promo-gift-total',
|
|
504
|
-
'msg-param-promo-name',
|
|
505
|
-
'msg-param-recipient-display-name',
|
|
506
|
-
'msg-param-recipient-id',
|
|
507
|
-
'msg-param-recipient-user-name',
|
|
508
|
-
'msg-param-sender-login',
|
|
509
|
-
'msg-param-sender-name',
|
|
510
|
-
'msg-param-should-share-streak',
|
|
511
|
-
'msg-param-streak-months',
|
|
512
|
-
'msg-param-sub-plan',
|
|
513
|
-
'msg-param-sub-plan-name',
|
|
514
|
-
'msg-param-viewerCount',
|
|
515
|
-
'msg-param-ritual-name',
|
|
516
|
-
'msg-param-threshold',
|
|
517
|
-
'msg-param-gift-months',
|
|
518
|
-
'msg-param-was-gifted',
|
|
519
|
-
'msg-param-community-gift-id',
|
|
520
|
-
'msg-param-mass-gift-count',
|
|
521
|
-
'msg-param-origin-id',
|
|
522
|
-
],
|
|
523
|
-
],
|
|
524
|
-
USERSTATE: [
|
|
525
|
-
'badge-info',
|
|
526
|
-
'badges',
|
|
527
|
-
'color',
|
|
528
|
-
'display-name',
|
|
529
|
-
'emote-sets',
|
|
530
|
-
'id',
|
|
531
|
-
'mod',
|
|
532
|
-
'subscriber',
|
|
533
|
-
'turbo',
|
|
534
|
-
'user-type',
|
|
535
|
-
],
|
|
536
|
-
WHISPER: [
|
|
537
|
-
'badges',
|
|
538
|
-
'color',
|
|
539
|
-
'display-name',
|
|
540
|
-
'emotes',
|
|
541
|
-
'message-id',
|
|
542
|
-
'thread-id',
|
|
543
|
-
'turbo',
|
|
544
|
-
'user-id',
|
|
545
|
-
'user-type',
|
|
546
|
-
],
|
|
547
|
-
} as const;
|