v5shoutbox/v5shoutbox_irc.js

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);
}
}
}