From d2f19f0e8a8ff9e69ac55147dda9d9b3ba2a5187 Mon Sep 17 00:00:00 2001 From: RGBCube <78925721+RGBCube@users.noreply.github.com> Date: Mon, 6 Jun 2022 08:51:00 +0000 Subject: [PATCH] Initial commit --- .gitignore | 163 ++++++++++++++++++++++++++++++++++++++++++ .replit | 1 + LICENSE | 20 ++++++ README.md | 23 ++++++ bot.py | 97 +++++++++++++++++++++++++ cogs/error_handler.py | 54 ++++++++++++++ cogs/mc_server.py | 81 +++++++++++++++++++++ cogs/misc.py | 116 ++++++++++++++++++++++++++++++ config.example.json | 7 ++ requirements.txt | 2 + webserver.py | 17 +++++ 11 files changed, 581 insertions(+) create mode 100644 .gitignore create mode 100644 .replit create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bot.py create mode 100644 cogs/error_handler.py create mode 100644 cogs/mc_server.py create mode 100644 cogs/misc.py create mode 100644 config.example.json create mode 100644 requirements.txt create mode 100644 webserver.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..009fc87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,163 @@ +# Configs +config.json + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ \ No newline at end of file diff --git a/.replit b/.replit new file mode 100644 index 0000000..4c2e064 --- /dev/null +++ b/.replit @@ -0,0 +1 @@ +run = "pip install -Ur requirements.txt && python bot.py" \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bbc9a0e --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +opyright (c) 2022-present RGBCube & Minearchy Team + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d7efe2a --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# Minearchy Bot +This is a simple bot made for the Minearchy Discord server. + +## Commands + +### Minecraft Server Related Commands +`=ip [java|bedrock]`: Sends both of the server IPs. If the version is specified, it sends that versions IP. + +`=status`: Sends the Minecraft servers player count and latency(ping). + +`=store`: Sends a link to the store. + +`=wiki`: Sends a link to the wiki. + +### Miscellanios Commands +`=hello`: Hello! + +`=help`: Sends bot help. + +`=info`: Sends info about the bot. This is the bots Python runtime version and uptime. + +`=ping`: Sends the bots latency. + \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..8a847de --- /dev/null +++ b/bot.py @@ -0,0 +1,97 @@ +import discord +import time +import aiohttp +from discord.ext import commands +import os +import json +import asyncio +from mcstatus import MinecraftServer +import traceback +import pathlib + + +class MinearchyBot(commands.Bot): + session: aiohttp.ClientSession + log_webhook: discord.Webhook + up_ts: float + + embed_color = 0x39FF14 + + def __init__(self, token: str, webhook_url: str, /) -> None: + ip = "play.landsofminearchy.com" + self.mc_server = MinecraftServer.lookup(ip) + self.mc_server.ip = ip + self.mc_server.bedrock_ip = "bedrock.landsofminearchy.com" + + self.token = token + self.webhook_url = webhook_url + + intents = discord.Intents( + guilds=True, + members=True, + messages=True, + message_content=True, + ) + stuff_to_cache = discord.MemberCacheFlags.from_intents(intents) + + super().__init__( + command_prefix="=", + owner_ids=set([512640455834337290]), + intents=intents, + case_insensitive=True, + allowed_mentions=discord.AllowedMentions.none(), + member_cache_flags=stuff_to_cache, + max_messages=1000, + strip_after_prefix=True, + help_attrs=dict( + brief="Sends help.", + help="Sends all the commands of the bot, or help of a specific command and module.", + ), + ) + + async def on_ready(self) -> None: + self.up_ts = time.time() + print(f"\nConnected to Discord!\nUser: {self.user}\nID: {self.user.id}") + await self.log_webhook.send("Bot is now online!") + + async def load_extensions(self) -> None: + for fn in map( + lambda file_path: str(file_path).replace("/", ".")[:-3], + pathlib.Path("./cogs").rglob("*.py"), + ): + try: + await self.load_extension(fn) + print(f"Loaded {fn}") + except (commands.ExtensionFailed, commands.NoEntryPointError): + print(f"Couldn't load {fn}:\n{traceback.format_exc()}") + + def run(self) -> None: + async def runner() -> None: + async with self, aiohttp.ClientSession() as session: + self.session = session + self.log_webhook = discord.Webhook.from_url( + self.webhook_url, session=self.session, bot_token=self.token + ) + await self.load_extensions() + await self.start(self.token, reconnect=True) + + try: + asyncio.run(runner()) + except KeyboardInterrupt: + pass + + +with open("./config.json") as f: + config = json.load(f) + +for key in ["BOT_TOKEN", "WEBHOOK_URL"]: + config.setdefault(key, os.getenv(key)) + +bot = MinearchyBot(config["BOT_TOKEN"], config["WEBHOOK_URL"]) + +if os.getenv("USING_REPLIT"): + import webserver + + webserver.keep_alive() + +bot.run() diff --git a/cogs/error_handler.py b/cogs/error_handler.py new file mode 100644 index 0000000..d837843 --- /dev/null +++ b/cogs/error_handler.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from discord.ext import commands +import discord +import sys +import traceback + +if TYPE_CHECKING: + from bot import MinearchyBot + + +class ErrorHandler(commands.Cog): + def __init__(self, bot: MinearchyBot, /) -> None: + self.bot = bot + + @commands.Cog.listener() + async def on_command_error( + self, ctx: commands.Context, error: commands.CommandError, / + ) -> None: + if hasattr(ctx.command, "on_error"): + return + + if cog := ctx.cog: + if cog._get_overridden_method(cog.cog_command_error) is not None: + return + + ignored = (commands.CommandNotFound,) + error = getattr(error, "original", error) + + if isinstance(error, ignored): + return + + elif isinstance(error, commands.NoPrivateMessage): + try: + await ctx.author.send( + f"The commmand `{ctx.command}` cannot be used in DMs." + ) + except discord.HTTPException: + pass + + elif isinstance(error, commands.MissingPermissions): + await ctx.reply("You can't use this command!") + + else: + trace = traceback.format_exception( + type(error), error, error.__traceback__ + ) + print(f"Ignoring exception in command {ctx.command}:\n{trace}") + self.bot.log_webhook.send(f"<@512640455834337290>```{trace}```") + + +async def setup(bot: MinearchyBot, /) -> None: + await bot.add_cog(ErrorHandler(bot)) diff --git a/cogs/mc_server.py b/cogs/mc_server.py new file mode 100644 index 0000000..0c2a05b --- /dev/null +++ b/cogs/mc_server.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from discord.ext import commands +import discord + +if TYPE_CHECKING: + from bot import MinearchyBot + + +class MinecraftServer( + commands.Cog, + name="Minecraft Server", + description="Utilites for the Minecraft server.", +): + def __init__(self, bot: MinearchyBot, /) -> None: + self.bot = bot + + @commands.group( + invoke_without_command=True, + brief="Sends the server IP.", + help="Sends the server IP.", + ) + async def ip(self, ctx: commands.Context, /) -> None: + await ctx.reply( + f"Java edition IP: `{self.bot.mc_server.ip}`\nBedrock edition IP: `{self.bot.mc_server.bedrock_ip}`" + ) + + @ip.command(brief="Sends the Java edition IP.", help="Sends the Java edition IP.") + async def java(self, ctx: commands.Context, /) -> None: + await ctx.reply( + f"The IP to connect on Minecraft Java edition is `{self.bot.mc_server.ip}`" + ) + + @ip.command( + brief="Sends the Bedrock edition IP.", help="Sends the Bedrock edition IP." + ) + async def bedrock(self, ctx: commands.Context, /) -> None: + await ctx.reply( + f"The IP to connect on Minecraft Bava edition is `{self.bot.mc_server.ip}`" + ) + + @commands.command( + brief="Shows information about the Minecraft server.", + help="Shows the total player count, the Minecraft server IP and the server latency.", + ) + async def status(self, ctx: commands.Context, /) -> None: + server = self.bot.mc_server + status = server.status() + await ctx.reply( + f"The server with the IP `{server.ip}` has {status.players.online} " + f"players and responded in `{int(status.latency)}ms`" + ) + + @commands.command( + brief="Sends the link to the wiki.", help="Sends the link to the wiki." + ) + async def wiki(self, ctx: commands.Context, /) -> None: + view = discord.ui.View() + view.add_item( + discord.ui.Button( + label="Go to the wiki!", url="https://www.landsofminearchy.com/wiki" + ) + ) + await ctx.reply(view=view) + + @commands.command( + brief="Sends the link to the store.", help="Sends the link to the store." + ) + async def store(self, ctx: commands.Context, /) -> None: + view = discord.ui.View() + view.add_item( + discord.ui.Button( + label="Go to the store!", url="https://www.landsofminearchy.com/store" + ) + ) + await ctx.reply(view=view) + + +async def setup(bot: MinearchyBot) -> None: + await bot.add_cog(MinecraftServer(bot)) diff --git a/cogs/misc.py b/cogs/misc.py new file mode 100644 index 0000000..9140551 --- /dev/null +++ b/cogs/misc.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from discord.ext import commands +import datetime +from collections import defaultdict, deque +import discord +from discord.utils import escape_markdown as es_md +import platform + +import time + +if TYPE_CHECKING: + from bot import MinearchyBot + + +class Miscellanious( + commands.Cog, + name="Miscellanious", + description="Various utilities.", +): + def __init__(self, bot: MinearchyBot, /) -> None: + self.bot = bot + self.bot.help_command.cog = self + self.sniped = defaultdict(deque) + + def cog_unload(self) -> None: + self.bot.help_command.cog = None + self.bot.help_command.hidden = True + + @commands.command(brief="Sends the bots ping.", help="Sends the bots ping.") + async def ping(self, ctx: commands.Context, /) -> None: + ts = time.time() + message = await ctx.reply("Pong!") + ts = time.time() - ts + await message.edit(content=f"Pong! `{int(ts * 1000)}ms`") + + @commands.command( + brief="Sends info about the bot.", help="Sends info about the bot." + ) + async def info(self, ctx: commands.Context, /) -> None: + embed = discord.Embed(title="Bot Info", color=self.bot.embed_color) + embed.add_field( + name="Python Version", value=f"```v{platform.python_version()}```" + ) + embed.add_field( + name="Uptime", + value=f"```{datetime.timedelta(seconds=int(time.time() - self.bot.up_ts))}```", + ) + await ctx.reply(embed=embed) + + @commands.command(brief="Hello!", help="Hello!") + async def hello(self, ctx: commands.Context, /) -> None: + await ctx.reply(f"Hi {es_md(ctx.author.name)}, yes the bot is running :)") + + @commands.Cog.listener() + async def on_message_delete(self, message: discord.Message, /) -> None: + if not message.guild: + return + + logs = self.sniped[message.channel.id] + + logs.appendleft((message, int(time.time()))) + + while len(logs) > 5: + logs.pop() + + @commands.command( + brief="Sends the latest deleted messages.", + help="Sends the last 5 deleted messages in a specified channel.\nIf the channel isn't specified, it uses the current channel.", + ) + @commands.has_permissions( + manage_messages=True + ) # needs to be able to delete messages to run the command + async def snipe( + self, ctx: commands.Context, channel: discord.TextChannel = None, / + ) -> None: + if channel is None: + channel = ( + ctx.channel + ) # i am not checking if the channel is in the current server, since this bot is for a private server + + logs = self.sniped[channel.id] + + if not logs: + await ctx.reply( + f"There are no messages to be sniped in {'this channel.' if channel.id == ctx.channel.id else channel.mention}" + ) + return + + embed = discord.Embed( + title=f"Showing last 5 deleted messages for {'the current channel' if ctx.channel.id == channel.id else channel}", + description="The lower the number is, the more recent it got deleted.", + color=self.bot.embed_color, + ) + zwsp = "\uFEFF" + for i, log in reversed(list(enumerate(logs))): + message, ts = log + embed.add_field( + name=str(i) + (" (latest)" if not i else ""), + value=f""" + Author: {message.author.mention} (ID: {message.author.id}, Plain: {discord.utils.escape_markdown(str(message.author))}) + Deleted at: (Relative: ) + Content: + ``` + {message.content.replace('`', f'{zwsp}`{zwsp}')} + ``` + """, # zero-width spaces, or it will break. + inline=False, + ) + + await ctx.reply(embed=embed) + + +async def setup(bot: MinearchyBot) -> None: + await bot.add_cog(Miscellanious(bot)) diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..5fe666d --- /dev/null +++ b/config.example.json @@ -0,0 +1,7 @@ +{ + "BEDROCK_IP": "bedrock.landsofminearchy.com", + "JAVA_IP": "play.landsofminearchy.com", + "BOT_PREFIX": "=", + "BOT_TOKEN": "xxxxxxxxxxxxxxxxxxxxxxxxx(also env files work with this too)", + "WEBHOOK_URL": "xxxxxxxxxxxxxxxxxxxxxxx(also env files work with this too)" +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8d8812c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +discord.py @ git+https://github.com/Rapptz/discord.py +mcstatus \ No newline at end of file diff --git a/webserver.py b/webserver.py new file mode 100644 index 0000000..4b179a1 --- /dev/null +++ b/webserver.py @@ -0,0 +1,17 @@ +from flask import Flask +from threading import Thread + +app = Flask(__name__) + + +@app.route("/") +def root() -> str: + return "Online!" + + +def run() -> None: + app.run(host="0.0.0.0", port=8080) + + +def keep_alive() -> None: + Thread(target=run).start()