393 lines
13 KiB
JavaScript
393 lines
13 KiB
JavaScript
/*
|
|
# WebSocket IRC client
|
|
|
|
This script is the v5 shoutbox IRC client. Unlike the legacy v4 shoutbox which
|
|
was a poll-based HTTP service, this shoutbox is run by an IRC server, hence
|
|
this IRC client. Support for IRC features is fairly limited as the shoutbox has
|
|
never been a very fancy chat. For full features, use a standard IRC client.
|
|
|
|
## Login mechanism
|
|
|
|
The IRC server shares its accounts with the Planète Casio website through LDAP.
|
|
However, we want a website login to automatically provide access to the chat,
|
|
without requiring users to also send their password to the IRC server. This is
|
|
achieved by having the website send the user a special IRC session cookie that
|
|
the IRC server is programmed to recognize as a valid login method.
|
|
|
|
-> TODO: Implement cookie authentication
|
|
|
|
## Viewing the shoutbox as guest
|
|
|
|
TODO: We still need to figure out a client-side or server-side way to view the
|
|
chat as read-only when not logged in to the Planète Casio website.
|
|
*/
|
|
|
|
"use strict";
|
|
|
|
/* Utility class for parsing IRC messages. Constructor takes raw string as
|
|
parameter, parses it, and sets the following attributes:
|
|
- tags: Map from tag names to (possibly empty) values (@name=value;...)
|
|
- source: Message source (:source)
|
|
- command: Command either as a string ("PRIVMSG") or an integer (900)
|
|
- args: List of arguments, as strings */
|
|
class IRCMessage {
|
|
static #rMessage = (function(){
|
|
const rTags = /(?:@([^ ]+) +)?/;
|
|
const rSource = /(?::([^ ]+) +)?/;
|
|
const rCommand = /(?:([a-zA-Z]+)|(\d{3}))/;
|
|
const rArgs = /((?: +[^\r\n :][^\r\n ]*)*)/;
|
|
const rTrailing = /(?: +:([^\r\n]*))?/;
|
|
return new RegExp([/^/, rTags, rSource, rCommand, rArgs, rTrailing, /$/]
|
|
.map(r => r.source).join(""));
|
|
})();
|
|
|
|
constructor(text) {
|
|
const matches = IRCMessage.#rMessage.exec(text);
|
|
if(matches === undefined)
|
|
return;
|
|
|
|
this.tags = new Map();
|
|
(matches[1] || "").split(";").filter(s => s).forEach(s => {
|
|
const parts = s.split(/=(.*)/);
|
|
const value = (parts[1] || "").replaceAll("\\\\", "\\")
|
|
.replaceAll("\\:", ";")
|
|
.replaceAll("\\r", "\r")
|
|
.replaceAll("\\n", "\n")
|
|
.replaceAll(/\\./g, m => m[1]);
|
|
this.tags.set(parts[0], value);
|
|
});
|
|
|
|
this.source = matches[2];
|
|
this.command = matches[3] || parseInt(matches[4]);
|
|
this.args = (matches[5] || "").split(" ").filter(s => s);
|
|
if(matches[6] !== undefined)
|
|
this.args.push(matches[6]);
|
|
}
|
|
}
|
|
|
|
/* Possible values for the irc.conn member of the state object. */
|
|
const IRCConn = Object.freeze({
|
|
/* Websocket is disconnected. */
|
|
DISCONNECTED: 0,
|
|
/* Socket is connected and we are going through the login sequence. */
|
|
CONNECTING: 1,
|
|
/* We are authenticated and using the server. */
|
|
RUNNING: 2,
|
|
});
|
|
|
|
/* https://stackoverflow.com/questions/30106476 */
|
|
function irc_base64_encode(str) {
|
|
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
|
|
function toSolidBytes(match, p1) {
|
|
return String.fromCharCode('0x' + p1);
|
|
}));
|
|
}
|
|
|
|
class IRCClient {
|
|
#ws;
|
|
#password;
|
|
|
|
constructor(serverUrl) {
|
|
/* IRC server's websocket URL (wss://...). */
|
|
this.serverUrl = serverUrl;
|
|
/* Websocket for IRC server communication. */
|
|
this.#ws = undefined;
|
|
/* Current connection state. */
|
|
this.conn = IRCConn.DISCONNECTED;
|
|
/* Capabilities enabled. */
|
|
this.caps = [];
|
|
/* List of channels and their information. */
|
|
this.channels = new Map();
|
|
/* Username with which we are logged in. Undefined when DISCONNECTED. */
|
|
this.username = undefined;
|
|
/* Password for connection. Erased after authenticating. */
|
|
this.#password = undefined;
|
|
/* Batches currently being processed. */
|
|
this.batches = new Map();
|
|
/* History cache. */
|
|
this.histcache = new Map();
|
|
}
|
|
|
|
/*** Callbacks (user of the class should override to receive events). ***/
|
|
|
|
onDebugLog(ctgy, message) {
|
|
/* A debug message was logged; either a copy of a client/server exchange
|
|
(ctgy "ircin" or "ircout"), or a log from this class (ctgy "irc"). */
|
|
}
|
|
onConnChanged(conn) {
|
|
/* The connection status has changed. The new connection state (IRCConn.*)
|
|
is given as parameter, but the full state can also be queried. */
|
|
}
|
|
onAuthResult(successful) {
|
|
/* Authentication has just either failed or succeeded. */
|
|
}
|
|
onChannelChanged(channel, info) {
|
|
/* A channel's info changed; either it was added, or its header, list of
|
|
users, etc. changed. */
|
|
}
|
|
onNewMessage(id, channel, date, author, message) {
|
|
/* A message was posted in one of the channels joined by the client or in a
|
|
private message feed. */
|
|
}
|
|
onHistoryReceived(channel, history) {
|
|
/* The requested history for the specified channel has been received. The
|
|
second argument is a list of { channel, data, author, message } objects,
|
|
similar to the onNewMessage callback. */
|
|
}
|
|
|
|
/*** Public API ***/
|
|
|
|
/* Initiate the connection sequence to the server. */
|
|
connect(username, password) {
|
|
this.doConnect(username, password);
|
|
}
|
|
|
|
/* Disconnect the websocket, thus leaving the server. */
|
|
disconnect() {
|
|
this.#ws.close();
|
|
}
|
|
|
|
/* Get current state info (`irc` member of state object). */
|
|
state() {
|
|
return {
|
|
conn: this.conn,
|
|
username: this.username,
|
|
channels: this.channels
|
|
};
|
|
};
|
|
|
|
/* Request the history for a given channel. The history is cached in a pretty
|
|
lightweight way; most comms with the IRC server reset it, but if it is
|
|
requested multiples in a row (eg. by creating many clients due to opening
|
|
a bunch of website tabs) the cache will hit. */
|
|
getHistory(channel) {
|
|
// TODO: IRC client does not respond to getHistory() if the CAP is missing
|
|
if(this.histcache.has(channel))
|
|
this.onHistoryReceived(channel, this.histcache.get(channel));
|
|
else if(this.caps.includes("draft/chathistory"))
|
|
this.send("CHATHISTORY", ["LATEST", channel, "*", "100"]);
|
|
}
|
|
|
|
/* Post a message to a public or private channel. */
|
|
postMessage(channel, message) {
|
|
if(message.startsWith("/")) {
|
|
this.#sendRaw(message.substring(1));
|
|
return;
|
|
}
|
|
this.send("PRIVMSG", [channel, message]);
|
|
if(!this.caps.includes("echo-message"))
|
|
this.onNewMessage(channel, new Date(), this.username, message);
|
|
}
|
|
|
|
/*** Internal functions ***/
|
|
|
|
setConn(conn) {
|
|
if(this.conn === conn)
|
|
return;
|
|
this.conn = conn;
|
|
this.onConnChanged(conn);
|
|
}
|
|
|
|
doConnect(username, password) {
|
|
let ws = new WebSocket(this.serverUrl);
|
|
this.#ws = ws;
|
|
this.onDebugLog("irc", "Connecting...");
|
|
|
|
ws.onopen = function() {
|
|
this.onDebugLog("irc", "Websocket opened.");
|
|
this.setConn(IRCConn.CONNECTING);
|
|
this.username = username;
|
|
this.#password = password;
|
|
this.#startExchange();
|
|
}.bind(this);
|
|
|
|
ws.onmessage = function(net) {
|
|
this.onDebugLog("ircin", net.data);
|
|
let msg = new IRCMessage(net.data);
|
|
if(msg.command !== undefined)
|
|
this.processIRCMessage(msg);
|
|
else
|
|
this.onDebugLog("irc", "Invalid message from IRC server!");
|
|
}.bind(this);
|
|
|
|
ws.onclose = function() {
|
|
this.onDebugLog("irc", "Websocket closed.");
|
|
this.setConn(IRCConn.DISCONNECTED);
|
|
}.bind(this);
|
|
}
|
|
|
|
#sendRaw(command) {
|
|
this.onDebugLog("ircout", command);
|
|
this.#ws.send(command);
|
|
}
|
|
|
|
send(command, args) {
|
|
if(args.slice(0,-1).some(x => x.includes(" "))) {
|
|
this.OnDebugLog("irc",
|
|
`invalid spaces in non-last args: ${command} ${args}`);
|
|
return;
|
|
}
|
|
|
|
if(args.length && args[args.length - 1].includes(" "))
|
|
args[args.length - 1] = ":" + args[args.length - 1];
|
|
|
|
this.#sendRaw(command + " " + args.join(" "));
|
|
}
|
|
|
|
#startExchange() {
|
|
this.#sendRaw("CAP LS 302");
|
|
this.send("NICK", [this.username]);
|
|
this.send("USER", [this.username, "*", "0", this.username]);
|
|
}
|
|
|
|
processIRCMessage(msg) {
|
|
if(msg.command === "PING")
|
|
return this.send("PONG", msg.args);
|
|
|
|
/* Reset history cache if anything else than a ping happens */
|
|
this.histcache = new Map();
|
|
|
|
/* Reconstruct batches */
|
|
if(msg.command === "BATCH") {
|
|
const [mode, id] = [msg.args[0][0], msg.args[0].substring(1)];
|
|
if(mode == "+")
|
|
this.batches.set(id, []);
|
|
this.batches.get(id).push(msg);
|
|
if(mode == "-") {
|
|
this.processBatch(this.batches.get(id));
|
|
this.batches.delete(id);
|
|
}
|
|
return;
|
|
}
|
|
if(msg.tags.has("batch")) {
|
|
this.batches.get(msg.tags.get("batch")).push(msg);
|
|
return;
|
|
}
|
|
|
|
/* Request any caps that's part of our wishlist. We know all of them are
|
|
enabled on the Planète Casio server; configurations where some of them
|
|
are missing are mostly untested. */
|
|
if(msg.command === "CAP" && msg.args[1] === "LS") {
|
|
const caps = msg.args[2].split(" ").filter(x => x);
|
|
const wishlist =
|
|
["message-tags", "server-time", "echo-message",
|
|
"batch", "sasl", "draft/chathistory"];
|
|
|
|
let req = wishlist.filter(x => caps.includes(x));
|
|
if(caps.some(x => x.startsWith("sasl=")))
|
|
req.push("sasl");
|
|
|
|
if(req.length)
|
|
this.send("CAP", ["REQ", req.join(" ")]);
|
|
else {
|
|
this.caps = [];
|
|
this.#sendRaw("CAP END");
|
|
}
|
|
}
|
|
|
|
/* After CAP sequence, authenticate with SASL. if possible. */
|
|
if(msg.command === "CAP" && msg.args[1] === "ACK") {
|
|
this.caps = msg.args[2].split(" ").filter(x => x);
|
|
if(this.caps.includes("sasl"))
|
|
this.#sendRaw("AUTHENTICATE PLAIN");
|
|
else
|
|
this.#sendRaw("CAP END");
|
|
}
|
|
if(msg.command === "AUTHENTICATE" && msg.args[0] === "+") {
|
|
const arg = `${this.username}\0${this.username}\0${this.#password}`;
|
|
this.onDebugLog("irc", "AUTHENTICATE command sent (not shown)");
|
|
this.#ws.send("AUTHENTICATE " + irc_base64_encode(arg));
|
|
}
|
|
/* ... if that's not available or fails, use the old AUTH command. */
|
|
if(msg.command === "NOTICE" && msg.args[1].includes("/AUTH")) {
|
|
this.onDebugLog("irc", "Legacy AUTH command sent (not shown)");
|
|
this.#ws.send(`AUTH ${this.username}:${this.#password}`);
|
|
}
|
|
|
|
if(msg.command == 433 || msg.command == 451) {
|
|
/* These are messy situations to be in, if this happens just disconnect
|
|
so we can at least start from a blank state */
|
|
this.onDebugLog("irc", "Nickname already in use / Not registered!");
|
|
this.#ws.close();
|
|
this.setConn(IRCConn.DISCONNECTED);
|
|
}
|
|
if(msg.command == 904) {
|
|
this.onDebugLog("irc", "Authentication failed!");
|
|
this.#ws.close();
|
|
this.setConn(IRCConn.DISCONNECTED);
|
|
this.onAuthResult(false);
|
|
}
|
|
|
|
if(msg.command === 900) {
|
|
this.onDebugLog("irc", "Authenticated.");
|
|
if(this.caps.includes("sasl"))
|
|
this.#sendRaw("CAP END");
|
|
this.setConn(IRCConn.RUNNING);
|
|
this.#password = undefined;
|
|
this.onAuthResult(true);
|
|
}
|
|
|
|
if(msg.command === "JOIN" && msg.tags.get("account") === this.username) {
|
|
this.channels.set(msg.args[0], {});
|
|
this.onChannelChanged(msg.args[0], this.channels.get(msg.args[0]));
|
|
}
|
|
if(msg.command === 332) {
|
|
this.channels.get(msg.args[1]).header = msg.args[2];
|
|
this.onChannelChanged(msg.args[1], this.channels.get(msg.args[1]));
|
|
}
|
|
if(msg.command === 353 && msg.args[1] === "=") {
|
|
this.channels.get(msg.args[2]).clients = msg.args[3].split(" ");
|
|
this.onChannelChanged(msg.args[2], this.channels.get(msg.args[2]));
|
|
}
|
|
|
|
if(msg.command === "PRIVMSG")
|
|
this.processPRIVMSG(msg);
|
|
}
|
|
|
|
/* Decode a PRIVMSG into a [id, channel, date, author, message] tuple. */
|
|
decodePRIVMSG(msg) {
|
|
if(msg.args.length !== 2) {
|
|
this.onDebugLog("irc", `Invalid PRIVMSG! ${msg}`);
|
|
return null;
|
|
}
|
|
let id = null;
|
|
if(msg.tags.has("msgid"))
|
|
id = msg.tags.get("msgid");
|
|
let source = msg.source;
|
|
if(source.includes("!"))
|
|
source = source.substr(0, source.indexOf("!"));
|
|
let date = new Date();
|
|
if(msg.tags.has("time"))
|
|
date = new Date(msg.tags.get("time"));
|
|
return [id, msg.args[0], date, source, msg.args[1]];
|
|
}
|
|
|
|
processPRIVMSG(msg) {
|
|
const q = this.decodePRIVMSG(msg);
|
|
if(q !== null)
|
|
this.onNewMessage(...q);
|
|
}
|
|
|
|
processBatch(batch) {
|
|
const batchArgs = batch[0].args.slice(1);
|
|
|
|
/* CHATHISTORY is handled through this class' history interface, which
|
|
caches the results, instead of being forwarded directly. */
|
|
if(batchArgs.length === 2 && batchArgs[0] === "chathistory") {
|
|
const channel = batchArgs[1];
|
|
let messages = [];
|
|
|
|
for(const msg of batch) {
|
|
if(msg.command !== "PRIVMSG")
|
|
continue;
|
|
const q = this.decodePRIVMSG(msg);
|
|
if(q !== null)
|
|
messages.push({
|
|
id: q[0], channel: q[1], date: q[2], source: q[3], message: q[4] });
|
|
}
|
|
this.histcache.set(channel, messages);
|
|
this.onHistoryReceived(channel, messages);
|
|
}
|
|
}
|
|
}
|