1
Fork 0
mirror of https://github.com/RGBCube/minearchy-bot synced 2025-07-27 00:47:44 +00:00

Rewrite the whole thing

This commit is contained in:
RGBCube 2022-10-18 13:43:32 +03:00
parent 430eedd979
commit 0eb8d37b7a
29 changed files with 2042 additions and 564 deletions

26
.github/workflows/bandit.yml vendored Normal file
View file

@ -0,0 +1,26 @@
name: Bandit
on: [ pull_request, push ]
jobs:
build:
if: github.event.pull_request.user.type != 'Bot'
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ "3.10" ]
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install Bandit
run: pip install -U pip bandit
- name: Run Bandit
run: bandit --recursive ./

22
.github/workflows/lint.py vendored Normal file
View file

@ -0,0 +1,22 @@
from __future__ import annotations
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parent.parent.parent / "minearchy_bot"
for fp in ROOT_DIR.rglob("__init__.py"):
if not (content := fp.read_text()):
continue
old_content = content[:]
while content[0] == "\n":
content = content[1:]
content = content.removeprefix("from __future__ import annotations")
while content[0] == "\n":
content = content[1:]
if old_content != content:
fp.write_text(content)

49
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,49 @@
name: Lint
on: [ pull_request, push ]
jobs:
build:
if: github.event.pull_request.user.type != 'Bot'
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ "3.10" ]
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install Unimport, Isort, Black, and Flynt
run: pip install -U pip unimport isort black flynt
- name: Run Unimport
continue-on-error: true
run: unimport ./ --ignore-init --gitignore -r
- name: Run Isort
run: |
isort ./
python ./.github/workflows/lint.py
- name: Run Black
run: black ./
- name: Run Flynt
run: flynt ./ -tc
- name: Setup Git
run: git config --global user.name "Automated Linter"
- name: Push To GitHub
continue-on-error: true
run: |
git pull
git add ./
git commit --amend
git push

28
.github/workflows/pyright.yml vendored Normal file
View file

@ -0,0 +1,28 @@
name: PyRight
on: [ pull_request, push ]
jobs:
build:
if: github.event.pull_request.user.type != 'Bot'
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ "3.10" ]
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install PyRight & Dependencies
run: |
pip install -U pip pyright poetry
poetry install --no-dev
- name: Run PyRight
run: poetry run pyright ./

84
.gitignore vendored
View file

@ -1,15 +1,8 @@
# Configs
config.json config.json
# Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
# C extensions
*.so *.so
# Distribution / packaging
.Python .Python
build/ build/
develop-eggs/ develop-eggs/
@ -28,18 +21,10 @@ share/python-wheels/
.installed.cfg .installed.cfg
*.egg *.egg
MANIFEST 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 *.manifest
*.spec *.spec
# Installer logs
pip-log.txt pip-log.txt
pip-delete-this-directory.txt pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/ htmlcov/
.tox/ .tox/
.nox/ .nox/
@ -53,76 +38,29 @@ coverage.xml
.hypothesis/ .hypothesis/
.pytest_cache/ .pytest_cache/
cover/ cover/
# Translations
*.mo *.mo
*.pot *.pot
# Django stuff:
*.log *.log
local_settings.py local_settings.py
db.sqlite3 db.sqlite3
db.sqlite3-journal db.sqlite3-journal
# Flask stuff:
instance/ instance/
.webassets-cache .webassets-cache
# Scrapy stuff:
.scrapy .scrapy
# Sphinx documentation
docs/_build/ docs/_build/
# PyBuilder
.pybuilder/ .pybuilder/
target/ target/
# Jupyter Notebook
.ipynb_checkpoints .ipynb_checkpoints
# IPython
profile_default/ profile_default/
ipython_config.py 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 .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 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.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 .pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/ __pypackages__/
# Celery stuff
celerybeat-schedule celerybeat-schedule
celerybeat.pid celerybeat.pid
# SageMath parsed files
*.sage.py *.sage.py
# Environments
.env .env
.venv .venv
env/ env/
@ -130,34 +68,14 @@ venv/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
# Spyder project settings
.spyderproject .spyderproject
.spyproject .spyproject
# Rope project settings
.ropeproject .ropeproject
# mkdocs documentation
/site /site
# mypy
.mypy_cache/ .mypy_cache/
.dmypy.json .dmypy.json
dmypy.json dmypy.json
# Pyre type checker
.pyre/ .pyre/
# pytype static type analyzer
.pytype/ .pytype/
# Cython debug symbols
cython_debug/ cython_debug/
.idea/
# 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/

View file

@ -19,4 +19,4 @@ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -6,6 +6,6 @@ This is a simple bot made for the [Minearchy Discord](https://discord.gg/2n6T78J
2: Edit the `config.example.json` to your liking and rename it to `config.json`. 2: Edit the `config.example.json` to your liking and rename it to `config.json`.
3: Install dependencies using `pip install --no-cache-dir -r requirements.txt` 3: Install dependencies using `pip install -U poetry && poetry install --no-dev`.
4: Run `python bot.py`. 4: Run `python -m minearchy_bot`.

98
bot.py
View file

@ -1,98 +0,0 @@
import asyncio
import itertools
import json
import os
import pathlib
import time
import traceback
import aiohttp
import discord
import mcstatus
from discord.ext import commands
class MinearchyBot(commands.Bot):
session: aiohttp.ClientSession
suggestions_channel: discord.TextChannel
log_webhook: discord.Webhook
up_ts: float
embed_color = 0x2C65FF
def __init__(self, *, token: str, webhook_url: str) -> None:
ip = "play.landsofminearchy.com"
self.mc_server = mcstatus.JavaServer.lookup(ip)
self.mc_server.ip = ip
self.mc_server.bedrock_ip = "bedrock.landsofminearchy.com"
self.token = token
self.webhook_url = webhook_url
super().__init__(
command_prefix="=",
owner_ids={512640455834337290, 160087716757897216},
intents=discord.Intents(
guilds=True,
members=True,
messages=True,
message_content=True,
),
status=discord.Game("on play.landsofminearchy.com"),
case_insensitive=True,
allowed_mentions=discord.AllowedMentions.none(),
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()
self.suggestions_channel = self.get_channel(955972394885984276)
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 itertools.chain(
map(
lambda file_path: str(file_path).replace("/", ".")[:-3],
pathlib.Path("./cogs").rglob("*.py"),
),
["jishaku"],
):
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(token=config["BOT_TOKEN"], webhook_url=config["WEBHOOK_URL"])
bot.run()

View file

@ -1,123 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import discord
from discord.ext import commands
if TYPE_CHECKING:
from bot import MinearchyBot
class MinecraftServer(
commands.Cog,
name="Minecraft Server",
description="Utilities 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:"
f" `{self.bot.mc_server.bedrock_ip}` (Port: 19132)\nNote: Minecraft 1.18+"
" is required to join."
)
@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(
"The IP to connect on Minecraft Java edition is"
f" `{self.bot.mc_server.ip}`\nNote: Minecraft 1.18+ is required to join."
)
@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(
"The IP to connect on Minecraft Bedrock edition is"
f" `{self.bot.mc_server.bedrock_ip}` (Port: 19132)\nNote: Minecraft 1.18+"
" is required to join."
)
@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://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://landsofminearchy.com/store",
)
)
await ctx.reply(view=view)
@commands.command(
brief="Sends the links you can use to vote for the Minecraft server.",
help="Sends the links you can use to vote for the Minecraft server.",
)
async def vote(self, ctx: commands.Context) -> None:
view = discord.ui.View()
view.add_item(
discord.ui.Button(
label="Vote for the Minecraft server!",
url="https://landsofminearchy.com/vote",
)
)
await ctx.reply(view=view)
@commands.command(
aliases=["apply", "staffapply", "applystaff", "applyforstaff", "staff-application", "staff-applications", "staff_applications"],
brief="Sends the link to the staff application.",
help="Sends the link to the staff application.",
)
async def staff_application(self, ctx: commands.Context) -> None:
view = discord.ui.View()
view.add_item(
discord.ui.Button(
label="Apply for staff!",
url="https://docs.google.com/forms/d/1I7Rh_e-ZTXm5L51XoKZsOAk7NAJcHomUUCuOlQcARvY/viewform",
)
)
await ctx.reply(view=view)
async def setup(bot: MinearchyBot) -> None:
await bot.add_cog(MinecraftServer(bot))

View file

@ -1,139 +0,0 @@
from __future__ import annotations
import datetime
import inspect
import platform
import time
from collections import defaultdict, deque
from typing import TYPE_CHECKING
import discord
from discord.ext import commands
from discord.utils import escape_markdown as es_md
if TYPE_CHECKING:
from bot import MinearchyBot
class Miscellaneous(
commands.Cog,
name="Miscellaneous",
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.command(
aliases=["server_count", "server-count"],
brief="Sends how many servers the bot is in.",
help="Sends how many servers the bot is in.",
)
async def count(self, ctx: commands.Context) -> None:
await ctx.reply(f"Currently in `{len(self.bot.guilds)}` servers.")
@commands.command(
brief="Sends the total members in the server.",
help="Sends the total members in the server.",
)
async def members(self, ctx: commands.Context) -> None:
await ctx.reply(f"There are `{ctx.guild.member_count}` users in this server.")
@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
logs = self.sniped[channel.id]
if not logs:
await ctx.reply(
"There are no messages to be sniped in"
f" {'this channel.' if channel.id == ctx.channel.id else channel.mention}"
)
return
embed = discord.Embed(
title=(
"Showing last 5 deleted messages for"
f" {'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) + ("" if i else " (latest)"),
value=inspect.cleandoc(
f"""Author: {message.author.mention} (ID: {message.author.id}, Plain: {discord.utils.escape_markdown(str(message.author))})
Deleted at: <t:{ts}:F> (Relative: <t:{ts}:R>)
Content:
```
{message.content.replace('`', f'{zwsp}`{zwsp}')}
```"""
),
inline=False,
)
await ctx.reply(embed=embed)
async def setup(bot: MinearchyBot) -> None:
await bot.add_cog(Miscellaneous(bot))

View file

@ -1,48 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import discord
from discord.ext import commands
from datetime import timedelta
from typing import Optional
if TYPE_CHECKING:
from bot import MinearchyBot
class Moderation(commands.Cog):
def __init__(self, bot: MinearchyBot) -> None:
self.bot = bot
@commands.command(
aliases=["mute"],
brief="Times out a user.",
help="Times out a user."
)
@commands.has_permissions(manage_messages=True)
async def timeout(self, ctx: commands.Context, member: discord.Member, duration: Optional[str]) -> None:
times = {
"d": "days",
"h": "hours",
"m": "minutes",
"s": "seconds",
}
if duration is None:
duration = "1d"
if duration[-1] not in times or len(duration) < 2:
await ctx.reply("Invalid duration. Valid durations are: d, h, m, s.")
return
try:
time = int(duration[:-1])
except ValueError:
await ctx.reply("Invalid time.")
return
clean_time_name = times[duration[-1]]
await member.timeout(timedelta(**{clean_time_name: time}), reason=f"Timed out by moderator {ctx.author}")
await ctx.send(f"Timed out {member.mention} for {time} {clean_time_name}.")
async def setup(bot: MinearchyBot) -> None:
await bot.add_cog(Moderation(bot))

View file

@ -1,44 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import discord
from discord.ext import commands
if TYPE_CHECKING:
from bot import MinearchyBot
class Suggestions(
commands.Cog,
name="Suggestions",
description="Suggest stuff.",
):
def __init__(self, bot: MinearchyBot) -> None:
self.bot = bot
@commands.command(brief="Make a suggestion.", help="Make a suggestion.")
async def suggest(self, ctx: commands.Context, *, suggestion: str) -> None:
embed = (
discord.Embed(
title=f"Suggestion from {ctx.author}",
description=suggestion,
color=self.bot.embed_color,
)
.set_thumbnail(url=ctx.author.display_avatar.url)
.set_footer(text="=suggest <suggestion>")
)
message = await self.bot.suggestions_channel.send(embed=embed)
await message.add_reaction("")
await message.add_reaction("")
await ctx.reply(
"Suggestion submitted!\nYou can view it at"
f" {self.bot.suggestions_channel.mention}"
)
async def setup(bot: MinearchyBot) -> None:
await bot.add_cog(Suggestions(bot))

View file

@ -1,4 +1,4 @@
{ {
"BOT_TOKEN": "XXX(ENV works too)", "BOT_TOKEN": "",
"WEBHOOK_URL": "XXX(ENV works too)" "WEBHOOK_URL": ""
} }

18
lint Executable file
View file

@ -0,0 +1,18 @@
#!/bin/sh
# run unimport
unimport ./ --ignore-init --gitignore -r
# run isort
isort ./
# run our lint script
python ./.github/workflows/lint.py
# run flynt
flynt ./ -tc
# run black
black ./
echo "Linting finished!"

View file

@ -0,0 +1 @@
from .core import *

21
minearchy_bot/__main__.py Normal file
View file

@ -0,0 +1,21 @@
from __future__ import annotations
from json import load as parse_json
from os import environ as env
from pathlib import Path
from uvloop import install as install_uvloop
from . import MinearchyBot
install_uvloop()
with (Path(__file__).parent.parent / "config.json").open() as f:
config = parse_json(f)
for key in ("HIDE", "NO_UNDERSCORE"):
env[f"JISHAKU_{key}"] = "True"
bot = MinearchyBot(token=config["BOT_TOKEN"], webhook_url=config["WEBHOOK_URL"])
bot.run()

View file

@ -1,24 +1,33 @@
from __future__ import annotations from __future__ import annotations
import contextlib from contextlib import suppress as suppress_error
import traceback from traceback import format_exception as format_exit
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import discord from discord import HTTPException
from discord.ext import commands from discord.ext.commands import (
ChannelNotFound,
Cog,
CommandNotFound,
MissingPermissions,
MissingRequiredArgument,
NoPrivateMessage,
NotOwner,
TooManyArguments,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from bot import MinearchyBot from discord.ext.commands import CommandError, Context
from ..core import MinearchyBot
class ErrorHandler(commands.Cog): class ErrorHandler(Cog):
def __init__(self, bot: MinearchyBot) -> None: def __init__(self, bot: MinearchyBot) -> None:
self.bot = bot self.bot = bot
@commands.Cog.listener() @Cog.listener()
async def on_command_error( async def on_command_error(self, ctx: Context, error: CommandError) -> None:
self, ctx: commands.Context, error: commands.CommandError
) -> None:
if hasattr(ctx.command, "on_error"): if hasattr(ctx.command, "on_error"):
return return
@ -26,34 +35,32 @@ class ErrorHandler(commands.Cog):
if cog._get_overridden_method(cog.cog_command_error) is not None: if cog._get_overridden_method(cog.cog_command_error) is not None:
return return
ignored = (commands.CommandNotFound,) ignored = (CommandNotFound,)
error = getattr(error, "original", error) error = getattr(error, "original", error)
if isinstance(error, ignored): if isinstance(error, ignored):
return return
elif isinstance(error, commands.NoPrivateMessage): elif isinstance(error, NoPrivateMessage):
with contextlib.suppress(discord.HTTPException): with suppress_error(HTTPException):
await ctx.author.send( await ctx.author.send(
f"The command `{ctx.command.qualified_name}` cannot be used in DMs." f"The command `{ctx.command.qualified_name}` cannot be used in DMs."
) )
elif isinstance(error, (commands.MissingPermissions, commands.NotOwner)): elif isinstance(error, (MissingPermissions, NotOwner)):
await ctx.reply("You can't use this command!") await ctx.reply("You can't use this command!")
elif isinstance(error, commands.MissingRequiredArgument): elif isinstance(error, MissingRequiredArgument):
await ctx.reply(f"Missing a required argument: `{error.param.name}`.") await ctx.reply(f"Missing a required argument: `{error.param.name}`.")
elif isinstance(error, commands.TooManyArguments): elif isinstance(error, TooManyArguments):
await ctx.reply("Too many arguments.") await ctx.reply("Too many arguments.")
elif isinstance(error, commands.ChannelNotFound): elif isinstance(error, ChannelNotFound):
await ctx.reply("Invalid channel.") await ctx.reply("Invalid channel.")
else: else:
trace = "".join( trace = "".join(format_exit(type(error), error, error.__traceback__))
traceback.format_exception(type(error), error, error.__traceback__)
)
print(f"Ignoring exception in command {ctx.command}:\n{trace}") print(f"Ignoring exception in command {ctx.command}:\n{trace}")
await self.bot.log_webhook.send(f"<@512640455834337290>```{trace}```") await self.bot.log_webhook.send(f"<@512640455834337290>```{trace}```")

View file

@ -0,0 +1,155 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from discord.ext.commands import Cog, command, group
from discord.ui import Button, View
if TYPE_CHECKING:
from discord.ext.commands import Context
from ..core import MinearchyBot
class MinecraftServer(
Cog,
name="Minecraft Server",
description="Utilities for the Minecraft server.",
):
def __init__(self, bot: MinearchyBot) -> None:
self.bot = bot
@group(
invoke_without_command=True,
brief="Sends the server IP.",
help="Sends the server IP.",
)
async def ip(self, ctx: Context) -> None:
await ctx.reply(
f"Java edition IP: `{self.bot.server.java.ip}`\nBedrock edition IP:"
f" `{self.bot.server.bedrock.ip}`\nNote: Minecraft 1.19 is required to join."
)
@ip.command(brief="Sends the Java edition IP.", help="Sends the Java edition IP.")
async def java(self, ctx: Context) -> None:
await ctx.reply(
"The IP to connect on Minecraft Java edition is"
f" `{self.bot.server.java.ip}`\nNote: Minecraft 1.19 is required to join."
)
@ip.command(
brief="Sends the Bedrock edition IP.",
help="Sends the Bedrock edition IP.",
)
async def bedrock(self, ctx: Context) -> None:
await ctx.reply(
"The IP to connect on Minecraft Bedrock edition is"
f" `{self.bot.server.bedrock.ip}`\nNote: Minecraft 1.19"
" is required to join."
)
@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: Context) -> None:
status = await self.bot.server.status()
await ctx.reply(f"The Minecraft server has {status.players.online} players online.")
@command(brief="Sends the link to the wiki.", help="Sends the link to the wiki.")
async def wiki(self, ctx: Context) -> None:
view = View()
view.add_item(
Button(
label="Go to the wiki!",
url="https://landsofminearchy.com/wiki",
)
)
await ctx.reply(view=view)
@command(
brief="Sends the link to the store.",
help="Sends the link to the store.",
)
async def store(self, ctx: Context) -> None:
view = View()
view.add_item(
Button(
label="Go to the store!",
url="https://landsofminearchy.com/store",
)
)
await ctx.reply(view=view)
@command(
aliases=("forums",),
brief="Sends the link to the forum.",
help="Sends the link to the forum.",
)
async def forum(self, ctx: Context) -> None:
view = View()
view.add_item(
Button(
label="Go to the forum!",
url="https://landsofminearchy.com/forum",
)
)
await ctx.reply(view=view)
@command(
aliases=("map",),
brief="Sends the link to the dynmap.",
help="Sends the link to the dynmap.",
)
async def dynmap(self, ctx: Context) -> None:
view = View()
view.add_item(
Button(
label="Go to the dynmap!",
url="https://landsofminearchy.com/dynmap",
)
)
await ctx.reply(
content="The dynmap is an interactive, live map of our Minecraft server.", view=view
)
@command(
brief="Sends the links you can use to vote for the Minecraft server.",
help="Sends the links you can use to vote for the Minecraft server.",
)
async def vote(self, ctx: Context) -> None:
view = View()
view.add_item(
Button(
label="Vote for the Minecraft server!",
url="https://landsofminearchy.com/vote",
)
)
await ctx.reply(view=view)
@command(
aliases=(
"apply",
"staffapply",
"applystaff",
"applyforstaff",
"staff-application",
"staff-applications",
"staff_applications",
),
brief="Sends the link to the staff application.",
help="Sends the link to the staff application.",
)
async def staff_application(self, ctx: Context) -> None:
view = View()
view.add_item(
Button(
label="Apply for staff!",
url="https://docs.google.com/forms/d/1I7Rh_e-ZTXm5L51XoKZsOAk7NAJcHomUUCuOlQcARvY/viewform",
)
)
await ctx.reply(view=view)
async def setup(bot: MinearchyBot) -> None:
await bot.add_cog(MinecraftServer(bot))

131
minearchy_bot/cogs/misc.py Normal file
View file

@ -0,0 +1,131 @@
from __future__ import annotations
from collections import defaultdict as DefaultDict, deque as Deque
from datetime import timedelta as TimeDelta
from inspect import cleandoc as strip_doc
from platform import python_version
from time import monotonic as ping_time, time as current_time
from typing import TYPE_CHECKING
from discord import Color, Embed, TextChannel
from discord.ext.commands import Cog, command, has_permissions
from discord.utils import escape_markdown
from ..util import override
if TYPE_CHECKING:
from discord import Message
from discord.ext.commands import Context
from ..core import MinearchyBot
class Miscellaneous(
Cog,
name="Miscellaneous",
description="Various utilities.",
):
def __init__(self, bot: MinearchyBot) -> None:
self.bot = bot
self.bot.help_command.cog = self
self.sniped = DefaultDict(Deque)
@override
def cog_unload(self) -> None:
self.bot.help_command.cog = None
self.bot.help_command.hidden = True
@command(brief="Sends the bots ping.", help="Sends the bots ping.")
async def ping(self, ctx: Context) -> None:
ts = ping_time()
message = await ctx.reply("Pong!")
ts = ping_time() - ts
await message.edit(content=f"Pong! `{int(ts * 1000)}ms`")
@command(brief="Sends info about the bot.", help="Sends info about the bot.")
async def info(self, ctx: Context) -> None:
await ctx.reply(
strip_doc(
f"""
__**Bot Info**__
**Python Version:** v{python_version()}
**Uptime:** `{TimeDelta(seconds=int(current_time() - self.bot.ready_timestamp))}`
"""
)
)
@command(brief="Hello!", help="Hello!")
async def hello(self, ctx: Context) -> None:
await ctx.reply(f"Hi {escape_markdown(ctx.author.name)}, yes the bot is running :)")
@command(
brief="Sends the total members in the server.",
help="Sends the total members in the server.",
)
async def members(self, ctx: Context) -> None:
await ctx.reply(f"There are `{ctx.guild.member_count}` users in this server.")
@Cog.listener()
async def on_message_delete(self, message: Message) -> None:
if not message.guild:
return
self.sniped[message.channel.id].appendleft((message, int(current_time())))
self.sniped[message.channel.id] = self.sniped[message.channel.id][:5] # type: ignore
@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."
),
)
@has_permissions(manage_messages=True) # needs to be able to delete messages to run the command
async def snipe(self, ctx: Context, channel: TextChannel = None) -> None: # type: ignore
if channel is None:
channel = ctx.channel
logs = self.sniped[channel.id]
if not logs:
await ctx.reply(
"There are no messages to be sniped in"
f" {'this channel.' if channel.id == ctx.channel.id else channel.mention}"
)
return
embed = Embed(
title=(
"Showing last 5 deleted messages for"
f" {'the current channel' if ctx.channel.id == channel.id else channel}"
),
description="The lower the number is, the more recent it got deleted.",
color=Color.random(),
)
zwsp = "\uFEFF"
for i, log in reversed(list(enumerate(logs))):
message, ts = log
embed.add_field(
name=str(i) + ("" if i else " (latest)"),
value=strip_doc(
f"""
Author: {message.author.mention} (ID: {message.author.id}, Plain: {escape_markdown(str(message.author))})
Deleted at: <t:{ts}:F> (Relative: <t:{ts}:R>)
Content:
```
{message.content.replace('`', f'{zwsp}`{zwsp}')}
```
"""
),
inline=False,
)
await ctx.reply(embed=embed)
async def setup(bot: MinearchyBot) -> None:
await bot.add_cog(Miscellaneous(bot))

View file

@ -0,0 +1,48 @@
from __future__ import annotations
from datetime import timedelta as TimeDelta
from typing import TYPE_CHECKING
from discord import Member
from discord.ext.commands import Cog, Context, command, has_permissions
if TYPE_CHECKING:
from ..core import MinearchyBot
class Moderation(Cog):
def __init__(self, bot: MinearchyBot) -> None:
self.bot = bot
self.time_values = {
"d": "days",
"h": "hours",
"m": "minutes",
"s": "seconds",
}
@command(aliases=("mute",), brief="Times out a user.", help="Times out a user.")
@has_permissions(manage_messages=True)
async def timeout(self, ctx: Context, member: Member, duration: str = "1d") -> None:
if duration[-1] not in self.time_values or len(duration) < 2:
await ctx.reply("Invalid duration. Valid durations are: d, h, m, s.")
return
try:
time = int(duration[:-1])
except ValueError:
await ctx.reply("Invalid time.")
return
# days, hours, minutes, seconds
clean_time_name = self.time_values[duration[-1]]
# this is so cursed but works
await member.timeout(
TimeDelta(**{clean_time_name: time}), reason=f"Timed out by moderator {ctx.author}"
)
await ctx.reply(f"Timed out {member.mention} for {time} {clean_time_name}.")
async def setup(bot: MinearchyBot) -> None:
await bot.add_cog(Moderation(bot))

View file

@ -0,0 +1 @@
from .minearchy_bot import *

View file

@ -0,0 +1,101 @@
from __future__ import annotations
__all__ = ("MinearchyBot",)
from asyncio import run as run_coro
from inspect import cleandoc as strip_doc
from itertools import chain as chain_iter
from pathlib import Path
from time import time as current_time
from traceback import format_exc as format_exit
from aiohttp import ClientSession as AIOHTTPSession
from discord import AllowedMentions, Game, Intents, Webhook
from discord.ext.commands import (
Bot as CommandsBot,
ExtensionFailed,
NoEntryPointError,
when_mentioned_or,
)
from ..minecraft_server import GeyserServer
from ..util import override
class MinearchyBot(CommandsBot):
ready_timestamp: float
log_webhook: Webhook
def __init__(self, *, token: str, webhook_url: str) -> None:
self.token = token
self.webhook_url = webhook_url
self.server = GeyserServer(
java_ip="play.landsofminearchy.com",
bedrock_ip="bedrock.landsofminearchy.com",
)
super().__init__(
command_prefix=when_mentioned_or("="),
strip_after_prefix=True,
case_insensitive=True,
status=Game("on play.landsofminearchy.com"),
owner_ids={512640455834337290, 160087716757897216},
allowed_mentions=AllowedMentions.none(),
max_messages=100,
intents=Intents(
guilds=True,
members=True,
messages=True,
message_content=True,
),
help_attrs=dict(
brief="Sends help.",
help="Sends all the commands of the bot, or help of a specific command or module.",
),
)
@override
async def on_ready(self) -> None:
print(
strip_doc(
f"""
Connected to Discord!
User: {self.user}
ID: {self.user.id}
"""
)
)
self.ready_timestamp = current_time()
await self.log_webhook.send("Bot is now online!")
async def load_extensions(self) -> None:
cogs = Path(__file__).parent.parent / "cogs"
for file_name in chain_iter(
map(
lambda file_path: ".".join(file_path.relative_to(cogs.parent.parent).parts)[:-3],
cogs.rglob("*.py"),
),
("jishaku",),
):
try:
await self.load_extension(file_name)
print(f"Loaded {file_name}")
except (ExtensionFailed, NoEntryPointError):
print(f"Couldn't load {file_name}:\n{format_exit()}")
@override
def run(self) -> None:
async def runner() -> None:
async with self, AIOHTTPSession() as self.session:
self.log_webhook = 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:
run_coro(runner())
except KeyboardInterrupt:
pass

View file

@ -0,0 +1 @@
from .geyser_server import *

View file

@ -0,0 +1,34 @@
from __future__ import annotations
__all__ = ("GeyserServer",)
from dataclasses import dataclass
from typing import TYPE_CHECKING
from mcstatus import JavaServer
if TYPE_CHECKING:
from mcstatus.pinger import PingResponse
@dataclass
class ServerInfo:
ip: str
port: int
class GeyserServer:
def __init__(
self,
*,
java_ip: str,
java_port: int = 25565,
bedrock_ip: str,
bedrock_port: int = 19132,
) -> None:
self.__server = JavaServer.lookup(java_ip, java_port)
self.java = ServerInfo(java_ip, java_port)
self.bedrock = ServerInfo(bedrock_ip, bedrock_port)
async def status(self) -> PingResponse:
return await self.__server.async_status()

View file

@ -0,0 +1 @@
from .override import *

View file

@ -0,0 +1,12 @@
from __future__ import annotations
__all__ = ("override",)
from typing import Callable, TypeVar
T = TypeVar("T", bound=Callable)
def override(function: T) -> T:
"""Basically Java's @Override annotation. Makes stuff less ambiguous."""
return function

1294
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

65
pyproject.toml Normal file
View file

@ -0,0 +1,65 @@
[tool.poetry]
name = "MinearchyBot"
version = "1.0.0"
description = "A simple bot for the Lands of Minearchy Discord server."
authors = [ "RGBCube", "The Lands of Minearchy team" ]
[tool.poetry.dependencies]
python = "^3.10"
"discord.py" = { version = "*", extras = [ "speed" ] }
jishaku = "*"
uvloop = "*"
[tool.poetry.dev-dependencies]
black = "*"
flynt = "*"
isort = "*"
unimport = "*"
pyright = "*"
[tool.black]
line-length = 100
preview = true
[tool.isort]
py_version = 310
add_imports = ["from __future__ import annotations"]
line_length = 79
profile = "black"
combine_as_imports = true
combine_star = true
[tool.pyright]
pythonVersion = "3.10"
typeCheckingMode = "basic"
strictListInference = true
strictDictionaryInference = true
strictSetInference = true
reportMissingModuleSource = "error"
reportAssertAlwaysTrue = "error"
reportInvalidStringEscapeSequence = "error"
reportInvalidTypeVarUse = "error"
reportSelfClsParameterName = "error"
reportUnsupportedDunderAll = "error"
reportUnusedExpression = "error"
reportWildcardImportFromLibrary = "error"
reportConstantRedefinition = "error"
reportDuplicateImport = "error"
reportImportCycles = "error"
reportIncompatibleVariableOverride = "error"
reportIncompleteStub = "error"
reportInconsistentConstructor = "error"
reportInvalidStubStatement = "error"
reportMatchNotExhaustive = "error"
reportMissingParameterType = "error"
reportTypeCommentUsage = "error"
reportUnnecessaryCast = "error"
reportUnnecessaryComparison = "error"
reportUnnecessaryIsInstance = "error"
reportUnusedClass = "error"
reportUnusedVariable = "error"
reportUntypedClassDecorator = "error"
reportUntypedNamedTuple = "error"
reportCallInDefaultInitializer = "error"
reportPropertyTypeMismatch = "error"
reportUnnecessaryTypeIgnoreComment = "error"

View file

@ -1,3 +0,0 @@
discord.py
jishaku==2.5.0
mcstatus