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