<commit_before>import { DiscordBot } from "./bot";
import {
    Bridge,
    RemoteRoom,
    thirdPartyLookup,
    thirdPartyProtocolResult,
    thirdPartyUserResult,
    thirdPartyLocationResult,
    BridgeContext,
    ProvisionedRoom,
    Intent,
} from "matrix-appservice-bridge";
import { DiscordBridgeConfig } from "./config";

import * as Discord from "discord.js";
import * as Bluebird from "bluebird";
import { Util, ICommandActions, ICommandParameters } from "./util";
import { Provisioner } from "./provisioner";
import { Log } from "./log";
const log = new Log("MatrixRoomHandler");
import { IMatrixEvent } from "./matrixtypes";

const ICON_URL = "https://matrix.org/_matrix/media/r0/download/matrix.org/mlxoESwIsTbJrfXyAAogrNxA";
/* tslint:disable:no-magic-numbers */
const HTTP_UNSUPPORTED = 501;
const ROOM_NAME_PARTS = 2;
const AGE_LIMIT = 900000; // 15 * 60 * 1000
const PROVISIONING_DEFAULT_POWER_LEVEL = 50;
const PROVISIONING_DEFAULT_USER_POWER_LEVEL = 0;
const USERSYNC_STATE_DELAY_MS = 5000;
const ROOM_CACHE_MAXAGE_MS = 15 * 60 * 1000;

// Note: The schedule must not have duplicate values to avoid problems in positioning.
// Disabled because it complains about the values in the array
const JOIN_ROOM_SCHEDULE = [
    0,              // Right away
    1000,           // 1 second
    30000,          // 30 seconds
    300000,         // 5 minutes
    900000,         // 15 minutes
];
/* tslint:enable:no-magic-numbers */

export class MatrixRoomHandler {

    private config: DiscordBridgeConfig;
    private bridge: Bridge;
    private discord: DiscordBot;
    private botUserId: string;
    private botJoinedRooms: Set<string>; // roomids
    private botJoinedRoomsCacheUpdatedAt = 0;
    constructor(discord: DiscordBot, config: DiscordBridgeConfig, botUserId: string, private provisioner: Provisioner) {
        this.discord = discord;
        this.config = config;
        this.botUserId = botUserId;
        this.botJoinedRooms = new Set();
    }

    public get ThirdPartyLookup(): thirdPartyLookup {
        return {
            getLocation: this.tpGetLocation.bind(this),
            getProtocol: this.tpGetProtocol.bind(this),
            getUser: this.tpGetUser.bind(this),
            parseLocation: this.tpParseLocation.bind(this),
            parseUser: this.tpParseUser.bind(this),
            protocols: ["discord"],
        };
    }

    public setBridge(bridge: Bridge) {
        this.bridge = bridge;
    }

    public async OnAliasQueried(alias: string, roomId: string) {
        log.verbose("OnAliasQueried", `Got OnAliasQueried for ${alias} ${roomId}`);
        let channel: Discord.GuildChannel;
        try {
            channel = await this.discord.GetChannelFromRoomId(roomId) as Discord.GuildChannel;
        } catch (err) {
            log.error("OnAliasQueried", `Cannot find discord channel for ${alias} ${roomId}`, err);
            throw err;
        }

        // Fire and forget RoomDirectory mapping
        this.bridge.getIntent().getClient().setRoomDirectoryVisibilityAppService(
            channel.guild.id,
            roomId,
            "public",
        );
        await this.discord.ChannelSyncroniser.OnUpdate(channel);
        const promiseList: Promise<void>[] = [];
        // Join a whole bunch of users.
        // We delay the joins to give some implementations a chance to breathe
        let delay = this.config.limits.roomGhostJoinDelay;
        for (const member of (channel as Discord.TextChannel).members.array()) {
            if (member.id === this.discord.GetBotId()) {
              continue;
            }
            promiseList.push((async () => {
                await Bluebird.delay(delay);
                log.info("OnAliasQueried", `UserSyncing ${member.id}`);
                try {
                    // Ensure the profile is up to date.
                    await this.discord.UserSyncroniser.OnUpdateUser(member.user);
                } catch (err) {
                    log.warn("OnAliasQueried", `Failed to update profile of user ${member.id}`, err);
                }
                log.info("OnAliasQueried", `Joining ${member.id} to ${roomId}`);
                await this.joinRoom(this.discord.GetIntentFromDiscordMember(member), roomId);
                // set the correct discord guild name
                await this.discord.UserSyncroniser.EnsureJoin(member, roomId);
            })());
            delay += this.config.limits.roomGhostJoinDelay;
        }
        await Promise.all(promiseList);
    }

    public async OnEvent(request, context): Promise<void> {
        const event = request.getData() as IMatrixEvent;
        if (event.unsigned.age > AGE_LIMIT) {
            log.warn(`Skipping event due to age ${event.unsigned.age} > ${AGE_LIMIT}`);
            throw new Error("Event too old");
        }
        if (event.type === "m.room.member" && event.content!.membership === "invite") {
            await this.HandleInvite(event);
            return;
        } else if (event.type === "m.room.member" && event.content!.membership === "join") {
            if (this.bridge.getBot().isRemoteUser(event.state_key)) {
                await this.discord.UserSyncroniser.OnMemberState(event, USERSYNC_STATE_DELAY_MS);
            } else {
                await this.discord.ProcessMatrixStateEvent(event);
            }
            return;
        } else if (["m.room.member", "m.room.name", "m.room.topic"].includes(event.type)) {
            await this.discord.ProcessMatrixStateEvent(event);
            return;
        } else if (event.type === "m.room.redaction" && context.rooms.remote) {
            await this.discord.ProcessMatrixRedact(event);
            return;
        } else if (event.type === "m.room.message" || event.type === "m.sticker") {
            log.verbose(`Got ${event.type} event`);
            const isBotCommand = event.type === "m.room.message" &&
                event.content!.body &&
                event.content!.body!.startsWith("!discord");
            if (isBotCommand) {
                await this.ProcessCommand(event, context);
                return;
            } else if (context.rooms.remote) {
                const srvChanPair = context.rooms.remote.roomId.substr("_discord".length).split("_", ROOM_NAME_PARTS);
                try {
                    await this.discord.ProcessMatrixMsgEvent(event, srvChanPair[0], srvChanPair[1]);
                    return;
                } catch (err) {
                    log.warn("There was an error sending a matrix event", err);
                    return;
                }
            }
        } else if (event.type === "m.room.encryption" && context.rooms.remote) {
            try {
                await this.HandleEncryptionWarning(event.room_id);
                return;
            } catch (err) {
                throw new Error(`Failed to handle encrypted room, ${err}`);
            }
        } else {
            log.verbose("Got non m.room.message event");
        }
        throw new Error("Event not processed by bridge");
    }

    public async HandleEncryptionWarning(roomId: string): Promise<void> {
        const intent = this.bridge.getIntent();
        log.info(`User has turned on encryption in ${roomId}, so leaving.`);
        /* N.B 'status' is not specced but https://github.com/matrix-org/matrix-doc/pull/828
         has been open for over a year with no resolution. */
        const sendPromise = intent.sendMessage(roomId, {
            body: "You have turned on encryption in this room, so the service will not bridge any new messages.",
            msgtype: "m.notice",
            status: "critical",
        });
        const channel = await this.discord.GetChannelFromRoomId(roomId);
        await (channel as Discord.TextChannel).send(
          "Someone on Matrix has turned on encryption in this room, so the service will not bridge any new messages",
        );
        await sendPromise;
        await intent.leave(roomId);
        await this.bridge.getRoomStore().removeEntriesByMatrixRoomId(roomId);
    }

    public async HandleInvite(event: IMatrixEvent) {
        log.info(`Received invite for ${event.state_key} in room ${event.room_id}`);
        if (event.state_key === this.botUserId) {
            log.info("Accepting invite for bridge bot");
            await this.joinRoom(this.bridge.getIntent(), event.room_id);
            this.botJoinedRooms.add(event.room_id);
        } else {
            await this.discord.ProcessMatrixStateEvent(event);
        }
    }

    public async ProcessCommand(event: IMatrixEvent, context: BridgeContext) {
        const intent = this.bridge.getIntent();
        if (!(await this.isBotInRoom(event.room_id))) {
            log.warn(`Bot is not in ${event.room_id}. Ignoring command`);
            return;
        }

        if (!this.config.bridge.enableSelfServiceBridging) {
            // We can do this here because the only commands we support are self-service bridging
            return this.bridge.getIntent().sendMessage(event.room_id, {
                body: "The owner of this bridge does not permit self-service bridging.",
                msgtype: "m.notice",
            });
        }

        // Check to make sure the user has permission to do anything in the room. We can do this here
        // because the only commands we support are self-service commands (which therefore require some
        // level of permissions)
        const plEvent = await this.bridge.getIntent().getClient()
            .getStateEvent(event.room_id, "m.room.power_levels", "");
        let userLevel = PROVISIONING_DEFAULT_USER_POWER_LEVEL;
        let requiredLevel = PROVISIONING_DEFAULT_POWER_LEVEL;
        if (plEvent && plEvent.state_default) {
            requiredLevel = plEvent.state_default;
        }
        if (plEvent && plEvent.users_default) {
            userLevel = plEvent.users_default;
        }
        if (plEvent && plEvent.users && plEvent.users[event.sender]) {
            userLevel = plEvent.users[event.sender];
        }

        if (userLevel < requiredLevel) {
            return this.bridge.getIntent().sendMessage(event.room_id, {
                body: "You do not have the required power level in this room to create a bridge to a Discord channel.",
                msgtype: "m.notice",
            });
        }

        const {command, args} = Util.MsgToArgs(event.content!.body as string, "!discord");

        if (command === "help" && args[0] === "bridge") {
            const link = Util.GetBotLink(this.config);
            // tslint:disable prefer-template
            return this.bridge.getIntent().sendMessage(event.room_id, {
                body: "How to bridge a Discord guild:\n" +
                "1. Invite the bot to your Discord guild using this link: " + link + "\n" +
                "2. Invite me to the matrix room you'd like to bridge\n" +
                "3. Open the Discord channel you'd like to bridge in a web browser\n" +
                "4. In the matrix room, send the message `!discord bridge <guild id> <channel id>` " +
                "(without the backticks)\n" +
                "   Note: The Guild ID and Channel ID can be retrieved from the URL in your web browser.\n" +
                "   The URL is formatted as https://discordapp.com/channels/GUILD_ID/CHANNEL_ID\n" +
                "5. Enjoy your new bridge!",
                msgtype: "m.notice",
            });
            // tslint:enable prefer-template
        } else if (command === "bridge") {
            if (context.rooms.remote) {
                return this.bridge.getIntent().sendMessage(event.room_id, {
                    body: "This room is already bridged to a Discord guild.",
                    msgtype: "m.notice",
                });
            }

            const minArgs = 2;
            if (args.length < minArgs) {
                return this.bridge.getIntent().sendMessage(event.room_id, {
                    body: "Invalid syntax. For more information try !discord help bridge",
                    msgtype: "m.notice",
                });
            }

            let guildId: string;
            let channelId: string;

            const AMOUNT_OF_IDS_DISCORD_IDENTIFIES_ROOMS_BY = 2;

            if (args.length === AMOUNT_OF_IDS_DISCORD_IDENTIFIES_ROOMS_BY) { // "x y" syntax
                guildId = args[0];
                channelId = args[1];
            } else if (args.length === 1 && args[0].includes("/")) { // "x/y" syntax
                const split = args[0].split("/");
                guildId = split[0];
                channelId = split[1];
            } else {
                return this.bridge.getIntent().sendMessage(event.room_id, {
                    msgtype: "m.notice",
                    body: "Invalid syntax",
                });
            }

            try {
                const discordResult = await this.discord.LookupRoom(guildId, channelId);
                const channel = discordResult.channel as Discord.TextChannel;

                log.info(`Bridging matrix room ${event.room_id} to ${guildId}/${channelId}`);
                this.bridge.getIntent().sendMessage(event.room_id, {<commit_msg>fix linting 2: electric boogaloo<commit_after>import { DiscordBot } from "./bot";
import {
    Bridge,
    RemoteRoom,
    thirdPartyLookup,
    thirdPartyProtocolResult,
    thirdPartyUserResult,
    thirdPartyLocationResult,
    BridgeContext,
    ProvisionedRoom,
    Intent,
} from "matrix-appservice-bridge";
import { DiscordBridgeConfig } from "./config";

import * as Discord from "discord.js";
import * as Bluebird from "bluebird";
import { Util, ICommandActions, ICommandParameters } from "./util";
import { Provisioner } from "./provisioner";
import { Log } from "./log";
const log = new Log("MatrixRoomHandler");
import { IMatrixEvent } from "./matrixtypes";

const ICON_URL = "https://matrix.org/_matrix/media/r0/download/matrix.org/mlxoESwIsTbJrfXyAAogrNxA";
/* tslint:disable:no-magic-numbers */
const HTTP_UNSUPPORTED = 501;
const ROOM_NAME_PARTS = 2;
const AGE_LIMIT = 900000; // 15 * 60 * 1000
const PROVISIONING_DEFAULT_POWER_LEVEL = 50;
const PROVISIONING_DEFAULT_USER_POWER_LEVEL = 0;
const USERSYNC_STATE_DELAY_MS = 5000;
const ROOM_CACHE_MAXAGE_MS = 15 * 60 * 1000;

// Note: The schedule must not have duplicate values to avoid problems in positioning.
// Disabled because it complains about the values in the array
const JOIN_ROOM_SCHEDULE = [
    0,              // Right away
    1000,           // 1 second
    30000,          // 30 seconds
    300000,         // 5 minutes
    900000,         // 15 minutes
];
/* tslint:enable:no-magic-numbers */

export class MatrixRoomHandler {

    private config: DiscordBridgeConfig;
    private bridge: Bridge;
    private discord: DiscordBot;
    private botUserId: string;
    private botJoinedRooms: Set<string>; // roomids
    private botJoinedRoomsCacheUpdatedAt = 0;
    constructor(discord: DiscordBot, config: DiscordBridgeConfig, botUserId: string, private provisioner: Provisioner) {
        this.discord = discord;
        this.config = config;
        this.botUserId = botUserId;
        this.botJoinedRooms = new Set();
    }

    public get ThirdPartyLookup(): thirdPartyLookup {
        return {
            getLocation: this.tpGetLocation.bind(this),
            getProtocol: this.tpGetProtocol.bind(this),
            getUser: this.tpGetUser.bind(this),
            parseLocation: this.tpParseLocation.bind(this),
            parseUser: this.tpParseUser.bind(this),
            protocols: ["discord"],
        };
    }

    public setBridge(bridge: Bridge) {
        this.bridge = bridge;
    }

    public async OnAliasQueried(alias: string, roomId: string) {
        log.verbose("OnAliasQueried", `Got OnAliasQueried for ${alias} ${roomId}`);
        let channel: Discord.GuildChannel;
        try {
            channel = await this.discord.GetChannelFromRoomId(roomId) as Discord.GuildChannel;
        } catch (err) {
            log.error("OnAliasQueried", `Cannot find discord channel for ${alias} ${roomId}`, err);
            throw err;
        }

        // Fire and forget RoomDirectory mapping
        this.bridge.getIntent().getClient().setRoomDirectoryVisibilityAppService(
            channel.guild.id,
            roomId,
            "public",
        );
        await this.discord.ChannelSyncroniser.OnUpdate(channel);
        const promiseList: Promise<void>[] = [];
        // Join a whole bunch of users.
        // We delay the joins to give some implementations a chance to breathe
        let delay = this.config.limits.roomGhostJoinDelay;
        for (const member of (channel as Discord.TextChannel).members.array()) {
            if (member.id === this.discord.GetBotId()) {
              continue;
            }
            promiseList.push((async () => {
                await Bluebird.delay(delay);
                log.info("OnAliasQueried", `UserSyncing ${member.id}`);
                try {
                    // Ensure the profile is up to date.
                    await this.discord.UserSyncroniser.OnUpdateUser(member.user);
                } catch (err) {
                    log.warn("OnAliasQueried", `Failed to update profile of user ${member.id}`, err);
                }
                log.info("OnAliasQueried", `Joining ${member.id} to ${roomId}`);
                await this.joinRoom(this.discord.GetIntentFromDiscordMember(member), roomId);
                // set the correct discord guild name
                await this.discord.UserSyncroniser.EnsureJoin(member, roomId);
            })());
            delay += this.config.limits.roomGhostJoinDelay;
        }
        await Promise.all(promiseList);
    }

    public async OnEvent(request, context): Promise<void> {
        const event = request.getData() as IMatrixEvent;
        if (event.unsigned.age > AGE_LIMIT) {
            log.warn(`Skipping event due to age ${event.unsigned.age} > ${AGE_LIMIT}`);
            throw new Error("Event too old");
        }
        if (event.type === "m.room.member" && event.content!.membership === "invite") {
            await this.HandleInvite(event);
            return;
        } else if (event.type === "m.room.member" && event.content!.membership === "join") {
            if (this.bridge.getBot().isRemoteUser(event.state_key)) {
                await this.discord.UserSyncroniser.OnMemberState(event, USERSYNC_STATE_DELAY_MS);
            } else {
                await this.discord.ProcessMatrixStateEvent(event);
            }
            return;
        } else if (["m.room.member", "m.room.name", "m.room.topic"].includes(event.type)) {
            await this.discord.ProcessMatrixStateEvent(event);
            return;
        } else if (event.type === "m.room.redaction" && context.rooms.remote) {
            await this.discord.ProcessMatrixRedact(event);
            return;
        } else if (event.type === "m.room.message" || event.type === "m.sticker") {
            log.verbose(`Got ${event.type} event`);
            const isBotCommand = event.type === "m.room.message" &&
                event.content!.body &&
                event.content!.body!.startsWith("!discord");
            if (isBotCommand) {
                await this.ProcessCommand(event, context);
                return;
            } else if (context.rooms.remote) {
                const srvChanPair = context.rooms.remote.roomId.substr("_discord".length).split("_", ROOM_NAME_PARTS);
                try {
                    await this.discord.ProcessMatrixMsgEvent(event, srvChanPair[0], srvChanPair[1]);
                    return;
                } catch (err) {
                    log.warn("There was an error sending a matrix event", err);
                    return;
                }
            }
        } else if (event.type === "m.room.encryption" && context.rooms.remote) {
            try {
                await this.HandleEncryptionWarning(event.room_id);
                return;
            } catch (err) {
                throw new Error(`Failed to handle encrypted room, ${err}`);
            }
        } else {
            log.verbose("Got non m.room.message event");
        }
        throw new Error("Event not processed by bridge");
    }

    public async HandleEncryptionWarning(roomId: string): Promise<void> {
        const intent = this.bridge.getIntent();
        log.info(`User has turned on encryption in ${roomId}, so leaving.`);
        /* N.B 'status' is not specced but https://github.com/matrix-org/matrix-doc/pull/828
         has been open for over a year with no resolution. */
        const sendPromise = intent.sendMessage(roomId, {
            body: "You have turned on encryption in this room, so the service will not bridge any new messages.",
            msgtype: "m.notice",
            status: "critical",
        });
        const channel = await this.discord.GetChannelFromRoomId(roomId);
        await (channel as Discord.TextChannel).send(
          "Someone on Matrix has turned on encryption in this room, so the service will not bridge any new messages",
        );
        await sendPromise;
        await intent.leave(roomId);
        await this.bridge.getRoomStore().removeEntriesByMatrixRoomId(roomId);
    }

    public async HandleInvite(event: IMatrixEvent) {
        log.info(`Received invite for ${event.state_key} in room ${event.room_id}`);
        if (event.state_key === this.botUserId) {
            log.info("Accepting invite for bridge bot");
            await this.joinRoom(this.bridge.getIntent(), event.room_id);
            this.botJoinedRooms.add(event.room_id);
        } else {
            await this.discord.ProcessMatrixStateEvent(event);
        }
    }

    public async ProcessCommand(event: IMatrixEvent, context: BridgeContext) {
        const intent = this.bridge.getIntent();
        if (!(await this.isBotInRoom(event.room_id))) {
            log.warn(`Bot is not in ${event.room_id}. Ignoring command`);
            return;
        }

        if (!this.config.bridge.enableSelfServiceBridging) {
            // We can do this here because the only commands we support are self-service bridging
            return this.bridge.getIntent().sendMessage(event.room_id, {
                body: "The owner of this bridge does not permit self-service bridging.",
                msgtype: "m.notice",
            });
        }

        // Check to make sure the user has permission to do anything in the room. We can do this here
        // because the only commands we support are self-service commands (which therefore require some
        // level of permissions)
        const plEvent = await this.bridge.getIntent().getClient()
            .getStateEvent(event.room_id, "m.room.power_levels", "");
        let userLevel = PROVISIONING_DEFAULT_USER_POWER_LEVEL;
        let requiredLevel = PROVISIONING_DEFAULT_POWER_LEVEL;
        if (plEvent && plEvent.state_default) {
            requiredLevel = plEvent.state_default;
        }
        if (plEvent && plEvent.users_default) {
            userLevel = plEvent.users_default;
        }
        if (plEvent && plEvent.users && plEvent.users[event.sender]) {
            userLevel = plEvent.users[event.sender];
        }

        if (userLevel < requiredLevel) {
            return this.bridge.getIntent().sendMessage(event.room_id, {
                body: "You do not have the required power level in this room to create a bridge to a Discord channel.",
                msgtype: "m.notice",
            });
        }

        const {command, args} = Util.MsgToArgs(event.content!.body as string, "!discord");

        if (command === "help" && args[0] === "bridge") {
            const link = Util.GetBotLink(this.config);
            // tslint:disable prefer-template
            return this.bridge.getIntent().sendMessage(event.room_id, {
                body: "How to bridge a Discord guild:\n" +
                "1. Invite the bot to your Discord guild using this link: " + link + "\n" +
                "2. Invite me to the matrix room you'd like to bridge\n" +
                "3. Open the Discord channel you'd like to bridge in a web browser\n" +
                "4. In the matrix room, send the message `!discord bridge <guild id> <channel id>` " +
                "(without the backticks)\n" +
                "   Note: The Guild ID and Channel ID can be retrieved from the URL in your web browser.\n" +
                "   The URL is formatted as https://discordapp.com/channels/GUILD_ID/CHANNEL_ID\n" +
                "5. Enjoy your new bridge!",
                msgtype: "m.notice",
            });
            // tslint:enable prefer-template
        } else if (command === "bridge") {
            if (context.rooms.remote) {
                return this.bridge.getIntent().sendMessage(event.room_id, {
                    body: "This room is already bridged to a Discord guild.",
                    msgtype: "m.notice",
                });
            }

            const minArgs = 2;
            if (args.length < minArgs) {
                return this.bridge.getIntent().sendMessage(event.room_id, {
                    body: "Invalid syntax. For more information try !discord help bridge",
                    msgtype: "m.notice",
                });
            }

            let guildId: string;
            let channelId: string;

            const AMOUNT_OF_IDS_DISCORD_IDENTIFIES_ROOMS_BY = 2;

            if (args.length === AMOUNT_OF_IDS_DISCORD_IDENTIFIES_ROOMS_BY) { // "x y" syntax
                guildId = args[0];
                channelId = args[1];
            } else if (args.length === 1 && args[0].includes("/")) { // "x/y" syntax
                const split = args[0].split("/");
                guildId = split[0];
                channelId = split[1];
            } else {
                return this.bridge.getIntent().sendMessage(event.room_id, {
                    body: "Invalid syntax",
                    msgtype: "m.notice",
                });
            }

            try {
                const discordResult = await this.discord.LookupRoom(guildId, channelId);
                const channel = discordResult.channel as Discord.TextChannel;

                log.info(`Bridging matrix room ${event.room_id} to ${guildId}/${channelId}`);
                this.bridge.getIntent().sendMessage(event.room_id, {
{'fraction_non_alphanumeric': 0.11091755973794827, 'fraction_numerical': 0.006324080736168441, 'mean_word_length': 2.0385456219077205, 'pattern_counts': {'":': 0, '<': 19, '<?xml version=': 0, '>': 21, 'https://': 6, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 2, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '2099309', 'n_tokens_mistral': 7534, 'n_tokens_neox': 7232, 'n_words': 1854}