gamja/lib/client.js

650 lines
16 KiB
JavaScript
Raw Normal View History

2020-06-14 08:50:59 -04:00
import * as irc from "./irc.js";
2020-06-12 13:12:17 -04:00
// Static list of capabilities that are always requested when supported by the
// server
2020-06-29 03:06:47 -04:00
const permanentCaps = [
"away-notify",
"batch",
"echo-message",
"invite-notify",
"labeled-response",
2020-06-29 03:06:47 -04:00
"message-tags",
"multi-prefix",
"server-time",
2021-05-25 14:22:21 -04:00
"setname",
"draft/chathistory",
2021-06-04 13:45:51 -04:00
"draft/event-playback",
2021-05-25 14:22:21 -04:00
"soju.im/bouncer-networks",
2020-06-29 03:06:47 -04:00
];
2020-06-12 13:12:17 -04:00
2021-01-22 12:44:06 -05:00
const RECONNECT_DELAY_SEC = 10;
var lastLabel = 0;
2020-06-23 14:00:49 -04:00
export default class Client extends EventTarget {
2021-01-22 12:29:22 -05:00
static Status = {
DISCONNECTED: "disconnected",
CONNECTING: "connecting",
REGISTERING: "registering",
REGISTERED: "registered",
};
status = Client.Status.DISCONNECTED;
serverPrefix = { name: "*" };
2020-06-23 14:00:49 -04:00
nick = null;
2021-01-22 12:44:06 -05:00
availableCaps = {};
enabledCaps = {};
2021-05-11 10:03:16 -04:00
isupport = new Map();
2021-01-22 12:44:06 -05:00
ws = null;
2020-06-23 14:00:49 -04:00
params = {
url: null,
2020-06-23 14:00:49 -04:00
username: null,
realname: null,
nick: null,
pass: null,
saslPlain: null,
bouncerNetwork: null,
2020-04-24 13:01:02 -04:00
};
2020-06-29 03:06:47 -04:00
batches = new Map();
2021-01-22 12:44:06 -05:00
autoReconnect = true;
reconnectTimeoutID = null;
pingIntervalID = null;
pendingHistory = Promise.resolve(null);
cm = irc.CaseMapping.RFC1459;
whoisDB = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
2020-04-24 13:01:02 -04:00
2020-06-23 14:00:49 -04:00
constructor(params) {
super();
2020-04-24 13:01:02 -04:00
2021-01-22 14:58:57 -05:00
this.params = { ...this.params, ...params };
2020-04-24 13:01:02 -04:00
2021-01-22 12:29:22 -05:00
this.reconnect();
}
reconnect() {
2021-01-22 12:44:06 -05:00
var autoReconnect = this.autoReconnect;
2021-01-22 12:29:22 -05:00
this.disconnect();
2021-01-22 12:44:06 -05:00
this.autoReconnect = autoReconnect;
2021-01-22 12:29:22 -05:00
this.setStatus(Client.Status.CONNECTING);
2020-06-23 14:00:49 -04:00
try {
2021-01-22 12:29:22 -05:00
this.ws = new WebSocket(this.params.url);
2020-06-23 14:00:49 -04:00
} catch (err) {
console.error("Failed to create connection:", err);
setTimeout(() => {
this.dispatchEvent(new CustomEvent("error", { detail: "Failed to create connection: " + err }));
2021-01-22 12:29:22 -05:00
this.setStatus(Client.Status.DISCONNECTED);
}, 0);
2020-06-23 14:00:49 -04:00
return;
}
2020-06-23 14:00:49 -04:00
this.ws.addEventListener("open", this.handleOpen.bind(this));
this.ws.addEventListener("message", this.handleMessage.bind(this));
2020-06-12 12:17:49 -04:00
2021-03-03 12:30:15 -05:00
this.ws.addEventListener("close", (event) => {
console.log("Connection closed (code: " + event.code + ")");
2021-01-22 12:29:22 -05:00
this.ws = null;
this.setStatus(Client.Status.DISCONNECTED);
this.nick = null;
this.serverPrefix = null;
this.availableCaps = {};
this.enabledCaps = {};
this.batches = new Map();
this.pendingHistory = Promise.resolve(null);
2021-05-11 10:03:16 -04:00
this.isupport = new Map();
2021-01-22 12:44:06 -05:00
if (this.autoReconnect) {
console.info("Reconnecting to server in " + RECONNECT_DELAY_SEC + " seconds");
clearTimeout(this.reconnectTimeoutID);
this.reconnectTimeoutID = setTimeout(() => {
this.reconnect();
}, RECONNECT_DELAY_SEC * 1000);
}
});
2020-06-12 12:17:49 -04:00
2020-06-23 14:00:49 -04:00
this.ws.addEventListener("error", () => {
2020-08-10 09:01:48 -04:00
this.dispatchEvent(new CustomEvent("error", { detail: "Connection error" }));
2020-06-12 12:17:49 -04:00
});
}
2021-01-22 12:29:22 -05:00
disconnect() {
2021-01-22 12:44:06 -05:00
this.autoReconnect = false;
clearTimeout(this.reconnectTimeoutID);
this.reconnectTimeoutID = null;
this.setPingInterval(0);
2021-01-22 12:29:22 -05:00
if (this.ws) {
this.ws.close(1000);
}
}
setStatus(status) {
if (this.status === status) {
return;
}
this.status = status;
this.dispatchEvent(new CustomEvent("status"));
}
2020-06-23 14:00:49 -04:00
handleOpen() {
2020-06-05 17:35:33 -04:00
console.log("Connection opened");
2021-01-22 12:29:22 -05:00
this.setStatus(Client.Status.REGISTERING);
2020-04-24 13:01:02 -04:00
2020-06-23 14:00:49 -04:00
this.nick = this.params.nick;
this.send({ command: "CAP", params: ["LS", "302"] });
if (this.params.pass) {
this.send({ command: "PASS", params: [this.params.pass] });
2020-04-25 08:59:20 -04:00
}
2020-06-23 14:00:49 -04:00
this.send({ command: "NICK", params: [this.nick] });
this.send({
2020-06-05 17:35:33 -04:00
command: "USER",
2020-06-23 14:00:49 -04:00
params: [this.params.username, "0", "*", this.params.realname],
2020-06-07 06:46:38 -04:00
});
2020-06-23 14:00:49 -04:00
}
2020-06-05 17:35:33 -04:00
2020-06-23 14:00:49 -04:00
handleMessage(event) {
2020-06-14 08:50:59 -04:00
var msg = irc.parseMessage(event.data);
console.debug("Received:", msg);
2020-06-05 17:35:33 -04:00
2021-05-28 03:44:07 -04:00
// If the prefix is missing, assume it's coming from the server on the
// other end of the connection
if (!msg.prefix) {
msg.prefix = this.serverPrefix;
}
2020-06-29 03:06:47 -04:00
var msgBatch = null;
if (msg.tags["batch"]) {
msgBatch = this.batches.get(msg.tags["batch"]);
if (msgBatch) {
msgBatch.messages.push(msg);
2021-06-04 13:45:51 -04:00
msg.batch = msgBatch;
2020-06-29 03:06:47 -04:00
}
}
var deleteBatch = null;
2020-06-05 17:35:33 -04:00
switch (msg.command) {
2020-06-14 08:50:59 -04:00
case irc.RPL_WELCOME:
2020-06-23 14:00:49 -04:00
if (this.params.saslPlain && this.availableCaps["sasl"] === undefined) {
this.dispatchEvent(new CustomEvent("error", { detail: "Server doesn't support SASL PLAIN" }));
2021-01-22 12:29:22 -05:00
this.disconnect();
2020-06-12 12:17:49 -04:00
return;
}
if (msg.prefix) {
this.serverPrefix = msg.prefix;
}
2020-06-06 04:06:07 -04:00
console.log("Registration complete");
2021-01-22 12:29:22 -05:00
this.setStatus(Client.Status.REGISTERED);
break;
2021-05-11 10:03:16 -04:00
case irc.RPL_ISUPPORT:
var tokens = msg.params.slice(1, -1);
var changed = irc.parseISUPPORT(tokens, this.isupport);
if (changed.indexOf("CASEMAPPING") >= 0) {
this.setCaseMapping(this.isupport.get("CASEMAPPING"));
}
break;
case irc.RPL_ENDOFMOTD:
case irc.ERR_NOMOTD:
// These messages are used to indicate the end of the ISUPPORT list
if (!this.isupport.has("CASEMAPPING")) {
// Server didn't send any CASEMAPPING token, assume RFC 1459
this.setCaseMapping("rfc1459");
}
2021-05-11 10:03:16 -04:00
break;
case "CAP":
2020-06-23 14:00:49 -04:00
this.handleCap(msg);
break;
2020-06-12 12:17:49 -04:00
case "AUTHENTICATE":
2020-06-23 14:00:49 -04:00
this.handleAuthenticate(msg);
2020-06-12 12:17:49 -04:00
break;
2020-06-14 08:50:59 -04:00
case irc.RPL_LOGGEDIN:
2020-06-12 12:17:49 -04:00
console.log("Logged in");
break;
2020-06-14 08:50:59 -04:00
case irc.RPL_LOGGEDOUT:
2020-06-12 12:17:49 -04:00
console.log("Logged out");
break;
2020-06-14 08:50:59 -04:00
case irc.RPL_SASLSUCCESS:
2020-06-12 12:17:49 -04:00
console.log("SASL authentication success");
2021-01-22 12:29:22 -05:00
if (this.status != Client.Status.REGISTERED) {
if (this.enabledCaps["soju.im/bouncer-networks"] && this.params.bouncerNetwork) {
this.send({ command: "BOUNCER", params: ["BIND", this.params.bouncerNetwork] });
}
2020-06-23 14:00:49 -04:00
this.send({ command: "CAP", params: ["END"] });
2020-06-12 12:17:49 -04:00
}
break;
case irc.RPL_WHOISUSER:
case irc.RPL_WHOISSERVER:
case irc.RPL_WHOISOPERATOR:
case irc.RPL_WHOISIDLE:
case irc.RPL_WHOISCHANNELS:
case irc.RPL_ENDOFWHOIS:
var nick = msg.params[1];
if (!this.whoisDB.has(nick)) {
this.whoisDB.set(nick, {});
}
this.whoisDB.get(nick)[msg.command] = msg;
break;
2020-06-14 08:50:59 -04:00
case irc.ERR_NICKLOCKED:
case irc.ERR_SASLFAIL:
case irc.ERR_SASLTOOLONG:
case irc.ERR_SASLABORTED:
case irc.ERR_SASLALREADY:
2020-08-10 09:01:48 -04:00
this.dispatchEvent(new CustomEvent("error", { detail: "SASL error (" + msg.command + "): " + msg.params[1] }));
2021-01-22 12:29:22 -05:00
this.disconnect();
2020-06-05 17:35:33 -04:00
break;
2020-07-01 06:12:56 -04:00
case "PING":
this.send({ command: "PONG", params: [msg.params[0]] });
break;
2020-06-06 04:02:22 -04:00
case "NICK":
var newNick = msg.params[0];
2020-06-23 14:00:49 -04:00
if (msg.prefix.name == this.nick) {
this.nick = newNick;
2020-06-10 13:24:03 -04:00
}
break;
2020-06-29 03:06:47 -04:00
case "BATCH":
var enter = msg.params[0].startsWith("+");
var name = msg.params[0].slice(1);
if (enter) {
var batch = {
name,
type: msg.params[1],
params: msg.params.slice(2),
tags: msg.tags,
2020-06-29 03:06:47 -04:00
parent: msgBatch,
messages: [],
};
this.batches.set(name, batch);
} else {
deleteBatch = name;
}
break;
2020-08-25 05:42:40 -04:00
case "ERROR":
this.dispatchEvent(new CustomEvent("error", { detail: "Fatal IRC error: " + msg.params[0] }));
2021-01-22 12:29:22 -05:00
this.disconnect();
2020-08-25 05:42:40 -04:00
break;
case irc.ERR_PASSWDMISMATCH:
case irc.ERR_ERRONEUSNICKNAME:
case irc.ERR_NICKNAMEINUSE:
case irc.ERR_NICKCOLLISION:
case irc.ERR_UNAVAILRESOURCE:
case irc.ERR_NOPERMFORHOST:
case irc.ERR_YOUREBANNEDCREEP:
this.dispatchEvent(new CustomEvent("error", { detail: "Error (" + msg.command + "): " + msg.params[msg.params.length - 1] }));
2021-01-22 12:29:22 -05:00
if (this.status != Client.Status.REGISTERED) {
this.disconnect();
2020-08-25 05:42:40 -04:00
}
break;
2021-03-10 03:28:25 -05:00
case "FAIL":
if (msg.params[0] === "BOUNCER" && msg.params[2] === "BIND") {
this.dispatchEvent(new CustomEvent("error", {
detail: "Failed to bind to bouncer network: " + msg.params[3],
}));
this.disconnect();
}
break;
2020-06-06 04:58:32 -04:00
}
2020-06-05 17:35:33 -04:00
2020-06-23 14:00:49 -04:00
this.dispatchEvent(new CustomEvent("message", {
2020-06-29 03:06:47 -04:00
detail: { message: msg, batch: msgBatch },
2020-06-23 14:00:49 -04:00
}));
2020-06-29 03:06:47 -04:00
// Delete after firing the message event so that handlers can access
// the batch
if (deleteBatch) {
this.batches.delete(name);
}
2020-04-25 04:33:52 -04:00
}
2020-06-05 17:35:33 -04:00
2021-05-31 11:11:42 -04:00
who(mask) {
var msg = { command: "WHO", params: [mask] };
var l = [];
2021-05-31 11:13:55 -04:00
return this.roundtrip(msg, (msg) => {
2021-05-31 11:11:42 -04:00
switch (msg.command) {
case irc.RPL_WHOREPLY:
// TODO: match with mask
l.push(msg);
break;
case irc.RPL_ENDOFWHO:
if (msg.params[1] === mask) {
return l;
}
break;
}
});
}
2021-05-31 11:04:52 -04:00
whois(target) {
var targetCM = this.cm(target);
var msg = { command: "WHOIS", params: [target] };
2021-05-31 11:13:55 -04:00
return this.roundtrip(msg, (msg) => {
switch (msg.command) {
case irc.RPL_ENDOFWHOIS:
var nick = msg.params[1];
if (this.cm(nick) === targetCM) {
return this.whoisDB.get(nick);
}
break;
case irc.ERR_NOSUCHNICK:
var nick = msg.params[1];
if (this.cm(nick) === targetCM) {
throw msg;
}
break;
}
});
}
2020-06-23 14:00:49 -04:00
addAvailableCaps(s) {
var l = s.split(" ");
l.forEach((s) => {
var parts = s.split("=");
2021-01-22 06:27:32 -05:00
var k = parts[0].toLowerCase();
2020-06-23 14:00:49 -04:00
var v = "";
if (parts.length > 1) {
v = parts[1];
}
this.availableCaps[k] = v;
});
2020-06-05 17:35:33 -04:00
}
2020-06-26 06:37:45 -04:00
supportsSASL(mech) {
var saslCap = this.availableCaps["sasl"];
if (saslCap === undefined) {
return false;
}
return saslCap.split(",").includes(mech);
}
requestCaps(extra) {
var reqCaps = extra || [];
permanentCaps.forEach((cap) => {
if (this.availableCaps[cap] !== undefined && !this.enabledCaps[cap]) {
reqCaps.push(cap);
}
});
if (reqCaps.length > 0) {
this.send({ command: "CAP", params: ["REQ", reqCaps.join(" ")] });
}
}
2020-06-23 14:00:49 -04:00
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);
2020-06-05 17:35:33 -04:00
2020-06-23 14:00:49 -04:00
var reqCaps = [];
var capEnd = true;
2020-06-26 06:37:45 -04:00
if (this.params.saslPlain && this.supportsSASL("PLAIN")) {
2020-06-23 14:00:49 -04:00
// CAP END is deferred after authentication finishes
reqCaps.push("sasl");
capEnd = false;
}
2020-06-05 17:35:33 -04:00
if (!this.params.bouncerNetwork && this.availableCaps["soju.im/bouncer-networks-notify"] !== undefined) {
reqCaps.push("soju.im/bouncer-networks-notify");
}
2020-06-26 06:37:45 -04:00
this.requestCaps(reqCaps);
2020-06-12 12:17:49 -04:00
2021-01-22 12:29:22 -05:00
if (this.status != Client.Status.REGISTERED && capEnd) {
2020-06-23 14:00:49 -04:00
this.send({ command: "CAP", params: ["END"] });
}
}
break;
case "NEW":
this.addAvailableCaps(args[0]);
console.log("Server added available caps:", args[0]);
2020-06-26 06:37:45 -04:00
this.requestCaps();
2020-06-23 14:00:49 -04:00
break;
case "DEL":
args[0].split(" ").forEach((cap) => {
2021-01-22 06:27:32 -05:00
cap = cap.toLowerCase();
2020-06-23 14:00:49 -04:00
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) => {
2021-01-22 06:27:32 -05:00
cap = cap.toLowerCase();
2020-06-23 14:00:49 -04:00
this.enabledCaps[cap] = true;
2020-06-07 06:46:38 -04:00
2020-06-23 14:00:49 -04:00
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]);
2021-01-22 12:29:22 -05:00
if (this.status != Client.Status.REGISTERED) {
2020-06-23 14:00:49 -04:00
this.send({ command: "CAP", params: ["END"] });
}
break;
}
}
2020-06-23 14:00:49 -04:00
handleAuthenticate(msg) {
var challengeStr = msg.params[0];
2020-06-05 17:35:33 -04:00
2020-06-23 14:00:49 -04:00
// For now only PLAIN is supported
if (challengeStr != "+") {
this.dispatchEvent(new CustomEvent("error", { detail: "Expected an empty challenge, got: " + challengeStr }));
2020-06-23 14:00:49 -04:00
this.send({ command: "AUTHENTICATE", params: ["*"] });
return;
}
2020-06-23 14:00:49 -04:00
var respStr = btoa("\0" + this.params.saslPlain.username + "\0" + this.params.saslPlain.password);
this.send({ command: "AUTHENTICATE", params: [respStr] });
}
2020-06-07 07:46:46 -04:00
2020-06-23 14:00:49 -04:00
send(msg) {
if (!this.ws) {
throw new Error("Failed to send IRC message " + msg.command + ": socket is closed");
}
2020-06-23 14:00:49 -04:00
this.ws.send(irc.formatMessage(msg));
console.debug("Sent:", msg);
2020-06-07 06:31:01 -04:00
}
2020-06-07 07:46:46 -04:00
setCaseMapping(name) {
this.cm = irc.CaseMapping.byName(name);
if (!this.cm) {
console.error("Unsupported case-mapping '" + name + "', falling back to RFC 1459");
this.cm = irc.CaseMapping.RFC1459;
}
this.whoisDB = new irc.CaseMapMap(this.whoisDB, this.cm);
}
isServer(name) {
return name === "*" || this.cm(name) === this.cm(this.serverPrefix.name);
}
isMyNick(nick) {
return this.cm(nick) == this.cm(this.nick);
}
isChannel(name) {
var chanTypes = this.isupport.get("CHANTYPES") || irc.STD_CHANTYPES;
return chanTypes.indexOf(name[0]) >= 0;
}
setPingInterval(sec) {
clearInterval(this.pingIntervalID);
this.pingIntervalID = null;
if (sec <= 0) {
return;
}
this.pingIntervalID = setInterval(() => {
if (this.ws) {
this.send({ command: "PING", params: ["gamja"] });
}
}, sec * 1000);
}
2020-07-15 12:21:09 -04:00
/* Execute a command that expects a response. `done` is called with message
* events until it returns a truthy value. */
roundtrip(msg, done) {
var label;
if (this.enabledCaps["labeled-response"]) {
lastLabel++;
label = String(lastLabel);
msg.tags = { ...msg.tags, label };
}
2020-07-15 12:21:09 -04:00
return new Promise((resolve, reject) => {
var handleMessage = (event) => {
var msg = event.detail.message;
var msgLabel = irc.getMessageLabel(msg);
if (msgLabel && msgLabel != label) {
return;
}
var result;
2020-07-15 12:21:09 -04:00
try {
result = done(msg);
2020-07-15 12:21:09 -04:00
} catch (err) {
this.removeEventListener("message", handleMessage);
reject(err);
}
if (result) {
this.removeEventListener("message", handleMessage);
resolve(result);
}
// TODO: handle end of labeled response somehow
2020-07-15 12:21:09 -04:00
};
this.addEventListener("message", handleMessage);
this.send(msg);
});
}
fetchBatch(msg, batchType) {
2021-05-31 11:13:55 -04:00
return this.roundtrip(msg, (msg) => {
switch (msg.command) {
case "BATCH":
var enter = msg.params[0].startsWith("+");
var name = msg.params[0].slice(1);
if (enter) {
break;
}
var batch = this.batches.get(name);
if (batch.type === batchType) {
return batch;
}
break;
case "FAIL":
if (msg.params[0] === msg.command) {
throw msg;
}
break;
}
});
}
roundtripChatHistory(params) {
// Don't send multiple CHATHISTORY commands in parallel, we can't
// properly handle batches and errors.
this.pendingHistory = this.pendingHistory.catch(() => {}).then(() => {
var msg = {
command: "CHATHISTORY",
params,
};
return this.fetchBatch(msg, "chathistory");
});
return this.pendingHistory;
}
chatHistoryPageSize() {
if (this.isupport.has("CHATHISTORY")) {
var pageSize = parseInt(this.isupport.get("CHATHISTORY"), 10);
if (pageSize > 0) {
return pageSize;
}
}
return 100;
}
2021-01-23 06:16:57 -05:00
/* Fetch one page of history before the given date. */
fetchHistoryBefore(target, before, limit) {
var max = Math.min(limit, this.chatHistoryPageSize());
var params = ["BEFORE", target, "timestamp=" + before, max];
2021-01-23 06:16:57 -05:00
return this.roundtripChatHistory(params).then((batch) => {
return { more: batch.messages.length >= max };
2021-01-23 06:16:57 -05:00
});
}
/* Fetch history in ascending order. */
fetchHistoryBetween(target, after, before, limit) {
var max = Math.min(limit, this.chatHistoryPageSize());
var params = ["AFTER", target, "timestamp=" + after.time, max];
return this.roundtripChatHistory(params).then((batch) => {
limit -= batch.messages.length;
if (limit <= 0) {
throw new Error("Cannot fetch all chat history: too many messages");
}
if (batch.messages.length == max) {
// There are still more messages to fetch
after.time = batch.messages[batch.messages.length - 1].tags.time;
return this.fetchHistoryBetween(target, after, before, limit);
}
2021-01-23 06:16:57 -05:00
return null;
});
}
fetchHistoryTargets(t1, t2) {
var msg = {
command: "CHATHISTORY",
params: ["TARGETS", "timestamp=" + t1, "timestamp=" + t2, 1000],
};
return this.fetchBatch(msg, "draft/chathistory-targets").then((batch) => {
return batch.messages.map((msg) => {
if (msg.command != "CHATHISTORY" || msg.params[0] != "TARGETS") {
throw new Error("Cannot fetch chat history targets: unexpected message " + msg);
}
return {
name: msg.params[1],
latestMessage: msg.params[2],
};
});
});
}
listBouncerNetworks() {
if (!this.enabledCaps["soju.im/bouncer-networks"]) {
return Promise.reject(new Error("Server doesn't support the BOUNCER extension"));
}
var req = { command: "BOUNCER", params: ["LISTNETWORKS"] };
return this.fetchBatch(req, "soju.im/bouncer-networks").then((batch) => {
var networks = new Map();
for (var msg of batch.messages) {
console.assert(msg.command === "BOUNCER" && msg.params[0] === "NETWORK");
var id = msg.params[1];
var params = irc.parseTags(msg.params[2]);
networks.set(id, params);
}
return networks;
});
}
}