Bot config and channel init
This commit is contained in:
parent
8ca93c1bab
commit
72edbe6599
7 changed files with 499 additions and 109 deletions
18
bot.py
18
bot.py
|
|
@ -1,29 +1,13 @@
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
|
||||||
|
|
||||||
from breadtube_bot.manager import DiscordManager
|
from breadtube_bot.manager import DiscordManager
|
||||||
from breadtube_bot.objects import TextChannel
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
bot_token = Path('data/discord_bot_token.txt').read_text(encoding='utf-8').strip()
|
bot_token = Path('data/discord_bot_token.txt').read_text(encoding='utf-8').strip()
|
||||||
guild_id = 1306964577812086824
|
guild_id = 1306964577812086824
|
||||||
manager = DiscordManager(bot_token=bot_token, guild_id=guild_id)
|
manager = DiscordManager(bot_token=bot_token, guild_id=guild_id)
|
||||||
_categories, text_channel = manager.list_channels()
|
print(manager.rate_limit)
|
||||||
breadtube_channel: TextChannel | None = None
|
|
||||||
for channel in text_channel:
|
|
||||||
if channel.name == 'breadtube-bot':
|
|
||||||
breadtube_channel = channel
|
|
||||||
break
|
|
||||||
|
|
||||||
if breadtube_channel is None:
|
|
||||||
print('Cannot find beadtube-bot channel')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
messages = manager.list_text_channel_messages(breadtube_channel)
|
|
||||||
for message in messages:
|
|
||||||
print(message)
|
|
||||||
manager.delete_message(message)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
from breadtube_bot.objects import Overwrite
|
||||||
|
|
||||||
|
|
||||||
class ApiVersion(Enum):
|
class ApiVersion(Enum):
|
||||||
|
|
@ -17,11 +20,117 @@ class ApiAction(Enum):
|
||||||
|
|
||||||
class Api:
|
class Api:
|
||||||
class Guild:
|
class Guild:
|
||||||
|
@staticmethod
|
||||||
|
def create_channel(guild_id: int) -> tuple[ApiAction, str]:
|
||||||
|
return ApiAction.POST, f'/guilds/{guild_id}/channels'
|
||||||
|
|
||||||
|
class CreateChannelParams(TypedDict, total=False):
|
||||||
|
# All
|
||||||
|
# channel name (1-100 characters)
|
||||||
|
name: str
|
||||||
|
# All
|
||||||
|
# the type of channel
|
||||||
|
type: int
|
||||||
|
# Text, Announcement, Forum, Media
|
||||||
|
# channel topic (0-1024 characters)
|
||||||
|
topic: str
|
||||||
|
# Voice, Stage
|
||||||
|
# the bitrate (in bits) of the voice or stage channel; min 8000
|
||||||
|
bitrate: int
|
||||||
|
# Voice, Stage
|
||||||
|
# the user limit of the voice channel
|
||||||
|
user_limit: int
|
||||||
|
# Text, Voice, Stage, Forum, Media
|
||||||
|
# amount of seconds a user has to wait before sending another message (0-21600);
|
||||||
|
# bots, as well as users with the permission manage_messages or manage_channel, are unaffected
|
||||||
|
rate_limit_per_user: int
|
||||||
|
# All
|
||||||
|
# sorting position of the channel (channels with the same position are sorted by id)
|
||||||
|
position: int
|
||||||
|
# All
|
||||||
|
# the channel's permission overwrites
|
||||||
|
permission_overwrites: list[dict]
|
||||||
|
# Text, Voice, Announcement, Stage, Forum, Media
|
||||||
|
# id of the parent category for a channel
|
||||||
|
parent_id: int
|
||||||
|
# Text, Voice, Announcement, Stage, Forum
|
||||||
|
# whether the channel is nsfw
|
||||||
|
nsfw: bool
|
||||||
|
# Voice, Stage
|
||||||
|
# channel voice region id of the voice or stage channel, automatic when set to null
|
||||||
|
rtc_region: str
|
||||||
|
# Voice, Stage
|
||||||
|
# the camera video quality mode of the voice channel
|
||||||
|
video_quality_mode: int
|
||||||
|
# Text, Announcement, Forum, Media
|
||||||
|
# the default duration that the clients use (not the API) for newly created threads in the channel,
|
||||||
|
# in minutes, to automatically archive the thread after recent activity
|
||||||
|
default_auto_archive_duration: int
|
||||||
|
# Forum, Media
|
||||||
|
# emoji to show in the add reaction button on a thread in a GUILD_FORUM or a GUILD_MEDIA channel
|
||||||
|
default_reaction_emoji: dict
|
||||||
|
# Forum, Media
|
||||||
|
# set of tags that can be used in a GUILD_FORUM or a GUILD_MEDIA channel
|
||||||
|
available_tags: list[dict]
|
||||||
|
# Forum, Media
|
||||||
|
# the default sort order type used to order posts in GUILD_FORUM and GUILD_MEDIA channels
|
||||||
|
default_sort_order: int
|
||||||
|
# Forum
|
||||||
|
# the default forum layout view used to display posts in GUILD_FORUM channels
|
||||||
|
default_forum_layout: int
|
||||||
|
# Text, Announcement, Forum, Media
|
||||||
|
# the initial rate_limit_per_user to set on newly created threads in a channel.
|
||||||
|
# this field is copied to the thread at creation time and does not live update.
|
||||||
|
default_thread_rate_limit_per_user: int
|
||||||
|
|
||||||
|
class CreateTextChannelParams(TypedDict, total=False):
|
||||||
|
name: str
|
||||||
|
type: int
|
||||||
|
topic: str
|
||||||
|
rate_limit_per_user: int
|
||||||
|
position: int
|
||||||
|
permission_overwrites: list[Overwrite]
|
||||||
|
parent_id: int
|
||||||
|
nsfw: bool
|
||||||
|
default_auto_archive_duration: int
|
||||||
|
default_thread_rate_limit_per_user: int
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def list_guilds(guild_id: int) -> tuple[ApiAction, str]:
|
def list_guilds(guild_id: int) -> tuple[ApiAction, str]:
|
||||||
return ApiAction.GET, f'/guilds/{guild_id}/channels'
|
return ApiAction.GET, f'/guilds/{guild_id}/channels'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_roles(guild_id: int) -> tuple[ApiAction, str]:
|
||||||
|
return ApiAction.GET, f'/guilds/{guild_id}/roles'
|
||||||
|
|
||||||
class Message:
|
class Message:
|
||||||
|
@staticmethod
|
||||||
|
def create(channel_id: int) -> tuple[ApiAction, str]:
|
||||||
|
return ApiAction.POST, f'/channels/{channel_id}/messages'
|
||||||
|
|
||||||
|
class CreateParams(TypedDict, total=False):
|
||||||
|
content: str # Message contents (up to 2000 characters)
|
||||||
|
# Can be used to verify a message was sent (up to 25 characters).
|
||||||
|
# Value will appear in the Message Create event.
|
||||||
|
nonce: int | str
|
||||||
|
tts: bool # true if this is a TTS message
|
||||||
|
# embeds: list[Embeded] # Up to 10 rich embeds (up to 6000 characters)
|
||||||
|
# allowed_mentions: MentionObject # Allowed mentions for the message
|
||||||
|
# message_reference: MessageReference # Include to make your message a reply or a forward
|
||||||
|
# components: list[MessageComponent] # Components to include with the message
|
||||||
|
sticker_ids: list[int] # IDs of up to 3 stickers in the server to send in the message
|
||||||
|
# files[n]: FileContents # Contents of the file being sent. See Uploading Files
|
||||||
|
# payload_json: str # JSON-encoded body of non-file params, only for multipart/form-data requests
|
||||||
|
# attachments: list[Attachment] # Attachment objects with filename and description. See Uploading Files
|
||||||
|
# Message flags combined as a bitfield
|
||||||
|
# (only SUPPRESS_EMBEDS, SUPPRESS_NOTIFICATIONS, IS_VOICE_MESSAGE, and IS_COMPONENTS_V2 can be set)
|
||||||
|
# flags: MessageFlags
|
||||||
|
# If true and nonce is present, it will be checked for uniqueness in the past few minutes.
|
||||||
|
# If another message was created by the same author with the same nonce, that message will be returned
|
||||||
|
# and no new message will be created.
|
||||||
|
# enforce_nonce: bool
|
||||||
|
# poll: PollRequest # A poll!
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def delete(channel_id: int, message_id: int) -> tuple[ApiAction, str]:
|
def delete(channel_id: int, message_id: int) -> tuple[ApiAction, str]:
|
||||||
return ApiAction.DELETE, f'/channels/{channel_id}/messages/{message_id}'
|
return ApiAction.DELETE, f'/channels/{channel_id}/messages/{message_id}'
|
||||||
|
|
|
||||||
21
breadtube_bot/config.py
Normal file
21
breadtube_bot/config.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
bot_channel: str = 'breadtube-bot'
|
||||||
|
bot_role: str = 'BreadTube'
|
||||||
|
bot_channel_init_retries: int = 3
|
||||||
|
|
||||||
|
def to_str(self) -> str:
|
||||||
|
return '\n'.join(['config', *[f'{k}={v}' for k, v in asdict(self).items()]])
|
||||||
|
|
||||||
|
def from_str(self, text: str):
|
||||||
|
lines = text.strip().splitlines()
|
||||||
|
if not lines:
|
||||||
|
raise RuntimeError('Config cannot load: empty input')
|
||||||
|
if lines[0] != 'config':
|
||||||
|
raise RuntimeError('Config cannot load: first line is not "config"')
|
||||||
|
for line in lines[1:]:
|
||||||
|
key, value = line.split('=', maxsplit=1)
|
||||||
|
setattr(self, key, self.__annotations__[key](value))
|
||||||
60
breadtube_bot/logger.py
Normal file
60
breadtube_bot/logger.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
from logging import handlers
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
class ConsoleColor:
|
||||||
|
"""Simple shortcut to use colors in console"""
|
||||||
|
HEADER = '\033[95m'
|
||||||
|
BLUE = '\033[94m'
|
||||||
|
GREEN = '\033[92m'
|
||||||
|
ORANGE = '\033[93m'
|
||||||
|
RED = '\033[91m'
|
||||||
|
ENDCOLOR = '\033[0m'
|
||||||
|
BOLD = '\033[1m'
|
||||||
|
UNDERLINE = '\033[4m'
|
||||||
|
|
||||||
|
|
||||||
|
class ColoredFormatter(logging.Formatter):
|
||||||
|
"""Formatter changing the record during format : adds colors to levelname"""
|
||||||
|
def format(self, record):
|
||||||
|
levelno = record.levelno
|
||||||
|
if levelno == logging.ERROR:
|
||||||
|
levelname_color = ConsoleColor.RED + record.levelname + ConsoleColor.ENDCOLOR
|
||||||
|
elif levelno == logging.WARNING:
|
||||||
|
levelname_color = ConsoleColor.ORANGE + record.levelname + ConsoleColor.ENDCOLOR
|
||||||
|
elif levelno == logging.INFO:
|
||||||
|
levelname_color = ConsoleColor.GREEN + record.levelname + ConsoleColor.ENDCOLOR
|
||||||
|
elif levelno == logging.DEBUG:
|
||||||
|
levelname_color = ConsoleColor.BLUE + record.levelname + ConsoleColor.ENDCOLOR
|
||||||
|
else:
|
||||||
|
levelname_color = record.levelname
|
||||||
|
record.levelname = levelname_color
|
||||||
|
return logging.Formatter.format(self, record)
|
||||||
|
|
||||||
|
|
||||||
|
def create_logger(name: str, level: int, log_dir: Path | None = None, stdout=False) -> logging.Logger:
|
||||||
|
logger = logging.getLogger(name)
|
||||||
|
logger.setLevel(level)
|
||||||
|
|
||||||
|
if log_dir is not None:
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger.setLevel(level)
|
||||||
|
file_log_handler = handlers.RotatingFileHandler(
|
||||||
|
log_dir / f'{name}.log',
|
||||||
|
maxBytes=500000,
|
||||||
|
backupCount=5)
|
||||||
|
file_log_handler.setLevel(level)
|
||||||
|
log_formatter = logging.Formatter('%(asctime)s %(levelname)s : %(message)s')
|
||||||
|
file_log_handler.setFormatter(log_formatter)
|
||||||
|
logger.addHandler(file_log_handler)
|
||||||
|
|
||||||
|
if stdout:
|
||||||
|
terminal_log_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
terminal_log_handler.setLevel(level)
|
||||||
|
colored_log_formatter = ColoredFormatter('%(asctime)s %(levelname)s : %(message)s')
|
||||||
|
terminal_log_handler.setFormatter(colored_log_formatter)
|
||||||
|
logger.addHandler(terminal_log_handler)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
@ -1,26 +1,39 @@
|
||||||
from dataclasses import dataclass
|
from dataclasses import asdict, dataclass, is_dataclass
|
||||||
from datetime import datetime
|
from enum import Enum
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json
|
import json
|
||||||
|
import random
|
||||||
|
import time
|
||||||
import tomllib
|
import tomllib
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from .api import Api, ApiAction, ApiVersion
|
from .api import Api, ApiAction, ApiVersion
|
||||||
|
from .config import Config
|
||||||
|
from .logger import create_logger
|
||||||
from .objects import (
|
from .objects import (
|
||||||
ChannelCategory, ChannelFlags, ChannelType, Message, Overwrite, OverwriteType, Permissions, TextChannel, User)
|
ChannelCategory, ChannelType, FileMime, Message, Overwrite, OverwriteType, Permissions, Role, TextChannel)
|
||||||
|
|
||||||
|
|
||||||
HTTPHeaders = dict[str, str]
|
HTTPHeaders = dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
class ApiEncoder(json.JSONEncoder):
|
||||||
class _RateLimit:
|
def default(self, o):
|
||||||
remaining: int
|
if is_dataclass(o):
|
||||||
next_reset: float
|
return asdict(o) # type: ignore
|
||||||
|
if isinstance(o, Enum):
|
||||||
|
return o.value
|
||||||
|
return super().default(o)
|
||||||
|
|
||||||
|
|
||||||
class DiscordManager:
|
class DiscordManager:
|
||||||
|
@dataclass
|
||||||
|
class RateLimit:
|
||||||
|
remaining: int
|
||||||
|
next_reset: float
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_code_version() -> str:
|
def _get_code_version() -> str:
|
||||||
pyproject_path = Path(__file__).parents[1] / 'pyproject.toml'
|
pyproject_path = Path(__file__).parents[1] / 'pyproject.toml'
|
||||||
|
|
@ -28,133 +41,176 @@ class DiscordManager:
|
||||||
raise RuntimeError('Cannot current bot version')
|
raise RuntimeError('Cannot current bot version')
|
||||||
return tomllib.loads(pyproject_path.read_text(encoding='utf-8'))['project']['version']
|
return tomllib.loads(pyproject_path.read_text(encoding='utf-8'))['project']['version']
|
||||||
|
|
||||||
def __init__(self, bot_token: str, guild_id: int) -> None:
|
def __init__(self, bot_token: str, guild_id: int, config: Config | None = None,
|
||||||
|
log_level: int = logging.INFO) -> None:
|
||||||
|
self.config = config or Config()
|
||||||
self.guild_id = guild_id
|
self.guild_id = guild_id
|
||||||
self._bot_token = bot_token
|
self._bot_token = bot_token
|
||||||
|
self.logger = create_logger('breadtube', log_level, stdout=True)
|
||||||
|
|
||||||
self.rate_limit: _RateLimit = _RateLimit(remaining=1, next_reset=0)
|
self.rate_limit = self.RateLimit(remaining=1, next_reset=0)
|
||||||
self.version = self._get_code_version()
|
self.version = self._get_code_version()
|
||||||
|
|
||||||
|
self.guild_roles: list = self.list_roles()
|
||||||
|
for _ in range(self.config.bot_channel_init_retries):
|
||||||
|
while not self.init_bot_channel():
|
||||||
|
time.sleep(10)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
self.logger.info('Bot init OK')
|
||||||
|
break
|
||||||
|
raise RuntimeError("Couldn't initialize bot channel/role/permission")
|
||||||
|
self.logger.info('Bot initialized')
|
||||||
|
|
||||||
def _update_rate_limit(self, headers: HTTPHeaders):
|
def _update_rate_limit(self, headers: HTTPHeaders):
|
||||||
for header_key in ['x-ratelimit-remaining', 'x-ratelimit-reset']:
|
for header_key in ['x-ratelimit-remaining', 'x-ratelimit-reset']:
|
||||||
if header_key not in headers:
|
if header_key not in headers:
|
||||||
print(f'Warning: no "{header_key}" found in headers')
|
self.logger.info('Warning: no "%s" found in headers', header_key)
|
||||||
return
|
return
|
||||||
self.rate_limit.remaining = int(headers['x-ratelimit-remaining'])
|
self.rate_limit.remaining = int(headers['x-ratelimit-remaining'])
|
||||||
self.rate_limit.next_reset = float(headers['x-ratelimit-reset'])
|
self.rate_limit.next_reset = float(headers['x-ratelimit-reset'])
|
||||||
|
|
||||||
def _send_request(self, api_action: ApiAction, endpoint: str, api_version: ApiVersion = ApiVersion.V10,
|
def _send_request(self, api_action: ApiAction, endpoint: str, api_version: ApiVersion = ApiVersion.V10,
|
||||||
expected_code: int = 200) -> tuple[
|
data: bytes | None = None, upload_files: list[tuple[str, FileMime, bytes]] | None = None,
|
||||||
HTTPHeaders, dict]:
|
expected_code: int = 200) -> tuple[HTTPHeaders, dict | list | None]:
|
||||||
timeout = 3
|
timeout = 3
|
||||||
min_api_version = 9
|
min_api_version = 9
|
||||||
|
|
||||||
if api_action == ApiAction.POST:
|
|
||||||
raise NotImplementedError
|
|
||||||
if api_version.value < min_api_version:
|
if api_version.value < min_api_version:
|
||||||
print(f'Warning: using deprecated API version {api_version} (minimum non deprecated is {min_api_version})')
|
self.logger.warning(
|
||||||
|
'Warning: using deprecated API version %d (minimum non deprecated is %d)',
|
||||||
|
api_version, min_api_version)
|
||||||
url = f'https://discord.com/api/v{api_version.value}{endpoint}'
|
url = f'https://discord.com/api/v{api_version.value}{endpoint}'
|
||||||
request = urllib.request.Request(url)
|
|
||||||
|
boundary: str = ''
|
||||||
|
if upload_files:
|
||||||
|
boundary = f'{random.randbytes(16).hex()}'
|
||||||
|
data = (f'--{boundary}\r\nContent-Disposition: form-data; name="payload_json"\r\n'
|
||||||
|
'Content-Type: application/json\r\n\r\n'.encode() + data
|
||||||
|
+ f'\r\n--{boundary}'.encode()) if data else b''
|
||||||
|
for file_index, (name, mime, content) in enumerate(upload_files):
|
||||||
|
data += (f'\r\n--{boundary}\r\nContent-Disposition: form-data; name="files[{file_index}]";'
|
||||||
|
f' filename="{name}"\r\nContent-Type: {mime.value}\r\n\r\n').encode() + content
|
||||||
|
data += f'\r\n--{boundary}--'.encode()
|
||||||
|
request = urllib.request.Request(url, data=data)
|
||||||
|
request.method = api_action.value
|
||||||
request.add_header('User-Agent', f'BreadTube (v{self.version})')
|
request.add_header('User-Agent', f'BreadTube (v{self.version})')
|
||||||
request.add_header('Accept', 'application/json')
|
request.add_header('Accept', 'application/json')
|
||||||
|
if upload_files:
|
||||||
|
request.add_header('Content-Type', f'multipart/form-data; boundary={boundary}')
|
||||||
|
else:
|
||||||
|
request.add_header('Content-Type', 'application/json')
|
||||||
request.add_header('Authorization', f'Bot {self._bot_token}')
|
request.add_header('Authorization', f'Bot {self._bot_token}')
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
if response.status != expected_code:
|
if response.status != expected_code:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'Unexpected code {response.status} (expected: {expected_code}) -> {response.read().decode()}')
|
f'Unexpected code {response.status} (expected: {expected_code}) -> {response.read().decode()}')
|
||||||
return dict(response.getheaders()), json.loads(response.read().decode())
|
body = response.read()
|
||||||
|
return dict(response.getheaders()), json.loads(body.decode()) if body else None
|
||||||
except urllib.error.HTTPError as error:
|
except urllib.error.HTTPError as error:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'HTTP error calling API ({url}): {error}:\nHeaders:\n{error.headers}Body:\n{error.read()}') from error
|
f'HTTP error calling API ({url}): {error}:\nHeaders:\n{error.headers}Body:\n{error.read()}') from error
|
||||||
except urllib.error.URLError as error:
|
except urllib.error.URLError as error:
|
||||||
raise RuntimeError(f'URL error calling API ({url}): {error}') from error
|
raise RuntimeError(f'URL error calling API ({url}): {error}') from error
|
||||||
|
|
||||||
@staticmethod
|
def init_bot_channel(self) -> bool:
|
||||||
def _parse_overwrite(info: dict) -> Overwrite:
|
_, text_channel = self.list_channels()
|
||||||
return Overwrite(
|
breadtube_role: Role | None = None
|
||||||
id=int(info['id']),
|
everyone_role: Role | None = None
|
||||||
type=OverwriteType(info['type']),
|
for role in self.guild_roles:
|
||||||
allow=Permissions(int(info['allow'])),
|
if role.name == self.config.bot_role:
|
||||||
deny=Permissions(int(info['deny']))
|
breadtube_role = role
|
||||||
)
|
elif role.name == '@everyone':
|
||||||
|
everyone_role = role
|
||||||
|
if breadtube_role is None:
|
||||||
|
self.logger.info('No BreadTube role found')
|
||||||
|
return False
|
||||||
|
if everyone_role is None:
|
||||||
|
self.logger.info('No everyone role found')
|
||||||
|
return False
|
||||||
|
|
||||||
def _parse_channel_category(self, info: dict) -> ChannelCategory:
|
breadtube_channel: TextChannel | None = None
|
||||||
parent_id: str | None = info.get('parent_id')
|
for channel in text_channel:
|
||||||
return ChannelCategory(
|
if channel.name == self.config.bot_channel:
|
||||||
id=int(info['id']),
|
breadtube_channel = channel
|
||||||
guild_id=int(info['guild_id']),
|
self.logger.info('Found breadtube bot channel')
|
||||||
position=int(info['position']),
|
for perm in breadtube_channel.permission_overwrites:
|
||||||
permission_overwrites=[self._parse_overwrite(o) for o in info['permission_overwrites']],
|
if perm.id == breadtube_role.id:
|
||||||
name=info.get('name'),
|
if not perm.allow | Permissions.VIEW_CHANNEL:
|
||||||
parent_id=int(parent_id) if parent_id is not None else None,
|
self.logger.info('BreadTube bot cannot view BreadTube channel: permission missing')
|
||||||
flags=ChannelFlags(info['flags']),
|
return False
|
||||||
)
|
self.logger.info('BreadTube channel permission OK')
|
||||||
|
break
|
||||||
|
messages = self.list_text_channel_messages(breadtube_channel)
|
||||||
|
for message in messages:
|
||||||
|
self.logger.debug('Deleting message: %s', message)
|
||||||
|
self.delete_message(message)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
breadtube_channel = self.create_text_channel({
|
||||||
|
'name': self.config.bot_channel,
|
||||||
|
'permission_overwrites': [
|
||||||
|
Overwrite(everyone_role.id, OverwriteType.ROLE, allow=Permissions.NONE,
|
||||||
|
deny=Permissions.VIEW_CHANNEL),
|
||||||
|
Overwrite(breadtube_role.id, OverwriteType.ROLE, allow=Permissions.VIEW_CHANNEL,
|
||||||
|
deny=Permissions.NONE)]
|
||||||
|
})
|
||||||
|
self.logger.info('Created breadtube bot channel')
|
||||||
|
|
||||||
def _parse_text_channel(self, info: dict) -> TextChannel:
|
self.create_message(
|
||||||
parent_id: str | None = info.get('parent_id')
|
breadtube_channel,
|
||||||
last_message_id: str | None = info.get('last_message_id')
|
{'content': 'This is the current configuration used, upload a new one to update the configuration'},
|
||||||
last_pin_timestamp: str | None = info.get('last_pin_timestamp')
|
upload_files=[('config.txt', FileMime.TEXT_PLAIN, self.config.to_str().encode())])
|
||||||
return TextChannel(
|
return True
|
||||||
id=int(info['id']),
|
|
||||||
guild_id=int(info['guild_id']),
|
|
||||||
position=int(info['position']),
|
|
||||||
permission_overwrites=[self._parse_overwrite(o) for o in info['permission_overwrites']],
|
|
||||||
name=info.get('name'),
|
|
||||||
topic=info.get('topic'),
|
|
||||||
nsfw=info['nsfw'],
|
|
||||||
last_message_id=int(last_message_id) if last_message_id is not None else None,
|
|
||||||
rate_limit_per_user=int(info['rate_limit_per_user']),
|
|
||||||
parent_id=int(parent_id) if parent_id is not None else None,
|
|
||||||
last_pin_timestamp=(datetime.fromisoformat(last_pin_timestamp) if last_pin_timestamp is not None else None),
|
|
||||||
flags=ChannelFlags(info['flags']),
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
def create_text_channel(self, params: Api.Guild.CreateTextChannelParams) -> TextChannel:
|
||||||
def _parse_user(info: dict) -> User:
|
headers, channel_info = self._send_request(
|
||||||
return User(
|
*Api.Guild.create_channel(guild_id=self.guild_id), data=json.dumps(params, cls=ApiEncoder).encode(),
|
||||||
id=int(info['id']),
|
expected_code=201)
|
||||||
username=info['username'],
|
self._update_rate_limit(headers)
|
||||||
discriminator=info['discriminator'],
|
if not isinstance(channel_info, dict):
|
||||||
global_name=info.get('global_name')
|
raise RuntimeError(f'Error creating channel with params (no info): {params}')
|
||||||
)
|
return TextChannel.from_dict(channel_info)
|
||||||
|
|
||||||
def _parse_message(self, info: dict) -> Message:
|
def create_message(self, channel: TextChannel, params: Api.Message.CreateParams,
|
||||||
edited_timestamp: str | None = info.get('edited_timestamp')
|
upload_files: list[tuple[str, FileMime, bytes]] | None = None) -> Message:
|
||||||
return Message(
|
headers, message_info = self._send_request(
|
||||||
id=int(info['id']),
|
*Api.Message.create(channel_id=channel.id), data=json.dumps(params, cls=ApiEncoder).encode(),
|
||||||
channel_id=int(info['channel_id']),
|
upload_files=upload_files)
|
||||||
author=(self._parse_user(info['author']) if info.get('webhook_id') is None else User(
|
self._update_rate_limit(headers)
|
||||||
id=info['webhook_id'], username='webhook', discriminator='webhook', global_name=None)),
|
if not isinstance(message_info, dict):
|
||||||
content=info['content'],
|
raise RuntimeError(f'Error creating message with params (no info): {params}')
|
||||||
timestamp=datetime.fromisoformat(info['timestamp']),
|
return Message.from_dict(message_info)
|
||||||
edited_timestamp=datetime.fromisoformat(edited_timestamp) if edited_timestamp is not None else None
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete_message(self, message: Message):
|
def delete_message(self, message: Message):
|
||||||
try:
|
headers, _ = self._send_request(
|
||||||
headers, _ = self._send_request(
|
*Api.Message.delete(channel_id=message.channel_id, message_id=message.id), expected_code=204)
|
||||||
*Api.Message.delete(channel_id=message.channel_id, message_id=message.id), expected_code=204)
|
self._update_rate_limit(headers)
|
||||||
self._update_rate_limit(headers)
|
|
||||||
print(f'Message {message.id} deleted')
|
|
||||||
except RuntimeError as error:
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
def list_channels(self) -> tuple[list[ChannelCategory], list[TextChannel]]:
|
def list_channels(self) -> tuple[list[ChannelCategory], list[TextChannel]]:
|
||||||
headers, channels = self._send_request(*Api.Guild.list_guilds(self.guild_id))
|
headers, channels_info = self._send_request(*Api.Guild.list_guilds(self.guild_id))
|
||||||
self._update_rate_limit(headers)
|
self._update_rate_limit(headers)
|
||||||
categories: list[ChannelCategory] = []
|
categories: list[ChannelCategory] = []
|
||||||
text_channels: list[TextChannel] = []
|
text_channels: list[TextChannel] = []
|
||||||
for channel in channels:
|
if channels_info is not None:
|
||||||
channel_type = ChannelType(channel['type'])
|
for channel_info in channels_info:
|
||||||
match channel_type:
|
channel_type = ChannelType(channel_info['type'])
|
||||||
case ChannelType.GUILD_CATEGORY:
|
match channel_type:
|
||||||
categories.append(self._parse_channel_category(channel))
|
case ChannelType.GUILD_CATEGORY:
|
||||||
case ChannelType.GUILD_TEXT:
|
categories.append(ChannelCategory.from_dict(channel_info))
|
||||||
text_channels.append(self._parse_text_channel(channel))
|
case ChannelType.GUILD_TEXT:
|
||||||
|
text_channels.append(TextChannel.from_dict(channel_info))
|
||||||
return categories, text_channels
|
return categories, text_channels
|
||||||
|
|
||||||
def list_text_channel_messages(self, channel: TextChannel) -> list:
|
def list_roles(self) -> list[Role]:
|
||||||
|
headers, roles_info = self._send_request(*Api.Guild.list_roles(self.guild_id))
|
||||||
|
self._update_rate_limit(headers)
|
||||||
|
if not isinstance(roles_info, list):
|
||||||
|
raise RuntimeError(f'Error listing roles (not a list): {roles_info}')
|
||||||
|
return [Role.from_dict(r) for r in roles_info]
|
||||||
|
|
||||||
|
def list_text_channel_messages(self, channel: TextChannel) -> list[Message]:
|
||||||
headers, messages = self._send_request(*Api.Message.list_by_channel(channel.id))
|
headers, messages = self._send_request(*Api.Message.list_by_channel(channel.id))
|
||||||
self._update_rate_limit(headers)
|
self._update_rate_limit(headers)
|
||||||
return [self._parse_message(m) for m in messages]
|
return [Message.from_dict(m) for m in messages or []]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,27 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum, IntFlag
|
from enum import Enum, IntFlag
|
||||||
|
|
||||||
|
|
||||||
|
class FileMime(Enum):
|
||||||
|
AUDIO_OGG = 'audio/ogg'
|
||||||
|
IMAGE_JPEG = 'image/jpeg'
|
||||||
|
IMAGE_PNG = 'image/png'
|
||||||
|
IMAGE_SVG = 'image/svg'
|
||||||
|
JSON = 'application/json'
|
||||||
|
PDF = 'application/pdf'
|
||||||
|
TEXT_CSV = 'text/csv'
|
||||||
|
TEXT_HTML = 'text/html'
|
||||||
|
TEXT_MARKDOWN = 'text/markdown'
|
||||||
|
TEXT_PLAIN = 'text/plain'
|
||||||
|
VIDEO_MP4 = 'video/mp4'
|
||||||
|
VIDEO_MPEG = 'video/mpeg'
|
||||||
|
VIDEO_WEBM = 'video/webm'
|
||||||
|
ZIP = 'application/zip'
|
||||||
|
|
||||||
|
|
||||||
class ChannelType(Enum):
|
class ChannelType(Enum):
|
||||||
GUILD_TEXT = 0
|
GUILD_TEXT = 0
|
||||||
DM = 1
|
DM = 1
|
||||||
|
|
@ -35,8 +54,9 @@ class OverwriteType(Enum):
|
||||||
|
|
||||||
|
|
||||||
class Permissions(IntFlag):
|
class Permissions(IntFlag):
|
||||||
|
NONE = 0
|
||||||
# Allows creation of instant invites
|
# Allows creation of instant invites
|
||||||
CREATE_INSTANT_INVITE = 1 << 0
|
CREATE_INSTANT_INVITE = 1
|
||||||
# Allows kicking members
|
# Allows kicking members
|
||||||
KICK_MEMBERS = 1 << 1
|
KICK_MEMBERS = 1 << 1
|
||||||
# Allows banning members
|
# Allows banning members
|
||||||
|
|
@ -153,6 +173,14 @@ class Overwrite:
|
||||||
allow: Permissions
|
allow: Permissions
|
||||||
deny: Permissions
|
deny: Permissions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(info: dict) -> Overwrite:
|
||||||
|
return Overwrite(
|
||||||
|
id=int(info['id']),
|
||||||
|
type=OverwriteType(info['type']),
|
||||||
|
allow=Permissions(int(info['allow'])),
|
||||||
|
deny=Permissions(int(info['deny'])))
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ChannelCategory:
|
class ChannelCategory:
|
||||||
|
|
@ -164,6 +192,18 @@ class ChannelCategory:
|
||||||
parent_id: int | None
|
parent_id: int | None
|
||||||
flags: ChannelFlags
|
flags: ChannelFlags
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(info: dict) -> ChannelCategory:
|
||||||
|
parent_id: str | None = info.get('parent_id')
|
||||||
|
return ChannelCategory(
|
||||||
|
id=int(info['id']),
|
||||||
|
guild_id=int(info['guild_id']),
|
||||||
|
position=int(info['position']),
|
||||||
|
permission_overwrites=[Overwrite.from_dict(o) for o in info['permission_overwrites']],
|
||||||
|
name=info.get('name'),
|
||||||
|
parent_id=int(parent_id) if parent_id is not None else None,
|
||||||
|
flags=ChannelFlags(info['flags']))
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TextChannel:
|
class TextChannel:
|
||||||
|
|
@ -180,6 +220,25 @@ class TextChannel:
|
||||||
last_pin_timestamp: datetime | None
|
last_pin_timestamp: datetime | None
|
||||||
flags: ChannelFlags
|
flags: ChannelFlags
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(info: dict) -> TextChannel:
|
||||||
|
parent_id: str | None = info.get('parent_id')
|
||||||
|
last_message_id: str | None = info.get('last_message_id')
|
||||||
|
last_pin_timestamp: str | None = info.get('last_pin_timestamp')
|
||||||
|
return TextChannel(
|
||||||
|
id=int(info['id']),
|
||||||
|
guild_id=int(info['guild_id']),
|
||||||
|
position=int(info['position']),
|
||||||
|
permission_overwrites=[Overwrite.from_dict(o) for o in info['permission_overwrites']],
|
||||||
|
name=info.get('name'),
|
||||||
|
topic=info.get('topic'),
|
||||||
|
nsfw=info['nsfw'],
|
||||||
|
last_message_id=int(last_message_id) if last_message_id is not None else None,
|
||||||
|
rate_limit_per_user=int(info['rate_limit_per_user']),
|
||||||
|
parent_id=int(parent_id) if parent_id is not None else None,
|
||||||
|
last_pin_timestamp=(datetime.fromisoformat(last_pin_timestamp) if last_pin_timestamp is not None else None),
|
||||||
|
flags=ChannelFlags(info['flags']))
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class User: # TODO : complete attributes
|
class User: # TODO : complete attributes
|
||||||
|
|
@ -188,6 +247,14 @@ class User: # TODO : complete attributes
|
||||||
discriminator: str
|
discriminator: str
|
||||||
global_name: str | None
|
global_name: str | None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(info: dict) -> User:
|
||||||
|
return User(
|
||||||
|
id=int(info['id']),
|
||||||
|
username=info['username'],
|
||||||
|
discriminator=info['discriminator'],
|
||||||
|
global_name=info.get('global_name'))
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Message: # TODO : complete attributes
|
class Message: # TODO : complete attributes
|
||||||
|
|
@ -197,3 +264,96 @@ class Message: # TODO : complete attributes
|
||||||
content: str
|
content: str
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
edited_timestamp: datetime | None
|
edited_timestamp: datetime | None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(info: dict) -> Message:
|
||||||
|
edited_timestamp: str | None = info.get('edited_timestamp')
|
||||||
|
return Message(
|
||||||
|
id=int(info['id']),
|
||||||
|
channel_id=int(info['channel_id']),
|
||||||
|
author=(User.from_dict(info['author']) if info.get('webhook_id') is None else User(
|
||||||
|
id=info['webhook_id'], username='webhook', discriminator='webhook', global_name=None)),
|
||||||
|
content=info['content'],
|
||||||
|
timestamp=datetime.fromisoformat(info['timestamp']),
|
||||||
|
edited_timestamp=datetime.fromisoformat(edited_timestamp) if edited_timestamp is not None else None)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RoleColors:
|
||||||
|
primary_color: int
|
||||||
|
seconday_color: int | None
|
||||||
|
tertiary_color: int | None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(info: dict) -> RoleColors:
|
||||||
|
seconday_color = info.get('secondary_color')
|
||||||
|
tertiary_color = info.get('tertiary_color')
|
||||||
|
return RoleColors(
|
||||||
|
primary_color=int(info['primary_color']),
|
||||||
|
seconday_color=int(seconday_color) if seconday_color is not None else None,
|
||||||
|
tertiary_color=int(tertiary_color) if tertiary_color is not None else None)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleFlags(IntFlag):
|
||||||
|
NONE = 0
|
||||||
|
# role can be selected by members in an onboarding prompt
|
||||||
|
IN_PROMPT = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RoleTags:
|
||||||
|
bot_id: int | None
|
||||||
|
intergration_id: int | None
|
||||||
|
premium_subscriber: bool
|
||||||
|
subcription_listing_id: int | None
|
||||||
|
available_for_purchase: bool
|
||||||
|
guild_connections: bool
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(info: dict) -> RoleTags:
|
||||||
|
bot_id = info.get('bot_id')
|
||||||
|
intergration_id = info.get('intergration_id')
|
||||||
|
subcription_listing_id = info.get('subcription_listing_id')
|
||||||
|
return RoleTags(
|
||||||
|
bot_id=int(bot_id) if bot_id is not None else None,
|
||||||
|
intergration_id=int(intergration_id) if intergration_id is not None else None,
|
||||||
|
premium_subscriber='premium_subscriber' in info,
|
||||||
|
subcription_listing_id=int(subcription_listing_id) if subcription_listing_id is not None else None,
|
||||||
|
available_for_purchase='available_for_purchase' in info,
|
||||||
|
guild_connections='guild_connections' in info)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Role:
|
||||||
|
id: int # role id
|
||||||
|
name: str # role name
|
||||||
|
color: int # Deprecated integer representation of hexadecimal color code
|
||||||
|
colors: RoleColors # the role's colors
|
||||||
|
hoist: bool # if this role is pinned in the user listing
|
||||||
|
icon: str | None # role icon hash
|
||||||
|
unicode_emoji: str | None # role unicode emoji
|
||||||
|
position: int # position of this role (roles with the same position are sorted by id)
|
||||||
|
permissions: Permissions # permission bit set
|
||||||
|
managed: bool # whether this role is managed by an integration
|
||||||
|
mentionable: bool # whether this role is mentionable
|
||||||
|
tags: RoleTags | None # the tags this role has
|
||||||
|
flags: int # role flags combined as a bitfield
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(info: dict) -> Role:
|
||||||
|
tags = info.get('tags')
|
||||||
|
return Role(
|
||||||
|
id=int(info['id']),
|
||||||
|
name=info['name'],
|
||||||
|
color=int(info['color']),
|
||||||
|
colors=RoleColors.from_dict(info['colors']),
|
||||||
|
hoist=info['hoist'],
|
||||||
|
icon=info.get('icon'),
|
||||||
|
unicode_emoji=info.get('unicode_emoji'),
|
||||||
|
position=int(info['position']),
|
||||||
|
permissions=Permissions(int(info['permissions'])),
|
||||||
|
managed=info['managed'],
|
||||||
|
mentionable=info['mentionable'],
|
||||||
|
tags=RoleTags.from_dict(tags) if tags is not None else None,
|
||||||
|
flags=RoleFlags(int(info['flags']))
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ pythonpath = ["."]
|
||||||
preview = true
|
preview = true
|
||||||
select = ["A", "ARG", "B", "C", "E", "F", "FURB", "G", "I","ICN", "ISC", "PERF", "PIE", "PL", "PLE", "PTH",
|
select = ["A", "ARG", "B", "C", "E", "F", "FURB", "G", "I","ICN", "ISC", "PERF", "PIE", "PL", "PLE", "PTH",
|
||||||
"Q", "RET", "RSE", "RUF", "SLF", "SIM", "T20", "TCH", "UP", "W"]
|
"Q", "RET", "RSE", "RUF", "SLF", "SIM", "T20", "TCH", "UP", "W"]
|
||||||
ignore = ["E275", "FURB140", "I001", "PERF203", "RET502", "RET503", "SIM105", "T201"]
|
ignore = ["E275", "FURB140", "I001", "PERF203", "RET502", "RET503", "SIM105"]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"tests/*" = ["SLF001"]
|
"tests/*" = ["SLF001"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue