78 Commits
30 changed files with 815 additions and 185 deletions
+40
View File
@@ -0,0 +1,40 @@
---
name: Bug Report
about: Create a report to help us improve
title: '[BUG] '
labels: ['Kind/Bug']
assignees: ''
---
<!--- Provide a general summary of the issue in the Title above -->
## Description
<!--- Provide a more detailed introduction to the issue itself, and why you consider it to be a bug -->
## Expected Behavior
<!--- Tell us what should happen -->
## Current Behavior
<!--- Tell us what happens instead -->
## Possible Fix
<!--- Not obligatory, but suggest a fix or reason for the bug -->
## Steps to Reproduce
<!--- Provide a link to a live example, or an unambiguous set of steps to -->
<!--- reproduce this bug. Include code to reproduce, if relevant -->
1.
2.
3.
4.
## Context
<!--- How has this bug affected you? What were you trying to accomplish? -->
## Your Environment
<!--- Include as many relevant details about the environment you experienced the bug in -->
* Version used:
* Environment name and version (e.g., Python 3.12):
* Server type and version:
* Operating System and version:
* Link to your project or a reproduction:
+27
View File
@@ -0,0 +1,27 @@
---
name: Feature Request
about: Suggest an idea for this project
title: '[FEAT] '
labels: ['Kind/Feature']
assignees: ''
---
<!--- Provide a general summary of the issue in the Title above -->
## Detailed Description
<!--- Provide a detailed description of the change or addition you are proposing -->
## Context
<!--- Why is this change important to you? How would you use it? -->
<!--- How can it benefit other users? -->
## Possible Implementation
<!--- Not obligatory, but suggest an idea for implementing addition or change -->
## Your Environment
<!--- Include as many relevant details about the environment you experienced the bug in -->
* Version used:
* Environment name and version (e.g., Python 3.12):
* Server type and version:
* Operating System and version:
* Link to your project or a reproduction:
+31
View File
@@ -0,0 +1,31 @@
<!--- Provide a general summary of your changes in the Title above -->
## Description
<!--- Describe your changes in detail -->
## Motivation and Context
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->
## How Has This Been Tested?
<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->
## Screenshots (if appropriate)
## Types of Changes
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Refactor (non-breaking change that neither fixes nor adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Documentation change (non-breaking change that only affected the documentation)
- [ ] Other change (non-breaking change with clear description in other sections)
## Checklist
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
- [ ] My code follows the code style of this project.
- [ ] My change requires a change to the documentation.
- [ ] I have updated the documentation accordingly.
+32
View File
@@ -0,0 +1,32 @@
name: build-docker
on:
push:
branches:
- main
- staging
- dev
jobs:
docker:
runs-on: ubuntu-24.04
steps:
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.DOCKER_REGISTRY_FQDN }}
username: ${{ vars.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
tags: ${{ vars.DOCKER_REGISTRY_FQDN }}/${{ vars.DOCKER_IMAGE_USERNAME }}/${{ vars.DOCKER_IMAGE_NAME }}:${{ gitea.REF_NAME }}
secrets: |
GIT_AUTH_TOKEN=${{ secrets.DOCKER_REGISTRY_TOKEN }}
+30
View File
@@ -0,0 +1,30 @@
name: build-docker-release
on:
release:
types:
- published
jobs:
docker:
runs-on: ubuntu-24.04
steps:
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.DOCKER_REGISTRY_FQDN }}
username: ${{ vars.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
tags: ${{ vars.DOCKER_REGISTRY_FQDN }}/${{ vars.DOCKER_IMAGE_USERNAME }}/${{ vars.DOCKER_IMAGE_NAME }}:${{ gitea.REF_NAME }},${{ vars.DOCKER_REGISTRY_FQDN }}/${{ vars.DOCKER_IMAGE_USERNAME }}/${{ vars.DOCKER_IMAGE_NAME }}:latest
secrets: |
GIT_AUTH_TOKEN=${{ secrets.DOCKER_REGISTRY_TOKEN }}
+28
View File
@@ -0,0 +1,28 @@
name: build-docker
on:
pull_request:
branches:
- dev
jobs:
docker:
runs-on: ubuntu-24.04
steps:
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.DOCKER_REGISTRY_FQDN }}
username: ${{ vars.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker
- name: Build
uses: docker/build-push-action@v6
with:
push: false
tags: ${{ vars.DOCKER_REGISTRY_FQDN }}/${{ vars.DOCKER_IMAGE_USERNAME }}/${{ vars.DOCKER_IMAGE_NAME }}:${{ gitea.REF_NAME }}
+1 -1
View File
@@ -24,4 +24,4 @@ USER appuser
COPY . .
ENTRYPOINT ["python", "main.py"]
ENTRYPOINT ["python", "main.py", "--migrate"]
+65 -3
View File
@@ -6,6 +6,11 @@
<a href="https://git.end-play.xyz/HoloUA/Discord/src/branch/master/LICENSE"><img alt="License: GPL" src="https://img.shields.io/badge/License-GPL-blue"></a>
<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>
<p align="center">
<a href="https://status.end-play.xyz/endpoints/holoua_discord"><img alt="Health" src="https://status.end-play.xyz/api/v1/endpoints/holoua_discord/health/badge.svg"></a>
<a href="https://status.end-play.xyz/endpoints/holoua_discord"><img alt="Uptime 24h" src="https://status.end-play.xyz/api/v1/endpoints/holoua_discord/uptimes/24h/badge.svg"></a>
<a href="https://status.end-play.xyz/endpoints/holoua_discord"><img alt="Uptime 7d" src="https://status.end-play.xyz/api/v1/endpoints/holoua_discord/uptimes/7d/badge.svg"></a>
</p>
## Installation from release
@@ -30,9 +35,27 @@
3. Clone the repository:
`git clone https://git.end-play.xyz/HoloUA/Discord.git`
4. `cd Discord`
5. Install dependencies:
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`
6. Run the bot with `python main.py` after completing the [configuration](#Configuration)
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
@@ -55,10 +78,49 @@ Mandatory keys to modify:
After all of that you're good to go! Happy using :)
## Caching
Although general database access speed is fast, caching might become helpful for
bigger servers with many bot interactions. Currently, Redis and Memcached are supported.
Configuration happens through the config key `caching`.
Set `caching.type` to the service of you choice ("redis" or "memcached") and then update
the URI to access the service. It's Redis' default URI format for Redis and "address:port"
for Memcached.
Which one should I choose?
| Service | Read/write speed | Config flexibility |
|-----------|------------------|--------------------|
| Redis | High | Very flexible |
| Memcached | Very high | Basic |
> Performance difference between Redis and Memcached is generally quite low, so your setup
> should normally depend more on the configuration flexibility than on raw speed.
## Debugging
There's a config key `debug` that can be set to `true` for debugging purposes.
It should be set to `false` in production, otherwise log becomes very verbose and might
contain data that shouldn't normally be logged.
## Docker [Experimental]
As an experiment, Docker deployment option has been added.
### 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 --name holoua-discord git.end-play.xyz/holoua/holoua-discord`
## Building Docker images [Experimental]
### Building the image
1. `git clone https://git.end-play.xyz/HoloUA/Discord.git`
@@ -72,4 +134,4 @@ As an experiment, Docker deployment option has been added.
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`
4. `docker run -d -v /path/to/config.json:/app/config.json holoua-discord`
View File
+56
View File
@@ -1,6 +1,62 @@
import logging
from datetime import datetime, timedelta, timezone
from logging import Logger
from typing import Literal
from aiohttp import ClientSession, ClientTimeout
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from libbot.cache.classes import CacheMemcached, CacheRedis
from libbot.cache.manager import create_cache_client
from libbot.pycord.classes import PycordBot
logger: Logger = logging.getLogger(__name__)
class HoloBot(PycordBot):
cache: CacheMemcached | CacheRedis | None = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._schedule_jobs()
self._set_cache_engine()
def _set_cache_engine(self) -> None:
cache_type: Literal["redis", "memcached"] | None = self.config["cache"]["type"]
if "cache" in self.config and cache_type is not None:
self.cache = create_cache_client(
self.config,
cache_type,
prefix=self.config["cache"][cache_type]["prefix"],
default_ttl_seconds=3600,
)
async def _send_heartbeat(self) -> None:
if self.config["monitoring"]["enabled"] is False:
return
async with ClientSession() as session:
await session.post(
self.config["monitoring"]["url"],
headers={
"Authorization": f"Bearer {self.config['monitoring']['token']}"
},
timeout=ClientTimeout(3.0),
)
def _schedule_jobs(self) -> None:
if self.scheduler is None:
return
self.scheduler.add_job(
self._send_heartbeat,
trigger=DateTrigger(
datetime.now(tz=timezone.utc) + timedelta(seconds=5),
timezone=timezone.utc,
),
)
self.scheduler.add_job(
self._send_heartbeat,
trigger=CronTrigger.from_crontab("* * * * *"),
)
+140 -41
View File
@@ -1,75 +1,84 @@
import logging
from logging import Logger
from typing import Any, Dict
from typing import Any
from bson import ObjectId
from discord import User, Member
from discord import Member, User
from libbot.cache.classes import Cache
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_users
logger: Logger = logging.getLogger(__name__)
class HoloUser:
def __init__(self, user: 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,
cache: Cache | None = None,
) -> "HoloUser":
"""Get an object that has a proper binding between Discord ID and database
### Args:
* `user` (User | Member | int): Object from which ID can be extracted
* `user` (User | Member): Object from which an ID can be extracted
* `allow_creation` (bool, optional): Whether to allow creation of a new user record if none found. Defaults to True.
* `cache` (Cache, optional): Cache engine to get the cache from
### Raises:
* `UserNotFoundError`: User with such ID does not seem to exist in database
"""
if cache is not None:
cached_entry: dict[str, Any] | None = cache.get_json(f"user_{user.id}")
self.id: int = user if not hasattr(user, "id") else user.id
if cached_entry is not None:
return cls(**cached_entry)
jav_user: Dict[str, Any] | None = sync_col_users.find_one({"user": self.id})
db_entry: dict[str, Any] | None = await col_users.find_one({"id": user.id})
if jav_user is None:
raise UserNotFoundError(user=user, user_id=self.id)
if db_entry is None:
if not allow_creation:
raise UserNotFoundError(user=user, user_id=user.id)
self.db_id: ObjectId = jav_user["_id"]
db_entry = HoloUser.get_defaults(user.id)
self.customrole: int | None = jav_user["customrole"]
self.customchannel: int | None = jav_user["customchannel"]
self.warnings: int = self.warns()
insert_result: InsertOneResult = await col_users.insert_one(db_entry)
def warns(self) -> int:
"""Get number of warnings user has
db_entry["_id"] = insert_result.inserted_id
### Returns:
* `int`: Number of warnings
"""
warns: Dict[str, Any] | None = sync_col_warnings.find_one({"user": self.id})
if cache is not None:
cache.set_json(f"user_{user.id}", db_entry)
return 0 if warns is None else warns["warns"]
return cls(**db_entry)
async def warn(self, count=1, reason: str = "Not provided") -> None:
"""Warn and add count to warns number
@classmethod
async def from_id(cls, user_id: int) -> "HoloUser":
raise NotImplementedError()
### Args:
* `count` (int, optional): Count of warnings to be added. Defaults to 1.
"""
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},
{"$set": {"warns": warns["warns"] + count}},
)
else:
await col_warnings.insert_one(document={"user": self.id, "warns": count})
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, cache: Cache | None = None) -> None:
"""Set attribute data and save it into the database
### Args:
* `key` (str): Attribute to be changed
* `value` (Any): Value to set
* `cache` (Cache, optional): Cache engine to write the update into
"""
if not hasattr(self, key):
raise AttributeError()
@@ -77,10 +86,100 @@ 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)
self._update_cache(cache)
logger.info("Set attribute '%s' of user %s to '%s'", key, self.id, value)
async def _remove(self, key: str, cache: Cache | None = None) -> None:
"""Remove attribute data and save it into the database
### Args:
* `key` (str): Attribute to be removed
* `cache` (Cache, optional): Cache engine to write the update into
"""
if not hasattr(self, key):
raise AttributeError()
default_value: Any = HoloUser.get_default_value(key)
setattr(self, key, default_value)
await col_users.update_one(
{"_id": self._id}, {"$set": {key: default_value}}, upsert=True
)
self._update_cache(cache)
logger.info("Removed attribute '%s' of user %s", key, self.id)
def _get_cache_key(self) -> str:
return f"user_{self.id}"
def _update_cache(self, cache: Cache | None = None) -> None:
if cache is None:
return
user_dict: dict[str, Any] = self._to_dict()
if user_dict is not None:
cache.set_json(self._get_cache_key(), user_dict)
else:
self._delete_cache(cache)
def _delete_cache(self, cache: Cache | None = None) -> None:
if cache is None:
return
cache.delete(self._get_cache_key())
@staticmethod
def get_defaults(user_id: int | None = None) -> dict[str, Any]:
return {
"id": user_id,
"custom_role": None,
"custom_channel": None,
}
@staticmethod
def get_default_value(key: str) -> Any:
if key not in HoloUser.get_defaults():
raise KeyError(f"There's no default value for key '{key}' in HoloUser")
return HoloUser.get_defaults()[key]
def _to_dict(self) -> dict[str, Any]:
return {
"_id": self._id,
"id": self.id,
"custom_role": self.custom_role,
"custom_channel": self.custom_channel,
}
async def set_custom_channel(
self, channel_id: int, cache: Cache | None = None
) -> None:
await self._set("custom_channel", channel_id, cache=cache)
async def set_custom_role(self, role_id: int, cache: Cache | None = None) -> None:
await self._set("custom_role", role_id, cache=cache)
async def remove_custom_channel(self, cache: Cache | None = None) -> None:
await self._remove("custom_channel", cache=cache)
async def remove_custom_role(self, cache: Cache | None = None) -> None:
await self._remove("custom_role", cache=cache)
async def purge(self, cache: Cache | None = None) -> None:
"""Completely remove user data from database. Only removes the user record from users collection.
### Args:
* `cache` (Cache, optional): Cache engine to write the update into
"""
await col_users.delete_one({"_id": self._id})
self._delete_cache(cache)
@staticmethod
async def is_moderator(member: User | Member) -> bool:
+2 -2
View File
@@ -5,11 +5,11 @@ from logging import Logger
from discord import (
ApplicationContext,
Embed,
Role,
TextChannel,
User,
option,
slash_command,
Role,
TextChannel,
)
from discord import utils as ds_utils
from discord.ext import commands
+4 -4
View File
@@ -1,6 +1,6 @@
import logging
from logging import Logger
from typing import Dict, List, Any
from typing import Any
from discord import Cog, Message
from discord.ext import commands
@@ -24,7 +24,7 @@ class Analytics(commands.Cog):
and (message.author.system is False)
):
# Handle stickers
stickers: List[Dict[str, Any]] = []
stickers: list[dict[str, Any]] = []
for sticker in message.stickers:
stickers.append(
@@ -37,7 +37,7 @@ class Analytics(commands.Cog):
)
# Handle attachments
attachments: List[Dict[str, Any]] = []
attachments: list[dict[str, Any]] = []
for attachment in message.attachments:
attachments.append(
@@ -56,7 +56,7 @@ class Analytics(commands.Cog):
# Insert entry into the database
await col_analytics.insert_one(
{
"user": message.author.id,
"user_id": message.author.id,
"channel": message.channel.id,
"content": message.content,
"stickers": stickers,
+23 -15
View File
@@ -1,8 +1,8 @@
import logging
from logging import Logger
from typing import Any, Dict
from typing import Any
from discord import ApplicationContext, Embed, option, TextChannel, Role
from discord import ApplicationContext, Embed, Role, TextChannel, option
from discord import utils as ds_utils
from discord.abc import GuildChannel
from discord.commands import SlashCommandGroup
@@ -25,7 +25,7 @@ class CustomChannels(commands.Cog):
@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(
@@ -47,7 +47,9 @@ class CustomChannels(commands.Cog):
Command to create a custom channel for a user.
"""
holo_user_ctx: HoloUser = HoloUser(ctx.user)
holo_user_ctx: HoloUser = await HoloUser.from_user(
ctx.user, cache=self.client.cache
)
# Return if the user is using the command outside of a guild
if not hasattr(ctx.author, "guild"):
@@ -62,7 +64,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(
@@ -80,7 +82,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"),
),
)
@@ -100,7 +102,9 @@ 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, cache=self.client.cache
)
await ctx.respond(
embed=Embed(
@@ -110,7 +114,7 @@ class CustomChannels(commands.Cog):
)
)
bots: Dict[str, Any] = await config_get("bots")
bots: dict[str, Any] = await config_get("bots")
for bot in bots:
role: Role | None = ds_utils.get(ctx.user.guild.roles, id=bots[bot]["role"])
@@ -136,10 +140,12 @@ class CustomChannels(commands.Cog):
Command to change properties of a custom channel.
"""
holo_user_ctx: HoloUser = HoloUser(ctx.user)
holo_user_ctx: HoloUser = await HoloUser.from_user(
ctx.user, cache=self.client.cache
)
custom_channel: TextChannel | None = ds_utils.get(
ctx.guild.channels, id=holo_user_ctx.customchannel
ctx.guild.channels, id=holo_user_ctx.custom_channel
)
# Return if the channel was not found
@@ -182,10 +188,12 @@ class CustomChannels(commands.Cog):
"""Command /customchannel remove [<confirm>]
Command to remove a custom channel. Requires additional confirmation."""
holo_user_ctx: HoloUser = HoloUser(ctx.user)
holo_user_ctx: HoloUser = await HoloUser.from_user(
ctx.user, cache=self.client.cache
)
# 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(
@@ -199,7 +207,7 @@ class CustomChannels(commands.Cog):
await ctx.defer()
custom_channel: TextChannel | None = ds_utils.get(
ctx.guild.channels, id=holo_user_ctx.customchannel
ctx.guild.channels, id=holo_user_ctx.custom_channel
)
# Return if the channel was not found
@@ -211,7 +219,7 @@ class CustomChannels(commands.Cog):
color=Color.FAIL,
)
)
await holo_user_ctx.set("customchannel", None)
await holo_user_ctx.remove_custom_channel(cache=self.client.cache)
return
# Return if the confirmation is missing
@@ -227,7 +235,7 @@ class CustomChannels(commands.Cog):
await custom_channel.delete(reason="Власник запросив видалення")
await holo_user_ctx.set("customchannel", None)
await holo_user_ctx.remove_custom_channel(cache=self.client.cache)
try:
await ctx.respond(
+7 -19
View File
@@ -2,10 +2,10 @@ import logging
from logging import Logger
from os import makedirs
from pathlib import Path
from typing import List, Dict, Any
from typing import Any
from uuid import uuid4
from discord import ApplicationContext, Embed, File, option, Role, TextChannel
from discord import ApplicationContext, Embed, File, Role, TextChannel, option
from discord import utils as ds_utils
from discord.commands import SlashCommandGroup
from discord.ext import commands
@@ -14,7 +14,6 @@ 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: Logger = logging.getLogger(__name__)
@@ -83,24 +82,26 @@ class Data(commands.Cog):
uuid: str = str(uuid4())
if kind == "Користувачі":
users: List[Dict[str, Any]] = []
users: list[dict[str, Any]] = []
for member in ctx.guild.members:
users.append(
{
"id": member.id,
"nick": member.nick,
"username": f"{member.name}#{member.discriminator}",
"username": member.name,
"bot": member.bot,
}
)
# Temporary file must be written synchronously,
# otherwise it will not be there when ctx.respond() is be called
# TODO Find a way to give this file to Pycord without FS operations
json_write(users, Path(f"tmp/{uuid}"))
await ctx.respond(file=File(Path(f"tmp/{uuid}"), filename="users.json"))
# TODO Deprecate this command
@data.command(
name="migrate",
description="Мігрувати всіх користувачів до бази",
@@ -164,20 +165,7 @@ class Data(commands.Cog):
if member.bot:
continue
if (await col_users.find_one({"user": member.id})) is None:
user: Dict[str, Any] = {}
defaults: Dict[str, Any] = await config_get("user", "defaults")
user["user"] = member.id
for key in defaults:
user[key] = defaults[key]
await col_users.insert_one(document=user)
logging.info(
"Added DB record for user %s during migration", member.id
)
await HoloUser.from_user(member, cache=self.client.cache)
await ctx.respond(
embed=Embed(
+12 -30
View File
@@ -1,13 +1,13 @@
import logging
from logging import Logger
from typing import Dict, Any
from discord import Member, Message, TextChannel, MessageType
from discord import Member, Message, MessageType, TextChannel
from discord import utils as ds_utils
from discord.ext import commands
from libbot.utils import config_get
from classes.holo_bot import HoloBot
from classes.holo_user import HoloUser
from modules.database import col_users
logger: Logger = logging.getLogger(__name__)
@@ -25,22 +25,13 @@ class Logger(commands.Cog):
and (message.author.bot is False)
and (message.author.system is False)
):
if (await col_users.find_one({"user": message.author.id})) is None:
user: Dict[str, Any] = {}
defaults: Dict[str, Any] = await config_get("user", "defaults")
user["user"] = message.author.id
for key in defaults:
user[key] = defaults[key]
await col_users.insert_one(document=user)
await HoloUser.from_user(message.author, cache=self.client.cache)
if (
(message.type == MessageType.thread_created)
and (message.channel is not None)
and (
await col_users.count_documents({"customchannel": message.channel.id})
await col_users.count_documents({"custom_channel": message.channel.id})
> 0
)
):
@@ -69,30 +60,21 @@ class Logger(commands.Cog):
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)
and (member.system is False)
):
await welcome_chan.send(
content=(await config_get("welcome", "messages")).format(
mention=member.mention, rules=rules_chan.mention
if welcome_chan is not None and rules_chan is not None:
await welcome_chan.send(
content=(await config_get("welcome", "messages")).format(
mention=member.mention, rules=rules_chan.mention
)
)
)
else:
logger.warning("Could not find a welcome and/or rules channel by id")
if (await col_users.find_one({"user": member.id})) is None:
user: Dict[str, Any] = {}
defaults: Dict[str, Any] = await config_get("user", "defaults")
user["user"] = member.id
for key in defaults:
user[key] = defaults[key]
await col_users.insert_one(document=user)
await HoloUser.from_user(member, cache=self.client.cache)
def setup(client: HoloBot) -> None:
+18 -7
View File
@@ -22,18 +22,29 @@
"port": 27017,
"name": "holo_discord"
},
"cache": {
"type": null,
"memcached": {
"uri": "127.0.0.1:11211",
"prefix": null
},
"redis": {
"uri": "redis://127.0.0.1:6379/0",
"prefix": null
}
},
"monitoring": {
"enabled": false,
"url": "https://status.example.org/api/v1/endpoints/my-group_my-service/external?success=true",
"token": "12345678"
},
"logging": {
"size": 512,
"location": "logs"
},
"defaults": {
"user": {
"customrole": null,
"customchannel": null
}
},
"defaults": {},
"categories": {
"customchannels": 0
"custom_channels": 0
},
"channels": {
"text": {
+2
View File
@@ -1 +1,3 @@
from .colors import Color
__all__: list[str] = ["Color"]
+2
View File
@@ -1 +1,3 @@
from .user import UserNotFoundError
__all__: list[str] = ["UserNotFoundError"]
+32 -11
View File
@@ -1,36 +1,57 @@
import contextlib
import logging
import sys
from argparse import ArgumentParser
from logging import Logger
from pathlib import Path
from discord import LoginFailure, Intents
from discord import Intents, LoginFailure
from libbot.utils import config_get
from classes.holo_bot import HoloBot
from modules.migrator import migrate_database
from modules.scheduler import scheduler
if not Path("config.json").exists():
print(
"Config file is missing: Make sure the configuration file 'config.json' is in place.",
flush=True,
)
sys.exit()
logging.basicConfig(
level=logging.INFO,
level=logging.INFO if not config_get("debug") else logging.DEBUG,
format="%(name)s.%(funcName)s | %(levelname)s | %(message)s",
datefmt="[%X]",
)
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
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)
+79
View File
@@ -0,0 +1,79 @@
import logging
from logging import Logger
from libbot.utils import config_delete, config_get, config_set
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"}},
)
+63
View File
@@ -0,0 +1,63 @@
import logging
from logging import Logger
from libbot.utils import config_delete, config_set
from mongodb_migrations.base import BaseMigration
logger: Logger = logging.getLogger(__name__)
class Migration(BaseMigration):
def upgrade(self):
try:
config_set(
"cache",
{
"type": None,
"memcached": {"uri": "127.0.0.1:11211"},
"redis": {"uri": "redis://127.0.0.1:6379/0"},
},
*[],
)
except Exception as exc:
logger.error(
"Could not upgrade the config during migration '%s' due to: %s",
__name__,
exc,
)
self.db.users.update_many(
{"user": {"$exists": True}},
{"$rename": {"user": "id"}},
)
self.db.analytics.update_many(
{"user": {"$exists": True}},
{"$rename": {"user": "user_id"}},
)
self.db.warnings.update_many(
{"user": {"$exists": True}},
{"$rename": {"user": "user_id"}},
)
def downgrade(self):
try:
config_delete("cache", *[])
except Exception as exc:
logger.error(
"Could not downgrade the config during migration '%s' due to: %s",
__name__,
exc,
)
self.db.users.update_many(
{"id": {"$exists": True}},
{"$rename": {"id": "user"}},
)
self.db.analytics.update_many(
{"user": {"$exists": True}},
{"$rename": {"user_id": "user"}},
)
self.db.warnings.update_many(
{"user": {"$exists": True}},
{"$rename": {"user_id": "user"}},
)
+31
View File
@@ -0,0 +1,31 @@
import logging
from logging import Logger
from libbot.utils import config_delete, config_set
from mongodb_migrations.base import BaseMigration
logger: Logger = logging.getLogger(__name__)
class Migration(BaseMigration):
def upgrade(self):
try:
config_set("prefix", None, "cache", "memcached")
config_set("prefix", None, "cache", "redis")
except Exception as exc:
logger.error(
"Could not upgrade the config during migration '%s' due to: %s",
__name__,
exc,
)
def downgrade(self):
try:
config_delete("prefix", "cache", "redis")
config_delete("prefix", "cache", "memcached")
except Exception as exc:
logger.error(
"Could not downgrade the config during migration '%s' due to: %s",
__name__,
exc,
)
+33
View File
@@ -0,0 +1,33 @@
import logging
from logging import Logger
from libbot.utils import config_delete, config_set
from mongodb_migrations.base import BaseMigration
logger: Logger = logging.getLogger(__name__)
class Migration(BaseMigration):
def upgrade(self):
try:
config_set("enabled", False, "monitoring")
config_set("url", "", "monitoring")
config_set("token", "", "monitoring")
except Exception as exc:
logger.error(
"Could not upgrade the config during migration '%s' due to: %s",
__name__,
exc,
)
def downgrade(self):
try:
config_delete("monitoring", "enabled")
config_delete("monitoring", "url")
config_delete("monitoring", "token")
except Exception as exc:
logger.error(
"Could not downgrade the config during migration '%s' due to: %s",
__name__,
exc,
)
+4 -13
View File
@@ -1,12 +1,9 @@
from typing import Dict, Any
from typing import Any
from async_pymongo import AsyncClient, AsyncCollection, AsyncDatabase
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] = config_get("database")
db_config: dict[str, Any] = config_get("database")
con_string: str = (
"mongodb://{0}:{1}/{2}".format(
@@ -23,18 +20,12 @@ con_string: str = (
)
db_client: AsyncClient = AsyncClient(con_string)
db_client_sync: MongoClient = MongoClient(con_string)
# Async declarations per default
db: AsyncDatabase = db_client.get_database(name=db_config["name"])
col_users: AsyncCollection = db.get_collection("users")
col_warnings: AsyncCollection = db.get_collection("warnings")
col_analytics: AsyncCollection = db.get_collection("analytics")
# Sync declarations as a fallback
sync_db: Database = db_client_sync.get_database(name=db_config["name"])
sync_col_users: Collection = sync_db.get_collection("users")
sync_col_warnings: Collection = sync_db.get_collection("warnings")
sync_col_analytics: Collection = sync_db.get_collection("analytics")
# Update indexes
db.dispatch.get_collection("users").create_index("id", unique=True)
+22
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()
+10 -8
View File
@@ -1,11 +1,13 @@
# Waifu pics related dependencies (not listed directly by waifupics)
aiohttp>=3.10.0
requests>=2.32.2
aiohttp>=3.10.0,<4.0.0
requests>=2.32.2,<3.0.0
aiofiles~=24.1.0
apscheduler>=3.10.0
async_pymongo==0.1.11
libbot[speed,pycord]==4.0.0
typing-extensions~=4.12.2
ujson~=5.10.0
aiofiles>=23.0.0,<26.0.0
apscheduler>=3.0.0,<4.0.0
async_pymongo==0.1.12
libbot[speed,pycord,cache]==4.6.1
mongodb-migrations==1.3.1
pymemcache>=4.0.0,<5.0.0
redis>=7.0.0,<9.0.0
ujson>=5.0.0,<6.0.0
WaifuPicsPython==0.2.0
+7 -4
View File
@@ -1,14 +1,14 @@
{
"$jsonSchema": {
"required": [
"user",
"user_id",
"channel",
"content",
"stickers",
"attachments"
],
"properties": {
"user": {
"user_id": {
"bsonType": "long",
"description": "Discord ID of user"
},
@@ -17,7 +17,10 @@
"description": "Discord ID of a channel"
},
"content": {
"bsonType": ["null", "string"],
"bsonType": [
"null",
"string"
],
"description": "Text of the message"
},
"stickers": {
@@ -40,7 +43,7 @@
"format": {
"bsonType": "array"
},
"user": {
"user_id": {
"bsonType": "string"
}
}
+14 -9
View File
@@ -1,22 +1,27 @@
{
"$jsonSchema": {
"required": [
"user",
"customrole",
"customchannel"
"id",
"custom_role",
"custom_channel"
],
"properties": {
"user": {
"id": {
"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"
}
}
-18
View File
@@ -1,18 +0,0 @@
{
"$jsonSchema": {
"required": [
"user",
"warns"
],
"properties": {
"user": {
"bsonType": "long",
"description": "Discord ID of user"
},
"warns": {
"bsonType": "int",
"description": "Number of warnings on count"
}
}
}
}