19 Commits

Author SHA1 Message Date
09da774f26 Merge pull request 'Update dependency libbot to v4.0.1' (#21) from renovate/libbot-4.x into dev
Reviewed-on: #21
2024-12-29 18:55:38 +02:00
kku
f97e6e4e93 Removed now unused typing-extensions 2024-12-29 17:54:40 +01:00
8f73cab327 Update dependency libbot to v4.0.1 2024-12-29 18:09:02 +02:00
kku
cd9e4187f7 Closes #17; Fixed migrations 2024-12-27 23:00:27 +01:00
kku
4f6c99f211 WIP: Database migrations 2024-12-27 22:43:40 +01:00
kku
eb8019ccfe Updated the documentation 2024-12-27 22:33:58 +01:00
kku
ce57755eee Improved type-hinting for loggers and removed legacy 2024-12-27 22:23:41 +01:00
kku
7a64e334d2 Added logging for thread creation message deletion 2024-12-27 20:42:12 +01:00
kku
9417951f55 Removed legacy and improved documentation 2024-12-27 20:30:32 +01:00
kku
6060a3df83 Closes #18 2024-12-27 20:16:30 +01:00
eed084cd91 Replaced legacy Union[] with new syntax 2024-12-27 00:18:54 +01:00
7b64f6938b Added a nice comment explaining the sync call inside async function 2024-12-26 20:28:44 +01:00
c54586940e Added a fix in case typing extensions is missing 2024-12-26 20:28:06 +01:00
0195706e92 PycordBot now handles scheduler on its own 2024-12-26 19:18:02 +01:00
162898f5eb WIP: libbot 4.0.0 adoption 2024-12-26 19:12:50 +01:00
a753918432 Added experimental Docker instructions 2024-12-17 22:29:03 +01:00
36d63e0240 Changed the Client structure 2024-12-17 22:14:06 +01:00
62a36a3747 Merge pull request 'Update dependency libbot to v3.3.1' (#16) from renovate/libbot-3.x into dev
Reviewed-on: #16
2024-12-17 00:03:17 +02:00
8edf70e21c Update dependency libbot to v3.3.1 2024-12-17 00:00:36 +02:00
22 changed files with 595 additions and 292 deletions

40
.dockerignore Normal file
View File

@@ -0,0 +1,40 @@
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
**/.DS_Store
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
config.json
.renovaterc
**/.idea
**/.mypy_cache
validation

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
ARG PYTHON_VERSION=3.12.8
FROM python:${PYTHON_VERSION}-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
ARG UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/sbin/nologin" \
--no-create-home \
--uid "${UID}" \
appuser
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=bind,source=requirements.txt,target=requirements.txt \
python -m pip install -r requirements.txt
USER appuser
COPY . .
ENTRYPOINT ["python", "main.py", "--migrate"]

View File

@@ -7,23 +7,59 @@
<a href="https://git.end-play.xyz/HoloUA/Discord"><img alt="Code style: black" src="https://img.shields.io/badge/code%20style-black-000000.svg"></a>
</p>
## Installation
## Installation from release
1. Install MongoDB using the [official installation manual](https://www.mongodb.com/docs/manual/installation/).
2. `git clone https://git.end-play.xyz/HoloUA/Discord.git`
3. `cd Discord`
4. Install Python 3.9+ (at least 3.11 is recommended) for your OS
5. `python3 -m pip install -r requirements.txt`
6. Run it with `python3 main.py` after configuring
1. Install MongoDB using the [official installation manual](https://www.mongodb.com/docs/manual/installation)
2. Install Python 3.11+
3. Download the [latest release](https://git.end-play.xyz/HoloUA/Discord/releases/latest)'s archive
4. Extract the archive
5. Navigate to the extracted folder and subfolder `Discord` in it
6. Create a virtual environment:
`python -m venv .venv` or `virtualenv .venv`
7. Activate the virtual environment:
Windows: `.venv\Scripts\activate.bat`
Linux/macOS: `.venv/bin/activate`
8. Install the dependencies:
`python -m pip install -r requirements.txt`
9. Run the bot with `python main.py` after completing the [configuration](#Configuration)
## Installation with Git
1. Install MongoDB using the [official installation manual](https://www.mongodb.com/docs/manual/installation)
2. Install Python 3.11+
3. Clone the repository:
`git clone https://git.end-play.xyz/HoloUA/Discord.git`
4. `cd Discord`
5. Create a virtual environment:
`python -m venv .venv` or `virtualenv .venv`
6. Activate the virtual environment:
Windows: `.venv\Scripts\activate.bat`
Linux/macOS: `.venv/bin/activate`
7. Install the dependencies:
`python -m pip install -r requirements.txt`
8. Run the bot with `python main.py` after completing the [configuration](#Configuration)
## Upgrading with Git
1. Go to the bot's directory
2. `git pull`
3. Activate the virtual environment:
Windows: `.venv\Scripts\activate.bat`
Linux/macOS: `.venv/bin/activate`
4. Update the dependencies:
`python -m pip install -r requirements.txt`
5. First start after the upgrade must initiate the migration:
`python main.py --migrate`
6. Now the bot is up to date and the next run will not require `--migrate` anymore
## Configuration
There's a file `config_example.json` which contains default configuration
and should be used as a base config.
and should be used as a base config.
Copy this file to `config.json` and open it with any text editor of your liking.
Copy this file to `config.json` and open it with any text editor of your liking.
Modify the newly created configuration file to fit your needs.
Modify the newly created configuration file to fit your needs.
Mandatory keys to modify:
@@ -35,4 +71,23 @@ Mandatory keys to modify:
- channels.*
- roles.*
After all of that you're good to go! Happy using :)
After all of that you're good to go! Happy using :)
## Docker [Experimental]
As an experiment, Docker deployment option has been added.
### Building the image
1. `git clone https://git.end-play.xyz/HoloUA/Discord.git`
2. `cd Discord`
3. `docker build -t holoua-discord .`
### Starting the bot
1. Install MongoDB using the [official installation manual](https://www.mongodb.com/docs/manual/installation)
2. Download
the [configuration example file](https://git.end-play.xyz/HoloUA/Discord/src/branch/main/config_example.json) and
store it somewhere you would like your bot to access it
3. Complete the [configuration](#Configuration) step for this file
4. `docker run -d -v /path/to/config.json:/app/config.json holoua-discord`

6
classes/holo_bot.py Normal file
View File

@@ -0,0 +1,6 @@
from libbot.pycord.classes import PycordBot
class HoloBot(PycordBot):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@@ -1,67 +1,89 @@
import logging
from typing import Any, Union, Dict
from logging import Logger
from typing import Any, Dict
from bson import ObjectId
from discord import User, Member
from libbot import config_get
from libbot.utils import config_get
from pymongo.results import InsertOneResult
from errors import UserNotFoundError
from modules.database import col_warnings, sync_col_users, sync_col_warnings, col_users
from modules.database import col_warnings, col_users
logger = logging.getLogger(__name__)
logger: Logger = logging.getLogger(__name__)
class HoloUser:
def __init__(self, user: Union[User, Member, int]) -> None:
def __init__(
self,
_id: ObjectId,
id: int,
custom_role: int | None,
custom_channel: int | None,
) -> None:
self._id: ObjectId = _id
self.id: int = id
self.custom_role: int | None = custom_role
self.custom_channel: int | None = custom_channel
@classmethod
async def from_user(
cls, user: User | Member, allow_creation: bool = True
) -> "HoloUser":
"""Get an object that has a proper binding between Discord ID and database
### Args:
* `user` (Union[User, Member, int]): Object from which ID can be extracted
* `user` (User | Member): Object from which an ID can be extracted
### Raises:
* `UserNotFoundError`: User with such ID does not seem to exist in database
"""
db_entry: Dict[str, Any] | None = await col_users.find_one({"user": user.id})
self.id: int = user if not hasattr(user, "id") else user.id
if db_entry is None:
if not allow_creation:
raise UserNotFoundError(user=user, user_id=user.id)
jav_user: Union[Dict[str, Any], None] = sync_col_users.find_one(
{"user": self.id}
)
db_entry = {
"user": user.id,
"custom_role": None,
"custom_channel": None,
}
if jav_user is None:
raise UserNotFoundError(user=user, user_id=self.id)
insert_result: InsertOneResult = await col_users.insert_one(db_entry)
self.db_id: ObjectId = jav_user["_id"]
db_entry["_id"] = insert_result.inserted_id()
self.customrole: Union[int, None] = jav_user["customrole"]
self.customchannel: Union[int, None] = jav_user["customchannel"]
self.warnings: int = self.warns()
db_entry["id"] = db_entry.pop("user")
def warns(self) -> int:
return cls(**db_entry)
@classmethod
async def from_id(cls, user_id: int) -> "HoloUser":
return NotImplemented
async def get_warnings(self) -> int:
"""Get number of warnings user has
### Returns:
* `int`: Number of warnings
"""
warns: Union[Dict[str, Any], None] = sync_col_warnings.find_one(
{"user": self.id}
)
warns: Dict[str, Any] | None = await col_warnings.find_one({"user": self.id})
return 0 if warns is None else warns["warns"]
async def warn(self, count=1, reason: str = "Not provided") -> None:
async def warn(self, count: int = 1, reason: str = "Not provided") -> None:
"""Warn and add count to warns number
### Args:
* `count` (int, optional): Count of warnings to be added. Defaults to 1.
"""
warns: Union[Dict[str, Any], None] = await col_warnings.find_one(
{"user": self.id}
)
warns: Dict[str, Any] | None = await col_warnings.find_one({"user": self.id})
if warns is not None:
await col_warnings.update_one(
{"_id": self.db_id},
{"_id": self._id},
{"$set": {"warns": warns["warns"] + count}},
)
else:
@@ -69,8 +91,8 @@ class HoloUser:
logger.info("User %s was warned %s times due to: %s", self.id, count, reason)
async def set(self, key: str, value: Any) -> None:
"""Set attribute data and save it into database
async def _set(self, key: str, value: Any) -> None:
"""Set attribute data and save it into the database
### Args:
* `key` (str): Attribute to be changed
@@ -82,17 +104,50 @@ class HoloUser:
setattr(self, key, value)
await col_users.update_one(
{"_id": self.db_id}, {"$set": {key: value}}, upsert=True
{"_id": self._id}, {"$set": {key: value}}, upsert=True
)
logger.info("Set attribute %s of user %s to %s", key, self.id, value)
async def _remove(self, key: str) -> None:
"""Remove attribute data and save it into the database
### Args:
* `key` (str): Attribute to be removed
"""
if not hasattr(self, key):
raise AttributeError()
setattr(self, key, None)
await col_users.update_one(
{"_id": self._id}, {"$unset": {key: None}}, upsert=True
)
logger.info("Removed attribute %s of user %s", key, self.id)
async def set_custom_channel(self, channel_id: int) -> None:
await self._set("custom_channel", channel_id)
async def set_custom_role(self, role_id: int) -> None:
await self._set("custom_role", role_id)
async def remove_custom_channel(self) -> None:
await self._remove("custom_channel")
async def remove_custom_role(self) -> None:
await self._remove("custom_role")
async def purge(self) -> None:
"""Completely remove user data from database. Will not remove transactions logs and warnings."""
await col_users.delete_one({"_id": self._id})
@staticmethod
async def is_moderator(member: Union[User, Member]) -> bool:
async def is_moderator(member: User | Member) -> bool:
"""Check if user is moderator or council member
### Args:
* `member` (Union[User, Member]): Member object
* `member` (User | Member): Member object
### Returns:
`bool`: `True` if member is a moderator or member of council and `False` if not
@@ -100,8 +155,8 @@ class HoloUser:
if isinstance(member, User):
return False
moderator_role: Union[int, None] = await config_get("moderators", "roles")
council_role: Union[int, None] = await config_get("council", "roles")
moderator_role: int | None = await config_get("moderators", "roles")
council_role: int | None = await config_get("council", "roles")
for role in member.roles:
if role.id in (moderator_role, council_role):
@@ -110,11 +165,11 @@ class HoloUser:
return False
@staticmethod
async def is_council(member: Union[User, Member]) -> bool:
async def is_council(member: User | Member) -> bool:
"""Check if user is a member of council
### Args:
* `member` (Union[User, Member]): Member object
* `member` (User | Member): Member object
### Returns:
`bool`: `True` if member is a member of council and `False` if not
@@ -129,8 +184,3 @@ class HoloUser:
return True
return False
# def purge(self) -> None:
# """Completely remove data from database. Will not remove transactions logs and warnings."""
# col_users.delete_one(filter={"_id": self.db_id})
# self.unauthorize()

View File

@@ -1,6 +1,6 @@
import logging
import sys
from typing import Union
from logging import Logger
from discord import (
ApplicationContext,
@@ -13,111 +13,27 @@ from discord import (
)
from discord import utils as ds_utils
from discord.ext import commands
from libbot import config_get
from libbot.pycord.classes import PycordBot
from libbot.sync import config_get as sync_config_get
from libbot.utils import config_get
from classes.holo_bot import HoloBot
from enums import Color
from modules.scheduler import scheduler
from modules.utils_sync import guild_name
from modules.waifu_pics import waifu_pics
logger = logging.getLogger(__name__)
logger: Logger = logging.getLogger(__name__)
class Admin(commands.Cog):
"""Cog with utility commands for admins."""
def __init__(self, client: PycordBot):
self.client: PycordBot = client
# Disabled because warning functionality is temporarily not needed
# @slash_command(
# name="warning",
# description="Попередити юзера про порушення правил",
# guild_ids=[config_get_sync("guild")],
# )
# @option("user", description="Користувач")
# @option("reason", description="Причина")
# async def warn_cmd(
# self,
# ctx: ApplicationContext,
# user: User,
# reason: str = "Не вказана",
# ):
# logging.info(f"User {ctx.user.id} warned {user.id} for {reason}")
# await ctx.defer()
# jav_user = HoloUser(user)
# if ctx.user.id in await config_get("admins"):
# logging.info(
# f"Moderator {guild_name(ctx.user)} warned {guild_name(user)} for {reason} (has {jav_user.warnings} warns)"
# )
# if jav_user.warnings >= 5:
# logging.info(
# f"User {guild_name(user)} was banned due to a big amount of warns ({jav_user.warnings})"
# )
# await user.send(
# embed=Embed(
# title="Перманентне блокування",
# description=f"Вас було заблоковано за неодноразове порушення правил сервера.",
# color=Color.fail,
# )
# )
# await user.ban(reason=reason)
# elif jav_user.warnings >= 2:
# logging.info(
# f"User {guild_name(user)} was muted due to a big amount of warns ({jav_user.warnings})"
# )
# jav_user.warn(reason=reason)
# await user.send(
# embed=Embed(
# title="Тимчасове блокування",
# description=f"Причина: `{reason}`\n\nНа вашому рахунку вже {jav_user.warnings} попереджень. Вас було тимчасово заблоковано на **1 годину**.\n\nЯкщо Ви продовжите порушувати правила сервера згодом Вас заблокують.",
# color=0xDED56B,
# )
# )
# await user.timeout_for(timedelta(hours=1), reason=reason)
# else:
# jav_user.warn()
# await ctx.respond(
# embed=Embed(
# title="Попередження",
# description=f"{user.mention} Будь ласка, не порушуйте правила. Ви отримали попередження з причини `{reason}`.\n\nЯкщо Ви продовжите порушувати правила це може призвести до блокування в спільноті.",
# color=0xDED56B,
# )
# )
# else:
# logging.warning(
# f"User {guild_name(ctx.user)} tried to use /warn but permission denied"
# )
# await ctx.respond(
# embed=Embed(
# title="Відмовлено в доступі",
# description="Здається, це команда лише для модераторів",
# color=Color.fail,
# )
# )
# mod_role = ds_utils.get(
# ctx.user.guild.roles, id=await config_get("moderators", "roles")
# )
# admin_chan = ds_utils.get(
# ctx.user.guild.channels,
# id=await config_get("adminchat", "channels", "text"),
# )
# await admin_chan.send(
# content=f"{mod_role.mention}",
# embed=Embed(
# title="Неавторизований запит",
# description=f"Користувач {ctx.user.mention} запитав у каналі {ctx.channel.mention} команду, до якої не повинен мати доступу/бачити.",
# color=Color.fail,
# ),
# )
def __init__(self, client: HoloBot):
self.client: HoloBot = client
@slash_command(
name="clear",
description="Видалити деяку кількість повідомлень в каналі",
guild_ids=[sync_config_get("guild")],
guild_ids=[config_get("guild")],
)
@option("amount", description="Кількість")
@option("user", description="Користувач", default=None)
@@ -127,6 +43,10 @@ class Admin(commands.Cog):
amount: int,
user: User,
) -> None:
"""Command /clear <amount> [<user>]
Removes last <amount> messages in the current channel. Optionally from a specific user.
"""
if ctx.user.id in self.client.owner_ids:
logging.info(
"User %s removed %s message(s) in %s",
@@ -161,10 +81,10 @@ class Admin(commands.Cog):
)
)
mod_role: Union[Role, None] = ds_utils.get(
mod_role: Role | None = ds_utils.get(
ctx.user.guild.roles, id=await config_get("moderators", "roles")
)
admin_chan: Union[TextChannel, None] = ds_utils.get(
admin_chan: TextChannel | None = ds_utils.get(
ctx.user.guild.channels,
id=await config_get("adminchat", "channels", "text"),
)
@@ -182,9 +102,13 @@ class Admin(commands.Cog):
@slash_command(
name="reboot",
description="Перезапустити бота",
guild_ids=[sync_config_get("guild")],
guild_ids=[config_get("guild")],
)
async def reboot_cmd(self, ctx: ApplicationContext) -> None:
"""Command /reboot
Stops the bot. Is called "reboot" because it's assumed that the bot has automatic restart.
"""
await ctx.defer(ephemeral=True)
if ctx.user.id in self.client.owner_ids:
@@ -217,10 +141,10 @@ class Admin(commands.Cog):
)
)
mod_role: Union[Role, None] = ds_utils.get(
mod_role: Role | None = ds_utils.get(
ctx.user.guild.roles, id=await config_get("moderators", "roles")
)
admin_chan: Union[TextChannel, None] = ds_utils.get(
admin_chan: TextChannel | None = ds_utils.get(
ctx.user.guild.channels,
id=await config_get("adminchat", "channels", "text"),
)
@@ -236,5 +160,5 @@ class Admin(commands.Cog):
)
def setup(client: PycordBot) -> None:
def setup(client: HoloBot) -> None:
client.add_cog(Admin(client))

View File

@@ -1,26 +1,29 @@
import logging
from logging import Logger
from typing import Dict, List, Any
from discord import Cog, Message
from discord.ext import commands
from libbot.pycord.classes import PycordBot
from classes.holo_bot import HoloBot
from modules.database import col_analytics
logger = logging.getLogger(__name__)
logger: Logger = logging.getLogger(__name__)
class Analytics(commands.Cog):
def __init__(self, client: PycordBot):
self.client: PycordBot = client
def __init__(self, client: HoloBot):
self.client: HoloBot = client
@Cog.listener()
async def on_message(self, message: Message) -> None:
"""Listener that collects analytical data (stickers, attachments, messages)."""
if (
(message.author != self.client.user)
and (message.author.bot is False)
and (message.author.system is False)
):
# Handle stickers
stickers: List[Dict[str, Any]] = []
for sticker in message.stickers:
@@ -33,6 +36,7 @@ class Analytics(commands.Cog):
}
)
# Handle attachments
attachments: List[Dict[str, Any]] = []
for attachment in message.attachments:
@@ -49,6 +53,7 @@ class Analytics(commands.Cog):
}
)
# Insert entry into the database
await col_analytics.insert_one(
{
"user": message.author.id,
@@ -60,5 +65,5 @@ class Analytics(commands.Cog):
)
def setup(client: PycordBot) -> None:
def setup(client: HoloBot) -> None:
client.add_cog(Analytics(client))

View File

@@ -1,31 +1,31 @@
import logging
from typing import Any, Dict, Union
from logging import Logger
from typing import Any, Dict
from discord import ApplicationContext, Embed, option, TextChannel, Role
from discord import utils as ds_utils
from discord.abc import GuildChannel
from discord.commands import SlashCommandGroup
from discord.ext import commands
from libbot import config_get
from libbot.pycord.classes import PycordBot
from libbot.sync import config_get as sync_config_get
from libbot.utils import config_get
from classes.holo_bot import HoloBot
from classes.holo_user import HoloUser
from enums import Color
from modules.database import col_users
from modules.utils_sync import guild_name
logger = logging.getLogger(__name__)
logger: Logger = logging.getLogger(__name__)
class CustomChannels(commands.Cog):
def __init__(self, client: PycordBot):
self.client: PycordBot = client
def __init__(self, client: HoloBot):
self.client: HoloBot = client
@commands.Cog.listener()
async def on_guild_channel_delete(self, channel: GuildChannel) -> None:
await col_users.find_one_and_update(
{"customchannel": channel.id}, {"$set": {"customchannel": None}}
{"custom_channel": channel.id}, {"$set": {"custom_channel": None}}
)
custom_channel_group: SlashCommandGroup = SlashCommandGroup(
@@ -35,7 +35,7 @@ class CustomChannels(commands.Cog):
@custom_channel_group.command(
name="get",
description="Отримати персональний текстовий канал",
guild_ids=[sync_config_get("guild")],
guild_ids=[config_get("guild")],
)
@option("name", description="Назва каналу")
@option("reactions", description="Дозволити реакції")
@@ -43,7 +43,11 @@ class CustomChannels(commands.Cog):
async def custom_channel_get_cmd(
self, ctx: ApplicationContext, name: str, reactions: bool, threads: bool
) -> None:
holo_user_ctx: HoloUser = HoloUser(ctx.user)
"""Command /customchannel get <name> <reactions> <threads>
Command to create a custom channel for a user.
"""
holo_user_ctx: HoloUser = await HoloUser.from_user(ctx.user)
# Return if the user is using the command outside of a guild
if not hasattr(ctx.author, "guild"):
@@ -58,7 +62,7 @@ class CustomChannels(commands.Cog):
return
# Return if the user already has a custom channel
if holo_user_ctx.customchannel is not None:
if holo_user_ctx.custom_channel is not None:
await ctx.defer(ephemeral=True)
await ctx.respond(
embed=Embed(
@@ -76,7 +80,7 @@ class CustomChannels(commands.Cog):
reason=f"Користувач {guild_name(ctx.user)} отримав власний приватний канал",
category=ds_utils.get(
ctx.author.guild.categories,
id=await config_get("customchannels", "categories"),
id=await config_get("custom_channels", "categories"),
),
)
@@ -96,7 +100,7 @@ class CustomChannels(commands.Cog):
manage_channels=True,
)
await holo_user_ctx.set("customchannel", created_channel.id)
await holo_user_ctx.set_custom_channel(created_channel.id)
await ctx.respond(
embed=Embed(
@@ -109,9 +113,7 @@ class CustomChannels(commands.Cog):
bots: Dict[str, Any] = await config_get("bots")
for bot in bots:
role: Union[Role, None] = ds_utils.get(
ctx.user.guild.roles, id=bots[bot]["role"]
)
role: Role | None = ds_utils.get(ctx.user.guild.roles, id=bots[bot]["role"])
if role is not None:
await created_channel.set_permissions(
@@ -122,7 +124,7 @@ class CustomChannels(commands.Cog):
@custom_channel_group.command(
name="edit",
description="Змінити параметри особистого каналу",
guild_ids=[sync_config_get("guild")],
guild_ids=[config_get("guild")],
)
@option("name", description="Назва каналу")
@option("reactions", description="Дозволити реакції")
@@ -130,10 +132,14 @@ class CustomChannels(commands.Cog):
async def custom_channel_edit_cmd(
self, ctx: ApplicationContext, name: str, reactions: bool, threads: bool
) -> None:
holo_user_ctx: HoloUser = HoloUser(ctx.user)
"""Command /customchannel edit <name> <reactions> <threads>
custom_channel: Union[TextChannel, None] = ds_utils.get(
ctx.guild.channels, id=holo_user_ctx.customchannel
Command to change properties of a custom channel.
"""
holo_user_ctx: HoloUser = await HoloUser.from_user(ctx.user)
custom_channel: TextChannel | None = ds_utils.get(
ctx.guild.channels, id=holo_user_ctx.custom_channel
)
# Return if the channel was not found
@@ -167,16 +173,19 @@ class CustomChannels(commands.Cog):
@custom_channel_group.command(
name="remove",
description="Відібрати канал, знищуючи його, та частково повернути кошти",
guild_ids=[sync_config_get("guild")],
guild_ids=[config_get("guild")],
)
@option("confirm", description="Підтвердження операції")
async def custom_channel_remove_cmd(
self, ctx: ApplicationContext, confirm: bool = False
) -> None:
holo_user_ctx: HoloUser = HoloUser(ctx.user)
"""Command /customchannel remove [<confirm>]
Command to remove a custom channel. Requires additional confirmation."""
holo_user_ctx: HoloUser = await HoloUser.from_user(ctx.user)
# Return if the user does not have a custom channel
if holo_user_ctx.customchannel is None:
if holo_user_ctx.custom_channel is None:
await ctx.defer(ephemeral=True)
await ctx.respond(
embed=Embed(
@@ -189,8 +198,8 @@ class CustomChannels(commands.Cog):
await ctx.defer()
custom_channel: Union[TextChannel, None] = ds_utils.get(
ctx.guild.channels, id=holo_user_ctx.customchannel
custom_channel: TextChannel | None = ds_utils.get(
ctx.guild.channels, id=holo_user_ctx.custom_channel
)
# Return if the channel was not found
@@ -202,7 +211,7 @@ class CustomChannels(commands.Cog):
color=Color.FAIL,
)
)
await holo_user_ctx.set("customchannel", None)
await holo_user_ctx.remove_custom_channel()
return
# Return if the confirmation is missing
@@ -218,7 +227,7 @@ class CustomChannels(commands.Cog):
await custom_channel.delete(reason="Власник запросив видалення")
await holo_user_ctx.set("customchannel", None)
await holo_user_ctx.remove_custom_channel()
try:
await ctx.respond(
@@ -234,5 +243,5 @@ class CustomChannels(commands.Cog):
)
def setup(client: PycordBot) -> None:
def setup(client: HoloBot) -> None:
client.add_cog(CustomChannels(client))

View File

@@ -1,41 +1,43 @@
import logging
from logging import Logger
from os import makedirs
from pathlib import Path
from typing import Union, List, Dict, Any
from typing import List, Dict, Any
from uuid import uuid4
from discord import ApplicationContext, Embed, File, option, Role, TextChannel
from discord import utils as ds_utils
from discord.commands import SlashCommandGroup
from discord.ext import commands
from libbot import config_get
from libbot.pycord.classes import PycordBot
from libbot.sync import config_get as sync_config_get
from libbot.sync import json_write as sync_json_write
from libbot.utils import config_get, json_write
from classes.holo_bot import HoloBot
from classes.holo_user import HoloUser
from enums import Color
from modules.database import col_users
from modules.utils_sync import guild_name
logger = logging.getLogger(__name__)
logger: Logger = logging.getLogger(__name__)
class Data(commands.Cog):
def __init__(self, client: PycordBot):
self.client: PycordBot = client
def __init__(self, client: HoloBot):
self.client: HoloBot = client
data: SlashCommandGroup = SlashCommandGroup("data", "Керування даними користувачів")
@data.command(
name="export",
description="Експортувати дані",
guild_ids=[sync_config_get("guild")],
guild_ids=[config_get("guild")],
)
@option(
"kind", description="Тип даних, які треба експортувати", choices=["Користувачі"]
)
async def data_export_cmd(self, ctx: ApplicationContext, kind: str) -> None:
"""Command /data export <kind>
Command to export specific kind of data."""
await ctx.defer()
# Return if the user is not an owner and not in the council
@@ -55,10 +57,10 @@ class Data(commands.Cog):
)
)
mod_role: Union[Role, None] = ds_utils.get(
mod_role: Role | None = ds_utils.get(
ctx.user.guild.roles, id=await config_get("moderators", "roles")
)
admin_chan: Union[TextChannel, None] = ds_utils.get(
admin_chan: TextChannel | None = ds_utils.get(
ctx.user.guild.channels,
id=await config_get("adminchat", "channels", "text"),
)
@@ -93,19 +95,26 @@ class Data(commands.Cog):
}
)
sync_json_write(users, Path(f"tmp/{uuid}"))
# Temporary file must be written synchronously,
# otherwise it will not be there when ctx.respond() is be called
json_write(users, Path(f"tmp/{uuid}"))
await ctx.respond(file=File(Path(f"tmp/{uuid}"), filename="users.json"))
@data.command(
name="migrate",
description="Мігрувати всіх користувачів до бази",
guild_ids=[sync_config_get("guild")],
guild_ids=[config_get("guild")],
)
@option(
"kind", description="Тип даних, які треба експортувати", choices=["Користувачі"]
)
async def data_migrate_cmd(self, ctx: ApplicationContext, kind: str) -> None:
"""Command /migrate <kind>
Command to migrate specific kind of data.
Migration of users in this case means creation of their DB entries."""
await ctx.defer()
# Return if the user is not an owner and not in the council
@@ -125,10 +134,10 @@ class Data(commands.Cog):
)
)
mod_role: Union[Role, None] = ds_utils.get(
mod_role: Role | None = ds_utils.get(
ctx.user.guild.roles, id=await config_get("moderators", "roles")
)
admin_chan: Union[TextChannel, None] = ds_utils.get(
admin_chan: TextChannel | None = ds_utils.get(
ctx.user.guild.channels,
id=await config_get("adminchat", "channels", "text"),
)
@@ -179,5 +188,5 @@ class Data(commands.Cog):
)
def setup(client: PycordBot) -> None:
def setup(client: HoloBot) -> None:
client.add_cog(Data(client))

View File

@@ -1,33 +1,36 @@
import logging
from logging import Logger
from discord import ApplicationContext, Embed, User, option, slash_command
from discord.ext import commands
from libbot import config_get
from libbot.pycord.classes import PycordBot
from libbot.sync import config_get as sync_config_get
from libbot.utils import config_get
from classes.holo_bot import HoloBot
from modules.utils_sync import guild_name
from modules.waifu_pics import waifu_pics
logger = logging.getLogger(__name__)
logger: Logger = logging.getLogger(__name__)
class Fun(commands.Cog):
def __init__(self, client: PycordBot):
self.client: PycordBot = client
def __init__(self, client: HoloBot):
self.client: HoloBot = client
@slash_command(
name="action",
description="Провести над користувачем РП дію",
guild_ids=[sync_config_get("guild")],
guild_ids=[config_get("guild")],
)
@option(
"type",
description="Тип дії, яку хочете провести з користувачем",
choices=sync_config_get("actions").keys(),
choices=config_get("actions").keys(),
)
@option("user", description="Користувач")
async def action_cmd(self, ctx: ApplicationContext, type: str, user: User) -> None:
"""Command /action <type> <user>
Command to perform some RP action on a user and send them a GIF."""
await ctx.defer()
action: str = await config_get("category", "actions", type)
@@ -54,5 +57,5 @@ class Fun(commands.Cog):
await ctx.respond(embed=embed)
def setup(client: PycordBot) -> None:
def setup(client: HoloBot) -> None:
client.add_cog(Fun(client))

View File

@@ -1,20 +1,25 @@
from typing import Dict, Any, Union
import logging
from logging import Logger
from typing import Dict, Any
from discord import Member, Message, TextChannel
from discord import Member, Message, TextChannel, MessageType
from discord import utils as ds_utils
from discord.ext import commands
from libbot import config_get
from libbot.pycord.classes import PycordBot
from libbot.utils import config_get
from classes.holo_bot import HoloBot
from modules.database import col_users
logger: Logger = logging.getLogger(__name__)
class Logger(commands.Cog):
def __init__(self, client: PycordBot):
self.client: PycordBot = client
def __init__(self, client: HoloBot):
self.client: HoloBot = client
@commands.Cog.listener()
async def on_message(self, message: Message):
"""Message listener. All actions on messages remain here for now."""
if (
(message.author != self.client.user)
and (message.author.bot is False)
@@ -31,17 +36,42 @@ class Logger(commands.Cog):
await col_users.insert_one(document=user)
if (
(message.type == MessageType.thread_created)
and (message.channel is not None)
and (
await col_users.count_documents({"custom_channel": message.channel.id})
> 0
)
):
try:
logger.info(
"Deleting the thread creation message in a custom channel %s",
message.channel.id,
)
await message.delete()
except Exception as exc:
logger.warning(
"Could not delete the thread creation message in a custom channel %s due to %s",
message.channel.id,
exc,
)
@commands.Cog.listener()
async def on_member_join(self, member: Member) -> None:
welcome_chan: Union[TextChannel, None] = ds_utils.get(
"""Member join handler. All actions on member join remain here for now."""
welcome_chan: TextChannel | None = ds_utils.get(
self.client.get_guild(await config_get("guild")).channels,
id=await config_get("welcome", "channels", "text"),
)
rules_chan: Union[TextChannel, None] = ds_utils.get(
rules_chan: TextChannel | None = ds_utils.get(
self.client.get_guild(await config_get("guild")).channels,
id=await config_get("rules", "channels", "text"),
)
if welcome_chan is None:
logger.warning("Could not find a welcome channel by its id")
if (
(member != self.client.user)
and (member.bot is False)
@@ -65,5 +95,5 @@ class Logger(commands.Cog):
await col_users.insert_one(document=user)
def setup(client: PycordBot) -> None:
def setup(client: HoloBot) -> None:
client.add_cog(Logger(client))

58
cogs/utility.py Normal file
View File

@@ -0,0 +1,58 @@
import logging
from logging import Logger
from discord import Activity, ActivityType
from discord.ext import commands
from libbot.utils import config_get
from classes.holo_bot import HoloBot
logger: Logger = logging.getLogger(__name__)
class Utility(commands.Cog):
def __init__(self, client: HoloBot):
self.client: HoloBot = client
@commands.Cog.listener()
async def on_ready(self) -> None:
"""Listener for the event when bot connects to Discord and becomes "ready"."""
logger.info("Logged in as %s", self.client.user)
activity_type: str = await config_get("type", "status")
activity_message: str = await config_get("message", "status")
if activity_type == "playing":
await self.client.change_presence(
activity=Activity(type=ActivityType.playing, name=activity_message)
)
elif activity_type == "watching":
await self.client.change_presence(
activity=Activity(type=ActivityType.watching, name=activity_message)
)
elif activity_type == "listening":
await self.client.change_presence(
activity=Activity(type=ActivityType.listening, name=activity_message)
)
elif activity_type == "streaming":
await self.client.change_presence(
activity=Activity(type=ActivityType.streaming, name=activity_message)
)
elif activity_type == "competing":
await self.client.change_presence(
activity=Activity(type=ActivityType.competing, name=activity_message)
)
elif activity_type == "custom":
await self.client.change_presence(
activity=Activity(type=ActivityType.custom, name=activity_message)
)
else:
return
logger.info(
"Set activity type to %s with message %s", activity_type, activity_message
)
def setup(client: HoloBot) -> None:
client.add_cog(Utility(client))

View File

@@ -28,12 +28,12 @@
},
"defaults": {
"user": {
"customrole": null,
"customchannel": null
"custom_role": null,
"custom_channel": null
}
},
"categories": {
"customchannels": 0
"custom_channels": 0
},
"channels": {
"text": {

92
main.py
View File

@@ -1,12 +1,15 @@
import contextlib
import logging
import sys
from argparse import ArgumentParser
from logging import Logger
from pathlib import Path
from discord import Activity, ActivityType
from libbot import config_get
from libbot.sync import config_get as sync_config_get
from discord import LoginFailure, Intents
from libbot.utils import config_get
from modules.client import client
from classes.holo_bot import HoloBot
from modules.migrator import migrate_database
from modules.scheduler import scheduler
logging.basicConfig(
@@ -17,61 +20,50 @@ logging.basicConfig(
logger: Logger = logging.getLogger(__name__)
try:
import uvloop # type: ignore
# Declare the parser that retrieves the command line arguments
parser = ArgumentParser(
prog="HoloUA Discord",
description="Discord bot for the HoloUA community.",
)
# Add a switch argument --migrate to be parsed...
parser.add_argument("--migrate", action="store_true")
# ...and parse the arguments we added
args = parser.parse_args()
# Try to import the module that improves performance
# and ignore errors when module is not installed
with contextlib.suppress(ImportError):
import uvloop
uvloop.install()
except ImportError:
pass
@client.event
async def on_ready() -> None:
logger.info("Logged in as %s", client.user)
activity_type: str = await config_get("type", "status")
activity_message: str = await config_get("message", "status")
if activity_type == "playing":
await client.change_presence(
activity=Activity(type=ActivityType.playing, name=activity_message)
)
elif activity_type == "watching":
await client.change_presence(
activity=Activity(type=ActivityType.watching, name=activity_message)
)
elif activity_type == "listening":
await client.change_presence(
activity=Activity(type=ActivityType.listening, name=activity_message)
)
elif activity_type == "streaming":
await client.change_presence(
activity=Activity(type=ActivityType.streaming, name=activity_message)
)
elif activity_type == "competing":
await client.change_presence(
activity=Activity(type=ActivityType.competing, name=activity_message)
)
elif activity_type == "custom":
await client.change_presence(
activity=Activity(type=ActivityType.custom, name=activity_message)
)
else:
return
logger.info(
"Set activity type to %s with message %s", activity_type, activity_message
)
def main() -> None:
if not Path("config.json").exists():
logger.error(
"Config file is missing: Make sure the configuration file 'config.json' is in place."
)
sys.exit()
# Perform migration if command line argument was provided
if args.migrate:
logger.info("Performing migrations...")
migrate_database()
intents: Intents = Intents().all()
client: HoloBot = HoloBot(intents=intents, scheduler=scheduler)
client.load_extension("cogs")
try:
scheduler.start()
client.run(sync_config_get("bot_token", "bot"))
client.run(config_get("bot_token", "bot"))
except LoginFailure as exc:
logger.error("Provided bot token is invalid: %s", exc)
except KeyboardInterrupt:
scheduler.shutdown()
logger.info("KeyboardInterrupt received: Shutting down gracefully.")
finally:
sys.exit()

View File

@@ -0,0 +1,79 @@
import logging
from logging import Logger
from libbot.utils import config_get, config_set, config_delete
from mongodb_migrations.base import BaseMigration
logger: Logger = logging.getLogger(__name__)
class Migration(BaseMigration):
def upgrade(self):
try:
# Categories
config_set(
"custom_channels",
config_get("customchannels", "categories"),
"categories",
)
config_delete("customchannels", "categories")
# User defaults
config_delete(
"user",
"defaults",
)
except Exception as exc:
logger.error(
"Could not upgrade the config during migration '%s' due to: %s",
__name__,
exc,
)
self.db.users.update_many(
{"customchannel": {"$exists": True}},
{"$rename": {"customchannel": "custom_channel"}},
)
self.db.users.update_many(
{"customrole": {"$exists": True}},
{"$rename": {"customrole": "custom_role"}},
)
def downgrade(self):
try:
# Categories
config_set(
"customchannels",
config_get("custom_channels", "categories"),
"categories",
)
config_delete("custom_channels", "categories")
# User defaults
config_set(
"customrole",
None,
"defaults",
"user",
)
config_set(
"customchannel",
None,
"defaults",
"user",
)
except Exception as exc:
logger.error(
"Could not downgrade the config during migration '%s' due to: %s",
__name__,
exc,
)
self.db.users.update_many(
{"custom_channel": {"$exists": True}},
{"$rename": {"custom_channel": "customchannel"}},
)
self.db.users.update_many(
{"custom_role": {"$exists": True}},
{"$rename": {"custom_role": "customrole"}},
)

View File

@@ -1,10 +0,0 @@
from discord import Intents
from libbot.pycord.classes import PycordBot
from modules.scheduler import scheduler
intents: Intents = Intents().all()
intents.members = True
client: PycordBot = PycordBot(intents=intents, scheduler=scheduler)

View File

@@ -1,12 +1,12 @@
from typing import Dict, Any
from async_pymongo import AsyncClient, AsyncCollection, AsyncDatabase
from libbot.sync import config_get as sync_config_get
from libbot.utils import config_get
from pymongo import MongoClient
from pymongo.synchronous.collection import Collection
from pymongo.synchronous.database import Database
db_config: Dict[str, Any] = sync_config_get("database")
db_config: Dict[str, Any] = config_get("database")
con_string: str = (
"mongodb://{0}:{1}/{2}".format(

22
modules/migrator.py Normal file
View File

@@ -0,0 +1,22 @@
from typing import Any, Mapping
from libbot.utils import config_get
from mongodb_migrations.cli import MigrationManager
from mongodb_migrations.config import Configuration
def migrate_database() -> None:
"""Apply migrations from folder `migrations/` to the database"""
database_config: Mapping[str, Any] = config_get("database")
manager_config = Configuration(
{
"mongo_host": database_config["host"],
"mongo_port": database_config["port"],
"mongo_database": database_config["name"],
"mongo_username": database_config["user"],
"mongo_password": database_config["password"],
}
)
manager = MigrationManager(manager_config)
manager.run()

View File

@@ -1,9 +1,7 @@
from typing import Union
from discord import Member, User
def guild_name(member: Union[Member, User]) -> str:
def guild_name(member: Member | User) -> str:
if isinstance(member, User):
return member.name

View File

@@ -5,6 +5,7 @@ requests>=2.32.2
aiofiles~=24.1.0
apscheduler>=3.10.0
async_pymongo==0.1.11
libbot[speed,pycord]==3.2.3
libbot[speed,pycord]==4.0.1
mongodb-migrations==1.3.1
ujson~=5.10.0
WaifuPicsPython==0.2.0

View File

@@ -2,21 +2,26 @@
"$jsonSchema": {
"required": [
"user",
"customrole",
"customchannel"
"custom_role",
"custom_channel"
],
"properties": {
"user": {
"bsonType": "long",
"description": "Discord ID of user"
},
"customrole": {
"bsonType": ["null", "long"],
"custom_role": {
"bsonType": [
"null",
"long"
],
"description": "Discord ID of custom role or 'null' if not set"
},
"customchannel": {
"bsonType": ["null", "long"],
"custom_channel": {
"bsonType": [
"null",
"long"
],
"description": "Discord ID of custom channel or 'null' if not set"
}
}

View File

@@ -2,14 +2,14 @@
"$jsonSchema": {
"required": [
"user",
"warns"
"warnings"
],
"properties": {
"user": {
"bsonType": "long",
"description": "Discord ID of user"
},
"warns": {
"warnings": {
"bsonType": "int",
"description": "Number of warnings on count"
}