From 8ca93c1babbc6819e571b8e095ac07595b793acb Mon Sep 17 00:00:00 2001 From: BreadTube Date: Sun, 21 Sep 2025 05:37:26 +0900 Subject: [PATCH] Initial commit --- .gitignore | 3 + bot.py | 30 ++++++ breadtube_bot/api.py | 33 +++++++ breadtube_bot/manager.py | 160 +++++++++++++++++++++++++++++++ breadtube_bot/objects.py | 199 +++++++++++++++++++++++++++++++++++++++ google_api_test.py | 27 ++++++ pyproject.toml | 62 ++++++++++++ 7 files changed, 514 insertions(+) create mode 100644 .gitignore create mode 100644 bot.py create mode 100644 breadtube_bot/api.py create mode 100644 breadtube_bot/manager.py create mode 100644 breadtube_bot/objects.py create mode 100644 google_api_test.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..377b6d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.pyc + +data diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..16db5a1 --- /dev/null +++ b/bot.py @@ -0,0 +1,30 @@ +from pathlib import Path +import sys + +from breadtube_bot.manager import DiscordManager +from breadtube_bot.objects import TextChannel + + +def main(): + bot_token = Path('data/discord_bot_token.txt').read_text(encoding='utf-8').strip() + guild_id = 1306964577812086824 + manager = DiscordManager(bot_token=bot_token, guild_id=guild_id) + _categories, text_channel = manager.list_channels() + 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__': + main() diff --git a/breadtube_bot/api.py b/breadtube_bot/api.py new file mode 100644 index 0000000..14570d1 --- /dev/null +++ b/breadtube_bot/api.py @@ -0,0 +1,33 @@ +from enum import Enum + + +class ApiVersion(Enum): + V10 = 10 + V9 = 9 + V8 = 8 + V7 = 7 + V6 = 6 + + +class ApiAction(Enum): + DELETE = 'DELETE' + GET = 'GET' + POST = 'POST' + + +class Api: + class Guild: + @staticmethod + def list_guilds(guild_id: int) -> tuple[ApiAction, str]: + return ApiAction.GET, f'/guilds/{guild_id}/channels' + + class Message: + @staticmethod + def delete(channel_id: int, message_id: int) -> tuple[ApiAction, str]: + return ApiAction.DELETE, f'/channels/{channel_id}/messages/{message_id}' + + @staticmethod + def list_by_channel(channel_id: int, limit: int | None = None) -> tuple[ApiAction, str]: + if limit is not None and not (0 < limit <= 100): # noqa: PLR2004 + raise RuntimeError('Cannot list messages by channel with limit outside [0, 100] range') + return ApiAction.GET, f'/channels/{channel_id}/messages' diff --git a/breadtube_bot/manager.py b/breadtube_bot/manager.py new file mode 100644 index 0000000..3ee2ec3 --- /dev/null +++ b/breadtube_bot/manager.py @@ -0,0 +1,160 @@ +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +import json +import tomllib +import urllib.error +import urllib.request + +from .api import Api, ApiAction, ApiVersion +from .objects import ( + ChannelCategory, ChannelFlags, ChannelType, Message, Overwrite, OverwriteType, Permissions, TextChannel, User) + + +HTTPHeaders = dict[str, str] + + +@dataclass +class _RateLimit: + remaining: int + next_reset: float + + +class DiscordManager: + @staticmethod + def _get_code_version() -> str: + pyproject_path = Path(__file__).parents[1] / 'pyproject.toml' + if not pyproject_path.exists(): + raise RuntimeError('Cannot current bot version') + return tomllib.loads(pyproject_path.read_text(encoding='utf-8'))['project']['version'] + + def __init__(self, bot_token: str, guild_id: int) -> None: + self.guild_id = guild_id + self._bot_token = bot_token + + self.rate_limit: _RateLimit = _RateLimit(remaining=1, next_reset=0) + self.version = self._get_code_version() + + def _update_rate_limit(self, headers: HTTPHeaders): + for header_key in ['x-ratelimit-remaining', 'x-ratelimit-reset']: + if header_key not in headers: + print(f'Warning: no "{header_key}" found in headers') + return + self.rate_limit.remaining = int(headers['x-ratelimit-remaining']) + self.rate_limit.next_reset = float(headers['x-ratelimit-reset']) + + def _send_request(self, api_action: ApiAction, endpoint: str, api_version: ApiVersion = ApiVersion.V10, + expected_code: int = 200) -> tuple[ + HTTPHeaders, dict]: + timeout = 3 + min_api_version = 9 + + if api_action == ApiAction.POST: + raise NotImplementedError + if api_version.value < min_api_version: + print(f'Warning: using deprecated API version {api_version} (minimum non deprecated is {min_api_version})') + url = f'https://discord.com/api/v{api_version.value}{endpoint}' + request = urllib.request.Request(url) + request.add_header('User-Agent', f'BreadTube (v{self.version})') + request.add_header('Accept', 'application/json') + request.add_header('Authorization', f'Bot {self._bot_token}') + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + if response.status != expected_code: + raise RuntimeError( + f'Unexpected code {response.status} (expected: {expected_code}) -> {response.read().decode()}') + return dict(response.getheaders()), json.loads(response.read().decode()) + except urllib.error.HTTPError as error: + raise RuntimeError( + f'HTTP error calling API ({url}): {error}:\nHeaders:\n{error.headers}Body:\n{error.read()}') from error + except urllib.error.URLError as error: + raise RuntimeError(f'URL error calling API ({url}): {error}') from error + + @staticmethod + def _parse_overwrite(info: dict) -> Overwrite: + return Overwrite( + id=int(info['id']), + type=OverwriteType(info['type']), + allow=Permissions(int(info['allow'])), + deny=Permissions(int(info['deny'])) + ) + + def _parse_channel_category(self, 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=[self._parse_overwrite(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']), + ) + + def _parse_text_channel(self, 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=[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 _parse_user(info: dict) -> User: + return User( + id=int(info['id']), + username=info['username'], + discriminator=info['discriminator'], + global_name=info.get('global_name') + ) + + def _parse_message(self, info: dict) -> Message: + edited_timestamp: str | None = info.get('edited_timestamp') + return Message( + id=int(info['id']), + channel_id=int(info['channel_id']), + author=(self._parse_user(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 + ) + + def delete_message(self, message: Message): + try: + headers, _ = self._send_request( + *Api.Message.delete(channel_id=message.channel_id, message_id=message.id), expected_code=204) + 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]]: + headers, channels = self._send_request(*Api.Guild.list_guilds(self.guild_id)) + self._update_rate_limit(headers) + categories: list[ChannelCategory] = [] + text_channels: list[TextChannel] = [] + for channel in channels: + channel_type = ChannelType(channel['type']) + match channel_type: + case ChannelType.GUILD_CATEGORY: + categories.append(self._parse_channel_category(channel)) + case ChannelType.GUILD_TEXT: + text_channels.append(self._parse_text_channel(channel)) + return categories, text_channels + + def list_text_channel_messages(self, channel: TextChannel) -> list: + headers, messages = self._send_request(*Api.Message.list_by_channel(channel.id)) + self._update_rate_limit(headers) + return [self._parse_message(m) for m in messages] diff --git a/breadtube_bot/objects.py b/breadtube_bot/objects.py new file mode 100644 index 0000000..071301c --- /dev/null +++ b/breadtube_bot/objects.py @@ -0,0 +1,199 @@ +from dataclasses import dataclass +from datetime import datetime +from enum import Enum, IntFlag + + +class ChannelType(Enum): + GUILD_TEXT = 0 + DM = 1 + GUILD_VOICE = 2 + GROUP_DM = 3 + GUILD_CATEGORY = 4 + GUILD_ANNOUNCEMENT = 5 + ANNOUNCEMENT_THREAD = 10 + PUBLIC_THREAD = 11 + PRIVATE_THREAD = 12 + GUILD_STAGE_VOICE = 13 + GUILD_DIRECTORY = 14 + GUILD_FORUM = 15 + GUILD_MEDIA = 16 + + +class ChannelFlags(IntFlag): + # this thread is pinned to the top of its parent GUILD_FORUM or GUILD_MEDIA channel + PINNED = 1 << 1 + # whether a tag is required to be specified when creating a thread in a GUILD_FORUM or a GUILD_MEDIA channel. + # Tags are specified in the applied_tags field. + REQUIRE_TAG = 1 << 4 + # when set hides the embedded media download options. Available only for media channels + HIDE_MEDIA_DOWNLOAD_OPTIONS = 1 << 15 + + +class OverwriteType(Enum): + ROLE = 0 + MEMBER = 1 + + +class Permissions(IntFlag): + # Allows creation of instant invites + CREATE_INSTANT_INVITE = 1 << 0 + # Allows kicking members + KICK_MEMBERS = 1 << 1 + # Allows banning members + BAN_MEMBERS = 1 << 2 + # Allows all permissions and bypasses channel permission overwrites + ADMINISTRATOR = 1 << 3 + # Allows management and editing of channels + MANAGE_CHANNELS = 1 << 4 + # Allows management and editing of the guild + MANAGE_GUILD = 1 << 5 + # Allows for adding new reactions to messages. + # This permission does not apply to reacting with an existing reaction on a message. + ADD_REACTIONS = 1 << 6 + # Allows for viewing of audit logs + VIEW_AUDIT_LOG = 1 << 7 + # Allows for using priority speaker in a voice channel + PRIORITY_SPEAKER = 1 << 8 + # Allows the user to go live + STREAM = 1 << 9 + # Allows guild members to view a channel, + # which includes reading messages in text channels and joining voice channels + VIEW_CHANNEL = 1 << 10 + # Allows for sending messages in a channel and creating threads in a forum + # (does not allow sending messages in threads) + SEND_MESSAGES = 1 << 11 + # Allows for sending of /tts messages + SEND_TTS_MESSAGES = 1 << 12 + # Allows for deletion of other users messages + MANAGE_MESSAGES = 1 << 13 + # Links sent by users with this permission will be auto-embedded + EMBED_LINKS = 1 << 14 + # Allows for uploading images and files + ATTACH_FILES = 1 << 15 + # Allows for reading of message history + READ_MESSAGE_HISTORY = 1 << 16 + # Allows for using the @everyone tag to notify all users in a channel, + # and the @here tag to notify all online users in a channel + MENTION_EVERYONE = 1 << 17 + # Allows the usage of custom emojis from other servers + USE_EXTERNAL_EMOJIS = 1 << 18 + # Allows for viewing guild insights + VIEW_GUILD_INSIGHTS = 1 << 19 + # Allows for joining of a voice channel + CONNECT = 1 << 20 + # Allows for speaking in a voice channel + SPEAK = 1 << 21 + # Allows for muting members in a voice channel + MUTE_MEMBERS = 1 << 22 + # Allows for deafening of members in a voice channel + DEAFEN_MEMBERS = 1 << 23 + # Allows for moving of members between voice channels + MOVE_MEMBERS = 1 << 24 + # Allows for using voice-activity-detection in a voice channel + USE_VAD = 1 << 25 + # Allows for modification of own nickname + CHANGE_NICKNAME = 1 << 26 + # Allows for modification of other users nicknames + MANAGE_NICKNAMES = 1 << 27 + # Allows management and editing of roles + MANAGE_ROLES = 1 << 28 + # Allows management and editing of webhooks + MANAGE_WEBHOOKS = 1 << 29 + # Allows for editing and deleting emojis, stickers, and soundboard sounds created by all users + MANAGE_GUILD_EXPRESSIONS = 1 << 30 + # Allows members to use application commands, including slash commands and context menu commands. + USE_APPLICATION_COMMANDS = 1 << 31 + # Allows for requesting to speak in stage channels. (This permission is under active development + # and may be changed or removed.) + REQUEST_TO_SPEAK = 1 << 32 + # Allows for editing and deleting scheduled events created by all users + MANAGE_EVENTS = 1 << 33 + # Allows for deleting and archiving threads, and viewing all private threads + MANAGE_THREADS = 1 << 34 + # Allows for creating public and announcement threads + CREATE_PUBLIC_THREADS = 1 << 35 + # Allows for creating private threads + CREATE_PRIVATE_THREADS = 1 << 36 + # Allows the usage of custom stickers from other servers + USE_EXTERNAL_STICKERS = 1 << 37 + # Allows for sending messages in threads + SEND_MESSAGES_IN_THREADS = 1 << 38 + # Allows for using Activities (applications with the EMBEDDED flag) + USE_EMBEDDED_ACTIVITIES = 1 << 39 + # Allows for timing out users to prevent them from sending or reacting to messages in chat and threads, + # and from speaking in voice and stage channels + MODERATE_MEMBERS = 1 << 40 + # Allows for viewing role subscription insights + VIEW_CREATOR_MONETIZATION_ANALYTICS = 1 << 41 + # Allows for using soundboard in a voice channel + USE_SOUNDBOARD = 1 << 42 + # Allows for creating emojis, stickers, and soundboard sounds, and editing and deleting those created + # by the current user. Not yet available to developers, see changelog. + CREATE_GUILD_EXPRESSIONS = 1 << 43 + # Allows for creating scheduled events, and editing and deleting those created by the current user. + # Not yet available to developers, see changelog. + CREATE_EVENTS = 1 << 44 + # Allows the usage of custom soundboard sounds from other servers + USE_EXTERNAL_SOUNDS = 1 << 45 + # Allows sending voice messages + SEND_VOICE_MESSAGES = 1 << 46 + # Allows sending polls + SEND_POLLS = 1 << 49 + # Allows user-installed apps to send public responses. When disabled, users will still be allowed to use their apps + # but the responses will be ephemeral. This only applies to apps not also installed to the server. + USE_EXTERNAL_APPS = 1 << 50 + # Allows pinning and unpinning messages + PIN_MESSAGES = 1 << 51 + + +@dataclass +class Overwrite: + id: int + type: OverwriteType + allow: Permissions + deny: Permissions + + +@dataclass +class ChannelCategory: + id: int + guild_id: int + position: int + permission_overwrites: list[Overwrite] + name: str | None + parent_id: int | None + flags: ChannelFlags + + +@dataclass +class TextChannel: + id: int + guild_id: int + position: int + permission_overwrites: list[Overwrite] + name: str | None + topic: str | None + nsfw: bool + last_message_id: int | None + rate_limit_per_user: int + parent_id: int | None + last_pin_timestamp: datetime | None + flags: ChannelFlags + + +@dataclass +class User: # TODO : complete attributes + id: int + username: str + discriminator: str + global_name: str | None + + +@dataclass +class Message: # TODO : complete attributes + id: int + channel_id: int + author: User + content: str + timestamp: datetime + edited_timestamp: datetime | None diff --git a/google_api_test.py b/google_api_test.py new file mode 100644 index 0000000..b596a1a --- /dev/null +++ b/google_api_test.py @@ -0,0 +1,27 @@ +from pathlib import Path +import urllib.error +import urllib.request + + +def main(): + api_key = Path('data/google_api_key.txt').read_text(encoding='utf-8').strip() + timeout = 3 + http_code_ok = 200 + request = urllib.request.Request( + 'https://www.googleapis.com/youtube/v3/search?part=snippet' + f'&channelId=UC-kM5kL9CgjN9s9pim089gg&maxResults=10&order=date&key={api_key}') + request.add_header('Accept', 'application/json') + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + if response.status != http_code_ok: + raise RuntimeError( + f'Non-OK response (status: {response.status}) : {response.read()}') + print(response.read().decode()) + except urllib.error.HTTPError as error: + raise RuntimeError(f'Cannot call API: {error}: {error.read()}') from error + except urllib.error.URLError as error: + raise RuntimeError(f'Cannot call API: {error}') from error + + +if __name__ == '__main__': + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f42820a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,62 @@ +[project] +name = "bot-discord" +version = "0.0.1" +requires-python = ">=3.10" +dependencies = ["google-api-python-client==2.182.0"] + +[project.optional-dependencies] +test = ["pytest"] + +[tool.ruff] +cache-dir = "/tmp/ruff" +exclude = [ + ".git", + ".ruff_cache", + ".venv" +] +line-length = 120 +indent-width = 4 +target-version = "py310" + + +[tool.isort] +combine_as_imports = true +force_sort_within_sections = true +lexicographical = true +lines_after_imports = 2 +multi_line_output = 4 +no_sections = false +order_by_type = true + +[tool.pytest.ini_options] +addopts = "-p no:cacheprovider -s -vv" +testpaths = ["tests"] +pythonpath = ["."] + +[tool.ruff.lint] +preview = true +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"] +ignore = ["E275", "FURB140", "I001", "PERF203", "RET502", "RET503", "SIM105", "T201"] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["SLF001"] + +[tool.ruff.lint.flake8-quotes] +inline-quotes = "single" + +[tool.ruff.lint.pylint] +max-args=16 +max-branches=24 +max-locals=16 +max-nested-blocks=8 +max-public-methods=16 +max-returns=8 +max-statements=96 + +[tool.ruff.lint.mccabe] +max-complexity = 20 + +[tool.coverage.run] +command_line = "-m pytest" +omit = ["tests/*"]