Skip to content
Snippets Groups Projects
telegram.service.ts 11.6 KiB
Newer Older
Pascal Kosak's avatar
Pascal Kosak committed
import { Injectable } from '@nestjs/common';
import { Message, Metadata } from 'node-telegram-bot-api';
import * as TelegramBot from 'node-telegram-bot-api';
import { PrismaService } from '../prisma/prisma.service';
import * as fs from 'fs';
import * as path from 'path';
import { v4 as uuid } from 'uuid';
import { Entry } from '@prisma/client';
import { pubSub } from '../pubSub.instance';
Pascal Kosak's avatar
Pascal Kosak committed
import * as seedWords from 'mnemonic-words';
Pascal Kosak's avatar
Pascal Kosak committed

@Injectable()
export class TelegramService {
Pascal Kosak's avatar
Pascal Kosak committed
    private telegram: TelegramBot;

    private messageCache = new Map<number, Partial<Entry>>();

Pascal Kosak's avatar
Pascal Kosak committed
    constructor(private prismaService: PrismaService) {
        this.telegram = new TelegramBot(process.env.BOT_TOKEN, {
            polling: true,
        });

        this.telegram.onText(
            /^\/register ([\w|-]+) (.+)/,
            this.register.bind(this),
        );
Pascal Kosak's avatar
Pascal Kosak committed
        this.telegram.onText(/^\/unregister/, this.unregister.bind(this));
Pascal Kosak's avatar
Pascal Kosak committed
        this.telegram.onText(/^\/profile/, this.profile.bind(this));
Pascal Kosak's avatar
Pascal Kosak committed
        this.telegram.onText(/^\/start/, this.start.bind(this));
Adrian Paschkowski's avatar
Adrian Paschkowski committed
        this.telegram.onText(/^\/entry( (\w+))?/, this.entry.bind(this));
Pascal Kosak's avatar
Pascal Kosak committed
        this.telegram.on('message', this.supplyValue.bind(this));
        this.telegram.on('edited_message_text', this.editMessage.bind(this));
Pascal Kosak's avatar
Pascal Kosak committed
    }

Pascal Kosak's avatar
Pascal Kosak committed
    async start(msg: Message) {
        await this.telegram.sendMessage(
            msg.chat.id,
            '/register <password> <name>\n - Registers you as a new Agent. The agentId will be assigned to you beforehand, while the name is what will be displayed to other users.\n' +
Adrian Paschkowski's avatar
Adrian Paschkowski committed
                '/unregister\n - Deletes all your Entries and Profile.\n\n' +
                '/entry <public|private>\n - Create new Public/Private Entry from your previous message cache.\n' +
                '/entry <clear>\n - Clears your current message cache.\n' +
                '/profile\n - Shows your current profile.',
Pascal Kosak's avatar
Pascal Kosak committed
    private generateSlug() {
Pascal Kosak's avatar
Pascal Kosak committed
        function generateWord() {
            return seedWords[Math.round(Math.random() * seedWords.length)];
        }

        return [
            generateWord(),
            generateWord(),
            generateWord(),
            generateWord(),
        ].join('-');
Pascal Kosak's avatar
Pascal Kosak committed
    }

Pascal Kosak's avatar
Pascal Kosak committed
    async entry(msg: Message, match: RegExpMatchArray) {
Adrian Paschkowski's avatar
Adrian Paschkowski committed
        switch (match[2]?.toLowerCase()) {
Pascal Kosak's avatar
Pascal Kosak committed
            case 'private':
                return await this.commitMessage(msg.from.id, true, msg);
            case 'public':
                return await this.commitMessage(msg.from.id, false, msg);
            case 'clear':
                this.messageCache.delete(msg.from.id);
Adrian Paschkowski's avatar
Adrian Paschkowski committed
                return void (await this.telegram.sendMessage(
Pascal Kosak's avatar
Pascal Kosak committed
                    msg.chat.id,
                    '♻ Message Queue cleared',
Adrian Paschkowski's avatar
Adrian Paschkowski committed
                ));
            default:
                return this.sendError(
                    msg,
Adrian Paschkowski's avatar
Adrian Paschkowski committed
                    'Usage: /entry \\<private\\|public\\|clear\\>',
Pascal Kosak's avatar
Pascal Kosak committed
    }

    async commitMessage(id: number, isPrivate: boolean, msg: Message) {
        if (!(await this.isRegistered(msg.from.id)))
Pascal Kosak's avatar
Pascal Kosak committed
            return await this.sendError(msg, 'Not registered');
Pascal Kosak's avatar
Pascal Kosak committed

        if (!this.messageCache.has(id))
Adrian Paschkowski's avatar
Adrian Paschkowski committed
            return await this.sendError(msg, 'No queued Messages');
Pascal Kosak's avatar
Pascal Kosak committed

        let entry: Partial<Entry> = this.messageCache.get(msg.from.id);

        entry.private = isPrivate;

        this.messageCache.delete(msg.from.id);
Pascal Kosak's avatar
Pascal Kosak committed

        entry = await this.prismaService.entry.create({
Pascal Kosak's avatar
Pascal Kosak committed
        });

Adrian Paschkowski's avatar
Adrian Paschkowski committed
        await this.sendSuccess(
            msg,
            `Created new ${isPrivate ? 'private' : 'public'} Entry`,
        // Send a notification to subscriptions
Pascal Kosak's avatar
Pascal Kosak committed
        if (!entry.private) {
            await pubSub.publish('newEntry', {
                newEntry: {
                    ...entry,
                    locked: isPrivate,
                },
            });
Pascal Kosak's avatar
Pascal Kosak committed
        } else {
            await pubSub.publish('newEntry', {
                newEntry: {
                    id: entry.id,
                    agentId: entry.agentId,
                    private: true,
                    createdAt: entry.createdAt,
                    locked: true,
                },
            });
        }
Pascal Kosak's avatar
Pascal Kosak committed
    }

    async register(msg: Message, match: RegExpMatchArray) {
        if (!match[1] && !match[2]) return;
Pascal Kosak's avatar
Pascal Kosak committed

        if (await this.isRegistered(msg.from.id))
Adrian Paschkowski's avatar
Adrian Paschkowski committed
            return await this.sendError(msg, 'Already registered');
Pascal Kosak's avatar
Pascal Kosak committed

        const existsCheck = await this.prismaService.agent.count({
            where: { password: match[1], uid: null },
Adrian Paschkowski's avatar
Adrian Paschkowski committed
            return await this.sendError(
                msg,
                'agentId not found or already taken',
            );

        const agent = await this.prismaService.agent.update({
                password: match[1],
Pascal Kosak's avatar
Pascal Kosak committed
            data: {
                uid: String(msg.from.id),
                name: match[2],
Pascal Kosak's avatar
Pascal Kosak committed
                slug: this.generateSlug(),
                tokenCode: {
                    create: { value: 1 },
                },
Pascal Kosak's avatar
Pascal Kosak committed
            },
        });

Adrian Paschkowski's avatar
Adrian Paschkowski committed
        await this.sendSuccess(
            msg,
            `Successfully registered\\!\n` +
Adrian Paschkowski's avatar
Adrian Paschkowski committed
                `\\- Id: \`${agent.id}\`\n` +
                `\\- Name: \`${agent.name}\`\n` +
                `\\- Code: \`${agent.slug}\``,
Pascal Kosak's avatar
Pascal Kosak committed
    async unregister(msg: Message) {
Pascal Kosak's avatar
Pascal Kosak committed
        if (!(await this.isRegistered(msg.from.id)))
Pascal Kosak's avatar
Pascal Kosak committed
            return await this.sendError(msg, 'Not registered');
Pascal Kosak's avatar
Pascal Kosak committed

        const agent = await this.prismaService.agent.findFirst({
            where: { uid: String(msg.from.id) },
        });

        await this.prismaService.entry.deleteMany({
            where: { agentId: agent.id },
        });

        await this.prismaService.entry.deleteMany({
Pascal Kosak's avatar
Pascal Kosak committed
            where: { agentId: agent.id },
        });

        await this.prismaService.tokenCode.delete({
            where: { id: agent.tokenCodeId },
        });

Pascal Kosak's avatar
Pascal Kosak committed
        await this.prismaService.agent.delete({
            where: { id: agent.id },
        });

Adrian Paschkowski's avatar
Adrian Paschkowski committed
        await this.sendSuccess(msg, 'Deleted all Entries');
Pascal Kosak's avatar
Pascal Kosak committed
    }

Pascal Kosak's avatar
Pascal Kosak committed
    async profile(msg: Message) {
Pascal Kosak's avatar
Pascal Kosak committed
        if (!(await this.isRegistered(msg.from.id)))
Pascal Kosak's avatar
Pascal Kosak committed
            return await this.sendError(msg, 'Not registered');
Pascal Kosak's avatar
Pascal Kosak committed

        const agent = await this.prismaService.agent.findFirst({
            where: { uid: String(msg.from.id) },
        });

        const publicEntries = await this.prismaService.entry.count({
            where: { agentId: agent.id, private: false },
Pascal Kosak's avatar
Pascal Kosak committed
        });

        const privateEntries = await this.prismaService.entry.count({
            where: { agentId: agent.id, private: true },
Pascal Kosak's avatar
Pascal Kosak committed
        });

        await this.telegram.sendMessage(
            msg.chat.id,
Pascal Kosak's avatar
Pascal Kosak committed
            `📂 Agent Information\n` +
Adrian Paschkowski's avatar
Adrian Paschkowski committed
                `\\- Id: \`${agent.id}\`\n` +
                `\\- Name: \`${agent.name}\`\n` +
                `\\- Code: \`${agent.slug}\`\n` +
                `\\- Public Entries: \`${publicEntries}\`\n` +
                `\\- Private Entries: \`${privateEntries}\``,
            { parse_mode: 'MarkdownV2' },
    async receiveImage(msg: Message): Promise<Partial<Entry>> {
Pascal Kosak's avatar
Pascal Kosak committed
        let file: string;
        let size = -1;

        for (const p of msg.photo) {
            if (p.width > size) {
                size = p.width;
                file = p.file_id;
            }
        }

        const id = uuid();

        const dest = fs.createWriteStream(
            path.join(process.cwd(), 'photos', `${id}.jpg`),
        );
        const pipe = this.telegram.getFileStream(file).pipe(dest);

        await new Promise((resolve) => pipe.on('finish', resolve));

        return {
            image: `${id}.jpg`,
        };
    }

    async editMessage(msg: Message) {
        this.telegram.sendMessage(
            msg.chat.id,
            '❌ Message Edits are not supported. Please send new values as new messages.',
            {
                reply_to_message_id: msg.message_id,
            },
        );
    }

Pascal Kosak's avatar
Pascal Kosak committed
    async supplyValue(msg: Message, metadata: Metadata) {
        if (metadata.type === 'text' && msg.text.startsWith('/')) return;

        if (!(await this.isRegistered(msg.from.id))) return;

Adrian Paschkowski's avatar
Adrian Paschkowski committed
        if (metadata.type === 'document')
            return await this.sendError(
                msg,
                "Unsupported DataType\\. Please send pictures as a 'Photo', not as a 'File'\\.",
            );

Pascal Kosak's avatar
Pascal Kosak committed
        const agent = await this.prismaService.agent.findFirst({
            where: { uid: String(msg.from.id) },
        });

        let entry: Partial<Entry> = {};
Pascal Kosak's avatar
Pascal Kosak committed
        if (this.messageCache.has(msg.from.id)) {
            entry = this.messageCache.get(msg.from.id);
Pascal Kosak's avatar
Pascal Kosak committed
        } else {
            await this.telegram.sendMessage(
                msg.chat.id,
                '📌 Message queue created\n' +
                    '<pre>/entry private</pre> to submit as private element\n' +
                    '<pre>/entry public</pre> to submit as public element\n' +
                    '<pre>/entry clear</pre> to reset the queue',
                { parse_mode: 'HTML' },
            );
        }
Pascal Kosak's avatar
Pascal Kosak committed

Pascal Kosak's avatar
Pascal Kosak committed

Adrian Paschkowski's avatar
Adrian Paschkowski committed
        switch (metadata.type) {
            case 'photo':
                const imageEntry = await this.receiveImage(msg);

                if (entry.image)
                    await this.telegram.sendMessage(
                        msg.chat.id,
                        '✂ Overwriting previous image',
                    );

                entry.image = imageEntry.image;

                await this.telegram.sendMessage(msg.chat.id, '➡ Queued Image');
                if (!msg.caption) {
                    // Allow captions to be handled like text messages
                    break;
                }

            case 'text':
                if (entry.content)
                    await this.telegram.sendMessage(
                        msg.chat.id,
                        '✂ Overwriting previous text',
                    );
                if (msg.entities)
                    await this.telegram.sendMessage(
                        msg.chat.id,
                        '⚠️ Note that Message Entities such as formatting are not supported and text will show up without formatting',
                    );
Adrian Paschkowski's avatar
Adrian Paschkowski committed

                entry.content = msg.text || msg.caption;

                await this.telegram.sendMessage(msg.chat.id, '➡ Queued Text');
                break;

            case 'location':
                if (entry.lat)
                    await this.telegram.sendMessage(
                        msg.chat.id,
                        '✂ Overwriting previous location',
                    );
                entry.lat = msg.location.latitude.toString();
                entry.lon = msg.location.longitude.toString();
                await this.telegram.sendMessage(
                    msg.chat.id,
Adrian Paschkowski's avatar
Adrian Paschkowski committed
                    '➡ Queued Location',
Adrian Paschkowski's avatar
Adrian Paschkowski committed
                break;
Pascal Kosak's avatar
Pascal Kosak committed

Adrian Paschkowski's avatar
Adrian Paschkowski committed
            default:
                return await this.sendError(msg, 'Unsupported DataType');
Pascal Kosak's avatar
Pascal Kosak committed
        }

        this.messageCache.set(msg.from.id, entry);
Pascal Kosak's avatar
Pascal Kosak committed
    }
Pascal Kosak's avatar
Pascal Kosak committed

    private async isRegistered(uid: string | number): Promise<boolean> {
        return (
            (await this.prismaService.agent.count({
                where: { uid: String(uid) },
            })) > 0
        );
    }
Pascal Kosak's avatar
Pascal Kosak committed

    private async sendError(msg: Message, message: string) {
Adrian Paschkowski's avatar
Adrian Paschkowski committed
        await this.telegram.sendMessage(msg.chat.id, `❌ ${message}`, {
            parse_mode: 'MarkdownV2',
        });
    }

    private async sendSuccess(msg: Message, message: string) {
        await this.telegram.sendMessage(msg.chat.id, `✅ ${message}`, {
            parse_mode: 'MarkdownV2',
        });
Pascal Kosak's avatar
Pascal Kosak committed
}