2022-06-19 00:25:21 +00:00
|
|
|
import _ from "lodash";
|
|
|
|
import UAParser from "ua-parser-js";
|
|
|
|
import {v4 as uuidv4} from "uuid";
|
|
|
|
import escapeRegExp from "lodash/escapeRegExp";
|
|
|
|
import crypto from "crypto";
|
|
|
|
import colors from "chalk";
|
|
|
|
|
|
|
|
import log from "./log";
|
|
|
|
import Chan, {Channel, ChanType} from "./models/chan";
|
|
|
|
import Msg, {MessageType, UserInMessage} from "./models/msg";
|
|
|
|
import Config from "./config";
|
2023-01-29 22:16:55 +00:00
|
|
|
import {condensedTypes} from "../shared/irc";
|
2022-06-19 00:25:21 +00:00
|
|
|
|
|
|
|
import inputs from "./plugins/inputs";
|
|
|
|
import PublicClient from "./plugins/packages/publicClient";
|
|
|
|
import SqliteMessageStorage from "./plugins/messageStorage/sqlite";
|
|
|
|
import TextFileMessageStorage from "./plugins/messageStorage/text";
|
|
|
|
import Network, {IgnoreListItem, NetworkWithIrcFramework} from "./models/network";
|
|
|
|
import ClientManager from "./clientManager";
|
2022-11-26 16:14:09 +00:00
|
|
|
import {MessageStorage, SearchQuery, SearchResponse} from "./plugins/messageStorage/types";
|
2022-06-19 00:25:21 +00:00
|
|
|
|
|
|
|
type OrderItem = Chan["id"] | Network["uuid"];
|
|
|
|
type Order = OrderItem[];
|
|
|
|
|
|
|
|
const events = [
|
|
|
|
"away",
|
|
|
|
"cap",
|
|
|
|
"connection",
|
|
|
|
"unhandled",
|
|
|
|
"ctcp",
|
|
|
|
"chghost",
|
|
|
|
"error",
|
|
|
|
"help",
|
|
|
|
"info",
|
|
|
|
"invite",
|
|
|
|
"join",
|
|
|
|
"kick",
|
|
|
|
"list",
|
|
|
|
"mode",
|
|
|
|
"modelist",
|
|
|
|
"motd",
|
|
|
|
"message",
|
|
|
|
"names",
|
|
|
|
"nick",
|
|
|
|
"part",
|
|
|
|
"quit",
|
|
|
|
"sasl",
|
|
|
|
"topic",
|
|
|
|
"welcome",
|
|
|
|
"whois",
|
|
|
|
];
|
|
|
|
|
|
|
|
type ClientPushSubscription = {
|
|
|
|
endpoint: string;
|
|
|
|
keys: {
|
|
|
|
p256dh: string;
|
|
|
|
auth: string;
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
export type UserConfig = {
|
|
|
|
log: boolean;
|
|
|
|
password: string;
|
|
|
|
sessions: {
|
|
|
|
[token: string]: {
|
|
|
|
lastUse: number;
|
|
|
|
ip: string;
|
|
|
|
agent: string;
|
|
|
|
pushSubscription?: ClientPushSubscription;
|
|
|
|
};
|
|
|
|
};
|
|
|
|
clientSettings: {
|
|
|
|
[key: string]: any;
|
|
|
|
};
|
|
|
|
browser?: {
|
|
|
|
language?: string;
|
|
|
|
ip?: string;
|
|
|
|
hostname?: string;
|
|
|
|
isSecure?: boolean;
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
export type Mention = {
|
|
|
|
chanId: number;
|
|
|
|
msgId: number;
|
|
|
|
type: MessageType;
|
|
|
|
time: Date;
|
|
|
|
text: string;
|
|
|
|
from: UserInMessage;
|
|
|
|
};
|
|
|
|
|
|
|
|
class Client {
|
|
|
|
awayMessage!: string;
|
|
|
|
lastActiveChannel!: number;
|
|
|
|
attachedClients!: {
|
|
|
|
[socketId: string]: {token: string; openChannel: number};
|
|
|
|
};
|
|
|
|
config!: UserConfig & {
|
|
|
|
networks?: Network[];
|
|
|
|
};
|
|
|
|
id!: number;
|
|
|
|
idMsg!: number;
|
|
|
|
idChan!: number;
|
|
|
|
name!: string;
|
|
|
|
networks!: Network[];
|
|
|
|
mentions!: Mention[];
|
|
|
|
manager!: ClientManager;
|
|
|
|
messageStorage!: MessageStorage[];
|
|
|
|
highlightRegex!: RegExp | null;
|
|
|
|
highlightExceptionRegex!: RegExp | null;
|
|
|
|
messageProvider?: SqliteMessageStorage;
|
|
|
|
|
|
|
|
fileHash!: string;
|
|
|
|
|
|
|
|
constructor(manager: ClientManager, name?: string, config = {} as UserConfig) {
|
|
|
|
_.merge(this, {
|
|
|
|
awayMessage: "",
|
|
|
|
lastActiveChannel: -1,
|
|
|
|
attachedClients: {},
|
|
|
|
config: config,
|
|
|
|
id: uuidv4(),
|
|
|
|
idChan: 1,
|
|
|
|
idMsg: 1,
|
|
|
|
name: name,
|
|
|
|
networks: [],
|
|
|
|
mentions: [],
|
|
|
|
manager: manager,
|
|
|
|
messageStorage: [],
|
|
|
|
highlightRegex: null,
|
|
|
|
highlightExceptionRegex: null,
|
|
|
|
messageProvider: undefined,
|
|
|
|
});
|
|
|
|
|
|
|
|
const client = this;
|
|
|
|
|
|
|
|
client.config.log = Boolean(client.config.log);
|
|
|
|
client.config.password = String(client.config.password);
|
|
|
|
|
|
|
|
if (!Config.values.public && client.config.log) {
|
|
|
|
if (Config.values.messageStorage.includes("sqlite")) {
|
2022-12-30 15:40:12 +00:00
|
|
|
client.messageProvider = new SqliteMessageStorage(client.name);
|
2022-06-19 00:25:21 +00:00
|
|
|
client.messageStorage.push(client.messageProvider);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (Config.values.messageStorage.includes("text")) {
|
2022-12-30 15:16:22 +00:00
|
|
|
client.messageStorage.push(new TextFileMessageStorage(client.name));
|
2022-06-19 00:25:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
for (const messageStorage of client.messageStorage) {
|
2022-11-01 21:51:40 +00:00
|
|
|
messageStorage.enable().catch((e) => log.error(e));
|
2022-06-19 00:25:21 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!_.isPlainObject(client.config.sessions)) {
|
|
|
|
client.config.sessions = {};
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!_.isPlainObject(client.config.clientSettings)) {
|
|
|
|
client.config.clientSettings = {};
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!_.isPlainObject(client.config.browser)) {
|
|
|
|
client.config.browser = {};
|
|
|
|
}
|
|
|
|
|
|
|
|
if (client.config.clientSettings.awayMessage) {
|
|
|
|
client.awayMessage = client.config.clientSettings.awayMessage;
|
|
|
|
}
|
|
|
|
|
|
|
|
client.config.clientSettings.searchEnabled = client.messageProvider !== undefined;
|
|
|
|
|
|
|
|
client.compileCustomHighlights();
|
|
|
|
|
|
|
|
_.forOwn(client.config.sessions, (session) => {
|
|
|
|
if (session.pushSubscription) {
|
|
|
|
this.registerPushSubscription(session, session.pushSubscription, true);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
(client.config.networks || []).forEach((network) => client.connect(network, true));
|
|
|
|
|
|
|
|
// Networks are stored directly in the client object
|
|
|
|
// We don't need to keep it in the config object
|
|
|
|
delete client.config.networks;
|
|
|
|
|
|
|
|
if (client.name) {
|
|
|
|
log.info(`User ${colors.bold(client.name)} loaded`);
|
|
|
|
|
|
|
|
// Networks are created instantly, but to reduce server load on startup
|
|
|
|
// We randomize the IRC connections and channel log loading
|
|
|
|
let delay = manager.clients.length * 500;
|
|
|
|
client.networks.forEach((network) => {
|
|
|
|
setTimeout(() => {
|
|
|
|
network.channels.forEach((channel) => channel.loadMessages(client, network));
|
|
|
|
|
|
|
|
if (!network.userDisconnected && network.irc) {
|
|
|
|
network.irc.connect();
|
|
|
|
}
|
|
|
|
}, delay);
|
|
|
|
|
|
|
|
delay += 1000 + Math.floor(Math.random() * 1000);
|
|
|
|
});
|
|
|
|
|
|
|
|
client.fileHash = manager.getDataToSave(client).newHash;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
createChannel(attr: Partial<Chan>) {
|
|
|
|
const chan = new Chan(attr);
|
|
|
|
chan.id = this.idChan++;
|
|
|
|
|
|
|
|
return chan;
|
|
|
|
}
|
|
|
|
|
|
|
|
emit(event: string, data?: any) {
|
|
|
|
if (this.manager !== null) {
|
|
|
|
this.manager.sockets.in(this.id.toString()).emit(event, data);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
find(channelId: number) {
|
|
|
|
let network: Network | null = null;
|
|
|
|
let chan: Chan | null | undefined = null;
|
|
|
|
|
|
|
|
for (const n of this.networks) {
|
|
|
|
chan = _.find(n.channels, {id: channelId});
|
|
|
|
|
|
|
|
if (chan) {
|
|
|
|
network = n;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (network && chan) {
|
|
|
|
return {network, chan};
|
|
|
|
}
|
|
|
|
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
connect(args: Record<string, any>, isStartup = false) {
|
|
|
|
const client = this;
|
|
|
|
let channels: Chan[] = [];
|
|
|
|
|
|
|
|
// Get channel id for lobby before creating other channels for nicer ids
|
|
|
|
const lobbyChannelId = client.idChan++;
|
|
|
|
|
|
|
|
if (Array.isArray(args.channels)) {
|
|
|
|
let badName = false;
|
|
|
|
|
|
|
|
args.channels.forEach((chan: Chan) => {
|
|
|
|
if (!chan.name) {
|
|
|
|
badName = true;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
channels.push(
|
|
|
|
client.createChannel({
|
|
|
|
name: chan.name,
|
|
|
|
key: chan.key || "",
|
|
|
|
type: chan.type,
|
|
|
|
muted: chan.muted,
|
|
|
|
})
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
if (badName && client.name) {
|
|
|
|
log.warn(
|
|
|
|
"User '" +
|
|
|
|
client.name +
|
|
|
|
"' on network '" +
|
|
|
|
String(args.name) +
|
|
|
|
"' has an invalid channel which has been ignored"
|
|
|
|
);
|
|
|
|
}
|
|
|
|
// `join` is kept for backwards compatibility when updating from versions <2.0
|
|
|
|
// also used by the "connect" window
|
|
|
|
} else if (args.join) {
|
|
|
|
channels = args.join
|
|
|
|
.replace(/,/g, " ")
|
|
|
|
.split(/\s+/g)
|
|
|
|
.map((chan: string) => {
|
|
|
|
if (!chan.match(/^[#&!+]/)) {
|
|
|
|
chan = `#${chan}`;
|
|
|
|
}
|
|
|
|
|
|
|
|
return client.createChannel({
|
|
|
|
name: chan,
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO; better typing for args
|
|
|
|
const network = new Network({
|
2022-06-28 20:32:08 +00:00
|
|
|
uuid: args.uuid,
|
2022-06-19 00:25:21 +00:00
|
|
|
name: String(
|
|
|
|
args.name || (Config.values.lockNetwork ? Config.values.defaults.name : "") || ""
|
|
|
|
),
|
|
|
|
host: String(args.host || ""),
|
|
|
|
port: parseInt(String(args.port), 10),
|
|
|
|
tls: !!args.tls,
|
|
|
|
userDisconnected: !!args.userDisconnected,
|
|
|
|
rejectUnauthorized: !!args.rejectUnauthorized,
|
|
|
|
password: String(args.password || ""),
|
|
|
|
nick: String(args.nick || ""),
|
|
|
|
username: String(args.username || ""),
|
|
|
|
realname: String(args.realname || ""),
|
|
|
|
leaveMessage: String(args.leaveMessage || ""),
|
|
|
|
sasl: String(args.sasl || ""),
|
|
|
|
saslAccount: String(args.saslAccount || ""),
|
|
|
|
saslPassword: String(args.saslPassword || ""),
|
|
|
|
commands: (args.commands as string[]) || [],
|
|
|
|
channels: channels,
|
|
|
|
ignoreList: args.ignoreList ? (args.ignoreList as IgnoreListItem[]) : [],
|
|
|
|
|
|
|
|
proxyEnabled: !!args.proxyEnabled,
|
|
|
|
proxyHost: String(args.proxyHost || ""),
|
|
|
|
proxyPort: parseInt(args.proxyPort, 10),
|
|
|
|
proxyUsername: String(args.proxyUsername || ""),
|
|
|
|
proxyPassword: String(args.proxyPassword || ""),
|
|
|
|
});
|
|
|
|
|
|
|
|
// Set network lobby channel id
|
|
|
|
network.channels[0].id = lobbyChannelId;
|
|
|
|
|
|
|
|
client.networks.push(network);
|
|
|
|
client.emit("network", {
|
|
|
|
networks: [network.getFilteredClone(this.lastActiveChannel, -1)],
|
|
|
|
});
|
|
|
|
|
|
|
|
if (!network.validate(client)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
(network as NetworkWithIrcFramework).createIrcFramework(client);
|
|
|
|
|
|
|
|
// TODO
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|
|
|
events.forEach(async (plugin) => {
|
|
|
|
(await import(`./plugins/irc-events/${plugin}`)).default.apply(client, [
|
|
|
|
network.irc,
|
|
|
|
network,
|
|
|
|
]);
|
|
|
|
});
|
|
|
|
|
|
|
|
if (network.userDisconnected) {
|
|
|
|
network.channels[0].pushMessage(
|
|
|
|
client,
|
|
|
|
new Msg({
|
|
|
|
text: "You have manually disconnected from this network before, use the /connect command to connect again.",
|
|
|
|
}),
|
|
|
|
true
|
|
|
|
);
|
|
|
|
} else if (!isStartup) {
|
|
|
|
// irc is created in createIrcFramework
|
|
|
|
// TODO; fix type
|
|
|
|
network.irc!.connect();
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!isStartup) {
|
|
|
|
client.save();
|
|
|
|
channels.forEach((channel) => channel.loadMessages(client, network));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
generateToken(callback: (token: string) => void) {
|
|
|
|
crypto.randomBytes(64, (err, buf) => {
|
|
|
|
if (err) {
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
|
|
|
|
callback(buf.toString("hex"));
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
calculateTokenHash(token: string) {
|
|
|
|
return crypto.createHash("sha512").update(token).digest("hex");
|
|
|
|
}
|
|
|
|
|
|
|
|
updateSession(token: string, ip: string, request: any) {
|
|
|
|
const client = this;
|
|
|
|
const agent = UAParser(request.headers["user-agent"] || "");
|
|
|
|
let friendlyAgent = "";
|
|
|
|
|
|
|
|
if (agent.browser.name) {
|
|
|
|
friendlyAgent = `${agent.browser.name} ${agent.browser.major || ""}`;
|
|
|
|
} else {
|
|
|
|
friendlyAgent = "Unknown browser";
|
|
|
|
}
|
|
|
|
|
|
|
|
if (agent.os.name) {
|
|
|
|
friendlyAgent += ` on ${agent.os.name}`;
|
|
|
|
|
|
|
|
if (agent.os.version) {
|
|
|
|
friendlyAgent += ` ${agent.os.version}`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
client.config.sessions[token] = _.assign(client.config.sessions[token], {
|
|
|
|
lastUse: Date.now(),
|
|
|
|
ip: ip,
|
|
|
|
agent: friendlyAgent,
|
|
|
|
});
|
|
|
|
|
|
|
|
client.save();
|
|
|
|
}
|
|
|
|
|
|
|
|
setPassword(hash: string, callback: (success: boolean) => void) {
|
|
|
|
const client = this;
|
|
|
|
|
|
|
|
const oldHash = client.config.password;
|
|
|
|
client.config.password = hash;
|
|
|
|
client.manager.saveUser(client, function (err) {
|
|
|
|
if (err) {
|
|
|
|
// If user file fails to write, reset it back
|
|
|
|
client.config.password = oldHash;
|
|
|
|
return callback(false);
|
|
|
|
}
|
|
|
|
|
|
|
|
return callback(true);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
input(data) {
|
|
|
|
const client = this;
|
|
|
|
data.text.split("\n").forEach((line) => {
|
|
|
|
data.text = line;
|
|
|
|
client.inputLine(data);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
inputLine(data) {
|
|
|
|
const client = this;
|
|
|
|
const target = client.find(data.target);
|
|
|
|
|
|
|
|
if (!target) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Sending a message to a channel is higher priority than merely opening one
|
|
|
|
// so that reloading the page will open this channel
|
|
|
|
this.lastActiveChannel = target.chan.id;
|
|
|
|
|
|
|
|
let text: string = data.text;
|
|
|
|
|
|
|
|
// This is either a normal message or a command escaped with a leading '/'
|
|
|
|
if (text.charAt(0) !== "/" || text.charAt(1) === "/") {
|
|
|
|
if (target.chan.type === ChanType.LOBBY) {
|
|
|
|
target.chan.pushMessage(
|
|
|
|
this,
|
|
|
|
new Msg({
|
|
|
|
type: MessageType.ERROR,
|
|
|
|
text: "Messages can not be sent to lobbies.",
|
|
|
|
})
|
|
|
|
);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
text = "say " + text.replace(/^\//, "");
|
|
|
|
} else {
|
|
|
|
text = text.substring(1);
|
|
|
|
}
|
|
|
|
|
|
|
|
const args = text.split(" ");
|
|
|
|
const cmd = args?.shift()?.toLowerCase() || "";
|
|
|
|
|
|
|
|
const irc = target.network.irc;
|
|
|
|
let connected = irc && irc.connection && irc.connection.connected;
|
|
|
|
|
|
|
|
if (inputs.userInputs.has(cmd)) {
|
|
|
|
const plugin = inputs.userInputs.get(cmd);
|
|
|
|
|
|
|
|
if (!plugin) {
|
|
|
|
// should be a no-op
|
|
|
|
throw new Error(`Plugin ${cmd} not found`);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (typeof plugin.input === "function" && (connected || plugin.allowDisconnected)) {
|
|
|
|
connected = true;
|
|
|
|
plugin.input.apply(client, [target.network, target.chan, cmd, args]);
|
|
|
|
}
|
|
|
|
} else if (inputs.pluginCommands.has(cmd)) {
|
|
|
|
const plugin = inputs.pluginCommands.get(cmd);
|
|
|
|
|
|
|
|
if (typeof plugin.input === "function" && (connected || plugin.allowDisconnected)) {
|
|
|
|
connected = true;
|
|
|
|
plugin.input(
|
|
|
|
new PublicClient(client, plugin.packageInfo),
|
|
|
|
{network: target.network, chan: target.chan},
|
|
|
|
cmd,
|
|
|
|
args
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} else if (connected) {
|
|
|
|
// TODO: fix
|
|
|
|
irc!.raw(text);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!connected) {
|
|
|
|
target.chan.pushMessage(
|
|
|
|
this,
|
|
|
|
new Msg({
|
|
|
|
type: MessageType.ERROR,
|
|
|
|
text: "You are not connected to the IRC network, unable to send your command.",
|
|
|
|
})
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
compileCustomHighlights() {
|
|
|
|
function compileHighlightRegex(customHighlightString: string) {
|
|
|
|
if (typeof customHighlightString !== "string") {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Ensure we don't have empty strings in the list of highlights
|
|
|
|
const highlightsTokens = customHighlightString
|
|
|
|
.split(",")
|
|
|
|
.map((highlight) => escapeRegExp(highlight.trim()))
|
|
|
|
.filter((highlight) => highlight.length > 0);
|
|
|
|
|
|
|
|
if (highlightsTokens.length === 0) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return new RegExp(
|
|
|
|
`(?:^|[ .,+!?|/:<>(){}'"@&~-])(?:${highlightsTokens.join(
|
|
|
|
"|"
|
|
|
|
)})(?:$|[ .,+!?|/:<>(){}'"-])`,
|
|
|
|
"i"
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
this.highlightRegex = compileHighlightRegex(this.config.clientSettings.highlights);
|
|
|
|
this.highlightExceptionRegex = compileHighlightRegex(
|
|
|
|
this.config.clientSettings.highlightExceptions
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
more(data) {
|
|
|
|
const client = this;
|
|
|
|
const target = client.find(data.target);
|
|
|
|
|
|
|
|
if (!target) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const chan = target.chan;
|
|
|
|
let messages: Msg[] = [];
|
|
|
|
let index = 0;
|
|
|
|
|
|
|
|
// If client requests -1, send last 100 messages
|
|
|
|
if (data.lastId < 0) {
|
|
|
|
index = chan.messages.length;
|
|
|
|
} else {
|
|
|
|
index = chan.messages.findIndex((val) => val.id === data.lastId);
|
|
|
|
}
|
|
|
|
|
|
|
|
// If requested id is not found, an empty array will be sent
|
|
|
|
if (index > 0) {
|
|
|
|
let startIndex = index;
|
|
|
|
|
|
|
|
if (data.condensed) {
|
|
|
|
// Limit to 1000 messages (that's 10x normal limit)
|
|
|
|
const indexToStop = Math.max(0, index - 1000);
|
|
|
|
let realMessagesLeft = 100;
|
|
|
|
|
|
|
|
for (let i = index - 1; i >= indexToStop; i--) {
|
|
|
|
startIndex--;
|
|
|
|
|
|
|
|
// Do not count condensed messages towards the 100 messages
|
2023-01-29 22:16:55 +00:00
|
|
|
if (condensedTypes.has(chan.messages[i].type)) {
|
2022-06-19 00:25:21 +00:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Count up actual 100 visible messages
|
|
|
|
if (--realMessagesLeft === 0) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
startIndex = Math.max(0, index - 100);
|
|
|
|
}
|
|
|
|
|
|
|
|
messages = chan.messages.slice(startIndex, index);
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
chan: chan.id,
|
|
|
|
messages: messages,
|
|
|
|
totalMessages: chan.messages.length,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
clearHistory(data) {
|
|
|
|
const client = this;
|
|
|
|
const target = client.find(data.target);
|
|
|
|
|
|
|
|
if (!target) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
target.chan.messages = [];
|
|
|
|
target.chan.unread = 0;
|
|
|
|
target.chan.highlight = 0;
|
|
|
|
target.chan.firstUnread = 0;
|
|
|
|
|
|
|
|
client.emit("history:clear", {
|
|
|
|
target: target.chan.id,
|
|
|
|
});
|
|
|
|
|
|
|
|
if (!target.chan.isLoggable()) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const messageStorage of this.messageStorage) {
|
2022-11-01 21:51:40 +00:00
|
|
|
messageStorage.deleteChannel(target.network, target.chan).catch((e) => log.error(e));
|
2022-06-19 00:25:21 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-11-26 16:14:09 +00:00
|
|
|
async search(query: SearchQuery): Promise<SearchResponse> {
|
2022-10-02 09:00:52 +00:00
|
|
|
if (!this.messageProvider?.isEnabled) {
|
2022-11-26 16:14:09 +00:00
|
|
|
return {
|
|
|
|
...query,
|
2022-06-19 00:25:21 +00:00
|
|
|
results: [],
|
2022-11-26 16:14:09 +00:00
|
|
|
};
|
2022-06-19 00:25:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return this.messageProvider.search(query);
|
|
|
|
}
|
|
|
|
|
|
|
|
open(socketId: string, target: number) {
|
|
|
|
// Due to how socket.io works internally, normal events may arrive later than
|
|
|
|
// the disconnect event, and because we can't control this timing precisely,
|
|
|
|
// process this event normally even if there is no attached client anymore.
|
|
|
|
const attachedClient =
|
|
|
|
this.attachedClients[socketId] ||
|
|
|
|
({} as Record<string, typeof this.attachedClients[0]>);
|
|
|
|
|
|
|
|
// Opening a window like settings
|
|
|
|
if (target === null) {
|
|
|
|
attachedClient.openChannel = -1;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const targetNetChan = this.find(target);
|
|
|
|
|
|
|
|
if (!targetNetChan) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
targetNetChan.chan.unread = 0;
|
|
|
|
targetNetChan.chan.highlight = 0;
|
|
|
|
|
|
|
|
if (targetNetChan.chan.messages.length > 0) {
|
|
|
|
targetNetChan.chan.firstUnread =
|
|
|
|
targetNetChan.chan.messages[targetNetChan.chan.messages.length - 1].id;
|
|
|
|
}
|
|
|
|
|
|
|
|
attachedClient.openChannel = targetNetChan.chan.id;
|
|
|
|
this.lastActiveChannel = targetNetChan.chan.id;
|
|
|
|
|
|
|
|
this.emit("open", targetNetChan.chan.id);
|
|
|
|
}
|
|
|
|
|
|
|
|
sort(data: {order: Order; type: "networks" | "channels"; target: string}) {
|
|
|
|
const order = data.order;
|
|
|
|
|
|
|
|
if (!_.isArray(order)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
switch (data.type) {
|
|
|
|
case "networks":
|
|
|
|
this.networks.sort((a, b) => order.indexOf(a.uuid) - order.indexOf(b.uuid));
|
|
|
|
|
|
|
|
// Sync order to connected clients
|
|
|
|
this.emit("sync_sort", {
|
|
|
|
order: this.networks.map((obj) => obj.uuid),
|
|
|
|
type: data.type,
|
|
|
|
});
|
|
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
case "channels": {
|
|
|
|
const network = _.find(this.networks, {uuid: data.target});
|
|
|
|
|
|
|
|
if (!network) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
network.channels.sort((a, b) => {
|
|
|
|
// Always sort lobby to the top regardless of what the client has sent
|
|
|
|
// Because there's a lot of code that presumes channels[0] is the lobby
|
|
|
|
if (a.type === ChanType.LOBBY) {
|
|
|
|
return -1;
|
|
|
|
} else if (b.type === ChanType.LOBBY) {
|
|
|
|
return 1;
|
|
|
|
}
|
|
|
|
|
|
|
|
return order.indexOf(a.id) - order.indexOf(b.id);
|
|
|
|
});
|
|
|
|
|
|
|
|
// Sync order to connected clients
|
|
|
|
this.emit("sync_sort", {
|
|
|
|
order: network.channels.map((obj) => obj.id),
|
|
|
|
type: data.type,
|
|
|
|
target: network.uuid,
|
|
|
|
});
|
|
|
|
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
this.save();
|
|
|
|
}
|
|
|
|
|
|
|
|
names(data: {target: number}) {
|
|
|
|
const client = this;
|
|
|
|
const target = client.find(data.target);
|
|
|
|
|
|
|
|
if (!target) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
client.emit("names", {
|
|
|
|
id: target.chan.id,
|
|
|
|
users: target.chan.getSortedUsers(target.network.irc),
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
part(network: Network, chan: Chan) {
|
|
|
|
const client = this;
|
|
|
|
network.channels = _.without(network.channels, chan);
|
|
|
|
client.mentions = client.mentions.filter((msg) => !(msg.chanId === chan.id));
|
|
|
|
chan.destroy();
|
|
|
|
client.save();
|
|
|
|
client.emit("part", {
|
|
|
|
chan: chan.id,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
quit(signOut?: boolean) {
|
|
|
|
const sockets = this.manager.sockets.sockets;
|
|
|
|
const room = sockets.adapter.rooms.get(this.id.toString());
|
|
|
|
|
|
|
|
if (room) {
|
|
|
|
for (const user of room) {
|
|
|
|
const socket = sockets.sockets.get(user);
|
|
|
|
|
|
|
|
if (socket) {
|
|
|
|
if (signOut) {
|
|
|
|
socket.emit("sign-out");
|
|
|
|
}
|
|
|
|
|
|
|
|
socket.disconnect();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
this.networks.forEach((network) => {
|
|
|
|
network.quit();
|
|
|
|
network.destroy();
|
|
|
|
});
|
|
|
|
|
|
|
|
for (const messageStorage of this.messageStorage) {
|
2022-11-01 21:51:40 +00:00
|
|
|
messageStorage.close().catch((e) => log.error(e));
|
2022-06-19 00:25:21 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
clientAttach(socketId: string, token: string) {
|
|
|
|
const client = this;
|
|
|
|
|
|
|
|
if (client.awayMessage && _.size(client.attachedClients) === 0) {
|
|
|
|
client.networks.forEach(function (network) {
|
|
|
|
// Only remove away on client attachment if
|
|
|
|
// there is no away message on this network
|
|
|
|
if (network.irc && !network.awayMessage) {
|
|
|
|
network.irc.raw("AWAY");
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
const openChannel = client.lastActiveChannel;
|
|
|
|
client.attachedClients[socketId] = {token, openChannel};
|
|
|
|
}
|
|
|
|
|
|
|
|
clientDetach(socketId: string) {
|
|
|
|
const client = this;
|
|
|
|
|
|
|
|
delete this.attachedClients[socketId];
|
|
|
|
|
|
|
|
if (client.awayMessage && _.size(client.attachedClients) === 0) {
|
|
|
|
client.networks.forEach(function (network) {
|
|
|
|
// Only set away on client deattachment if
|
|
|
|
// there is no away message on this network
|
|
|
|
if (network.irc && !network.awayMessage) {
|
|
|
|
network.irc.raw("AWAY", client.awayMessage);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: type session to this.attachedClients
|
|
|
|
registerPushSubscription(session: any, subscription: ClientPushSubscription, noSave = false) {
|
|
|
|
if (
|
|
|
|
!_.isPlainObject(subscription) ||
|
|
|
|
!_.isPlainObject(subscription.keys) ||
|
|
|
|
typeof subscription.endpoint !== "string" ||
|
|
|
|
!/^https?:\/\//.test(subscription.endpoint) ||
|
|
|
|
typeof subscription.keys.p256dh !== "string" ||
|
|
|
|
typeof subscription.keys.auth !== "string"
|
|
|
|
) {
|
|
|
|
session.pushSubscription = null;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const data = {
|
|
|
|
endpoint: subscription.endpoint,
|
|
|
|
keys: {
|
|
|
|
p256dh: subscription.keys.p256dh,
|
|
|
|
auth: subscription.keys.auth,
|
|
|
|
},
|
|
|
|
};
|
|
|
|
|
|
|
|
session.pushSubscription = data;
|
|
|
|
|
|
|
|
if (!noSave) {
|
|
|
|
this.save();
|
|
|
|
}
|
|
|
|
|
|
|
|
return data;
|
|
|
|
}
|
|
|
|
|
|
|
|
unregisterPushSubscription(token: string) {
|
|
|
|
this.config.sessions[token].pushSubscription = undefined;
|
|
|
|
this.save();
|
|
|
|
}
|
|
|
|
|
|
|
|
save = _.debounce(
|
|
|
|
function SaveClient(this: Client) {
|
|
|
|
if (Config.values.public) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const client = this;
|
|
|
|
client.manager.saveUser(client);
|
|
|
|
},
|
|
|
|
5000,
|
|
|
|
{maxWait: 20000}
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
export default Client;
|
|
|
|
|
|
|
|
// TODO: this should exist elsewhere?
|
|
|
|
export type IrcEventHandler = (
|
|
|
|
this: Client,
|
|
|
|
irc: NetworkWithIrcFramework["irc"],
|
|
|
|
network: NetworkWithIrcFramework
|
|
|
|
) => void;
|