Remove assets/
This commit is contained in:
228
lib/client.js
Normal file
228
lib/client.js
Normal file
@@ -0,0 +1,228 @@
|
||||
import * as irc from "./irc.js";
|
||||
|
||||
// Static list of capabilities that are always requested when supported by the
|
||||
// server
|
||||
const permanentCaps = ["message-tags", "server-time", "multi-prefix"];
|
||||
|
||||
export default class Client extends EventTarget {
|
||||
ws = null;
|
||||
nick = null;
|
||||
params = {
|
||||
username: null,
|
||||
realname: null,
|
||||
nick: null,
|
||||
pass: null,
|
||||
saslPlain: null,
|
||||
};
|
||||
registered = false;
|
||||
availableCaps = {};
|
||||
enabledCaps = {};
|
||||
|
||||
constructor(params) {
|
||||
super();
|
||||
|
||||
this.params = Object.assign(this.params, params);
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(params.url);
|
||||
} catch (err) {
|
||||
console.error("Failed to create connection:", err);
|
||||
setTimeout(() => this.dispatchEvent(new CustomEvent("close")), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.addEventListener("open", this.handleOpen.bind(this));
|
||||
this.ws.addEventListener("message", this.handleMessage.bind(this));
|
||||
|
||||
this.ws.addEventListener("close", () => {
|
||||
console.log("Connection closed");
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
});
|
||||
|
||||
this.ws.addEventListener("error", () => {
|
||||
console.error("Connection error");
|
||||
});
|
||||
}
|
||||
|
||||
handleOpen() {
|
||||
console.log("Connection opened");
|
||||
|
||||
this.nick = this.params.nick;
|
||||
|
||||
this.send({ command: "CAP", params: ["LS", "302"] });
|
||||
if (this.params.pass) {
|
||||
this.send({ command: "PASS", params: [this.params.pass] });
|
||||
}
|
||||
this.send({ command: "NICK", params: [this.nick] });
|
||||
this.send({
|
||||
command: "USER",
|
||||
params: [this.params.username, "0", "*", this.params.realname],
|
||||
});
|
||||
}
|
||||
|
||||
handleMessage(event) {
|
||||
var msg = irc.parseMessage(event.data);
|
||||
console.log("Received:", msg);
|
||||
|
||||
switch (msg.command) {
|
||||
case irc.RPL_WELCOME:
|
||||
if (this.params.saslPlain && this.availableCaps["sasl"] === undefined) {
|
||||
console.error("Server doesn't support SASL PLAIN");
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Registration complete");
|
||||
this.registered = true;
|
||||
break;
|
||||
case irc.ERR_PASSWDMISMATCH:
|
||||
console.error("Password mismatch");
|
||||
this.close();
|
||||
break;
|
||||
case "CAP":
|
||||
this.handleCap(msg);
|
||||
break;
|
||||
case "AUTHENTICATE":
|
||||
this.handleAuthenticate(msg);
|
||||
break;
|
||||
case irc.RPL_LOGGEDIN:
|
||||
console.log("Logged in");
|
||||
break;
|
||||
case irc.RPL_LOGGEDOUT:
|
||||
console.log("Logged out");
|
||||
break;
|
||||
case irc.RPL_SASLSUCCESS:
|
||||
console.log("SASL authentication success");
|
||||
if (!this.registered) {
|
||||
this.send({ command: "CAP", params: ["END"] });
|
||||
}
|
||||
break;
|
||||
case irc.ERR_NICKLOCKED:
|
||||
case irc.ERR_SASLFAIL:
|
||||
case irc.ERR_SASLTOOLONG:
|
||||
case irc.ERR_SASLABORTED:
|
||||
case irc.ERR_SASLALREADY:
|
||||
console.error("SASL error:", msg);
|
||||
this.close();
|
||||
break;
|
||||
case "NICK":
|
||||
var newNick = msg.params[0];
|
||||
if (msg.prefix.name == this.nick) {
|
||||
this.nick = newNick;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
this.dispatchEvent(new CustomEvent("message", {
|
||||
detail: { message: msg },
|
||||
}));
|
||||
}
|
||||
|
||||
addAvailableCaps(s) {
|
||||
var l = s.split(" ");
|
||||
l.forEach((s) => {
|
||||
var parts = s.split("=");
|
||||
var k = parts[0];
|
||||
var v = "";
|
||||
if (parts.length > 1) {
|
||||
v = parts[1];
|
||||
}
|
||||
this.availableCaps[k] = v;
|
||||
});
|
||||
}
|
||||
|
||||
handleCap(msg) {
|
||||
var subCmd = msg.params[1];
|
||||
var args = msg.params.slice(2);
|
||||
switch (subCmd) {
|
||||
case "LS":
|
||||
this.addAvailableCaps(args[args.length - 1]);
|
||||
if (args[0] != "*") {
|
||||
console.log("Available server caps:", this.availableCaps);
|
||||
|
||||
var reqCaps = [];
|
||||
|
||||
var saslCap = this.availableCaps["sasl"];
|
||||
var supportsSaslPlain = (saslCap !== undefined);
|
||||
if (saslCap.length > 0) {
|
||||
supportsSaslPlain = saslCap.split(",").includes("PLAIN");
|
||||
}
|
||||
|
||||
var capEnd = true;
|
||||
if (this.params.saslPlain && supportsSaslPlain) {
|
||||
// CAP END is deferred after authentication finishes
|
||||
reqCaps.push("sasl");
|
||||
capEnd = false;
|
||||
}
|
||||
|
||||
permanentCaps.forEach((cap) => {
|
||||
if (this.availableCaps[cap] !== undefined) {
|
||||
reqCaps.push(cap);
|
||||
}
|
||||
});
|
||||
|
||||
if (reqCaps.length > 0) {
|
||||
this.send({ command: "CAP", params: ["REQ", reqCaps.join(" ")] });
|
||||
}
|
||||
|
||||
if (!this.registered && capEnd) {
|
||||
this.send({ command: "CAP", params: ["END"] });
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "NEW":
|
||||
this.addAvailableCaps(args[0]);
|
||||
console.log("Server added available caps:", args[0]);
|
||||
// TODO: request caps
|
||||
break;
|
||||
case "DEL":
|
||||
args[0].split(" ").forEach((cap) => {
|
||||
delete this.availableCaps[cap];
|
||||
delete this.enabledCaps[cap];
|
||||
});
|
||||
console.log("Server removed available caps:", args[0]);
|
||||
break;
|
||||
case "ACK":
|
||||
console.log("Server ack'ed caps:", args[0]);
|
||||
args[0].split(" ").forEach((cap) => {
|
||||
this.enabledCaps[cap] = true;
|
||||
|
||||
if (cap == "sasl" && this.params.saslPlain) {
|
||||
console.log("Starting SASL PLAIN authentication");
|
||||
this.send({ command: "AUTHENTICATE", params: ["PLAIN"] });
|
||||
}
|
||||
});
|
||||
break;
|
||||
case "NAK":
|
||||
console.log("Server nak'ed caps:", args[0]);
|
||||
if (!this.registered) {
|
||||
this.send({ command: "CAP", params: ["END"] });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleAuthenticate(msg) {
|
||||
var challengeStr = msg.params[0];
|
||||
|
||||
// For now only PLAIN is supported
|
||||
if (challengeStr != "+") {
|
||||
console.error("Expected an empty challenge, got:", challengeStr);
|
||||
this.send({ command: "AUTHENTICATE", params: ["*"] });
|
||||
return;
|
||||
}
|
||||
|
||||
var respStr = btoa("\0" + this.params.saslPlain.username + "\0" + this.params.saslPlain.password);
|
||||
this.send({ command: "AUTHENTICATE", params: [respStr] });
|
||||
}
|
||||
|
||||
send(msg) {
|
||||
this.ws.send(irc.formatMessage(msg));
|
||||
console.log("Sent:", msg);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.ws.close(1000);
|
||||
this.registered = false;
|
||||
}
|
||||
}
|
||||
187
lib/irc.js
Normal file
187
lib/irc.js
Normal file
@@ -0,0 +1,187 @@
|
||||
export const RPL_WELCOME = "001";
|
||||
export const RPL_TOPIC = "332";
|
||||
export const RPL_NAMREPLY = "353";
|
||||
export const RPL_ENDOFNAMES = "366";
|
||||
export const ERR_PASSWDMISMATCH = "464";
|
||||
// https://ircv3.net/specs/extensions/sasl-3.1
|
||||
export const RPL_LOGGEDIN = "900";
|
||||
export const RPL_LOGGEDOUT = "901";
|
||||
export const ERR_NICKLOCKED = "902";
|
||||
export const RPL_SASLSUCCESS = "903";
|
||||
export const ERR_SASLFAIL = "904";
|
||||
export const ERR_SASLTOOLONG = "905";
|
||||
export const ERR_SASLABORTED = "906";
|
||||
export const ERR_SASLALREADY = "907";
|
||||
|
||||
var tagsEscape = {
|
||||
";": "\\:",
|
||||
" ": "\\s",
|
||||
"\\": "\\\\",
|
||||
"\r": "\\r",
|
||||
"\n": "\\n",
|
||||
};
|
||||
|
||||
function parseTags(s) {
|
||||
var tags = {};
|
||||
s.split(";").forEach(function(s) {
|
||||
if (!s) {
|
||||
return;
|
||||
}
|
||||
var parts = s.split("=", 2);
|
||||
if (parts.length != 2) {
|
||||
throw new Error("expected an equal sign in tag");
|
||||
}
|
||||
var k = parts[0];
|
||||
var v = parts[1];
|
||||
for (var ch in tagsEscape) {
|
||||
v = v.replaceAll(tagsEscape[ch], ch);
|
||||
}
|
||||
if (v.endsWith("\\")) {
|
||||
v = v.slice(0, v.length - 1)
|
||||
}
|
||||
tags[k] = v;
|
||||
});
|
||||
return tags;
|
||||
}
|
||||
|
||||
function formatTags(tags) {
|
||||
var l = [];
|
||||
for (var k in tags) {
|
||||
var v = tags[k];
|
||||
for (var ch in tagsEscape) {
|
||||
v = v.replaceAll(ch, tagsEscape[ch]);
|
||||
}
|
||||
l.push(k + "=" + v);
|
||||
}
|
||||
return l.join(";");
|
||||
}
|
||||
|
||||
function parsePrefix(s) {
|
||||
var prefix = {
|
||||
name: null,
|
||||
user: null,
|
||||
host: null,
|
||||
};
|
||||
|
||||
var i = s.indexOf("@");
|
||||
if (i < 0) {
|
||||
prefix.name = s;
|
||||
return prefix;
|
||||
}
|
||||
prefix.host = s.slice(i + 1);
|
||||
s = s.slice(0, i);
|
||||
|
||||
var i = s.indexOf("!");
|
||||
if (i < 0) {
|
||||
prefix.name = s;
|
||||
return prefix;
|
||||
}
|
||||
prefix.name = s.slice(0, i);
|
||||
prefix.user = s.slice(i + 1);
|
||||
return prefix;
|
||||
}
|
||||
|
||||
function formatPrefix(prefix) {
|
||||
if (!prefix.host) {
|
||||
return prefix.name;
|
||||
}
|
||||
if (!prefix.user) {
|
||||
return prefix.name + "@" + prefix.host;
|
||||
}
|
||||
return prefix.name + "!" + prefix.user + "@" + prefix.host;
|
||||
}
|
||||
|
||||
export function parseMessage(s) {
|
||||
if (s.endsWith("\r\n")) {
|
||||
s = s.slice(0, s.length - 2);
|
||||
}
|
||||
|
||||
var msg = {
|
||||
tags: {},
|
||||
prefix: null,
|
||||
command: null,
|
||||
params: [],
|
||||
};
|
||||
|
||||
if (s.startsWith("@")) {
|
||||
var i = s.indexOf(" ");
|
||||
if (i < 0) {
|
||||
throw new Error("expected a space after tags");
|
||||
}
|
||||
msg.tags = parseTags(s.slice(1, i));
|
||||
s = s.slice(i + 1);
|
||||
}
|
||||
|
||||
if (s.startsWith(":")) {
|
||||
var i = s.indexOf(" ");
|
||||
if (i < 0) {
|
||||
throw new Error("expected a space after prefix");
|
||||
}
|
||||
msg.prefix = parsePrefix(s.slice(1, i));
|
||||
s = s.slice(i + 1);
|
||||
}
|
||||
|
||||
var i = s.indexOf(" ");
|
||||
if (i < 0) {
|
||||
msg.command = s;
|
||||
return msg;
|
||||
}
|
||||
msg.command = s.slice(0, i);
|
||||
s = s.slice(i + 1);
|
||||
|
||||
while (true) {
|
||||
if (s.startsWith(":")) {
|
||||
msg.params.push(s.slice(1));
|
||||
break;
|
||||
}
|
||||
|
||||
i = s.indexOf(" ");
|
||||
if (i < 0) {
|
||||
msg.params.push(s);
|
||||
break;
|
||||
}
|
||||
|
||||
msg.params.push(s.slice(0, i));
|
||||
s = s.slice(i + 1);
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
export function formatMessage(msg) {
|
||||
var s = "";
|
||||
// TODO: format tags
|
||||
if (msg.tags && Object.keys(msg.tags).length > 0) {
|
||||
s += "@" + formatTags(msg.tags) + " ";
|
||||
}
|
||||
if (msg.prefix) {
|
||||
s += ":" + formatPrefix(msg.prefix) + " ";
|
||||
}
|
||||
s += msg.command;
|
||||
if (msg.params && msg.params.length > 0) {
|
||||
var last = msg.params[msg.params.length - 1];
|
||||
if (msg.params.length > 1) {
|
||||
s += " " + msg.params.slice(0, -1).join(" ");
|
||||
}
|
||||
s += " :" + last;
|
||||
}
|
||||
s += "\r\n";
|
||||
return s;
|
||||
}
|
||||
|
||||
export function parseMembership(s) {
|
||||
// TODO: use the PREFIX token from RPL_ISUPPORT
|
||||
const STD_MEMBERSHIPS = "~&@%+";
|
||||
|
||||
var i;
|
||||
for (i = 0; i < s.length; i++) {
|
||||
if (STD_MEMBERSHIPS.indexOf(s[i]) < 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
prefix: s.slice(0, i),
|
||||
nick: s.slice(i),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user