"use strict";
/*
Copyright 2019 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.IrcServer = void 0;
const logging_1 = require("../logging");
const BridgedClient_1 = require("./BridgedClient");
const IrcClientConfig_1 = require("../models/IrcClientConfig");
const Template_1 = require("../util/Template");
const log = (0, logging_1.getLogger)("IrcServer");
const GROUP_ID_REGEX = /^\+\S+:\S+$/;
/*
 * Represents a single IRC server from config.yaml
 */
class IrcServer {
    /**
     * Construct a new IRC Server.
     * @constructor
     * @param {string} domain : The IRC network address
     * @param {Object} serverConfig : The config options for this network.
     * @param {string} homeserverDomain : The domain of the homeserver
     * e.g "matrix.org"
     * @param {number} expiryTimeSeconds : How old a matrix message can be
     * before it is considered 'expired' and not sent to IRC. If 0, messages
     * will never expire.
     */
    constructor(domain, config, homeserverDomain, expiryTimeSeconds = 0) {
        this.domain = domain;
        this.config = config;
        this.homeserverDomain = homeserverDomain;
        this.expiryTimeSeconds = expiryTimeSeconds;
        this.addresses = [];
        this.groupIdValid = false;
        this.excludedUsers = [];
        this.enforceReconnectInterval = true;
        this.reconfigure(config, expiryTimeSeconds);
    }
    /**
     * Get how old a matrix message can be (in seconds) before it is considered
     * 'expired' and not sent to IRC.
     * @return {Number} The number of seconds. If 0, they never expire.
     */
    getExpiryTimeSeconds() {
        return this.expiryTimeSeconds;
    }
    /**
     * Get a string that represents the human-readable name for a server.
     * @return {string} this.config.name if truthy, otherwise it will return
     * an empty string.
     */
    getReadableName() {
        return this.config.name || "";
    }
    /**
     * Get an icon to represent the network
     * The icon URL, if configured.
     */
    getIcon() {
        return this.config.icon;
    }
    /**
     * Return a randomised server domain from the default and additional addresses.
     * @return {string}
     */
    randomDomain() {
        return this.addresses[Math.floor((Math.random() * 1000) % this.addresses.length)];
    }
    /**
     * Returns the network ID of this server, which should be unique across all
     * IrcServers on the bridge. Defaults to the domain of this IrcServer.
     * @return {string} this.config.networkId || this.domain
     */
    getNetworkId() {
        return this.config.networkId || this.domain;
    }
    /**
     * Returns whether the server is configured to wait getQuitDebounceDelayMs before
     * parting a user that has disconnected due to a net-split.
     * @return {Boolean} this.config.quitDebounce.enabled.
     */
    shouldDebounceQuits() {
        return this.config.quitDebounce.enabled;
    }
    /**
     * Get a random interval to delay a quits for when debouncing. Will be between
     * `delayMinMs` and `delayMaxMs`
     */
    getQuitDebounceDelay() {
        const { delayMaxMs, delayMinMs } = this.config.quitDebounce;
        return delayMinMs + (delayMaxMs - delayMinMs) * Math.random();
    }
    /**
     * Get the rate of maximum quits received per second before a net-split is
     * detected. If the rate of quits received becomes higher that this value,
     * a net split is considered ongoing.
     * @return {number}
     */
    getDebounceQuitsPerSecond() {
        return this.config.quitDebounce.quitsPerSecond;
    }
    /**
     * Get a map that converts IRC user modes to Matrix power levels.
     * @return {Object}
     */
    getModePowerMap() {
        return this.config.modePowerMap || {};
    }
    getHardCodedRoomIds() {
        const roomIds = new Set();
        const channels = Object.keys(this.config.mappings);
        channels.forEach((chan) => {
            this.config.mappings[chan].roomIds.forEach((roomId) => {
                roomIds.add(roomId);
            });
        });
        return Array.from(roomIds.keys());
    }
    getChannelKey(channel) {
        return this.config.mappings[channel]?.key;
    }
    shouldSendConnectionNotices() {
        return this.config.sendConnectionMessages;
    }
    isBotEnabled() {
        return this.config.botConfig.enabled;
    }
    getUserModes() {
        return this.config.ircClients.userModes || "";
    }
    getRealNameFormat() {
        return this.config.ircClients.realnameFormat || "mxid";
    }
    getJoinRule() {
        return this.config.dynamicChannels.joinRule;
    }
    areGroupsEnabled() {
        return this.groupIdValid;
    }
    getGroupId() {
        return this.config.dynamicChannels.groupId;
    }
    shouldFederatePMs() {
        return this.config.privateMessages.federate;
    }
    getMemberListFloodDelayMs() {
        return this.config.membershipLists.floodDelayMs;
    }
    shouldFederate() {
        return this.config.dynamicChannels.federate;
    }
    forceRoomVersion() {
        return this.config.dynamicChannels.roomVersion;
    }
    getPort() {
        return this.config.port;
    }
    isInWhitelist(userId) {
        return this.config.dynamicChannels.whitelist.includes(userId);
    }
    getSecureOptions() {
        // Return an empty object here if not defined, as a falsy secure opts will disable SSL.
        return this.config.tlsOptions ?? {};
    }
    useSsl() {
        return Boolean(this.config.ssl);
    }
    useSslSelfSigned() {
        return Boolean(this.config.sslselfsign);
    }
    useSasl() {
        return Boolean(this.config.sasl);
    }
    allowExpiredCerts() {
        return Boolean(this.config.allowExpiredCerts);
    }
    getIdleTimeout() {
        return this.config.ircClients.idleTimeout;
    }
    toggleReconnectInterval(enable) {
        this.enforceReconnectInterval = enable;
    }
    getReconnectIntervalMs() {
        return this.enforceReconnectInterval ? this.config.ircClients.reconnectIntervalMs : 0;
    }
    getConcurrentReconnectLimit() {
        return this.config.ircClients.concurrentReconnectLimit;
    }
    getMaxClients() {
        return this.config.ircClients.maxClients;
    }
    shouldPublishRooms() {
        return this.config.dynamicChannels.published;
    }
    shouldPublishRoomsToHomeserverDirectory() {
        return this.config.dynamicChannels.useHomeserverDirectory;
    }
    allowsNickChanges() {
        return this.config.ircClients.allowNickChanges;
    }
    getBotNickname() {
        return this.config.botConfig.nick;
    }
    createBotIrcClientConfig() {
        return IrcClientConfig_1.IrcClientConfig.newConfig(null, this.domain, this.config.botConfig.nick, this.config.botConfig.username, this.config.botConfig.password);
    }
    getIpv6Prefix() {
        return this.config.ircClients.ipv6.prefix;
    }
    getIpv6Only() {
        return this.config.ircClients.ipv6.only;
    }
    getLineLimit() {
        return this.config.ircClients.lineLimit;
    }
    getJoinAttempts() {
        return this.config.matrixClients.joinAttempts;
    }
    isExcludedChannel(channel) {
        return this.config.dynamicChannels.exclude.includes(channel);
    }
    isExcludedUser(userId) {
        return this.excludedUsers.find((exclusion) => {
            return exclusion.regex.exec(userId) !== null;
        });
    }
    get ignoreIdleUsersOnStartup() {
        return this.config.membershipLists.ignoreIdleOnStartup?.enabled;
    }
    get ignoreIdleUsersOnStartupAfterMs() {
        return (this.config.membershipLists.ignoreIdleOnStartup?.idleForHours || 0) * 1000 * 60 * 60;
    }
    get ignoreIdleUsersOnStartupExcludeRegex() {
        return this.idleUsersStartupExcludeRegex;
    }
    get aliasTemplateHasHashPrefix() {
        return this.config.dynamicChannels.aliasTemplate.startsWith("#");
    }
    /**
     * The amount of time to allow for inactivty on the connection, before considering the connection
     * dead. This usually happens if the IRCd doesn't ping us.
     */
    get pingTimeout() {
        return this.config.ircClients.pingTimeoutMs;
    }
    /**
     * The rate at which to send pings to the IRCd if the client is being quiet for a while.
     * Whilst the IRCd *should* be sending pings to us to keep the connection alive, it appears
     * that sometimes they don't get around to it and end up ping timing us out.
    */
    get pingRateMs() {
        return this.config.ircClients.pingRateMs;
    }
    canJoinRooms(userId) {
        return (this.config.dynamicChannels.enabled &&
            (this.getJoinRule() === "public" || this.isInWhitelist(userId)));
    }
    // check if this server dynamically create rooms with aliases.
    createsDynamicAliases() {
        return (this.config.dynamicChannels.enabled &&
            this.config.dynamicChannels.createAlias);
    }
    // check if this server dynamically creates rooms which are joinable via an alias only.
    createsPublicAliases() {
        return (this.createsDynamicAliases() &&
            this.getJoinRule() === "public");
    }
    allowsPms() {
        return this.config.privateMessages.enabled;
    }
    shouldSyncMembershipToIrc(kind, roomId) {
        return this.shouldSyncMembership(kind, roomId, true);
    }
    shouldSyncMembershipToMatrix(kind, channel) {
        return this.shouldSyncMembership(kind, channel, false);
    }
    shouldSyncMembership(kind, identifier, toIrc) {
        if (!["incremental", "initial"].includes(kind)) {
            throw new Error("Bad kind: " + kind);
        }
        if (!this.config.membershipLists.enabled) {
            return false;
        }
        let shouldSync = this.config.membershipLists.global[toIrc ? "matrixToIrc" : "ircToMatrix"][kind];
        if (!identifier) {
            return shouldSync;
        }
        // check for specific rules for the room id / channel
        if (toIrc) {
            // room rules clobber global rules
            this.config.membershipLists.rooms.forEach(function (r) {
                if (r.room === identifier && r.matrixToIrc) {
                    shouldSync = r.matrixToIrc[kind];
                }
            });
        }
        else {
            // channel rules clobber global rules
            this.config.membershipLists.channels.forEach(function (chan) {
                if (chan.channel === identifier && chan.ircToMatrix) {
                    shouldSync = chan.ircToMatrix[kind];
                }
            });
        }
        return shouldSync;
    }
    /**
     * Does the server/channel require all Matrix users to be joined?
     * @param channel The IRC channel.
     * @returns True if the server requires all Matrix users to be joined.
     */
    shouldRequireMatrixUserJoined(channel) {
        let shouldSync = this.config.membershipLists.global.ircToMatrix.requireMatrixJoined;
        this.config.membershipLists.channels.find((chan) => {
            if (chan.channel === channel) {
                if (typeof chan.ircToMatrix?.requireMatrixJoined === "boolean") {
                    shouldSync = chan.ircToMatrix.requireMatrixJoined;
                }
                return true;
            }
            return false;
        });
        return shouldSync;
    }
    shouldJoinChannelsIfNoUsers() {
        return this.config.botConfig.joinChannelsIfNoUsers;
    }
    isMembershipListsEnabled() {
        return this.config.membershipLists.enabled;
    }
    getUserLocalpart(nick) {
        // the template is just a literal string with special vars; so find/replace
        // the vars and strip the @
        return (0, Template_1.renderTemplate)(this.config.matrixClients.userTemplate, {
            server: this.domain,
            nick,
        }).substring(1); // the first character is guaranteed by config schema to be '@'
    }
    claimsUserId(userId) {
        // the server claims the given user ID if the ID matches the user ID template.
        const regex = IrcServer.templateToRegex(this.config.matrixClients.userTemplate, {
            "$SERVER": this.domain
        }, {
            "$NICK": "(.*)"
        }, ":" + IrcServer.escapeRegExp(this.homeserverDomain));
        return new RegExp(regex).test(userId);
    }
    getNickFromUserId(userId) {
        // extract the nick from the given user ID
        const regex = IrcServer.templateToRegex(this.config.matrixClients.userTemplate, {
            "$SERVER": this.domain
        }, {
            "$NICK": "(.*?)"
        }, ":" + IrcServer.escapeRegExp(this.homeserverDomain));
        const match = new RegExp(regex).exec(userId);
        if (!match) {
            return null;
        }
        return match[1];
    }
    getUserIdFromNick(nick) {
        const template = this.config.matrixClients.userTemplate;
        return template.replace(/\$NICK/g, nick).replace(/\$SERVER/g, this.domain) +
            ":" + this.homeserverDomain;
    }
    getDisplayNameFromNick(nick) {
        const template = this.config.matrixClients.displayName;
        let displayName = template.replace(/\$NICK/g, nick);
        displayName = displayName.replace(/\$SERVER/g, this.domain);
        return displayName;
    }
    claimsAlias(alias) {
        // the server claims the given alias if the alias matches the alias template
        const regex = IrcServer.templateToRegex(this.config.dynamicChannels.aliasTemplate, {
            "$SERVER": this.domain
        }, {
            "$CHANNEL": "#(.*)"
        }, ":" + IrcServer.escapeRegExp(this.homeserverDomain));
        return new RegExp(regex).test(alias);
    }
    getChannelFromAlias(alias) {
        // extract the channel from the given alias
        const regex = IrcServer.templateToRegex(this.config.dynamicChannels.aliasTemplate, {
            "$SERVER": this.domain
        }, {
            "$CHANNEL": "([^:]*)"
        }, ":" + IrcServer.escapeRegExp(this.homeserverDomain));
        const match = new RegExp(regex).exec(alias);
        if (!match) {
            return null;
        }
        log.info("getChannelFromAlias -> %s -> %s -> %s", alias, regex, match[1]);
        return match[1];
    }
    getAliasFromChannel(channel) {
        if (!channel.startsWith("#") && !this.aliasTemplateHasHashPrefix) {
            throw Error('Cannot get an alias for a channel not starting with a hash');
        }
        const alias = (0, Template_1.renderTemplate)(this.config.dynamicChannels.aliasTemplate, {
            channel,
            server: this.domain,
        });
        return alias + ":" + this.homeserverDomain;
    }
    getNick(userId, displayName) {
        let localpart = userId.substring(1).split(":")[0];
        localpart = localpart.replace(BridgedClient_1.illegalCharactersRegex, "");
        displayName = displayName ? displayName.replace(BridgedClient_1.illegalCharactersRegex, "") : undefined;
        const display = [displayName, localpart].find((n) => Boolean(n));
        if (!display) {
            throw new Error("Could not get nick for user, all characters were invalid");
        }
        return (0, Template_1.renderTemplate)(this.config.ircClients.nickTemplate, {
            userId, localpart, display
        });
    }
    getAliasRegex() {
        return IrcServer.templateToRegex(this.config.dynamicChannels.aliasTemplate, {
            "$SERVER": this.domain // find/replace $server
        }, {
            "$CHANNEL": ".*" // the nick is unknown, so replace with a wildcard
        }, 
        // Only match the domain of the HS
        ":" + IrcServer.escapeRegExp(this.homeserverDomain));
    }
    getUserRegex() {
        return IrcServer.templateToRegex(this.config.matrixClients.userTemplate, {
            "$SERVER": this.domain // find/replace $server
        }, {
            "$NICK": ".*" // the nick is unknown, so replace with a wildcard
        }, 
        // Only match the domain of the HS
        ":" + IrcServer.escapeRegExp(this.homeserverDomain));
    }
    getIpv6BlockForHomeserver(homeserver) {
        const result = this.config.ircClients.ipv6.blocks?.find(block => block.homeserver === homeserver);
        if (result) {
            return result.startFrom;
        }
        return null;
    }
    static get DEFAULT_CONFIG() {
        return {
            sendConnectionMessages: true,
            onlyAdditionalAddresses: false,
            quitDebounce: {
                enabled: false,
                quitsPerSecond: 5,
                delayMinMs: 3600000,
                delayMaxMs: 7200000, // 2h
            },
            botConfig: {
                nick: "appservicebot",
                username: "matrixbot",
                joinChannelsIfNoUsers: true,
                enabled: true
            },
            privateMessages: {
                enabled: true,
                exclude: [],
                federate: true
            },
            dynamicChannels: {
                enabled: false,
                published: true,
                useHomeserverDirectory: false,
                createAlias: true,
                joinRule: "public",
                federate: true,
                aliasTemplate: "#irc_$SERVER_$CHANNEL",
                whitelist: [],
                exclude: []
            },
            mappings: {},
            excludedUsers: [],
            matrixClients: {
                userTemplate: "@$SERVER_$NICK",
                displayName: "$NICK",
                joinAttempts: -1,
            },
            ircClients: {
                nickTemplate: "M-$DISPLAY",
                maxClients: 30,
                idleTimeout: 172800,
                reconnectIntervalMs: 5000,
                concurrentReconnectLimit: 50,
                allowNickChanges: false,
                ipv6: {
                    only: false
                },
                lineLimit: 3,
                pingTimeoutMs: 1000 * 60 * 10,
                pingRateMs: 1000 * 60,
                kickOn: {
                    ircConnectionFailure: true,
                    channelJoinFailure: true,
                    userQuit: true
                }
            },
            membershipLists: {
                enabled: false,
                floodDelayMs: 10000,
                global: {
                    ircToMatrix: {
                        initial: false,
                        incremental: false,
                        requireMatrixJoined: false,
                    },
                    matrixToIrc: {
                        initial: false,
                        incremental: false
                    }
                },
                channels: [],
                rooms: []
            }
        };
    }
    reconfigure(config, expiryTimeSeconds = 0) {
        log.info(`Reconfiguring ${this.domain}`);
        if (config.ca) {
            log.warn("** The IrcServer.ca is now deprecated, please use tlsOptions.ca. **");
            config.tlsOptions = {
                ...config.tlsOptions,
                ca: config.ca,
            };
        }
        if (config.ircClients.ipv6.blocks) {
            // Check those blocks
            const invalidBlocks = config.ircClients.ipv6.blocks.filter(block => isNaN(parseInt(block.startFrom.replace(/:/g, ''), 16))).map(block => block.homeserver).join(', ');
            if (invalidBlocks) {
                throw Error(`Invalid ircClients.ipv6.blocks entry(s): ${invalidBlocks}`);
            }
        }
        this.config = config;
        this.expiryTimeSeconds = expiryTimeSeconds;
        // This ensures that legacy mappings still work, but we prod the user to update.
        const stringMappings = Object.entries(config.mappings || {}).filter(([, data]) => {
            return Array.isArray(data);
        });
        if (stringMappings.length) {
            log.warn("** The IrcServer.mappings config schema has changed, allowing legacy format for now. **");
            log.warn("See https://github.com/matrix-org/matrix-appservice-irc/blob/master/CHANGELOG.md for details");
            for (const [channelId, roomIds] of stringMappings) {
                config.mappings[channelId] = { roomIds: roomIds };
            }
        }
        if (!this.aliasTemplateHasHashPrefix) {
            if (this.config.dynamicChannels.aliasTemplate !== "$CHANNEL") {
                throw Error("If no hash prefix is given in 'aliasTemplate', then the aliasTemplate must be exactly '$CHANNEL'");
            }
            log.warn("You have configured your aliasTemplate to not include a prefix hash. This means that only " +
                "channels starting with a hash are supported by the bridge.");
        }
        this.addresses = config.additionalAddresses || [];
        // Don't include the original domain if not configured to.
        if (!config.onlyAdditionalAddresses) {
            this.addresses.push(this.domain);
        }
        else if (this.addresses.length === 0) {
            throw Error("onlyAdditionalAddresses is true, but no additional addresses are provided in the config");
        }
        this.excludedUsers = config.excludedUsers.map((excluded) => {
            return {
                ...excluded,
                regex: new RegExp(excluded.regex)
            };
        });
        if (config.dynamicChannels.groupId !== undefined &&
            config.dynamicChannels.groupId.trim() !== "") {
            this.groupIdValid = GROUP_ID_REGEX.test(config.dynamicChannels.groupId);
            if (!this.groupIdValid) {
                log.warn(`${this.domain} has an incorrectly configured groupId for dynamicChannels and will not set groups.`);
            }
        }
        else {
            this.groupIdValid = false;
        }
        this.idleUsersStartupExcludeRegex =
            this.config.membershipLists.ignoreIdleOnStartup?.exclude ?
                new RegExp(this.config.membershipLists.ignoreIdleOnStartup.exclude)
                : undefined;
    }
    static templateToRegex(template, literalVars, regexVars, suffix) {
        // The 'template' is a literal string with some special variables which need
        // to be find/replaced.
        let regex = template;
        Object.keys(literalVars).forEach(function (varPlaceholder) {
            regex = regex.replace(new RegExp(IrcServer.escapeRegExp(varPlaceholder), 'g'), literalVars[varPlaceholder]);
        });
        // at this point the template is still a literal string, so escape it before
        // applying the regex vars.
        regex = IrcServer.escapeRegExp(regex);
        // apply regex vars
        Object.keys(regexVars).forEach(function (varPlaceholder) {
            regex = regex.replace(
            // double escape, because we bluntly escaped the entire string before
            // so our match is now escaped.
            new RegExp(IrcServer.escapeRegExp(IrcServer.escapeRegExp(varPlaceholder)), 'g'), regexVars[varPlaceholder]);
        });
        suffix = suffix || "";
        return regex + suffix;
    }
    static escapeRegExp(s) {
        // https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions
        return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    }
}
exports.IrcServer = IrcServer;
//# sourceMappingURL=IrcServer.js.map