gamja/lib/client.js

1015 lines
24 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 = [
2021-09-21 08:49:32 -04:00
"account-notify",
2020-06-29 03:06:47 -04:00
"away-notify",
"batch",
"chghost",
2020-06-29 03:06:47 -04:00
"echo-message",
2021-09-21 08:29:31 -04:00
"extended-join",
"invite-notify",
"labeled-response",
2020-06-29 03:06:47 -04:00
"message-tags",
"multi-prefix",
"sasl",
2020-06-29 03:06:47 -04:00
"server-time",
2021-05-25 14:22:21 -04:00
"setname",
"draft/account-registration",
2021-05-25 14:22:21 -04:00
"draft/chathistory",
2021-06-04 13:45:51 -04:00
"draft/event-playback",
"draft/extended-monitor",
2021-05-25 14:22:21 -04:00
"soju.im/bouncer-networks",
2022-02-11 12:21:17 -05:00
"soju.im/read",
2020-06-29 03:06:47 -04:00
];
2020-06-12 13:12:17 -04:00
const RECONNECT_MIN_DELAY_MSEC = 10 * 1000; // 10s
const RECONNECT_MAX_DELAY_MSEC = 10 * 60 * 1000; // 10min
2021-01-22 12:44:06 -05:00
// WebSocket status codes
// https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1
const NORMAL_CLOSURE = 1000;
const GOING_AWAY = 1001;
const UNSUPPORTED_DATA = 1003;
// See https://github.com/quakenet/snircd/blob/master/doc/readme.who
// Sorted by order of appearance in RPL_WHOSPCRPL
const WHOX_FIELDS = {
"channel": "c",
"username": "u",
"hostname": "h",
"server": "s",
"nick": "n",
"flags": "f",
"account": "a",
"realname": "r",
};
2021-06-10 12:11:11 -04:00
let lastLabel = 0;
let lastWhoxToken = 0;
2021-12-04 11:05:34 -05:00
class IRCError extends Error {
constructor(msg) {
let text;
if (msg.params.length > 0) {
// IRC errors have a human-readable message as last param
2021-12-04 11:05:34 -05:00
text = msg.params[msg.params.length - 1];
} else {
text = `unknown error (${msg.command})`;
2021-12-04 11:05:34 -05:00
}
super(text);
this.msg = msg;
}
}
/**
* Implements a simple exponential backoff.
*/
class Backoff {
n = 0;
constructor(min, max) {
this.min = min;
this.max = max;
}
reset() {
this.n = 0;
}
next() {
if (this.n === 0) {
this.n = 1;
return this.min;
}
let dur = this.n * this.min;
if (dur > this.max) {
dur = this.max;
} else {
this.n *= 2;
}
return dur;
}
}
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;
supportsCap = false;
2021-12-10 09:34:51 -05:00
caps = new irc.CapRegistry();
isupport = new irc.Isupport();
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,
saslExternal: false,
bouncerNetwork: null,
ping: 0,
2020-04-24 13:01:02 -04:00
};
debug = false;
2020-06-29 03:06:47 -04:00
batches = new Map();
2021-01-22 12:44:06 -05:00
autoReconnect = true;
reconnectTimeoutID = null;
reconnectBackoff = new Backoff(RECONNECT_MIN_DELAY_MSEC, RECONNECT_MAX_DELAY_MSEC);
pingIntervalID = null;
pendingCmds = {
WHO: Promise.resolve(null),
CHATHISTORY: Promise.resolve(null),
};
cm = irc.CaseMapping.RFC1459;
2021-08-24 06:53:46 -04:00
monitored = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
pendingLists = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
whoxQueries = new Map();
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-06-10 12:11:11 -04:00
let autoReconnect = this.autoReconnect;
2021-01-22 12:29:22 -05:00
this.disconnect();
2021-01-22 12:44:06 -05:00
this.autoReconnect = autoReconnect;
console.log("Connecting to " + this.params.url);
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.dispatchError(new Error("Failed to create connection", { cause: 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));
2021-11-21 07:48:07 -05:00
this.ws.addEventListener("message", (event) => {
try {
this.handleMessage(event);
} catch (err) {
this.dispatchError(err);
2021-11-21 07:48:07 -05:00
this.disconnect();
}
});
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 + ")");
if (event.code !== NORMAL_CLOSURE && event.code !== GOING_AWAY) {
this.dispatchError(new Error("Connection error"));
}
2021-01-22 12:29:22 -05:00
this.ws = null;
this.setStatus(Client.Status.DISCONNECTED);
this.nick = null;
this.serverPrefix = null;
2021-12-10 09:34:51 -05:00
this.caps = new irc.CapRegistry();
this.batches = new Map();
Object.keys(this.pendingCmds).forEach((k) => {
this.pendingCmds[k] = Promise.resolve(null);
});
this.isupport = new irc.Isupport();
2021-08-24 06:53:46 -04:00
this.monitored = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
2021-01-22 12:44:06 -05:00
if (this.autoReconnect) {
if (!navigator.onLine) {
console.info("Waiting for network to go back online");
const handleOnline = () => {
window.removeEventListener("online", handleOnline);
this.reconnect();
};
window.addEventListener("online", handleOnline);
} else {
let delay = this.reconnectBackoff.next();
console.info("Reconnecting to server in " + (delay / 1000) + " seconds");
clearTimeout(this.reconnectTimeoutID);
this.reconnectTimeoutID = setTimeout(() => {
this.reconnect();
}, delay);
}
2021-01-22 12:44:06 -05: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(NORMAL_CLOSURE);
2021-01-22 12:29:22 -05:00
}
}
setStatus(status) {
if (this.status === status) {
return;
}
this.status = status;
this.dispatchEvent(new CustomEvent("status"));
}
dispatchError(err) {
this.dispatchEvent(new CustomEvent("error", { detail: err }));
}
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
this.reconnectBackoff.reset();
this.setPingInterval(this.params.ping);
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
pushPendingList(k, msg) {
let l = this.pendingLists.get(k);
if (!l) {
l = [];
this.pendingLists.set(k, l);
}
l.push(msg);
}
endPendingList(k, msg) {
msg.list = this.pendingLists.get(k) || [];
this.pendingLists.delete(k);
}
2020-06-23 14:00:49 -04:00
handleMessage(event) {
if (typeof event.data !== "string") {
console.error("Received unsupported data type:", event.data);
this.ws.close(UNSUPPORTED_DATA);
return;
}
2021-06-10 12:11:11 -04:00
let msg = irc.parseMessage(event.data);
if (this.debug) {
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;
}
2021-06-10 12:11:11 -04:00
let msgBatch = null;
2020-06-29 03:06:47 -04:00
if (msg.tags["batch"]) {
msgBatch = this.batches.get(msg.tags["batch"]);
if (msgBatch) {
2021-06-04 13:45:51 -04:00
msg.batch = msgBatch;
2020-06-29 03:06:47 -04:00
}
}
2021-06-10 12:11:11 -04:00
let deleteBatch = null;
let k;
2020-06-05 17:35:33 -04:00
switch (msg.command) {
2020-06-14 08:50:59 -04:00
case irc.RPL_WELCOME:
if (this.params.saslPlain && !this.supportsCap) {
this.dispatchError(new Error("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;
}
this.nick = msg.params[0];
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:
let prevMaxMonitorTargets = this.isupport.monitor();
2021-06-10 12:11:11 -04:00
let tokens = msg.params.slice(1, -1);
this.isupport.parse(tokens);
this.updateCaseMapping();
let maxMonitorTargets = this.isupport.monitor();
if (prevMaxMonitorTargets === 0 && this.monitored.size > 0 && maxMonitorTargets > 0) {
let targets = Array.from(this.monitored.keys()).slice(0, maxMonitorTargets);
2021-08-24 06:53:46 -04:00
this.send({ command: "MONITOR", params: ["+", targets.join(",")] });
}
break;
case irc.RPL_ENDOFMOTD:
case irc.ERR_NOMOTD:
// These messages are used to indicate the end of the ISUPPORT list
if (!this.isupport.raw.has("CASEMAPPING")) {
// Server didn't send any CASEMAPPING token, assume RFC 1459
this.updateCaseMapping();
}
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":
// Both PLAIN and EXTERNAL expect an empty challenge
let challengeStr = msg.params[0];
if (challengeStr != "+") {
this.dispatchError(new Error("Expected an empty challenge, got: " + challengeStr));
this.send({ command: "AUTHENTICATE", params: ["*"] });
}
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;
case irc.RPL_NAMREPLY:
this.pushPendingList("NAMES " + msg.params[2], msg);
break;
case irc.RPL_ENDOFNAMES:
this.endPendingList("NAMES " + msg.params[1], msg);
break;
case irc.RPL_WHOISUSER:
case irc.RPL_WHOISSERVER:
case irc.RPL_WHOISOPERATOR:
case irc.RPL_WHOISIDLE:
case irc.RPL_WHOISCHANNELS:
this.pushPendingList("WHOIS " + msg.params[1], msg);
break;
case irc.RPL_ENDOFWHOIS:
this.endPendingList("WHOIS " + msg.params[1], msg);
break;
case irc.RPL_WHOREPLY:
case irc.RPL_WHOSPCRPL:
this.pushPendingList("WHO", msg);
break;
case irc.RPL_ENDOFWHO:
this.endPendingList("WHO", msg);
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":
2021-06-10 12:11:11 -04:00
let newNick = msg.params[0];
if (this.isMyNick(msg.prefix.name)) {
2020-06-23 14:00:49 -04:00
this.nick = newNick;
2020-06-10 13:24:03 -04:00
}
break;
2020-06-29 03:06:47 -04:00
case "BATCH":
2021-06-10 12:11:11 -04:00
let enter = msg.params[0].startsWith("+");
let name = msg.params[0].slice(1);
2020-06-29 03:06:47 -04:00
if (enter) {
2021-06-10 12:11:11 -04:00
let batch = {
2020-06-29 03:06:47 -04:00
name,
type: msg.params[1],
params: msg.params.slice(2),
tags: msg.tags,
2020-06-29 03:06:47 -04:00
parent: msgBatch,
};
this.batches.set(name, batch);
} else {
deleteBatch = name;
}
break;
2020-08-25 05:42:40 -04:00
case "ERROR":
this.dispatchError(new IRCError(msg));
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.dispatchError(new IRCError(msg));
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":
2021-12-06 16:54:15 -05:00
if (this.status === Client.Status.REGISTERED) {
break;
}
let reason = msg.params[msg.params.length - 1];
2021-03-10 03:28:25 -05:00
if (msg.params[0] === "BOUNCER" && msg.params[2] === "BIND") {
this.dispatchError(new Error("Failed to bind to bouncer network", {
cause: new IRCError(msg),
2021-12-06 16:54:15 -05:00
}));
this.disconnect();
}
if (msg.params[1] === "ACCOUNT_REQUIRED") {
this.dispatchError(new IRCError(msg));
2021-03-10 03:28:25 -05:00
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) {
2021-06-10 06:10:08 -04:00
this.batches.delete(deleteBatch);
2020-06-29 03:06:47 -04:00
}
2020-04-25 04:33:52 -04:00
}
2020-06-05 17:35:33 -04:00
authenticate(mechanism, params) {
if (!this.supportsSASL(mechanism)) {
throw new Error(`${mechanism} authentication not supported by the server`);
}
console.log(`Starting SASL ${mechanism} authentication`);
// Send the first SASL response immediately to avoid a roundtrip
let initialResp = null;
switch (mechanism) {
case "PLAIN":
let respStr = btoa("\0" + params.username + "\0" + params.password);
initialResp = { command: "AUTHENTICATE", params: [respStr] };
break;
case "EXTERNAL":
initialResp = { command: "AUTHENTICATE", params: [btoa("")] };
break;
default:
throw new Error(`Unknown authentication mechanism '${mechanism}'`);
}
let startMsg = { command: "AUTHENTICATE", params: [mechanism] };
let promise = this.roundtrip(startMsg, (msg) => {
switch (msg.command) {
case irc.RPL_SASLSUCCESS:
return true;
case irc.ERR_NICKLOCKED:
case irc.ERR_SASLFAIL:
case irc.ERR_SASLTOOLONG:
case irc.ERR_SASLABORTED:
case irc.ERR_SASLALREADY:
2021-12-04 11:05:34 -05:00
throw new IRCError(msg);
}
});
this.send(initialResp);
return promise;
}
who(mask, options) {
let params = [mask];
let fields = "", token = "";
if (options && this.isupport.whox()) {
let match = ""; // Matches exact channel or nick
fields = "t"; // Always include token in reply
if (options.fields) {
options.fields.forEach((k) => {
if (!WHOX_FIELDS[k]) {
throw new Error(`Unknown WHOX field ${k}`);
}
fields += WHOX_FIELDS[k];
});
}
token = String(lastWhoxToken % 1000);
lastWhoxToken++;
params.push(`${match}%${fields},${token}`);
this.whoxQueries.set(token, fields);
}
let msg = { command: "WHO", params };
2021-06-10 12:11:11 -04:00
let l = [];
let promise = this.pendingCmds.WHO.then(() => {
return this.roundtrip(msg, (msg) => {
switch (msg.command) {
case irc.RPL_WHOREPLY:
l.push(this.parseWhoReply(msg));
break;
case irc.RPL_WHOSPCRPL:
if (msg.params.length !== fields.length || msg.params[1] !== token) {
break;
}
l.push(this.parseWhoReply(msg));
break;
case irc.RPL_ENDOFWHO:
if (msg.params[1] === mask) {
return l;
}
break;
}
}).finally(() => {
this.whoxQueries.delete(token);
});
2021-05-31 11:11:42 -04:00
});
this.pendingCmds.WHO = promise.catch(() => {});
return promise;
2021-05-31 11:11:42 -04:00
}
parseWhoReply(msg) {
switch (msg.command) {
case irc.RPL_WHOREPLY:
let last = msg.params[msg.params.length - 1];
return {
username: msg.params[2],
hostname: msg.params[3],
server: msg.params[4],
nick: msg.params[5],
flags: msg.params[6],
realname: last.slice(last.indexOf(" ") + 1),
};
case irc.RPL_WHOSPCRPL:
let token = msg.params[1];
let fields = this.whoxQueries.get(token);
if (!fields) {
throw new Error("Unknown WHOX token: " + token);
}
let who = {};
let i = 0;
Object.keys(WHOX_FIELDS).forEach((k) => {
if (fields.indexOf(WHOX_FIELDS[k]) < 0) {
return;
}
who[k] = msg.params[2 + i];
i++;
});
if (who.account === "0") {
// WHOX uses "0" to mean "no account"
who.account = null;
}
return who;
default:
throw new Error("Not a WHO reply: " + msg.command);
}
}
2021-05-31 11:04:52 -04:00
whois(target) {
2021-06-10 12:11:11 -04:00
let targetCM = this.cm(target);
let msg = { command: "WHOIS", params: [target] };
2021-05-31 11:13:55 -04:00
return this.roundtrip(msg, (msg) => {
2021-06-10 12:11:11 -04:00
let nick;
switch (msg.command) {
case irc.RPL_ENDOFWHOIS:
2021-06-10 12:11:11 -04:00
nick = msg.params[1];
if (this.cm(nick) === targetCM) {
let whois = {};
msg.list.forEach((reply) => {
whois[reply.command] = reply;
});
return whois;
}
break;
case irc.ERR_NOSUCHNICK:
2021-06-10 12:11:11 -04:00
nick = msg.params[1];
if (this.cm(nick) === targetCM) {
2021-12-04 11:05:34 -05:00
throw new IRCError(msg);
}
break;
}
});
}
2020-06-26 06:37:45 -04:00
supportsSASL(mech) {
2021-12-10 09:34:51 -05:00
let saslCap = this.caps.available.get("sasl");
2020-06-26 06:37:45 -04:00
if (saslCap === undefined) {
return false;
}
return saslCap.split(",").includes(mech);
}
checkAccountRegistrationCap(k) {
2021-12-10 09:34:51 -05:00
let v = this.caps.available.get("draft/account-registration");
if (v === undefined) {
return false;
}
return v.split(",").includes(k);
}
requestCaps() {
let wantCaps = [].concat(permanentCaps);
if (!this.params.bouncerNetwork) {
wantCaps.push("soju.im/bouncer-networks-notify");
}
2020-06-26 06:37:45 -04:00
2021-12-10 09:34:51 -05:00
let msg = this.caps.requestAvailable(wantCaps);
if (msg) {
this.send(msg);
2020-06-26 06:37:45 -04:00
}
}
2020-06-23 14:00:49 -04:00
handleCap(msg) {
2021-12-10 09:34:51 -05:00
this.caps.parse(msg);
2021-06-10 12:11:11 -04:00
let subCmd = msg.params[1];
let args = msg.params.slice(2);
2020-06-23 14:00:49 -04:00
switch (subCmd) {
case "LS":
this.supportsCap = true;
if (args[0] == "*") {
break;
}
2020-06-05 17:35:33 -04:00
2021-12-10 09:34:51 -05:00
console.log("Available server caps:", this.caps.available);
this.requestCaps();
2020-06-05 17:35:33 -04:00
if (this.status !== Client.Status.REGISTERED) {
2021-12-10 09:34:51 -05:00
if (this.caps.available.has("sasl")) {
let promise;
if (this.params.saslPlain) {
promise = this.authenticate("PLAIN", this.params.saslPlain);
} else if (this.params.saslExternal) {
promise = this.authenticate("EXTERNAL");
}
(promise || Promise.resolve()).catch((err) => {
this.dispatchError(err);
this.disconnect();
});
}
2020-06-12 12:17:49 -04:00
2021-12-10 09:34:51 -05:00
if (this.caps.available.has("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-23 14:00:49 -04:00
}
break;
case "NEW":
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":
console.log("Server removed available caps:", args[0]);
break;
case "ACK":
console.log("Server ack'ed caps:", args[0]);
break;
case "NAK":
console.log("Server nak'ed caps:", args[0]);
2021-12-10 09:34:51 -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
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));
if (this.debug) {
console.debug("Sent:", msg);
}
2020-06-07 06:31:01 -04:00
}
2020-06-07 07:46:46 -04:00
updateCaseMapping() {
this.cm = this.isupport.caseMapping();
this.pendingLists = new irc.CaseMapMap(this.pendingLists, this.cm);
2021-08-24 06:53:46 -04:00
this.monitored = new irc.CaseMapMap(this.monitored, 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) {
let chanTypes = this.isupport.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) {
let cmd = msg.command;
2021-06-10 12:11:11 -04:00
let label;
2021-12-10 09:34:51 -05:00
if (this.caps.enabled.has("labeled-response")) {
lastLabel++;
label = String(lastLabel);
msg.tags = { ...msg.tags, label };
}
2020-07-15 12:21:09 -04:00
return new Promise((resolve, reject) => {
let removeEventListeners;
2021-06-10 12:11:11 -04:00
let handleMessage = (event) => {
let msg = event.detail.message;
2021-06-10 12:11:11 -04:00
let msgLabel = irc.getMessageLabel(msg);
if (msgLabel && msgLabel != label) {
return;
}
let isError = false;
switch (msg.command) {
case "FAIL":
isError = msg.params[0] === cmd;
break;
case irc.ERR_UNKNOWNERROR:
case irc.ERR_UNKNOWNCOMMAND:
case irc.ERR_NEEDMOREPARAMS:
case irc.RPL_TRYAGAIN:
isError = msg.params[1] === cmd;
break;
}
if (isError) {
removeEventListeners();
reject(new IRCError(msg));
return;
}
2021-06-10 12:11:11 -04:00
let result;
2020-07-15 12:21:09 -04:00
try {
result = done(msg);
2020-07-15 12:21:09 -04:00
} catch (err) {
removeEventListeners();
2020-07-15 12:21:09 -04:00
reject(err);
}
if (result) {
removeEventListeners();
resolve(result);
}
// TODO: handle end of labeled response somehow
2020-07-15 12:21:09 -04:00
};
let handleStatus = () => {
if (this.status === Client.Status.DISCONNECTED) {
removeEventListeners();
reject(new Error("Connection closed"));
}
};
removeEventListeners = () => {
this.removeEventListener("message", handleMessage);
this.removeEventListener("status", handleStatus);
};
2020-07-15 12:21:09 -04:00
this.addEventListener("message", handleMessage);
this.addEventListener("status", handleStatus);
2020-07-15 12:21:09 -04:00
this.send(msg);
});
}
2022-02-02 11:40:19 -05:00
join(channel, password) {
let params = [channel];
if (password) {
params.push(password);
}
let msg = {
command: "JOIN",
2022-02-02 11:40:19 -05:00
params: params,
};
return this.roundtrip(msg, (msg) => {
switch (msg.command) {
case irc.ERR_NOSUCHCHANNEL:
case irc.ERR_TOOMANYCHANNELS:
case irc.ERR_BADCHANNELKEY:
case irc.ERR_BANNEDFROMCHAN:
case irc.ERR_CHANNELISFULL:
case irc.ERR_INVITEONLYCHAN:
if (this.cm(msg.params[1]) === this.cm(channel)) {
throw new IRCError(msg);
}
break;
case "JOIN":
if (this.isMyNick(msg.prefix.name) && this.cm(msg.params[0]) === this.cm(channel)) {
return true;
}
break;
}
});
}
fetchBatch(msg, batchType) {
2021-06-10 12:11:11 -04:00
let batchName = null;
let messages = [];
let cmd = msg.command;
2021-05-31 11:13:55 -04:00
return this.roundtrip(msg, (msg) => {
2021-06-10 06:43:09 -04:00
if (batchName) {
2021-06-10 12:11:11 -04:00
let batch = msg.batch;
2021-06-10 06:43:09 -04:00
while (batch) {
if (batch.name === batchName) {
messages.push(msg);
break;
}
batch = batch.parent;
}
}
if (msg.command !== "BATCH") {
return;
}
let enter = msg.params[0].startsWith("+");
let name = msg.params[0].slice(1);
if (enter && msg.params[1] === batchType) {
batchName = name;
return;
}
if (!enter && name === batchName) {
return { ...this.batches.get(name), messages };
}
});
}
roundtripChatHistory(params) {
// Don't send multiple CHATHISTORY commands in parallel, we can't
// properly handle batches and errors.
let promise = this.pendingCmds.CHATHISTORY.then(() => {
2021-06-10 12:11:11 -04:00
let msg = {
command: "CHATHISTORY",
params,
};
return this.fetchBatch(msg, "chathistory").then((batch) => batch.messages);
});
this.pendingCmds.CHATHISTORY = promise.catch(() => {});
return promise;
}
2021-01-23 06:16:57 -05:00
/* Fetch one page of history before the given date. */
fetchHistoryBefore(target, before, limit) {
let max = Math.min(limit, this.isupport.chatHistory());
2021-06-10 12:11:11 -04:00
let params = ["BEFORE", target, "timestamp=" + before, max];
return this.roundtripChatHistory(params).then((messages) => {
return { more: messages.length >= max };
2021-01-23 06:16:57 -05:00
});
}
/* Fetch history in ascending order. */
fetchHistoryBetween(target, after, before, limit) {
let max = Math.min(limit, this.isupport.chatHistory());
2021-06-10 12:11:11 -04:00
let params = ["AFTER", target, "timestamp=" + after.time, max];
return this.roundtripChatHistory(params).then((messages) => {
limit -= messages.length;
if (limit <= 0) {
throw new Error("Cannot fetch all chat history: too many messages");
}
if (messages.length == max) {
// There are still more messages to fetch
after.time = messages[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) {
2021-06-10 12:11:11 -04:00
let 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() {
2021-06-10 12:11:11 -04:00
let req = { command: "BOUNCER", params: ["LISTNETWORKS"] };
return this.fetchBatch(req, "soju.im/bouncer-networks").then((batch) => {
2021-06-10 12:11:11 -04:00
let networks = new Map();
for (let msg of batch.messages) {
console.assert(msg.command === "BOUNCER" && msg.params[0] === "NETWORK");
2021-06-10 12:11:11 -04:00
let id = msg.params[1];
let params = irc.parseTags(msg.params[2]);
networks.set(id, params);
}
return networks;
});
}
2021-08-24 06:53:46 -04:00
monitor(target) {
if (this.monitored.has(target)) {
return;
}
this.monitored.set(target, true);
// TODO: add poll-based fallback when MONITOR is not supported
if (this.monitored.size + 1 > this.isupport.monitor()) {
2021-08-24 06:53:46 -04:00
return;
}
this.send({ command: "MONITOR", params: ["+", target] });
}
unmonitor(target) {
if (!this.monitored.has(target)) {
return;
}
this.monitored.delete(target);
if (this.isupport.monitor() <= 0) {
2021-08-24 06:53:46 -04:00
return;
}
this.send({ command: "MONITOR", params: ["-", target] });
}
createBouncerNetwork(attrs) {
let msg = {
command: "BOUNCER",
params: ["ADDNETWORK", irc.formatTags(attrs)],
};
return this.roundtrip(msg, (msg) => {
if (msg.command === "BOUNCER" && msg.params[0] === "ADDNETWORK") {
return msg.params[1];
}
});
}
registerAccount(email, password) {
let msg = {
command: "REGISTER",
params: ["*", email || "*", password],
};
return this.roundtrip(msg, (msg) => {
if (msg.command !== "REGISTER") {
return;
}
let result = msg.params[0];
return {
verificationRequired: result === "VERIFICATION_REQUIRED",
account: msg.params[1],
message: msg.params[2],
};
});
}
verifyAccount(account, code) {
let msg = {
command: "VERIFY",
params: [account, code],
};
return this.roundtrip(msg, (msg) => {
if (msg.command !== "VERIFY") {
return;
}
return { message: msg.params[2] };
});
}
}