From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: X-Spam-Checker-Version: SpamAssassin 3.4.6 (2021-04-09) on gnuweeb.org X-Spam-Level: X-Spam-Status: No, score=-0.8 required=5.0 tests=ALL_TRUSTED,DKIM_SIGNED, DKIM_VALID,DKIM_VALID_AU,DKIM_VALID_EF,NO_DNS_FOR_FROM,URIBL_BLOCKED autolearn=no autolearn_force=no version=3.4.6 Received: from localhost.localdomain (unknown [101.128.125.254]) by gnuweeb.org (Postfix) with ESMTPSA id 533CF80ABD; Sat, 27 Aug 2022 03:02:58 +0000 (UTC) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=gnuweeb.org; s=default; t=1661569380; bh=wlcmffPsrGW+lu5/QpX8YW+WJTKDQNh6uF6w8IupMXo=; h=From:To:Cc:Subject:Date:In-Reply-To:References:From; b=KeYejIP1oCgLM2qeUbnU6GhRJ8JfDXqli7ieQLIpY9aSBaQmdxoTPIo8DCs8Bntk4 PMcLRpcgRq8tN8gkjLixnia80gvLXTlSltrJaGS8homQoKDJIMkf7hqirRI43TFRkG 3AF4rBh/vz82PrtA0OhOVXGsF9g5Xy4h/nSxYrhCVl/oSeDoI8rH9CO0wb5kwbEpvJ LPD9k+dqU3wnDZF/U+uBke4bmNcVHYX7/OcgL/GeNu9Ia88+rvVuybvUl6gYx2cPKu mpKyA1rjKHjPFF/hlwcuYNIVF7oqxAbRZynNlhD2yJc5LOVYMmIXkbVOy4isizk+5C KauDWNr05LmRw== From: Muhammad Rizki To: Ammar Faizi Cc: Muhammad Rizki , GNU/Weeb Mailing List , Alviro Iskandar Setiawan Subject: [PATCH v2 2/3] First release Discord bot Date: Sat, 27 Aug 2022 10:02:35 +0700 Message-Id: <20220827030236.1094-3-kiizuha@gnuweeb.org> X-Mailer: git-send-email 2.34.1.windows.1 In-Reply-To: <20220827030236.1094-1-kiizuha@gnuweeb.org> References: <20220827030236.1094-1-kiizuha@gnuweeb.org> MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit List-Id: The Discord bot for lore email message has been released. Signed-off-by: Muhammad Rizki --- daemon/discord/.env.example | 8 + daemon/discord/config.py.example | 9 + daemon/discord/execute_me.sql | 64 +++++ daemon/discord/gnuweeb/__init__.py | 6 + daemon/discord/gnuweeb/client.py | 110 ++++++++ daemon/discord/gnuweeb/filters.py | 67 +++++ daemon/discord/gnuweeb/models/__init__.py | 6 + daemon/discord/gnuweeb/models/ui/__init__.py | 6 + .../gnuweeb/models/ui/buttons/__init__.py | 6 + .../models/ui/buttons/full_message_btn.py | 21 ++ daemon/discord/gnuweeb/plugins/__init__.py | 20 ++ .../plugins/basic_commands/__init__.py | 13 + .../plugins/basic_commands/debugger.py | 20 ++ .../gnuweeb/plugins/basic_commands/sync_it.py | 20 ++ .../gnuweeb/plugins/events/__init__.py | 13 + .../gnuweeb/plugins/events/on_error.py | 17 ++ .../gnuweeb/plugins/events/on_ready.py | 24 ++ .../plugins/slash_commands/__init__.py | 15 ++ .../plugins/slash_commands/get_lore_mail.py | 47 ++++ .../plugins/slash_commands/manage_atom.py | 81 ++++++ .../slash_commands/manage_broadcast.py | 83 ++++++ daemon/discord/gnuweeb/utils.py | 52 ++++ daemon/discord/mailer/__init__.py | 7 + daemon/discord/mailer/database.py | 203 +++++++++++++++ daemon/discord/mailer/listener.py | 152 +++++++++++ daemon/discord/mailer/scraper.py | 63 +++++ daemon/discord/mailer/utils.py | 241 ++++++++++++++++++ daemon/discord/requirements.txt | 8 + daemon/discord/run.py | 48 ++++ daemon/discord/storage/.gitignore | 2 + daemon/telegram/packages/plugins/admin.py | 42 --- daemon/telegram/packages/plugins/scrape.py | 86 ------- 32 files changed, 1432 insertions(+), 128 deletions(-) create mode 100644 daemon/discord/.env.example create mode 100644 daemon/discord/config.py.example create mode 100644 daemon/discord/execute_me.sql create mode 100644 daemon/discord/gnuweeb/__init__.py create mode 100644 daemon/discord/gnuweeb/client.py create mode 100644 daemon/discord/gnuweeb/filters.py create mode 100644 daemon/discord/gnuweeb/models/__init__.py create mode 100644 daemon/discord/gnuweeb/models/ui/__init__.py create mode 100644 daemon/discord/gnuweeb/models/ui/buttons/__init__.py create mode 100644 daemon/discord/gnuweeb/models/ui/buttons/full_message_btn.py create mode 100644 daemon/discord/gnuweeb/plugins/__init__.py create mode 100644 daemon/discord/gnuweeb/plugins/basic_commands/__init__.py create mode 100644 daemon/discord/gnuweeb/plugins/basic_commands/debugger.py create mode 100644 daemon/discord/gnuweeb/plugins/basic_commands/sync_it.py create mode 100644 daemon/discord/gnuweeb/plugins/events/__init__.py create mode 100644 daemon/discord/gnuweeb/plugins/events/on_error.py create mode 100644 daemon/discord/gnuweeb/plugins/events/on_ready.py create mode 100644 daemon/discord/gnuweeb/plugins/slash_commands/__init__.py create mode 100644 daemon/discord/gnuweeb/plugins/slash_commands/get_lore_mail.py create mode 100644 daemon/discord/gnuweeb/plugins/slash_commands/manage_atom.py create mode 100644 daemon/discord/gnuweeb/plugins/slash_commands/manage_broadcast.py create mode 100644 daemon/discord/gnuweeb/utils.py create mode 100644 daemon/discord/mailer/__init__.py create mode 100644 daemon/discord/mailer/database.py create mode 100644 daemon/discord/mailer/listener.py create mode 100644 daemon/discord/mailer/scraper.py create mode 100644 daemon/discord/mailer/utils.py create mode 100644 daemon/discord/requirements.txt create mode 100644 daemon/discord/run.py create mode 100644 daemon/discord/storage/.gitignore delete mode 100644 daemon/telegram/packages/plugins/admin.py delete mode 100644 daemon/telegram/packages/plugins/scrape.py diff --git a/daemon/discord/.env.example b/daemon/discord/.env.example new file mode 100644 index 0000000..c2a9de8 --- /dev/null +++ b/daemon/discord/.env.example @@ -0,0 +1,8 @@ +# Input your Discord bot token below +DISCORD_TOKEN= + +# Input your MySQL connection below +DB_HOST= +DB_USER= +DB_PASS= +DB_NAME= diff --git a/daemon/discord/config.py.example b/daemon/discord/config.py.example new file mode 100644 index 0000000..0e2f5f5 --- /dev/null +++ b/daemon/discord/config.py.example @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +# Paste the admin role ID below +# to filter only admin who can +# access add/delete lore commands. +ADMIN_ROLE_ID = 0 diff --git a/daemon/discord/execute_me.sql b/daemon/discord/execute_me.sql new file mode 100644 index 0000000..80f2a32 --- /dev/null +++ b/daemon/discord/execute_me.sql @@ -0,0 +1,64 @@ +-- Adminer 4.7.6 MySQL dump + +SET NAMES utf8; +SET time_zone = '+00:00'; +SET foreign_key_checks = 0; +SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO'; + +SET NAMES utf8mb4; + +DROP TABLE IF EXISTS `emails`; +CREATE TABLE `emails` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `message_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `message_id` (`message_id`), + KEY `created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; + + +DROP TABLE IF EXISTS `discord_emails`; +CREATE TABLE `discord_emails` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `email_id` bigint unsigned NOT NULL, + `channel_id` bigint NOT NULL, + `dc_msg_id` bigint unsigned NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `email_id` (`email_id`), + KEY `channel_id` (`channel_id`), + KEY `dc_msg_id` (`dc_msg_id`), + KEY `created_at` (`created_at`), + CONSTRAINT `discord_emails_ibfk_2` FOREIGN KEY (`email_id`) REFERENCES `emails` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; + + +DROP TABLE IF EXISTS `atom_urls`; +CREATE TABLE `atom_urls` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `url` (`url`), + KEY `created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; + + +DROP TABLE IF EXISTS `broadcast_chats`; +CREATE TABLE `broadcast_chats` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `guild_id` BIGINT UNSIGNED NOT NULL, + `channel_id` BIGINT UNSIGNED NOT NULL, + `channel_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NOT NULL, + `channel_link` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `guild_id` (`guild_id`), + UNIQUE KEY `channel_id` (`channel_id`), + KEY `channel_name` (`channel_name`), + KEY `channel_link` (`channel_link`), + KEY `created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; + +-- 2022-07-07 14:25:28 diff --git a/daemon/discord/gnuweeb/__init__.py b/daemon/discord/gnuweeb/__init__.py new file mode 100644 index 0000000..f3d814a --- /dev/null +++ b/daemon/discord/gnuweeb/__init__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +from .client import GWClient diff --git a/daemon/discord/gnuweeb/client.py b/daemon/discord/gnuweeb/client.py new file mode 100644 index 0000000..cf88d36 --- /dev/null +++ b/daemon/discord/gnuweeb/client.py @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +import discord +from discord.ext import commands +from discord import Intents +from discord import Interaction +from typing import Union + +from .models.ui import buttons +from . import filters +from mailer import utils +from mailer import Database + + +class GWClient(commands.Bot): + def __init__(self, db_conn) -> None: + self.db = Database(db_conn) + self.mailer = None + intents = Intents.default() + intents.message_content = True + super().__init__( + command_prefix=["$", "."], + description="Just a bot for receiving lore emails.", + intents=intents, + activity=discord.Game( + name="with Columbina Damselette", + type=0 + ) + ) + + + async def setup_hook(self): + await self.load_extension("gnuweeb.plugins") + + # WARNING! NOT RECOMMENDED SYNCING WHEN THE BOT IS START!! + # guild = discord.Object(id=845302963739033611) + # self.tree.copy_global_to(guild=guild) + # await self.tree.sync(guild=guild) + + + @filters.wait_on_limit + async def send_text_email(self, guild_id: int, chat_id: int, text: str, + reply_to: Union[int, None] = None, url: str = None): + print("[send_text_email]") + text = utils.bottom_border(text) + channel = self.get_channel(chat_id) + + m = await channel.send( + content=text, + reference=discord.MessageReference( + guild_id=guild_id, + channel_id=chat_id, + message_id=reply_to + ) if reply_to else None, + + view=buttons.FullMessageBtn(url) + ) + return m + + + @filters.wait_on_limit + async def send_patch_email(self, mail, guild_id: int, chat_id: int, text: str, + reply_to: Union[int, None] = None, url: str = None): + print("[send_patch_email]") + tmp, doc, caption, url = utils.prepare_patch(mail, text, url) + channel = self.get_channel(chat_id) + + m = await channel.send( + content=caption, + file=discord.File(doc), + reference=discord.MessageReference( + guild_id=guild_id, + channel_id=chat_id, + message_id=reply_to + ) if reply_to else None, + + view=buttons.FullMessageBtn(url) + ) + + utils.remove_patch(tmp) + return m + + + async def send_text_mail_interaction(self, i: "Interaction", + text: str, url: str = None): + text = utils.border_and_trim(text) + + m = await i.response.send_message( + content=text, + view=buttons.FullMessageBtn(url) + ) + + return m + + + async def send_patch_mail_interaction(self, mail, i: "Interaction", + text: str, url: str = None): + tmp, doc, caption, url = utils.prepare_patch(mail, text, url) + + m = await i.response.send_message( + content=caption, + file=discord.File(doc), + view=buttons.FullMessageBtn(url) + ) + + utils.remove_patch(tmp) + return m diff --git a/daemon/discord/gnuweeb/filters.py b/daemon/discord/gnuweeb/filters.py new file mode 100644 index 0000000..735c77e --- /dev/null +++ b/daemon/discord/gnuweeb/filters.py @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +import config +import discord +import asyncio +from discord import Interaction +from discord.ext import commands +from typing import Any, Callable, TypeVar +from functools import wraps + + +T = TypeVar("T") + +def channel(channel_id: int, admin_role_only: bool) -> Callable[[T], T]: + def _(func): + async def callback(*args: Any, **kwargs: Any) -> Any: + ctx: "commands.Context" = args[1] + + if not channel_id == ctx.channel.id: return + if not admin_role_only: return + + user_roles = [role.id for role in ctx.author.roles] + if config.ADMIN_ROLE_ID in user_roles: + return await func(*args, **kwargs, + content=ctx.message.content) + + return callback + return _ + + +def lore_admin(func: Callable[[T], T]) -> Callable[[T], T]: + @wraps(func) + async def callback(*args: Any, **kwargs: Any) -> Any: + i: "Interaction" = args[1] + user_roles = [role.id for role in i.user.roles] + + if config.ADMIN_ROLE_ID not in user_roles: + return await i.response.send_message( + "Sorry, you don't have this permission\n"\ + "Tell the server admin to add you lore admin role.", + ephemeral=True + ) + if config.ADMIN_ROLE_ID in user_roles: + return await func(*args, **kwargs) + + return callback + + +def wait_on_limit(func: Callable[[T], T]) -> Callable[[T], T]: + @wraps(func) + async def callback(*args: Any) -> Any: + while True: + try: + return await func(*args) + except discord.errors.RateLimited as e: + _flood_exceptions(e) + print("[wait_on_limit]: Woken up from flood wait...") + return callback + + +async def _flood_exceptions(e: "discord.errors.RateLimited"): + wait = e.retry_after + print(f"[wait_on_limit]: Sleeping for {wait} seconds due to Telegram limit") + await asyncio.sleep(wait) diff --git a/daemon/discord/gnuweeb/models/__init__.py b/daemon/discord/gnuweeb/models/__init__.py new file mode 100644 index 0000000..7199f33 --- /dev/null +++ b/daemon/discord/gnuweeb/models/__init__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +from .ui import * diff --git a/daemon/discord/gnuweeb/models/ui/__init__.py b/daemon/discord/gnuweeb/models/ui/__init__.py new file mode 100644 index 0000000..9625317 --- /dev/null +++ b/daemon/discord/gnuweeb/models/ui/__init__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +from .buttons import FullMessageBtn diff --git a/daemon/discord/gnuweeb/models/ui/buttons/__init__.py b/daemon/discord/gnuweeb/models/ui/buttons/__init__.py new file mode 100644 index 0000000..c1d2a56 --- /dev/null +++ b/daemon/discord/gnuweeb/models/ui/buttons/__init__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +from .full_message_btn import FullMessageBtn diff --git a/daemon/discord/gnuweeb/models/ui/buttons/full_message_btn.py b/daemon/discord/gnuweeb/models/ui/buttons/full_message_btn.py new file mode 100644 index 0000000..310d761 --- /dev/null +++ b/daemon/discord/gnuweeb/models/ui/buttons/full_message_btn.py @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +from discord import ui +from discord import ButtonStyle + + +class FullMessageBtn(ui.View): + def __init__(self, url: str): + super().__init__() + self.__add_button( + label='See the full message', + style=ButtonStyle.gray, + url=url + ) + + + def __add_button(self, **kwargs): + self.add_item(ui.Button(**kwargs)) diff --git a/daemon/discord/gnuweeb/plugins/__init__.py b/daemon/discord/gnuweeb/plugins/__init__.py new file mode 100644 index 0000000..2902e99 --- /dev/null +++ b/daemon/discord/gnuweeb/plugins/__init__.py @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +from discord.ext import commands +from .basic_commands import BasicCommands +from .events import Events +from .slash_commands import SlashCommands + + +class Plugins( + BasicCommands, + Events, + SlashCommands +): pass + + +async def setup(bot: "commands.Bot"): + await bot.add_cog(Plugins(bot)) diff --git a/daemon/discord/gnuweeb/plugins/basic_commands/__init__.py b/daemon/discord/gnuweeb/plugins/basic_commands/__init__.py new file mode 100644 index 0000000..06a735b --- /dev/null +++ b/daemon/discord/gnuweeb/plugins/basic_commands/__init__.py @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +from .debugger import DebuggerCommand +from .sync_it import SyncCommand + + +class BasicCommands( + DebuggerCommand, + SyncCommand +): pass diff --git a/daemon/discord/gnuweeb/plugins/basic_commands/debugger.py b/daemon/discord/gnuweeb/plugins/basic_commands/debugger.py new file mode 100644 index 0000000..be5b9f1 --- /dev/null +++ b/daemon/discord/gnuweeb/plugins/basic_commands/debugger.py @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +from discord.ext import commands +from gnuweeb import utils +# from gnuweeb import filters + + +class DebuggerCommand(commands.Cog): + def __init__(self, bot) -> None: + self.bot = bot + + + @commands.command("exec", aliases=["exc", "d"]) + @commands.is_owner() # only the bot owner who have this access + # @filters.channel(channel_id=865963658726211604, admin_role_only=True) + async def exec(self, ctx: "commands.Context", *, content: str): + await utils.execute_python_code(ctx, content) diff --git a/daemon/discord/gnuweeb/plugins/basic_commands/sync_it.py b/daemon/discord/gnuweeb/plugins/basic_commands/sync_it.py new file mode 100644 index 0000000..2ed35cc --- /dev/null +++ b/daemon/discord/gnuweeb/plugins/basic_commands/sync_it.py @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +from discord.ext import commands + + +class SyncCommand(commands.Cog): + def __init__(self, bot) -> None: + self.bot = bot + + + @commands.command("sync", aliases=["s"]) + @commands.is_owner() + async def sync_it(self, ctx: "commands.Context"): + ctx.bot.tree.copy_global_to(guild=ctx.guild) + s = await ctx.bot.tree.sync(guild=ctx.guild) + + await ctx.send(f"Synced {len(s)} commands.") diff --git a/daemon/discord/gnuweeb/plugins/events/__init__.py b/daemon/discord/gnuweeb/plugins/events/__init__.py new file mode 100644 index 0000000..7074e4d --- /dev/null +++ b/daemon/discord/gnuweeb/plugins/events/__init__.py @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +from .on_ready import OnReady +from .on_error import OnError + + +class Events( + OnReady, + OnError +): pass diff --git a/daemon/discord/gnuweeb/plugins/events/on_error.py b/daemon/discord/gnuweeb/plugins/events/on_error.py new file mode 100644 index 0000000..e1e4e28 --- /dev/null +++ b/daemon/discord/gnuweeb/plugins/events/on_error.py @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +from discord.ext import commands + + +class OnError(commands.Cog): + def __init__(self, bot: "commands.Bot") -> None: + self.bot = bot + + + @commands.Cog.listener() + async def on_command_error(self, _, err): + if isinstance(err, commands.CommandNotFound): + pass diff --git a/daemon/discord/gnuweeb/plugins/events/on_ready.py b/daemon/discord/gnuweeb/plugins/events/on_ready.py new file mode 100644 index 0000000..1554050 --- /dev/null +++ b/daemon/discord/gnuweeb/plugins/events/on_ready.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +from discord.ext import commands + + +class OnReady(commands.Cog): + def __init__(self, bot: "commands.Bot") -> None: + self.bot = bot + + + @commands.Cog.listener() + async def on_ready(self): + t = "[ GNU/Weeb Bot is connected ]\n\n" + t += f"ID : {self.bot.user.id}\n" + t += f"Name : {self.bot.user.display_name}\n" + t += f"Tags : {self.bot.user}\n\n" + t += "Ready to get the latest of lore kernel emails." + # starting to listen new emails when the bot + # is ready to sending the messages. + self.bot.mailer.run() + print(t) diff --git a/daemon/discord/gnuweeb/plugins/slash_commands/__init__.py b/daemon/discord/gnuweeb/plugins/slash_commands/__init__.py new file mode 100644 index 0000000..6e5929a --- /dev/null +++ b/daemon/discord/gnuweeb/plugins/slash_commands/__init__.py @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +from .manage_atom import ManageAtomSC +from .manage_broadcast import ManageBroadcastSC +from .get_lore_mail import GetLoreSC + + +class SlashCommands( + ManageAtomSC, + ManageBroadcastSC, + GetLoreSC +): pass diff --git a/daemon/discord/gnuweeb/plugins/slash_commands/get_lore_mail.py b/daemon/discord/gnuweeb/plugins/slash_commands/get_lore_mail.py new file mode 100644 index 0000000..a2671f4 --- /dev/null +++ b/daemon/discord/gnuweeb/plugins/slash_commands/get_lore_mail.py @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +import shutil +import asyncio +import discord +from discord.ext import commands +from discord import Interaction +from discord import app_commands + +from mailer import utils +from mailer import Scraper + + +class GetLoreSC(commands.Cog): + def __init__(self, bot) -> None: + self.bot = bot + + + @app_commands.command( + name="lore", + description="Get lore email from raw email URL." + ) + @app_commands.describe(url="Raw lore email URL") + async def get_lore(self, i: "Interaction", url: str): + s = Scraper() + mail = await s.get_email_from_url(url) + text, files, is_patch = utils.create_template(mail) + + if is_patch: + m = await self.bot.send_patch_mail_interaction( + mail=mail, i=i, text=text, url=url + ) + else: + text = "#ml\n" + text + m = await self.bot.send_text_mail_interaction( + i=i, text=text, url=url + ) + + for d, f in files: + await m.reply(f"{d}/{f}", file=discord.File(f)) + await asyncio.sleep(1) + + if files: + shutil.rmtree(str(files[0][0])) diff --git a/daemon/discord/gnuweeb/plugins/slash_commands/manage_atom.py b/daemon/discord/gnuweeb/plugins/slash_commands/manage_atom.py new file mode 100644 index 0000000..2d9da80 --- /dev/null +++ b/daemon/discord/gnuweeb/plugins/slash_commands/manage_atom.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +from discord.ext import commands +from discord import Interaction +from discord import app_commands + +from gnuweeb import filters +from mailer import utils + + +class ManageAtomSC(commands.Cog): + atom = app_commands.Group( + name="atom", + description="Manage lore atom URL." + ) + + def __init__(self, bot) -> None: + self.bot = bot + + + @atom.command( + name="list", + description="List of lore atom URL." + ) + @filters.lore_admin + async def list_atom(self, i: "Interaction"): + atoms = self.bot.db.get_atom_urls() + if len(atoms) == 0: + t = "Currently empty." + await i.response.send_message(t, ephemeral=True) + return + + text = "List of atom URL that currently listened:\n" + for u,n in zip(atoms, range(1, len(atoms)+1)): + text += f"{n}. {u}\n" + + await i.response.send_message(text, ephemeral=True) + + + @atom.command( + name="add", + description="Add lore atom URL for receiving lore emails." + ) + @app_commands.describe(url='Lore atom URL.') + @filters.lore_admin + async def add_atom(self, i: "Interaction", url: str): + is_atom = await utils.is_atom_url(url) + if not is_atom: + t = "Invalid Atom URL." + await i.response.send_message(t, ephemeral=True) + return + + inserted = self.bot.db.insert_atom(url) + if inserted is None: + t = f"This URL already listened for new email." + await i.response.send_message(t, ephemeral=True) + return + + t = f"Success add **{url}** for listening new email." + await i.response.send_message(t, ephemeral=True) + + + @atom.command( + name="delete", + description="Delete lore atom URL from receiving lore emails." + ) + @app_commands.describe(url='Lore atom URL.') + @filters.lore_admin + async def del_atom(self, i: "Interaction", url: str): + success = self.bot.db.delete_atom(url) + if not success: + t = "Failed to delete atom URL\n" + t += "Maybe because already deleted or not exists." + await i.response.send_message(t, ephemeral=True) + return + + t = f"Success delete **{url}** from receiving lore emails." + await i.response.send_message(t, ephemeral=True) diff --git a/daemon/discord/gnuweeb/plugins/slash_commands/manage_broadcast.py b/daemon/discord/gnuweeb/plugins/slash_commands/manage_broadcast.py new file mode 100644 index 0000000..9eb6b98 --- /dev/null +++ b/daemon/discord/gnuweeb/plugins/slash_commands/manage_broadcast.py @@ -0,0 +1,83 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +from discord.ext import commands +from discord import Interaction +from discord import app_commands + +from gnuweeb import utils +from gnuweeb import filters + + +class ManageBroadcastSC(commands.Cog): + broadcast = app_commands.Group( + name="broadcast", + description="Manage broadcast channel." + ) + + def __init__(self, bot) -> None: + self.bot = bot + + + @broadcast.command( + name="list", + description="List of broadcast channel." + ) + @filters.lore_admin + async def list_channel(self, i: "Interaction"): + chats = self.bot.db.get_broadcast_chats() + if len(chats) == 0: + t = "Currently empty." + await i.response.send_message(t, ephemeral=True) + return + + text = "List of channels that will send email messages:\n" + for u,n in zip(chats, range(1, len(chats)+1)): + text += f"{n}. **{u[3]}**\n" + text += f"Link: {u[4]}\n\n" + + await i.response.send_message(text, ephemeral=True) + + + @broadcast.command( + name="add", + description="Add broadcast channel for sending lore emails." + ) + @filters.lore_admin + async def add_channel(self, i: "Interaction"): + inserted = self.bot.db.insert_broadcast( + guild_id=i.guild_id, + channel_id=i.channel_id, + channel_name=i.channel.name, + channel_link=utils.channel_link( + guild_id=i.guild_id, + channel_id=i.channel_id + ) + ) + + if inserted is None: + t = f"This channel already added for send email messages." + await i.response.send_message(t, ephemeral=True) + return + + t = f"Success add this channel for send email messages." + await i.response.send_message(t, ephemeral=True) + + + @broadcast.command( + name="delete", + description="Delete broadcast channel from sending email messages." + ) + @filters.lore_admin + async def del_channel(self, i: "Interaction"): + success = self.bot.db.delete_broadcast(i.channel_id) + if not success: + t = "Failed to delete this channel\n" + t += "Maybe because already deleted or not exists." + await i.response.send_message(t, ephemeral=True) + return + + t = f"Success delete this channel from sending email messages." + await i.response.send_message(t, ephemeral=True) diff --git a/daemon/discord/gnuweeb/utils.py b/daemon/discord/gnuweeb/utils.py new file mode 100644 index 0000000..d60e932 --- /dev/null +++ b/daemon/discord/gnuweeb/utils.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +import re +import io +import import_expression +import contextlib +import traceback +from discord.ext import commands +from textwrap import indent + + +def channel_link(guild_id: int, channel_id: int): + return f"https://discord.com/channels/{guild_id}/{channel_id}" + + +async def execute_python_code(ctx: "commands.Context", content: str): + reg = re.compile(r"`{3}py\n([\w\W]*?)`{3}", flags=re.DOTALL) + code = reg.search(content).group(1) + + env = {"ctx": ctx} + env.update(globals()) + + stdout = io.StringIO() + to_compile = f'async def func(ctx):\n{indent(code, " ")}' + + try: + import_expression.exec(to_compile, env) + except Exception as e: + o = f"```py\n{e.__class__.__name__}: {e}"[0:1904]+"\n```" + return await ctx.send(o) + + func = env["func"] + + try: + with contextlib.redirect_stdout(stdout): + ret = await func(ctx) + except Exception: + value = stdout.getvalue() + o = f"```{value}{traceback.format_exc()}"[0:1904]+"```" + return await ctx.send(o) + else: + value = stdout.getvalue() + if ret is None: + if value: + o = f"```py\n{value}"[0:1904]+"\n```" + return await ctx.send(o) + else: + o = f"```py\n{value}{ret}"[0:1904]+"\n```" + return await ctx.send(o) diff --git a/daemon/discord/mailer/__init__.py b/daemon/discord/mailer/__init__.py new file mode 100644 index 0000000..0da4329 --- /dev/null +++ b/daemon/discord/mailer/__init__.py @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +from .database import Database +from .scraper import Scraper diff --git a/daemon/discord/mailer/database.py b/daemon/discord/mailer/database.py new file mode 100644 index 0000000..0b91558 --- /dev/null +++ b/daemon/discord/mailer/database.py @@ -0,0 +1,203 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# Copyright (C) 2022 Ammar Faizi +# + +from datetime import datetime +import mysql + + +class Database: + def __init__(self, conn): + self.conn = conn + self.conn.autocommit = True + self.cur = self.conn.cursor(buffered=True) + + + def __del__(self): + self.cur.close() + self.conn.close() + + + def save_email_msg_id(self, email_msg_id): + try: + return self.__save_email_msg_id(email_msg_id) + except mysql.connector.errors.IntegrityError: + # + # Duplicate data, skip! + # + return None + + + def __save_email_msg_id(self, email_msg_id): + q = "INSERT INTO emails (message_id, created_at) VALUES (%s, %s)" + self.cur.execute(q, (email_msg_id, datetime.utcnow())) + return self.cur.lastrowid + + + def insert_discord(self, email_id, channel_id, dc_msg_id): + q = """ + INSERT INTO discord_emails + (email_id, channel_id, dc_msg_id, created_at) + VALUES (%s, %s, %s, %s); + """ + self.cur.execute(q, (email_id, channel_id, dc_msg_id, + datetime.utcnow())) + return self.cur.lastrowid + + + # + # Determine whether the email needs to be sent to @dc_chat_id. + # + # - Return an email id (PK) if it needs to be sent. + # - Return None if it doesn't need to be sent. + # + def get_email_id_sent(self, email_msg_id, channel_id): + q = """ + SELECT emails.id, discord_emails.id FROM emails + LEFT JOIN discord_emails + ON emails.id = discord_emails.email_id + WHERE emails.message_id = %(email_msg_id)s + AND discord_emails.channel_id = %(channel_id)s + LIMIT 1 + """ + + self.cur.execute( + q, + { + "email_msg_id": email_msg_id, + "channel_id": channel_id + } + ) + res = self.cur.fetchone() + if bool(res): + # + # This email has already been sent to + # @dc_chat_id. + # + return None + + q = """ + SELECT id FROM emails WHERE message_id = %(email_msg_id)s + """ + self.cur.execute(q, {"email_msg_id": email_msg_id}) + res = self.cur.fetchone() + if not bool(res): + # + # Something goes wrong, skip! + # + return None + + return int(res[0]) + + + def get_discord_reply(self, email_msg_id, channel_id): + q = """ + SELECT discord_emails.dc_msg_id + FROM emails INNER JOIN discord_emails + ON emails.id = discord_emails.email_id + WHERE emails.message_id = %(email_msg_id)s + AND discord_emails.channel_id = %(channel_id)s + """ + + self.cur.execute( + q, + { + "email_msg_id": email_msg_id, + "channel_id": channel_id + } + ) + res = self.cur.fetchone() + if not bool(res): + return None + + return res[0] + + + def insert_atom(self, atom: str): + try: + return self.__save_atom(atom) + except mysql.connector.errors.IntegrityError: + # + # Duplicate data, skip! + # + return None + + + def __save_atom(self, atom: str): + q = "INSERT INTO atom_urls (url, created_at) VALUES (%s, %s)" + self.cur.execute(q, (atom, datetime.utcnow())) + return self.cur.lastrowid + + + def delete_atom(self, atom: str): + q = """ + DELETE FROM atom_urls + WHERE url = %(atom)s + """ + self.cur.execute(q, {"atom": atom}) + return self.cur.rowcount > 0 + + + def get_atom_urls(self): + q = """ + SELECT atom_urls.url + FROM atom_urls + """ + self.cur.execute(q) + urls = self.cur.fetchall() + + return [u[0] for u in urls] + + + def insert_broadcast( + self, + guild_id: int, + channel_id: int, + channel_name: str, + channel_link: str = None, + ): + try: + return self.__save_broadcast( + guild_id=guild_id, + channel_id=channel_id, + channel_name=channel_name, + channel_link=channel_link + ) + except mysql.connector.errors.IntegrityError: + # + # Duplicate data, skip! + # + return None + + + def __save_broadcast( + self, + guild_id: int, + channel_id: int, + channel_name: str, + channel_link: str = None, + ): + q = """ + INSERT INTO broadcast_chats + (guild_id, channel_id, channel_name, channel_link, created_at) + VALUES (%s, %s, %s, %s, %s) + """ + values = (guild_id, channel_id, channel_name, channel_link, datetime.utcnow()) + self.cur.execute(q, values) + return self.cur.lastrowid + + + def delete_broadcast(self, channel_id: int): + q = """ + DELETE FROM broadcast_chats + WHERE channel_id = %(channel_id)s + """ + self.cur.execute(q, {"channel_id": channel_id}) + return self.cur.rowcount > 0 + + + def get_broadcast_chats(self): + self.cur.execute("SELECT * FROM broadcast_chats") + return self.cur.fetchall() diff --git a/daemon/discord/mailer/listener.py b/daemon/discord/mailer/listener.py new file mode 100644 index 0000000..523a87d --- /dev/null +++ b/daemon/discord/mailer/listener.py @@ -0,0 +1,152 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# Copyright (C) 2022 Ammar Faizi +# + +import asyncio +import traceback +import shutil +import re +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from discord import File + +from gnuweeb import GWClient +from .scraper import Scraper +from . import utils + + +class BotMutexes(): + def __init__(self): + self.lock = asyncio.Lock() + + +class Listener(): + def __init__( + self, + client: "GWClient", + sched: "AsyncIOScheduler", + scraper: Scraper, + mutexes: "BotMutexes" + ): + self.client = client + self.sched = sched + self.scraper = scraper + self.mutexes = mutexes + self.db = client.db + self.isRunnerFixed = False + self.runner = None + + + def run(self): + # + # Execute __run() once to avoid high latency at + # initilization. + # + self.sched.start() + self.runner = self.sched.add_job(func=self.__run) + + + async def __run(self): + print("[__run]: Running...") + for url in self.db.get_atom_urls(): + try: + await self.__handle_atom_url(url) + except: + print(traceback.format_exc()) + + if not self.isRunnerFixed: + self.isRunnerFixed = True + self.runner = self.sched.add_job( + func=self.__run, + trigger="interval", + seconds=30, + misfire_grace_time=None, + max_instances=1 + ) + + + async def __handle_atom_url(self, url): + urls = await self.scraper.get_new_threads_urls(url) + for url in urls: + mail = await self.scraper.get_email_from_url(url) + await self.__handle_mail(url, mail) + + + async def __handle_mail(self, url, mail): + chats = self.db.get_broadcast_chats() + for chat in chats: + async with self.mutexes.lock: + should_wait = \ + await self.__send_to_discord(url, mail, + chat[1], chat[2]) + + if should_wait: + await asyncio.sleep(1) + + + # @__must_hold(self.mutexes.lock) + async def __send_to_discord(self, url, mail, dc_guild_id, dc_chat_id): + email_msg_id = utils.get_email_msg_id(mail) + if not email_msg_id: + # + # It doesn't have a Message-Id. + # A malformed email. Skip! + # + return False + + email_id = self.__get_email_id_sent( + email_msg_id=email_msg_id, + dc_chat_id=dc_chat_id + ) + if not email_id: + # + # Email has already been sent to Discord. + # Skip! + # + return False + + text, files, is_patch = utils.create_template(mail) + reply_to = self.get_discord_reply(mail, dc_chat_id) + url = str(re.sub(r"/raw$", "", url)) + + if is_patch: + m = await self.client.send_patch_email( + mail, dc_guild_id, dc_chat_id, text, reply_to, url + ) + else: + text = "#ml\n" + text + m = await self.client.send_text_email( + dc_guild_id, dc_chat_id, text, reply_to, url + ) + + self.db.insert_discord(email_id, m.channel.id, m.id) + for d, f in files: + await m.reply(f"{d}/{f}", file=File(f)) + await asyncio.sleep(1) + + if files: + shutil.rmtree(str(files[0][0])) + + return True + + + def __get_email_id_sent(self, email_msg_id, dc_chat_id): + email_id = self.db.save_email_msg_id(email_msg_id) + if email_id: + return email_id + + email_id = self.db.get_email_id_sent(email_msg_id, dc_chat_id) + return email_id + + + def get_discord_reply(self, mail, dc_chat_id): + reply_to = mail.get("in-reply-to") + if not reply_to: + return None + + reply_to = utils.extract_email_msg_id(reply_to) + if not reply_to: + return None + + return self.db.get_discord_reply(reply_to, dc_chat_id) diff --git a/daemon/discord/mailer/scraper.py b/daemon/discord/mailer/scraper.py new file mode 100644 index 0000000..3873161 --- /dev/null +++ b/daemon/discord/mailer/scraper.py @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# Copyright (C) 2022 Ammar Faizi +# + +from typing import Dict, List +import email.policy +import xmltodict +import httpx +import email + + +class Scraper: + async def get_new_threads_urls(self, atom_url): + ret = await self.__get_atom_content(atom_url) + return await self.__get_new_threads_from_atom(ret) + + + async def __get_atom_content(self, atom_url): + async with httpx.AsyncClient() as client: + res = await client.get(atom_url) + if res.status_code == 200: + return res.text + raise Exception(f"[get_atom_content]: Returned {res.status_code} HTTP code") + + + async def __get_new_threads_from_atom(self, atom): + j: Dict[str, List[ + Dict[str, str] + ]] = xmltodict.parse(atom)["feed"] + + entry = [] + e = j["entry"] + for i in e: + entry.append({ + "link": i["link"]["@href"], + "title": i["title"], + "updated": i["updated"], + }) + # + # TODO(ammarfaizi2): Sort by title as well if the @updated is + # identic. + # + entry.sort(key=lambda x: x["updated"]) + + ret = [] + for i in entry: + link = i["link"].replace("http://", "https://") + ret.append(link + "raw") + + return ret + + + async def get_email_from_url(self, url): + async with httpx.AsyncClient() as client: + res = await client.get(url) + if res.status_code == 200: + return email.message_from_string( + res.text, + policy=email.policy.default + ) + raise Exception(f"[get_atom_content]: Returned {res.status_code} HTTP code") diff --git a/daemon/discord/mailer/utils.py b/daemon/discord/mailer/utils.py new file mode 100644 index 0000000..0036f24 --- /dev/null +++ b/daemon/discord/mailer/utils.py @@ -0,0 +1,241 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# Copyright (C) 2022 Ammar Faizi +# + +from email.message import Message +from typing import Dict +from slugify import slugify +import hashlib +import uuid +import os +import re +import shutil +import httpx + + +def get_email_msg_id(mail): + ret = mail.get("message-id") + if not ret: + return None + + ret = re.search(r"<([^\<\>]+)>", ret) + if not ret: + return None + + return ret.group(1) + + +# +# This increments the @i while we are seeing a whitespace. +# +def __skip_whitespace(i, ss_len, ss): + while i < ss_len: + c = ss[i] + if c != ' ' and c != '\t' and c != '\n': + break + i += 1 + + return i + + +# +# Pick a single element in the list. The delimiter here is +# a comma char ','. But note that when are inside a double +# quotes, we must not take the comma as a delimiter. +# +def __pick_element(i, ss_len, ss, ret): + acc = "" + in_quotes = False + + while i < ss_len: + c = ss[i] + i += 1 + + if c == '"': + in_quotes = (not in_quotes) + + if not in_quotes and c == ',': + break + + acc += c + + if acc != "": + ret.append(acc) + + return i + + +def __extract_list(ss): + ss = ss.strip() + ss_len = len(ss) + ret = [] + i = 0 + + while i < ss_len: + i = __skip_whitespace(i, ss_len, ss) + i = __pick_element(i, ss_len, ss, ret) + + return ret + + +def extract_list(key: str, content: Dict[str, str]): + people = content.get(key.lower()) + if not people: + return [] + return __extract_list(people) + + +def consruct_to_n_cc(to: list, cc: list): + NR_MAX_LIST = 20 + + n = 0 + ret = "" + for i in to: + if n >= NR_MAX_LIST: + ret += "To: ...\n" + break + + n += 1 + ret += f"To: {i}\n" + + for i in cc: + if n >= NR_MAX_LIST: + ret += "Cc: ...\n" + break + + n += 1 + ret += f"Cc: {i}\n" + + return ret + + +def gen_temp(name: str): + md5 = hashlib.md5(name.encode()).hexdigest() + ret = os.getenv("STORAGE_DIR", "storage") + "/" + md5 + try: + os.mkdir(ret) + except FileExistsError: + pass + + return ret + + +def extract_body(thread: Message): + if not thread.is_multipart(): + p = thread.get_payload(decode=True) + return f"{p.decode(errors='replace')}\n".lstrip(), [] + + ret = "" + files = [] + temp = gen_temp(str(uuid.uuid4())) + for p in thread.get_payload(): + fname = p.get_filename() + payload = p.get_payload(decode=True) + + if not payload: + continue + + if 'inline' in [p.get('content-disposition')] or not bool(fname): + ret += f"{payload.decode(errors='replace')}\n".lstrip() + continue + + with open(f"{temp}/{fname}", "wb") as f: + f.write(payload) + files.append((temp, fname)) + + ret = re.sub("^(>)", ">>> \\1", ret, 1, re.MULTILINE) + return ret, files + + + +PATCH_PATTERN = r"^\[.*(?:patch|rfc).*?(?:(\d+)\/(\d+))?\](.+)" +def __is_patch(subject, content): + x = re.search(PATCH_PATTERN, subject, re.IGNORECASE) + if not x or x.group(1) == "0": + return False + + x = re.search(r"diff --git", content) + if not x: + return False + + return True + + +def create_template(thread: Message, to=None, cc=None): + if not to: + to = extract_list("to", thread) + if not cc: + cc = extract_list("cc", thread) + + subject = thread.get('subject') + ret = f"From: {thread.get('from')}\n" + ret += consruct_to_n_cc(to, cc) + ret += f"Date: {thread.get('date')}\n" + ret += f"Subject: {subject}\n\n" + content, files = extract_body(thread) + is_patch = __is_patch(subject, content) + + if is_patch: + ret += content + else: + ret += content.strip().replace("\t", " ") + if len(ret) >= 1900: + ret = ret[:1900] + "..." + + ret = fix_utf8_char(ret) + + return ret, files, is_patch + + +def prepare_patch(mail, text, url): + tmp = gen_temp(url) + fnm = str(mail.get("subject")) + sch = re.search(PATCH_PATTERN, fnm, re.IGNORECASE) + + nr_patch = sch.group(1) + if not nr_patch: + nr_patch = 1 + else: + nr_patch = int(nr_patch) + + num = "%04d" % nr_patch + fnm = slugify(sch.group(3)).replace("_", "-") + file = f"{tmp}/{num}-{fnm}.patch" + + with open(file, "wb") as f: + f.write(bytes(text, encoding="utf8")) + + caption = "#patch #ml" + return tmp, file, caption, url + + +def remove_patch(tmp): + shutil.rmtree(tmp) + + +def fix_utf8_char(text: str): + return text.rstrip().replace("�"," ") + + +def bottom_border(text: str): + return text + "\n" + "-"*72 + + +EMAIL_MSG_ID_PATTERN = r"<([^\<\>]+)>" +def extract_email_msg_id(msg_id): + ret = re.search(EMAIL_MSG_ID_PATTERN, msg_id) + if not ret: + return None + return ret.group(1) + + +async def is_atom_url(text: str): + try: + async with httpx.AsyncClient() as ses: + res = await ses.get(text) + mime = res.headers.get("Content-Type") + + return mime == "application/atom+xml" + except: return False diff --git a/daemon/discord/requirements.txt b/daemon/discord/requirements.txt new file mode 100644 index 0000000..adc5a91 --- /dev/null +++ b/daemon/discord/requirements.txt @@ -0,0 +1,8 @@ +discord.py +apscheduler +python-slugify +httpx +import-expression +mysql-connector-python +python-dotenv +xmltodict diff --git a/daemon/discord/run.py b/daemon/discord/run.py new file mode 100644 index 0000000..e3eb7a6 --- /dev/null +++ b/daemon/discord/run.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2022 Muhammad Rizki +# + +import os +from mysql import connector +from dotenv import load_dotenv +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from mailer.listener import BotMutexes +from mailer.listener import Listener + +from gnuweeb import GWClient +from mailer import Scraper + + +def main(): + load_dotenv() + + sched = AsyncIOScheduler( + job_defaults={ + "max_instances": 3, + "misfire_grace_time": None + } + ) + + client = GWClient( + db_conn=connector.connect( + host=os.getenv("DB_HOST"), + user=os.getenv("DB_USER"), + password=os.getenv("DB_PASS"), + database=os.getenv("DB_NAME") + ) + ) + + mailer = Listener( + client=client, + sched=sched, + scraper=Scraper(), + mutexes=BotMutexes() + ) + client.mailer = mailer + + client.run(os.getenv("DISCORD_TOKEN")) + + +if __name__ == "__main__": + main() diff --git a/daemon/discord/storage/.gitignore b/daemon/discord/storage/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/daemon/discord/storage/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/daemon/telegram/packages/plugins/admin.py b/daemon/telegram/packages/plugins/admin.py deleted file mode 100644 index e0f145e..0000000 --- a/daemon/telegram/packages/plugins/admin.py +++ /dev/null @@ -1,42 +0,0 @@ -# SPDX-License-Identifier: GPL-2.0-only -# -# Copyright (C) 2022 Muhammad Rizki -# - -from pyrogram import Client, filters, enums -from pyrogram.types import Message -from textwrap import indent -import io, import_expression, contextlib, traceback - -@Client.on_message( - filters.command(['d','debug']) & - filters.user(["nekoha", "kiizuah"]) -) -async def execute_v2(c: Client, m: Message): - sep = m.text.split('\n') - body = m.text.replace(sep[0] + '\n','') - - env = {"bot": c} - env.update(globals()) - - stdout = io.StringIO() - to_compile = f'async def func(_, m):\n{indent(body, " ")}' - - try: - import_expression.exec(to_compile, env) - except Exception as e: - text = f"```{e.__class__.__name__}: {e}"[0:4096]+"```" - - func = env["func"] - - try: - with contextlib.redirect_stdout(stdout): - await func(c, m) - except Exception: - value = stdout.getvalue() - text = f"```{value}{traceback.format_exc()}"[0:4096]+"```" - else: - value = stdout.getvalue() - text = f"```{value}"[0:4096]+"```" - - await c.send_message(m.chat.id, text, parse_mode=enums.ParseMode.MARKDOWN) diff --git a/daemon/telegram/packages/plugins/scrape.py b/daemon/telegram/packages/plugins/scrape.py deleted file mode 100644 index 1698c6d..0000000 --- a/daemon/telegram/packages/plugins/scrape.py +++ /dev/null @@ -1,86 +0,0 @@ -# SPDX-License-Identifier: GPL-2.0-only -# -# Copyright (C) 2022 Muhammad Rizki -# Copyright (C) 2022 Ammar Faizi -# - -from pyrogram.types import InlineKeyboardMarkup -from pyrogram.types import InlineKeyboardButton -from pyrogram.types import Message -from pyrogram import filters -from pyrogram import Client -from scraper import Scraper -from pyrogram import enums -from scraper import utils -from scraper import Bot -import shutil -import re -import asyncio - - -# -# This allows user to invoke the following commands: -# /lore https://lore.kernel.org/path/message_id/raw -# !lore https://lore.kernel.org/path/message_id/raw -# .lore https://lore.kernel.org/path/message_id/raw -# -LORE_CMD_URL_PATTERN = r"^(?:\/|\.|\!)lore\s+(https?:\/\/lore\.kernel\.org\/\S+)" -@Client.on_message( - filters.regex(LORE_CMD_URL_PATTERN) & - filters.chat(["kiizuah", "nekoha", -1001673279485]) -) -async def scrap_email(_, m: Message): - p = re.search(LORE_CMD_URL_PATTERN, m.text) - if not p: - return - - url = p.group(1) - if not url: - return - - s = Scraper() - mail = await s.get_email_from_url(url) - text, files, is_patch = utils.create_template(mail) - - if is_patch: - m = await __send_patch_msg(m, mail, text, url) - else: - text = "#ml\n" + text - m = await __send_text_msg(m, text, url) - - for d, f in files: - await m.reply_document(f"{d}/{f}", file_name=f) - await asyncio.sleep(1) - - if files: - shutil.rmtree(str(files[0][0])) - - -async def __send_patch_msg(m, mail, text, url): - tmp, fnm, caption, url = Bot.prepare_send_patch(mail, text, url) - ret = await m.reply_document( - fnm, - caption=caption, - parse_mode=enums.ParseMode.HTML, - reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton( - "See the full message", - url=url - )] - ]) - ) - Bot.clean_up_after_send_patch(tmp) - return ret - - -async def __send_text_msg(m, text, url): - return await m.reply( - text, - parse_mode=enums.ParseMode.HTML, - reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton( - "See the full message", - url=url.replace("/raw","") - )] - ]) - ) -- Muhammad Rizki