split IRC client off so it can be used by script or worker

This commit is contained in:
Lephenixnoir 2023-07-04 19:42:22 +02:00
parent e8c9c293df
commit c56030b589
Signed by: Lephenixnoir
GPG Key ID: 1BBA026E13FC0495
3 changed files with 347 additions and 303 deletions

View File

@ -37,317 +37,39 @@ authentication setup: normal IRC (with password), PCv4, PCv5.
*/
"use strict";
import * as IRC from "./v5shoutbox_irc.js";
/* WebSocket IRC with HTTP-based website interface. */
let irc = new function() {
const State = Object.freeze({
/* Websocket is disconnected. */
DISCONNECTED: Symbol("DISCONNECTED"),
/* We just opened a socket to the IRC server and have yet to register. */
CONNECTING: Symbol("CONNECTING"),
/* We are authenticated and ready to use the server. */
READY: Symbol("READY"),
});
let conn = {
/* IRC server's websocket URL (wss://...). */
serverUrl: undefined,
/* The websocket connected to serverUrl */
socket: undefined,
/* HTTP-based message post URL. Undefined when using pure IRC, or a v4/v5
URL when using website authentication. */
postUrl: undefined,
/* Current connection state. */
state: State.DISCONNECTED,
/* Username with which we are logged in. Undefined when DISCONNECTED. */
username: undefined,
/* Current channel. This remains defined even when DISCONNECTED. */
channel: undefined,
/* Password for connection. Erased after authenticating. */
password: undefined,
/* Capabilities enabled. */
caps: [],
};
let batches = new Map();
/* List of channels and their information */
this.channels = new Map();
/*** Accessors/mutators ***/
this.isConnected = function() {
return conn.state !== State.DISCONNECTED;
}
this.isRunning = function() {
return conn.state === State.READY;
}
this.stateString = function() {
switch(conn.state) {
case State.DISCONNECTED: return "Disconnected";
case State.CONNECTED: return "Connecting...";
case State.READY: return "Connected";
}
const registerServiceWorker = async () => {
if(!("serviceWorker" in navigator)) {
// TODO: Use previous, local IRC client
console.error("Service workers not supported, shoutbox not available.");
return;
}
this.currentChannel = function() {
return conn.channel;
}
this.selectChannel = function(name) {
conn.channel = name;
}
this.isOnRemoteChannel = function() {
const c = conn.channel;
return (c !== undefined) && (c.startsWith("&") || c.startsWith("#"));
}
try {
const registration = await navigator.serviceWorker.register(
'v5shoutbox_worker.js', { scope: "./" });
/*** Overridable hooks and callbacks ***/
if (registration.installing)
console.log('Service worker installing');
else if (registration.waiting)
console.log('Service worker installed');
else if (registration.active)
console.log('Service worker active');
this.onLog = function(message) {};
this.onStateChanged = function() {};
this.onAuthenticated = function() {};
this.onNewMessage = function(channel, date, author, message) {};
this.onChannelChanged = function(channel) {};
function log(...args) {
irc.onLog(...args);
}
/*** Connection management ***/
function setState(state) {
conn.state = state;
irc.onStateChanged();
}
this.connect = function(serverUrl, postUrl, username, password) {
conn.serverUrl = serverUrl;
conn.postUrl = postUrl;
conn.socket = new WebSocket(conn.serverUrl);
log("[v5shoutbox] Connecting...");
conn.socket.onopen = function() {
log("[v5shoutbox] Connected.");
setState(State.CONNECTED);
conn.username = username;
conn.password = password;
startExchange();
}
conn.socket.onmessage = function(net) {
log("[<] " + net.data);
let msg = new Message(net.data);
if(msg.command === undefined) {
log("[v5shoutbox] invalid message");
return;
}
processMessage(msg);
}
conn.socket.onclose = function() {
log("[v5shoutbox] Disconnected.");
setState(State.DISCONNECTED);
}
}
function sendRaw(command) {
log("[>] " + command);
conn.socket.send(command);
}
function send(command, args) {
if(args.slice(0,-1).some(x => x.includes(" ")))
return log("invalid spaces in non-last args: " + command.toString() +
" " + args.toString());
if(args.length && args[args.length - 1].includes(" "))
args[args.length - 1] = ":" + args[args.length - 1];
sendRaw(command + " " + args.join(" "));
}
/*** IRC protocol ***/
/* Regex for parsing messages */
const 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(""));
})();
/* https://stackoverflow.com/questions/30106476 */
function base64_encode(str) {
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
function toSolidBytes(match, p1) {
return String.fromCharCode('0x' + p1);
}));
}
class Message {
constructor(text) {
const matches = 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]);
}
}
this.Message = Message;
function startExchange() {
sendRaw("CAP LS 302");
send("NICK", [conn.username]);
send("USER", [conn.username, "*", "0", conn.username]);
}
function processMessage(msg) {
if(msg.command === "PING") {
send("PONG", msg.args);
return;
}
if(msg.command === "BATCH") {
const [mode, id] = [msg.args[0][0], msg.args[0].substring(1)];
if(mode == "+")
batches.set(id, []);
batches.get(id).push(msg);
if(mode == "-") {
processBatch(batches.get(id));
batches.delete(id);
}
return;
}
if(msg.tags.has("batch")) {
batches.get(msg.tags.get("batch")).push(msg);
return;
}
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)
send("CAP", ["REQ", req.join(" ")]);
else {
conn.caps = [];
sendRaw("CAP END");
}
}
if(msg.command === "CAP" && msg.args[1] === "ACK") {
conn.caps = msg.args[2].split(" ").filter(x => x);
if(conn.caps.includes("sasl"))
sendRaw("AUTHENTICATE PLAIN");
else
sendRaw("CAP END");
}
if(msg.command === "AUTHENTICATE" && msg.args[0] === "+") {
const arg = conn.username + "\0" + conn.username + "\0" + conn.password;
const b64 = base64_encode(arg);
log("[v5shoutbox] AUTHENTICATE command sent (not shown)");
conn.socket.send("AUTHENTICATE " + b64);
}
/* Default authentication if SASL is unavailable or fails */
if(msg.command === "NOTICE" && msg.args[1].includes("/AUTH")) {
log("[v5shoutbox] AUTH command sent (not shown)");
conn.socket.send("AUTH " + conn.username + ":" + conn.password);
}
if(msg.command === 900) {
log("[v5shoutbox] Authenticated.");
if(conn.caps.includes("sasl"))
sendRaw("CAP END");
setState(State.READY);
conn.password = undefined;
irc.onAuthenticated();
if(conn.caps.includes("draft/chathistory")) {
["#annonces", "#projets", "#hs"].forEach(channel => {
send("CHATHISTORY", ["LATEST", channel, "*", "100"]);
});
}
}
if(msg.command === "JOIN" && msg.tags.get("account") === conn.username) {
irc.channels.set(msg.args[0], {});
irc.onChannelChanged(msg.args[0]);
}
if(msg.command === 332) {
irc.channels.get(msg.args[1]).header = msg.args[2];
irc.onChannelChanged(msg.args[1]);
}
if(msg.command === 353 && msg.args[1] === "=") {
irc.channels.get(msg.args[2]).clients = msg.args[3].split(" ");
irc.onChannelChanged(msg.args[2]);
}
if(msg.command === "PRIVMSG")
processPRIVMSG(msg);
}
function processBatch(batch) {
const batchArgs = batch[0].args.slice(1);
if(batchArgs.length === 2 && batchArgs[0] === "chathistory") {
batch.forEach(msg => {
if(msg.command === "PRIVMSG")
processPRIVMSG(msg);
});
}
}
function processPRIVMSG(msg) {
if(msg.args.length == 2) {
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"));
irc.onNewMessage(msg.args[0], date, source, msg.args[1]);
}
}
this.postMessage = function(channel, message) {
if(message.startsWith("/")) {
sendRaw(message.substring(1));
}
else {
send("PRIVMSG", [channel, message]);
if(!conn.caps.includes("echo-message"))
irc.onNewMessage(channel, new Date(), conn.username, message);
}
console.log(registration);
} catch (error) {
console.error(`Registration failed with ${error}`);
}
};
registerServiceWorker();
let irc = new IRC.Client("wss://irc.planet-casio.com:443", undefined);
/* Shoutbox entry point and DOM manipulation. */
let shoutbox = new function() {
const serverUrl = "wss://irc.planet-casio.com:443";
const postUrl = undefined;
const availableChannels = ["#annonces", "#projets", "#hs"];
/* Original tab title */
@ -377,7 +99,7 @@ let shoutbox = new function() {
this.eLoginForm.addEventListener("submit", e => {
e.preventDefault();
shoutbox.connect(serverUrl, postUrl);
shoutbox.connect();
});
this.eShoutboxForm.addEventListener("submit", e => {
e.preventDefault();
@ -543,7 +265,7 @@ let shoutbox = new function() {
/*** User interactions ***/
this.connect = function(serverUrl, postUrl) {
this.connect = function() {
if(irc.isConnected())
return this.log("Already connected!");
if(this.eLogin.value === "" || this.ePassword.value === "")
@ -551,7 +273,7 @@ let shoutbox = new function() {
if(this.eLogin.value.includes(" "))
return this.log("Login should not contain a space!");
irc.connect(serverUrl, postUrl, this.eLogin.value, this.ePassword.value);
irc.connect(this.eLogin.value, this.ePassword.value);
}
this.selectChannel = function(name) {

307
v5shoutbox_irc.js Normal file
View File

@ -0,0 +1,307 @@
/* WebSocket IRC with HTTP-based website interface. */
class Message {
/* Regex for parsing messages */
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 = Message.#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]);
}
}
const State = Object.freeze({
/* Websocket is disconnected. */
DISCONNECTED: Symbol("DISCONNECTED"),
/* We just opened a socket to the IRC server and have yet to register. */
CONNECTING: Symbol("CONNECTING"),
/* We are authenticated and ready to use the server. */
READY: Symbol("READY"),
});
/* https://stackoverflow.com/questions/30106476 */
function base64_encode(str) {
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
function toSolidBytes(match, p1) {
return String.fromCharCode('0x' + p1);
}));
}
export class Client {
#ws;
#password;
constructor(serverUrl, postUrl) {
/* IRC server's websocket URL (wss://...). */
this.serverUrl = serverUrl;
/* HTTP-based message post URL. Undefined when using pure IRC, or a v4/v5
URL when using website authentication. */
this.postUrl = postUrl;
/* Websocket for IRC server communication. */
this.#ws = undefined;
/* Current connection state. */
this.state = State.DISCONNECTED;
/* Capabilities enabled. */
this.caps = [];
/* List of channels and their information */
this.channels = new Map();
/* Current channel. This remains defined even when DISCONNECTED. */
this.channel = undefined;
/* 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();
}
/*** Accessors/mutators ***/
isConnected() {
return this.state !== State.DISCONNECTED;
}
isRunning() {
return this.state === State.READY;
}
stateString() {
switch(this.state) {
case State.DISCONNECTED: return "Disconnected";
case State.CONNECTED: return "Connecting...";
case State.READY: return "Connected";
}
}
currentChannel() {
return this.channel;
}
selectChannel(name) {
this.channel = name;
}
isOnRemoteChannel() {
const c = this.channel;
return (c !== undefined) && (c.startsWith("&") || c.startsWith("#"));
}
/*** Overridable hooks and callbacks ***/
onLog(message) {}
onStateChanged() {}
onAuthenticated() {}
onNewMessage(channel, date, author, message) {}
onChannelChanged(channel) {}
log(...args) {
this.onLog(...args);
}
/*** Connection management ***/
setState(state) {
this.state = state;
this.onStateChanged();
}
connect(username, password) {
let ws = new WebSocket(this.serverUrl);
this.#ws = ws;
this.log("[v5shoutbox] Connecting...");
ws.onopen = function() {
this.log("[v5shoutbox] Connected.");
this.setState(State.CONNECTED);
this.username = username;
this.#password = password;
this.#startExchange();
}.bind(this);
ws.onmessage = function(net) {
this.log("[<] " + net.data);
let msg = new Message(net.data);
if(msg.command === undefined) {
this.log("[v5shoutbox] invalid message");
return;
}
this.processMessage(msg);
}.bind(this);
ws.onclose = function() {
this.log("[v5shoutbox] Disconnected.");
this.setState(State.DISCONNECTED);
}.bind(this);
}
#sendRaw(command) {
this.log("[>] " + command);
this.#ws.send(command);
}
send(command, args) {
if(args.slice(0,-1).some(x => x.includes(" ")))
return this.log("invalid spaces in non-last args: " + command.toString() +
" " + args.toString());
if(args.length && args[args.length - 1].includes(" "))
args[args.length - 1] = ":" + args[args.length - 1];
this.#sendRaw(command + " " + args.join(" "));
}
/*** IRC protocol ***/
#startExchange() {
this.#sendRaw("CAP LS 302");
this.send("NICK", [this.username]);
this.send("USER", [this.username, "*", "0", this.username]);
}
processMessage(msg) {
if(msg.command === "PING") {
this.send("PONG", msg.args);
return;
}
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;
}
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");
}
}
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;
const b64 = base64_encode(arg);
this.log("[v5shoutbox] AUTHENTICATE command sent (not shown)");
this.#ws.send("AUTHENTICATE " + b64);
}
/* Default authentication if SASL is unavailable or fails */
if(msg.command === "NOTICE" && msg.args[1].includes("/AUTH")) {
this.log("[v5shoutbox] AUTH command sent (not shown)");
this.#ws.send("AUTH " + this.username + ":" + this.#password);
}
if(msg.command === 900) {
this.log("[v5shoutbox] Authenticated.");
if(this.caps.includes("sasl"))
this.#sendRaw("CAP END");
this.setState(State.READY);
this.#password = undefined;
this.onAuthenticated();
if(this.caps.includes("draft/chathistory")) {
["#annonces", "#projets", "#hs"].forEach(channel => {
this.send("CHATHISTORY", ["LATEST", channel, "*", "100"]);
});
}
}
if(msg.command === "JOIN"
&& msg.tags.get("account") === this.username) {
this.channels.set(msg.args[0], {});
this.onChannelChanged(msg.args[0]);
}
if(msg.command === 332) {
this.channels.get(msg.args[1]).header = msg.args[2];
this.onChannelChanged(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]);
}
if(msg.command === "PRIVMSG")
this.processPRIVMSG(msg);
}
processBatch(batch) {
const batchArgs = batch[0].args.slice(1);
if(batchArgs.length === 2 && batchArgs[0] === "chathistory") {
batch.forEach(msg => {
if(msg.command === "PRIVMSG")
this.processPRIVMSG(msg);
});
}
}
processPRIVMSG(msg) {
if(msg.args.length == 2) {
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"));
this.onNewMessage(msg.args[0], date, source, msg.args[1]);
}
}
postMessage(channel, message) {
if(message.startsWith("/")) {
this.#sendRaw(message.substring(1));
}
else {
this.send("PRIVMSG", [channel, message]);
if(!this.caps.includes("echo-message"))
this.onNewMessage(channel, new Date(), conn.username, message);
}
}
}

15
v5shoutbox_worker.js Normal file
View File

@ -0,0 +1,15 @@
self.addEventListener("activate", (e) => {
console.log("[worker] activating!");
});
self.addEventListener("install", (e) => {
console.log("[worker] installing!");
});
self.addEventListener("message", (e) => {
console.log("[worker] message!");
console.log(e);
console.log(self);
// if(e.data[0] == "!")
// postMessage(e.data.substring(1));
});