grigori-gru/matrix-cli

View on GitHub
src/lib/matrix-service.js

Summary

Maintainability
F
3 days
Test Coverage
const fileSystem = require('fs').promises;
const matrixSdk = require('matrix-js-sdk');
const url = require('url');
const { getParsedRooms, isEnglish, timing, getOutdatedRooms } = require('./utils');
const Listr = require('listr');
const chalk = require('chalk');
const delay = require('delay');
const path = require('path');
// eslint-disable-next-line
const { MatrixClient } = require('matrix-js-sdk');

const spinLoginText = 'login with password';
const spinSyncText = 'wait for sync with matrix server\n';

module.exports = class {
    /**
     * @param {object} sdk sdk client
     */
    constructor({
        sdk,
        domain,
        userName,
        password,
        sliceAmount = 25,
        delayTime,
        fs = fileSystem,
        logger = console,
        eventsCount,
        ignoreUsers = [],
    }) {
        this.protocol = 'https';
        this.matrixHost = 'matrix';
        this.logger = logger;
        this.sdk = sdk || matrixSdk;
        this.userName = userName;
        this.domain = domain;
        this.password = password;
        this.client;
        this.sliceAmount = sliceAmount;
        this.delayTime = delayTime || 500;
        this.fs = fs;
        this.eventsCount = eventsCount;
        this.matrixHostName = [this.matrixHost, this.domain].join('.');
        this.matrixUserId = this.getUserId(userName);
        this.ignoreUsers = ignoreUsers;
    }

    /**
     * @param {string} userName user name
     * @return {string} matrix format user id
     */
    getUserId(userName) {
        return `@${userName}:${this.matrixHostName}`;
    }

    /**
     * @param {object} client matrix client
     * @return {Promise<MatrixClient>} promise with resolve after connection
     */
    _getReadyClient(client) {
        return new Promise((resolve, reject) => {
            client.on('sync', state => {
                if (state === 'SYNCING') {
                    resolve(client);
                }
            });
        });
    }

    /**
     * Make user auto join when started
     */
    _shouldJoin() {
        this.client.on('RoomMember.membership', async (event, member) => {
            if (member.membership === 'invite' && member.userId === this.getUserId(this.userName)) {
                try {
                    await this.client.joinRoom(member.roomId);
                    this.logger.info(`${this.userId} joined to room with id = ${member.roomId}`);
                } catch (error) {
                    this.logger.error(`Error joining to room with id = ${member.roomId}`);
                }
            }
        });
    }

    /**
     * @param {any} data data to save
     * @param {string} name name alias
     * Save leaved to file
     */
    async saveToJson(data, name) {
        const fileName = `${name}_${Date.now()}.json`;
        const filePath = path.resolve(__dirname, fileName);
        await this.fs.writeFile(filePath, JSON.stringify(data), 'utf8');

        return filePath;
    }

    /**
     * Get matrix sync client
     */
    async getClient() {
        const tasks = new Listr([
            {
                title: spinLoginText,
                task: async ctx => {
                    const baseUrl = url.format({ protocol: this.protocol, hostname: this.matrixHostName });
                    const userId = this.getUserId(this.userName);
                    const client = this.sdk.createClient(baseUrl);
                    const { access_token: accessToken } = await client.loginWithPassword(userId, this.password);
                    const matrixClient = this.sdk.createClient({
                        baseUrl,
                        accessToken,
                        userId,
                    });
                    ctx.matrixClient = matrixClient;
                },
            },
            {
                title: spinSyncText,
                task: async ctx => {
                    await ctx.matrixClient.startClient({
                        initialSyncLimit: this.eventsCount,
                        disablePresence: true,
                    });
                    const readyClient = await this._getReadyClient(ctx.matrixClient);
                    // eslint-disable-next-line
                    ctx.matrixClient = readyClient;
                },
            },
        ]);
        const startTime = Date.now();

        const { matrixClient } = await tasks.run();
        this.client = matrixClient;
        const { min, sec } = timing(startTime);
        this.logger.info(chalk.green(`\nMatrix user have connected on ${min} min ${sec} sec\n`));

        return matrixClient;
    }

    /**
     * @typedef {Object} Room
     * @property {string} project
     * @property {string} roomId
     * @property {string} roomName
     * @property {string[]} members
     * @property {{author: string, date: string}[]} message
     * @property {{date: Object, timestamp: number}} lastMessageDate
     */

    /**
     * @param {Room} room matrix room
     * @return {Boolean} return true if room is chat
     */
    _isChat(room) {
        return room.members.length === 2 && !isEnglish(room.roomName);
    }

    /**
     * @param {Object} room matrix room
     * @return {Boolean} is only user in romm
     */
    async _isSingle(room) { }

    /**
     * @return {Promise<{allRooms: Room[], singleRoomsManyMessages: Room[], singleRoomsNoMessages: Room[], manyMembersNoMessages: Room[], manyMembersManyMessages: Room[]}>} matrix rooms
     */
    async getAllRoomsInfo() {
        const matrixClient = this.client || (await this.getClient());
        const rooms = await matrixClient.getRooms();
        const parsedRooms = rooms.map(getParsedRooms(this.ignoreUsers));

        const singleRoomsNoMessages = parsedRooms.filter(room => {
            return room.members.length === 1 && room.messages.length === 0;
        });

        const singleRoomsManyMessages = parsedRooms.filter(room => {
            return room.members.length === 1 && room.messages.length;
        });

        const manyMembersNoMessages = parsedRooms.filter(room => {
            return room.members.length > 1 && room.messages.length === 0;
        });

        const manyMembersManyMessages = parsedRooms.filter(room => {
            return room.members.length > 1 && room.messages.length;
        });
        // await this._saveRoomsToJson(parsedRooms);

        return {
            allRooms: parsedRooms,
            singleRoomsManyMessages,
            singleRoomsNoMessages,
            manyMembersNoMessages,
            manyMembersManyMessages,
        };
    }

    /**
     *
     * @param {string} roomId matrix roomId
     * @param {string} userId matrix userId
     * @param {number} level level, by default is 100
     */
    async setPowerLevel(roomId, userId, level = 100) {
        const client = this.client || (await this.getClient());

        const content = await client.getStateEvent(roomId, 'm.room.power_levels', '');
        const event = {
            getType: () => 'm.room.power_levels',
            getContent: () => content,
        };

        await client.setPowerLevel(roomId, userId, level, event);
        return true;
    }

    /**
     * @param {{ roomId: string, roomName: string }[]} rooms matrix rooms from getRooms
     * @param {string} matrixUserId matrix user
     * @param {string?} level level to update
     * @return {Promise<{poweredRooms: { roomId: string, roomName: string }[], errPoweredRooms: { roomId: string, roomName: string }[], errors: object[]}>} errors and powered rooms
     */
    async setPower(rooms, matrixUserId, level) {
        const poweredRooms = [];
        const errPoweredRooms = [];

        const iter = async (rooms = [], errors = []) => {
            if (!rooms.length) {
                return { errors, poweredRooms, errPoweredRooms };
            }

            // eslint-disable-next-line
            console.clear();
            this.logger.log(`Powered: ${chalk.green(poweredRooms.length)} Left: ${chalk.yellow(rooms.length)}`);
            const roomsToHandle = rooms.slice(0, this.sliceAmount);
            const restRooms = rooms.slice(this.sliceAmount);
            try {
                const preparedTasks = roomsToHandle.map(({ roomId, roomName }) => {
                    const title = `Powering room ${chalk.cyan(roomName)} for user ${chalk.cyan(matrixUserId)}`;
                    const task = async () => {
                        try {
                            await delay(this.delayTime);
                            await this.setPowerLevel(roomId, matrixUserId, level);
                            poweredRooms.push({ roomId, roomName });
                        } catch (error) {
                            errPoweredRooms.push({ roomId, roomName });
                            throw error;
                        }
                    };

                    return {
                        title,
                        task,
                    };
                });
                const tasks = new Listr(preparedTasks, {
                    exitOnError: false,
                });

                await tasks.run();

                return iter(restRooms, errors);
            } catch (err) {
                return iter(restRooms, [...errors, ...err.errors]);
            }
        };

        return iter(rooms);
    }

    /**
     * @return {Promise<array>} matrix rooms
     */
    async noMessagesEmptyRooms() {
        const { singleRoomsNoMessages } = await this.getAllRoomsInfo();

        return singleRoomsNoMessages;
    }

    /**
     * @param {number|undefined} limit timestamp limit date
     * @param {array|string|undefined} users users to ignore in events
     */
    async getRooms(limit) {
        const { allRooms } = await this.getAllRoomsInfo();
        const groupRooms = allRooms.filter(room => !this._isChat(room));

        return getOutdatedRooms(groupRooms, limit, this.ignoreUsers);
    }

    /**
     * @param {{ roomId: string, roomName: string, alias: string }[]} allRooms matrix rooms from getRooms
     * @param {object} options extra options
     * @param {boolean} [options.deleteAlias] delete or not alias
     * @return {Promise<{leavedRooms: { roomId: string, roomName: string }[], errLeavedRooms: { roomId: string, roomName: string }[], errors: object[]}>} errors and leaved rooms empty array
     */
    async leaveRooms(allRooms, options = {}) {
        const client = this.client || (await this.getClient());
        const leavedRooms = [];
        const errLeavedRooms = [];

        const iter = async (rooms = [], errors = []) => {
            if (!rooms.length) {
                return { errors, leavedRooms, errLeavedRooms };
            }

            // eslint-disable-next-line
            console.clear();
            this.logger.log(
                `Left: ${chalk.yellow(rooms.length)} Complited: ${chalk.green(leavedRooms.length)} Error: ${chalk.red(
                    errLeavedRooms.length,
                )}`,
            );
            const roomsToHandle = rooms.slice(0, this.sliceAmount);
            const restRooms = rooms.slice(this.sliceAmount);
            try {
                const preparedTasks = roomsToHandle.map(({ roomId, roomName, alias }) => {
                    const title = `Leaving room ${chalk.cyan(roomName)}`;
                    const task = async () => {
                        try {
                            await delay(this.delayTime);
                            await client.leave(roomId);
                            if (options.deleteAlias && alias) {
                                await client.deleteAlias(alias);
                            }
                            leavedRooms.push({ roomId, roomName, alias });
                        } catch (error) {
                            errLeavedRooms.push({ roomId, roomName, alias });
                            throw error;
                        }
                    };

                    return {
                        title,
                        task,
                    };
                });
                const tasks = new Listr(preparedTasks, {
                    exitOnError: false,
                });

                await tasks.run();

                return iter(restRooms, errors);
            } catch (err) {
                return iter(restRooms, [...errors, ...err.errors]);
            }
        };

        return iter(allRooms);
    }

    /**
     * Get matrix room id by alias
     * @param  {string} name matrix room alias
     */
    async getRoomByAlias(name) {
        const matrixClient = this.client || (await this.getClient());
        try {
            const alias = this._getMatrixAlias(name);
            const { room_id: roomId } = await matrixClient.getRoomIdForAlias(alias);

            return roomId;
        } catch {
            return;
        }
    }

    /**
     *
     * @param {string} roomName
     */
    async getRoomByName(roomName) {
        const { allRooms } = await this.getAllRoomsInfo();

        return allRooms.filter(item => item.roomName.includes(roomName));
    }

    /**
     *
     * @param {array} allRooms matrix rooms of user
     * @param {string} userId matrix userId
     * @return {Promise<{invitedRooms: { roomId: string, roomName: string }[], errInvitedRooms: { roomId: string, roomName: string }[], errors: object[]}>} errors and leaved rooms empty array
     */
    async inviteUserToRooms(allRooms, userId) {
        const client = this.client || (await this.getClient());
        const invitedRooms = [];
        const errInvitedRooms = [];

        const iter = async (rooms = [], errors = []) => {
            if (!rooms.length) {
                return { errors, invitedRooms, errInvitedRooms };
            }

            // eslint-disable-next-line
            console.clear();
            this.logger.log(
                `Left: ${chalk.yellow(rooms.length)} Complited: ${chalk.green(invitedRooms.length)} Error: ${chalk.red(
                    errInvitedRooms.length,
                )}`,
            );
            const roomsToHandle = rooms.slice(0, this.sliceAmount);
            const restRooms = rooms.slice(this.sliceAmount);
            try {
                const preparedTasks = roomsToHandle.map(({ roomId, roomName }) => {
                    const title = `Inviting user ${chalk.cyan(userId)} to room ${chalk.cyan(roomName)}`;
                    const task = async () => {
                        try {
                            await delay(this.delayTime);
                            await client.invite(roomId, userId);
                            invitedRooms.push({ roomId, roomName });
                        } catch (error) {
                            errInvitedRooms.push({ roomId, roomName });
                            throw error;
                        }
                    };

                    return {
                        title,
                        task,
                    };
                });
                const tasks = new Listr(preparedTasks, {
                    exitOnError: false,
                });

                await tasks.run();

                return iter(restRooms, errors);
            } catch (err) {
                return iter(restRooms, [...errors, ...err.errors]);
            }
        };

        return iter(allRooms);
    }

    /** @type { roomId: string, roomName: string } ParsedRoom */

    /**
     *
     * @param {Room[]} allRooms matrix rooms to join
     * @return {Promise<{joinedRooms: ParsedRoom[], errors: object[], errJoinedRooms: ParsedRoom[]}>} errors and leaved rooms empty array
     */
    async join(allRooms) {
        const client = this.client || (await this.getClient());
        const joinedRooms = [];
        const errJoinedRooms = [];

        const iter = async (rooms = [], errors = []) => {
            if (!rooms.length) {
                return { errors, joinedRooms, errJoinedRooms };
            }

            // eslint-disable-next-line
            console.clear();
            const roomsToHandle = rooms.slice(0, this.sliceAmount);
            const restRooms = rooms.slice(this.sliceAmount);
            try {
                const preparedTasks = roomsToHandle.map(({ roomId, roomName }) => {
                    const title = `Join user ${chalk.cyan(this.userName)} to room ${chalk.cyan(roomName)}`;
                    const task = async () => {
                        try {
                            await delay(this.delayTime);
                            await client.joinRoom(roomId);
                            joinedRooms.push({ roomId, roomName });
                        } catch (error) {
                            errJoinedRooms.push({ roomId, roomName });
                            throw error;
                        }
                    };

                    return {
                        title,
                        task,
                    };
                });
                const tasks = new Listr(preparedTasks, {
                    exitOnError: false,
                });

                await tasks.run();

                return iter(restRooms, errors);
            } catch (err) {
                return iter(restRooms, [...errors, ...err.errors]);
            }
        };

        return iter(allRooms);
    }

    /**
     * @param {string} name name matrix user
     */
    async getUser(name) {
        const client = this.client || (await this.getClient());
        const userId = this.getUserId(name);

        return userId && client.getUser(userId);
    }

    /**
     */
    async getknownUsers() {
        const client = this.client || (await this.getClient());
        return client.getUsers();
    }

    /**
     * Send message
     * @param {array} rooms room id
     * @param {string} message message to send
     */
    async sendMessage(rooms, message) {
        const client = this.client || (await this.getClient());
        try {
            const preparedTasks = rooms.map(({ roomId, roomName }) => {
                const title = `Sending message ${chalk.cyan(message)} to room ${chalk.cyan(roomName)}`;
                const task = () => client.sendTextMessage(roomId, message);

                return {
                    title,
                    task,
                };
            });
            const tasks = new Listr(preparedTasks, {
                concurrent: true,
                exitOnError: false,
            });

            await tasks.run();
        } catch (err) {
            return err.errors;
        }
    }

    /**
     *
     * @param {string} userId matrix user id
     * @param {string} roomId room id
     */
    async kickUser(userId, roomId) {
        const client = this.client || (await this.getClient());
        try {
            await client.kick(roomId, userId, 'kick by bot');

            return userId;
        } catch (error) {
            this.logger.error(error);
        }

    }

    /**
     * Stop service
     */
    stop() {
        this.client && this.client.stopClient();
    }

    /**
     * Get matrix style alias
     * @param {string} name matrix alias
     * @return {string} matrix alias
     */
    _getMatrixAlias(name) {
        return `#${name}:${this.matrixHostName}`;
    }

    /**
     * Delete matrix room alias
     * @param {string} name
     */
    async deleteAlias(name) {
        const alias = this._getMatrixAlias(name);
        await this.client.deleteAlias(alias);
    }
};