Skip to content

Commit 58331c1

Browse files
committed
feat: a working POC that joins the streamer's channel, sends a message and shows up in pajbot's presence log
1 parent 105797e commit 58331c1

2 files changed

Lines changed: 329 additions & 0 deletions

File tree

src/renderer/core/irc.ts

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
import { Storage } from 'renderer/utils/storage';
2+
3+
import { logging } from './logging';
4+
5+
/**
6+
* Twitch's IRC chat.
7+
*/
8+
9+
const IRC_URL = 'irc-ws.chat.twitch.tv';
10+
const IRC_PORT = '443';
11+
const PING_INTERVAL = 60 * 1000;
12+
13+
const log = logging.getLogger('IRC');
14+
15+
export class IRC {
16+
private channel: string;
17+
18+
private ws: WebSocket;
19+
20+
private pingHandle: NodeJS.Timeout | null = null;
21+
22+
constructor(channel: string) {
23+
this.channel = channel;
24+
25+
const url = `wss://${IRC_URL}:${IRC_PORT}`;
26+
27+
this.ws = new WebSocket(url);
28+
this.ws.onopen = this.onOpen.bind(this);
29+
this.ws.onmessage = this.onMessage.bind(this);
30+
}
31+
32+
private onOpen() {
33+
log.debug(`Connected to Twitch IRC for: ${this.channel}`);
34+
const password = `oauth:${Storage.get('access-token')}`;
35+
36+
// Emitting "logon" event
37+
log.debug('Sending authentication to server..');
38+
39+
let caps = 'twitch.tv/tags twitch.tv/commands';
40+
caps += ' twitch.tv/membership';
41+
this.ws.send(`CAP REQ :${caps}`);
42+
43+
this.ws.send(`PASS ${password}`);
44+
45+
this.ws.send(`NICK ${this.channel}`);
46+
}
47+
48+
private onMessage(event: MessageEvent) {
49+
const messages: string[] = event.data.trim().split('\r\n');
50+
51+
messages.forEach((message) => {
52+
const msg = parseMessage(message);
53+
54+
if (msg) {
55+
this.handleMessage(msg);
56+
}
57+
});
58+
}
59+
60+
private handleMessage(message: Message) {
61+
if (!message) {
62+
return;
63+
}
64+
65+
log.debug('IRC Message:', { message });
66+
// const msg = message.params[1] ?? null;
67+
// const msgid = message.tags['msg-id'] ?? null;
68+
69+
// Messages with no prefix
70+
if (message.prefix === null) {
71+
switch (message.command) {
72+
// Received PING from server
73+
case 'PING':
74+
log.debug('Recieved PING');
75+
if (this.isConnected()) {
76+
log.debug('Sending PONG');
77+
this.ws.send('PONG');
78+
}
79+
break;
80+
81+
// Received PONG from server, return current latency
82+
case 'PONG': {
83+
log.debug('Recieved PONG');
84+
break;
85+
}
86+
87+
default:
88+
log.warning(
89+
'Could not parse message with no prefix. Message:',
90+
message
91+
);
92+
break;
93+
}
94+
}
95+
96+
// Messages with "tmi.twitch.tv" as a prefix
97+
else if (message.prefix === 'tmi.twitch.tv') {
98+
switch (message.command) {
99+
case '002':
100+
case '003':
101+
case '004':
102+
case '372':
103+
case '375':
104+
case 'CAP':
105+
break;
106+
107+
// Retrieve username from server.
108+
case '001': {
109+
const username = message.params;
110+
log.debug('Username returned from server:', username);
111+
break;
112+
}
113+
114+
// Connected to server.
115+
case '376': {
116+
log.debug('Connected to server.');
117+
118+
// Set an internal ping timeout check interval.
119+
this.pingHandle = setInterval(() => {
120+
// Make sure the connection is opened before sending the message.
121+
if (this.isConnected()) {
122+
log.debug('Sending PING');
123+
this.ws.send('PING');
124+
}
125+
}, PING_INTERVAL);
126+
127+
if (this.isConnected()) {
128+
const channel = parseChannel(this.channel);
129+
this.join(channel);
130+
}
131+
break;
132+
}
133+
134+
default:
135+
break;
136+
}
137+
} else {
138+
switch (message.command) {
139+
case '353':
140+
log.debug(
141+
'names',
142+
message.params[2],
143+
message.params[3].split(' ')
144+
);
145+
break;
146+
147+
case '366':
148+
break;
149+
150+
// Someone has joined the channel.
151+
case 'JOIN': {
152+
const [nick] = message.prefix.split('!');
153+
// You have joined a channel.
154+
const matchesUsername = nick === 'ceoshikhar';
155+
const channel = parseChannel(message.params[0] ?? null);
156+
if (matchesUsername) {
157+
log.debug('Joined', { channel, nick, matchesUsername });
158+
this.sendMessage(parseChannel(this.channel), 'hi!');
159+
}
160+
break;
161+
}
162+
163+
default:
164+
break;
165+
}
166+
}
167+
}
168+
169+
private join(channel: string): void {
170+
log.debug('Trying to join channel:', channel);
171+
this.ws.send(`JOIN ${channel}`);
172+
}
173+
174+
private sendMessage(channel: string, message: string): void {
175+
log.debug(`Sending message: '${message}' to channel: ${channel}`);
176+
// PRIVMSG #<channel name> :This is a sample message
177+
this.ws.send(`PRIVMSG ${channel} :${message}`);
178+
}
179+
180+
// Determine if the client has a WebSocket and it's open.
181+
private isConnected(): boolean {
182+
return this.ws !== null && this.ws.readyState === 1;
183+
}
184+
185+
private clearPingHandle() {
186+
if (this.pingHandle) {
187+
clearInterval(this.pingHandle);
188+
}
189+
}
190+
}
191+
192+
interface Message {
193+
raw: string;
194+
tags: Record<string, any>;
195+
prefix: string | null;
196+
command: string | null;
197+
params: string[];
198+
}
199+
200+
function parseMessage(raw: string): Message | null {
201+
const message: Message = {
202+
raw,
203+
tags: {},
204+
prefix: null,
205+
command: null,
206+
params: [],
207+
};
208+
209+
// Position and nextspace are used by the parser as a reference
210+
let position = 0;
211+
let nextspace = 0;
212+
213+
// The first thing we check for is IRCv3.2 message tags.
214+
// http://ircv3.atheme.org/specification/message-tags-3.2
215+
if (raw.charCodeAt(0) === 64) {
216+
nextspace = raw.indexOf(' ');
217+
218+
// Malformed IRC message
219+
if (nextspace === -1) {
220+
return null;
221+
}
222+
223+
// Tags are split by a semi colon
224+
const rawTags = raw.slice(1, nextspace).split(';');
225+
226+
for (let i = 0; i < rawTags.length; i += 1) {
227+
// Tags delimited by an equals sign are key=value tags.
228+
// If there's no equals, we assign the tag a value of true.
229+
const tag = rawTags[i];
230+
const pair = tag.split('=');
231+
message.tags[pair[0]] = tag.slice(tag.indexOf('=') + 1) || true;
232+
}
233+
234+
position = nextspace + 1;
235+
}
236+
237+
// Skip any trailing whitespace
238+
while (raw.charCodeAt(position) === 32) {
239+
position += 1;
240+
}
241+
242+
// Extract the message's prefix if present. Prefixes are prepended with a colon
243+
if (raw.charCodeAt(position) === 58) {
244+
nextspace = raw.indexOf(' ', position);
245+
246+
// If there's nothing after the prefix, deem this message to be malformed.
247+
if (nextspace === -1) {
248+
return null;
249+
}
250+
251+
message.prefix = raw.slice(position + 1, nextspace);
252+
position = nextspace + 1;
253+
254+
// Skip any trailing whitespace
255+
while (raw.charCodeAt(position) === 32) {
256+
position += 1;
257+
}
258+
}
259+
260+
nextspace = raw.indexOf(' ', position);
261+
262+
// If there's no more whitespace left, extract everything from the
263+
// current position to the end of the string as the command
264+
if (nextspace === -1) {
265+
if (raw.length > position) {
266+
message.command = raw.slice(position);
267+
return message;
268+
}
269+
return null;
270+
}
271+
272+
// Else, the command is the current position up to the next space. After
273+
// that, we expect some parameters.
274+
message.command = raw.slice(position, nextspace);
275+
276+
position = nextspace + 1;
277+
278+
// Skip any trailing whitespace
279+
while (raw.charCodeAt(position) === 32) {
280+
position += 1;
281+
}
282+
283+
while (position < raw.length) {
284+
nextspace = raw.indexOf(' ', position);
285+
286+
// If the character is a colon, we've got a trailing parameter.
287+
// At this point, there are no extra params, so we push everything
288+
// from after the colon to the end of the string, to the params array
289+
// and break out of the loop.
290+
if (raw.charCodeAt(position) === 58) {
291+
message.params.push(raw.slice(position + 1));
292+
break;
293+
}
294+
295+
// If we still have some whitespace.
296+
if (nextspace !== -1) {
297+
// Push whatever's between the current position and the next
298+
// space to the params array.
299+
message.params.push(raw.slice(position, nextspace));
300+
position = nextspace + 1;
301+
302+
// Skip any trailing whitespace and continue looping.
303+
while (raw.charCodeAt(position) === 32) {
304+
position += 1;
305+
}
306+
307+
continue;
308+
}
309+
310+
// If we don't have any more whitespace and the param isn't trailing,
311+
// push everything remaining to the params array.
312+
if (nextspace === -1) {
313+
message.params.push(raw.slice(position));
314+
break;
315+
}
316+
}
317+
return message;
318+
}
319+
320+
function parseChannel(str: string) {
321+
const channel = (str || '').toLowerCase();
322+
// Return a valid channel name.
323+
return channel[0] === '#' ? channel : `#${channel}`;
324+
}

src/renderer/core/streamer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Stream } from './stream';
22
import { StreamerIsOfflineError } from './errors';
33
import { logging } from './logging';
44
import { rightNowInSecs } from '../utils/rightNowInSecs';
5+
import { IRC } from './irc';
56

67
export enum OnlineStatus {
78
ONLINE = 'ONLINE',
@@ -60,6 +61,8 @@ export class Streamer implements StreamerPayload {
6061

6162
public stream?: Stream;
6263

64+
private irc: IRC;
65+
6366
constructor(payload: StreamerPayload) {
6467
this.login = payload.login;
6568
this.id = payload.id;
@@ -72,6 +75,8 @@ export class Streamer implements StreamerPayload {
7275
this.lastMinuteWatchedEventTime = payload.lastMinuteWatchedEventTime;
7376
this.minutesWatched = payload.minutesWatched;
7477
this.pointsEarned = payload.pointsEarned;
78+
79+
this.irc = new IRC(payload.login);
7580
}
7681

7782
public setOnlineStatus(

0 commit comments

Comments
 (0)