diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..6963e9b --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,28 @@ +name: "Lint" +on: [push] +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: "Checkout repository" + uses: actions/checkout@v3 + - name: "Setup Python" + uses: actions/setup-python@v3 + with: + python-version: "3.9" + - name: "Install poetry" + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + - name: "Load cached venv" + id: load-cached-venv + uses: actions/cache@v3 + with: + path: .venv + key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }} + - name: Install dependencies + run: poetry install + if: steps.load-cached-venv.outputs.cache-hit != 'true' + - name: "Lint" + run: poetry run poe lint diff --git a/Dockerfile b/Dockerfile index 12c73b5..57a8929 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,4 +16,4 @@ COPY . . # -- Run the app ENV environment=production -CMD ["python", "main.py"] \ No newline at end of file +CMD ["python", "main.py"] diff --git a/create_index.py b/create_index.py new file mode 100644 index 0000000..7b4b96a --- /dev/null +++ b/create_index.py @@ -0,0 +1,17 @@ +import pymongo +from dotenv import load_dotenv +from os import getenv + +load_dotenv() + +db_client = pymongo.MongoClient(getenv("MONGO_URI")) +db = db_client[getenv("environment", "development")] +db.gc_room.create_index( + "channels", + unique=True, +) +db.gc_room.create_index( + "name", + unique=True, +) +print("Done.") diff --git a/emojis/check.png b/emojis/check.png new file mode 100644 index 0000000..cea572c Binary files /dev/null and b/emojis/check.png differ diff --git a/emojis/clock.png b/emojis/clock.png new file mode 100644 index 0000000..e7c32e7 Binary files /dev/null and b/emojis/clock.png differ diff --git a/locale/en.yml b/locale/en.yml index 31048f5..b3e2bfb 100644 --- a/locale/en.yml +++ b/locale/en.yml @@ -1,7 +1,6 @@ chat_input: ping: _: - name: "ping" description: "Pong! Returns the latency of SevenBot." args: embed: diff --git a/locale/ja.yml b/locale/ja.yml index 0d17599..db25e50 100644 --- a/locale/ja.yml +++ b/locale/ja.yml @@ -2,9 +2,96 @@ $schema: "./_scheme.yml" chat_input: ping: _: - name: "ping" description: "Pong! SevenBotの通信速度を返します。" args: embed: title: ":ping_pong: Pong!" - description: "通信速度は`{latency}ms`です。" \ No newline at end of file + description: "通信速度は`{latency}ms`です。" + global: + _: + description: "グローバルチャット。" + activate: + _: + description: "グローバルチャットを有効にします。" + create_confirm: + title: ":x: 不明なグローバルチャット" + description: | + チャット`{name}`が見付かりませんでした。 + 作成しますか? + create_success: + title: ":white_check_mark: 完了" + description: | + グローバルチャット{name}(`{id}`)を作成しました。 + `/global activate {id}`で有効にします。 + join_confirm: + title: ":question: 参加確認" + description: | + グローバルチャット`{name}`(`{id}`)に参加しますか? + >>> {description} + join_password: + title: "パスワードの確認" + fields: + password: + name: "パスワード" + placeholder: "パスワードを入力してください。" + password_failed: + title: ":x: パスワードが違います" + description: "パスワードが違います。" + join_success: + title: ":white_check_mark: 完了" + description: | + グローバルチャット`{name}`(`{id}`)に参加しました。 + join_announce: + title: ":inbox_tray: 参加" + description: | + {name}がグローバルチャットに参加しました。 + 現在{count}チャンネルが参加しています。 + already_in: + title: ":x: 参加済み" + description: | + このチャンネルは既にグローバルチャット{name}(`{id}`)に参加しています。 + `/global deactivate`で退出できます。 + create: + title: "グローバルチャットの作成" + fields: + id: + name: "チャットID" + placeholder: "接続に使用するチャットID。" + name: + name: "チャット名" + placeholder: "チャット名。デフォルトではチャットIDが使用されます。" + description: + name: "説明" + placeholder: "チャットの説明。接続時に表示されます。" + password: + name: "パスワード" + placeholder: "チャットのパスワード。省略すると誰でも入れるようになります。" + deactivate: + deactivate_confirm: + title: ":warning: 退出しますか?" + description: | + グローバルチャット{name}(`{id}`)から退出しますか? + deactivated: + title: ":white_check_mark: 完了" + description: | + グローバルチャット{name}(`{id}`)から退出しました。 + deactivated_deleted: + title: ":white_check_mark: 完了" + description: | + グローバルチャット{name}(`{id}`)から退出しました。 + 参加しているチャンネルが無くなったため、グローバルチャットは削除されました。 + not_in: + title: ":x: 未参加" + description: | + このチャンネルはグローバルチャットに参加していません。 + `/global activate `で参加できます。 + leave_announce: + title: ":outbox_tray: 退出" + description: | + {name}がグローバルチャットから退出しました。 + 現在{count}チャンネルが参加しています。 +common: + confirm: "はい" + cancel: "いいえ" + timeouted: "タイムアウトしました。もう一度最初からやり直してください。" + canceled: "キャンセルしました。" diff --git a/main.py b/main.py index b013ef4..16afc1a 100644 --- a/main.py +++ b/main.py @@ -9,21 +9,16 @@ def setup_logging(): - discord_logger = logging.getLogger("discord") - sevenbot_logger = logging.getLogger("SevenBot") - discord_logger.setLevel(logging.DEBUG) - sevenbot_logger.setLevel(logging.DEBUG) file_handler = logging.FileHandler(filename="sevenbot.log", encoding="utf-8", mode="w") - file_handler.setLevel(logging.INFO) + file_handler.setLevel(logging.DEBUG) file_handler.setFormatter(logging.Formatter("%(asctime)s:%(levelname)s:%(name)s: %(message)s")) - discord_logger.addHandler(file_handler) - sevenbot_logger.addHandler(file_handler) - console_handler = logging.StreamHandler() console_handler.setLevel(logging.INFO) console_handler.setFormatter(ColoredFormatter(True)) - discord_logger.addHandler(console_handler) - sevenbot_logger.addHandler(console_handler) + + logging.root.setLevel(logging.DEBUG) + logging.root.addHandler(file_handler) + logging.root.addHandler(console_handler) def main(): diff --git a/poetry.lock b/poetry.lock index e1e9bfc..46bc6cd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -103,9 +103,25 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "dataclasses-json" +version = "0.5.7" +description = "Easily serialize dataclasses to and from JSON" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +marshmallow = ">=3.3.0,<4.0.0" +marshmallow-enum = ">=1.5.1,<2.0.0" +typing-inspect = ">=0.4.0" + +[package.extras] +dev = ["pytest (>=6.2.3)", "ipython", "mypy (>=0.710)", "hypothesis", "portray", "flake8", "simplejson", "types-dataclasses"] + [[package]] name = "discord.py" -version = "2.0.0a4227+geee65ac3" +version = "2.0.0a4355+g2b9e43db" description = "A Python wrapper for the Discord API" category = "main" optional = false @@ -113,19 +129,19 @@ python-versions = ">=3.8.0" develop = false [package.dependencies] -aiohttp = ">=3.6.0,<4" +aiohttp = ">=3.7.4,<4" [package.extras] docs = ["sphinx (==4.4.0)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport", "typing-extensions"] -speed = ["orjson (>=3.5.4)"] +speed = ["orjson (>=3.5.4)", "aiodns (>=1.1)", "brotli", "cchardet"] test = ["coverage", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock"] voice = ["PyNaCl (>=1.3.0,<1.6)"] [package.source] type = "git" url = "https://github.com/Rapptz/discord.py.git" -reference = "eee65ac3" -resolved_reference = "eee65ac39b85edc0353402baa42ca7e72ec286d9" +reference = "2b9e43db" +resolved_reference = "2b9e43dbf94730a9fd3ef4da005c6e8c5d826e74" [[package]] name = "flake8" @@ -192,6 +208,34 @@ requirements_deprecated_finder = ["pipreqs", "pip-api"] colors = ["colorama (>=0.4.3,<0.5.0)"] plugins = ["setuptools"] +[[package]] +name = "marshmallow" +version = "3.16.0" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +packaging = ">=17.0" + +[package.extras] +dev = ["pytest", "pytz", "simplejson", "mypy (==0.960)", "flake8 (==4.0.1)", "flake8-bugbear (==22.4.25)", "pre-commit (>=2.4,<3.0)", "tox"] +docs = ["sphinx (==4.5.0)", "sphinx-issues (==3.0.1)", "alabaster (==0.7.12)", "sphinx-version-warning (==1.1.2)", "autodocsumm (==0.2.8)"] +lint = ["mypy (==0.960)", "flake8 (==4.0.1)", "flake8-bugbear (==22.4.25)", "pre-commit (>=2.4,<3.0)"] +tests = ["pytest", "pytz", "simplejson"] + +[[package]] +name = "marshmallow-enum" +version = "1.5.1" +description = "Enum field for Marshmallow" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +marshmallow = ">=2.0.0" + [[package]] name = "mccabe" version = "0.6.1" @@ -232,10 +276,21 @@ python-versions = ">=3.7" name = "mypy-extensions" version = "0.4.3" description = "Experimental type system extensions for programs checked with the mypy typechecker." -category = "dev" +category = "main" optional = false python-versions = "*" +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + [[package]] name = "pastel" version = "0.2.1" @@ -312,6 +367,17 @@ snappy = ["python-snappy"] srv = ["dnspython (>=1.16.0,<3.0.0)"] zstd = ["zstandard"] +[[package]] +name = "pyparsing" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "main" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["railroad-diagrams", "jinja2"] + [[package]] name = "pyproject-flake8" version = "0.0.1a4" @@ -363,10 +429,22 @@ python-versions = ">=3.7" name = "typing-extensions" version = "4.2.0" description = "Backported and Experimental Type Hints for Python 3.7+" -category = "dev" +category = "main" optional = false python-versions = ">=3.7" +[[package]] +name = "typing-inspect" +version = "0.7.1" +description = "Runtime inspection utilities for typing module." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +mypy-extensions = ">=0.3.0" +typing-extensions = ">=3.7.4" + [[package]] name = "yarl" version = "1.7.2" @@ -382,7 +460,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "f6f725bb9327175d409282b2dc044e0c02a41a2c858aabcde7d902de45903f3c" +content-hash = "caa3f556caa51acd6c4544edbd80b04a2d39129502dc61e39cced1ed82d33602" [metadata.files] aiohttp = [ @@ -508,6 +586,10 @@ colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] +dataclasses-json = [ + {file = "dataclasses-json-0.5.7.tar.gz", hash = "sha256:c2c11bc8214fbf709ffc369d11446ff6945254a7f09128154a7620613d8fda90"}, + {file = "dataclasses_json-0.5.7-py3-none-any.whl", hash = "sha256:bc285b5f892094c3a53d558858a88553dd6a61a11ab1a8128a0e554385dcc5dd"}, +] "discord.py" = [] flake8 = [ {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, @@ -590,6 +672,14 @@ isort = [ {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, ] +marshmallow = [ + {file = "marshmallow-3.16.0-py3-none-any.whl", hash = "sha256:53a1e0ee69f79e1f3e80d17393b25cfc917eda52f859e8183b4af72c3390c1f1"}, + {file = "marshmallow-3.16.0.tar.gz", hash = "sha256:a762c1d8b2bcb0e5c8e964850d03f9f3bffd6a12b626f3c14b9d6b1841999af5"}, +] +marshmallow-enum = [ + {file = "marshmallow-enum-1.5.1.tar.gz", hash = "sha256:38e697e11f45a8e64b4a1e664000897c659b60aa57bfa18d44e226a9920b6e58"}, + {file = "marshmallow_enum-1.5.1-py2.py3-none-any.whl", hash = "sha256:57161ab3dbfde4f57adeb12090f39592e992b9c86d206d02f6bd03ebec60f072"}, +] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, @@ -663,6 +753,10 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] pastel = [ {file = "pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364"}, {file = "pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d"}, @@ -772,6 +866,10 @@ pymongo = [ {file = "pymongo-4.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:f0aea377b9dfc166c8fa05bb158c30ee3d53d73f0ed2fc05ba6c638d9563422f"}, {file = "pymongo-4.1.1.tar.gz", hash = "sha256:d7b8f25c9b0043cbaf77b8b895814e33e7a3c807a097377c07e1bd49946030d5"}, ] +pyparsing = [ + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, +] pyproject-flake8 = [ {file = "pyproject-flake8-0.0.1a4.tar.gz", hash = "sha256:8ed9453f1d984cfe94c998f9840275359e29e7f435b8ddd188ae084e2dc1270c"}, {file = "pyproject_flake8-0.0.1a4-py2.py3-none-any.whl", hash = "sha256:1a8f94e18d08677ee780625049d9d00a9ee823661c6606caab8a383351037a75"}, @@ -827,6 +925,11 @@ typing-extensions = [ {file = "typing_extensions-4.2.0-py3-none-any.whl", hash = "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"}, {file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"}, ] +typing-inspect = [ + {file = "typing_inspect-0.7.1-py2-none-any.whl", hash = "sha256:b1f56c0783ef0f25fb064a01be6e5407e54cf4a4bf4f3ba3fe51e0bd6dcea9e5"}, + {file = "typing_inspect-0.7.1-py3-none-any.whl", hash = "sha256:3cd7d4563e997719a710a3bfe7ffb544c6b72069b6812a02e9b414a8fa3aaa6b"}, + {file = "typing_inspect-0.7.1.tar.gz", hash = "sha256:047d4097d9b17f46531bf6f014356111a1b6fb821a24fe7ac909853ca2a782aa"}, +] yarl = [ {file = "yarl-1.7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2a8508f7350512434e41065684076f640ecce176d262a7d54f0da41d99c5a95"}, {file = "yarl-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da6df107b9ccfe52d3a48165e48d72db0eca3e3029b5b8cb4fe6ee3cb870ba8b"}, diff --git a/pyproject.toml b/pyproject.toml index e348ae1..cf22876 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,12 +6,13 @@ authors = ["sevenc-nanashi "] [tool.poetry.dependencies] python = "^3.9" -"discord.py" = { git = "https://github.com/Rapptz/discord.py.git", rev = "eee65ac3" } +"discord.py" = { git = "https://github.com/Rapptz/discord.py.git", rev = "2b9e43db" } GitPython = "^3.1.27" colorama = "^0.4.4" python-dotenv = "^0.20.0" PyYAML = "^6.0" motor = "^3.0.0" +dataclasses-json = "^0.5.7" [tool.poetry.dev-dependencies] black = "^22.3.0" diff --git a/src/assets/global_chat.png b/src/assets/global_chat.png new file mode 100644 index 0000000..fc2c479 Binary files /dev/null and b/src/assets/global_chat.png differ diff --git a/src/common/confirm.py b/src/common/confirm.py new file mode 100644 index 0000000..f5343c0 --- /dev/null +++ b/src/common/confirm.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from secrets import token_urlsafe + +import discord +from discord.ui import Button, View +from src.lang import text + + +class ConfirmView(View): + def __init__( + self, + lang: str, + cancel_first: bool = False, + button_color: discord.ButtonStyle = discord.ButtonStyle.primary, + ): + super().__init__(timeout=30) + self.value: bool | None = None + self.nonce: str = token_urlsafe(16) + self.interaction: discord.Interaction = None + confirm_button = Button( + label=text(lang, "common.confirm"), + style=button_color, + row=0, + custom_id=self.nonce + ":confirm", + ) + cancel_button = Button( + label=text(lang, "common.cancel"), + style=discord.ButtonStyle.secondary, + row=0, + custom_id=self.nonce + ":cancel", + ) + confirm_button.callback = self.callback + cancel_button.callback = self.callback + + if cancel_first: + self.add_item( + cancel_button, + ) + self.add_item(confirm_button) + else: + self.add_item(confirm_button) + self.add_item(cancel_button) + + async def on_timeout(self) -> None: + self.stop() + + async def callback(self, interaction: discord.Interaction): + self.value = interaction.data["custom_id"].split(":")[1] == "confirm" + + self.interaction = interaction + self.stop() diff --git a/src/common/embed.py b/src/common/embed.py new file mode 100644 index 0000000..06e9c49 --- /dev/null +++ b/src/common/embed.py @@ -0,0 +1,46 @@ +from discord import Embed +import yaml + +from src.const.color import Color + +from .. import lang + + +class LocaleEmbed(Embed): + def __init__(self, data: lang.LocaleGroup, **formats): + self.locale_data = data + super().__init__() + self.title = self.locale_data("title", **formats) + self.description = self.locale_data("description", **formats) + with open("src/embed.yml") as file: + embed_data = yaml.safe_load(file) + try: + for part in self.locale_data.code.split("."): + embed_data = embed_data[part] + except KeyError: + embed_data = {} + color = embed_data.get("color") + if color is None: + pass + elif isinstance(color, int): + self.color = embed_data.get("color") + elif color.startswith("#"): + self.color = int(color[1:], 16) + else: + self.color = Color[color]._value_ + self.set_footer( + text=self.locale_data("footer", None, **formats), icon_url=embed_data.get("footer_icon_url", None) + ) + self.set_thumbnail(url=embed_data.get("thumbnail_url", None)) + if self.locale_data("author.name", embed_data.get("author_name", None)): + self.set_author( + name=self.locale_data("author.name", embed_data.get("author_name", None), **formats), + url=self.locale_data("author.url", embed_data.get("author_url", None), **formats), + icon_url=embed_data.get("author_icon_url", None), + ) + self.set_image(url=embed_data.get("image_url", None)) + for field_name, field_data in self.locale_data("fields", {}).items(): + self.add_field( + name=field_name.format(**formats), + value=field_data.format(**formats), + ) diff --git a/src/common/missing.py b/src/common/missing.py new file mode 100644 index 0000000..13e47f7 --- /dev/null +++ b/src/common/missing.py @@ -0,0 +1,20 @@ +from typing import Any + + +class _MissingSentinel: + __slots__ = () + + def __eq__(self, other): + return False + + def __bool__(self): + return False + + def __hash__(self): + return 0 + + def __repr__(self): + return "..." + + +MISSING: Any = _MissingSentinel() diff --git a/src/const/color.py b/src/const/color.py index e8e9245..ec2c8fe 100644 --- a/src/const/color.py +++ b/src/const/color.py @@ -8,3 +8,4 @@ class Color(Enum): success = discord.Color.green() info = discord.Color.blue() warning = discord.Color.orange() + prompt = discord.Color.dark_gray() diff --git a/src/core.py b/src/core.py index 9d406fa..01f96b1 100644 --- a/src/core.py +++ b/src/core.py @@ -1,4 +1,4 @@ -import hashlib +import json import logging import os from base64 import b64decode @@ -10,8 +10,9 @@ if TYPE_CHECKING: from .exts._common import Cog -from .exts._common import CogFlag + from . import lang # noqa: F401 +from .exts._common import CogFlag class SevenBot(commands.Bot): @@ -19,17 +20,18 @@ def __init__(self): intents = discord.Intents.all() intents.typing = False super().__init__( - command_prefix=["sb#", "sb."], + command_prefix=[], help_command=None, strip_after_prefix=True, case_insensitive=True, intents=intents, - application_id=int(b64decode(os.environ["TOKEN"].split(".")[0])) + application_id=int(b64decode(os.environ["TOKEN"].split(".")[0])), ) - self.prev_hash = {} + self.prev_update = {} self.prev_commands = [] self.logger = logging.getLogger("SevenBot") - self.db = motor.AsyncIOMotorClient(os.environ["MONGO_URI"]) + self.db_client = motor.AsyncIOMotorClient(os.environ["MONGO_URI"]) + self.db = self.db_client["production" if self.is_production else "development"] async def on_ready(self) -> None: """bot起動時のイベント""" @@ -50,24 +52,30 @@ async def watch_files(self) -> None: for file in os.listdir("src/exts/"): if not file.endswith(".py") or file.startswith("_"): continue - with open(f"src/exts/{file}", "rb") as f: - file_hash = hashlib.sha256(f.read()).hexdigest() - if file_hash != self.prev_hash.get(file): - if self.prev_hash.get(file) is not None: + update_time = os.path.getmtime(f"src/exts/{file}") + if update_time != self.prev_update.get(file): + if self.prev_update.get(file) is not None: self.logger.info("Reloading %s by auto reloading", file) try: await self.reload_extension(f"src.exts.{file[:-3]}") except commands.errors.ExtensionNotLoaded: await self.load_extension(f"src.exts.{file[:-3]}") await self.sync() - self.prev_hash[file] = file_hash + self.prev_update[file] = update_time except Exception as e: self.logger.exception(e) def run(self) -> None: """SevenBotを起動します。""" self.logger.info("Starting SevenBot...") - super().run(os.environ["TOKEN"]) + super().run(os.environ["TOKEN"], log_handler=None) + + def emoji(self, name: str): + return discord.utils.get(sum([list(g.emojis) for g in self.emoji_guilds], []), name=name) + + @property + def emoji_guilds(self) -> list[discord.Guild]: + return [g for g in self.guilds if str(g.id) in os.environ["EMOJI_GUILDS"].split(",")] def setup(self) -> None: pass @@ -77,8 +85,10 @@ def test_guild(self): return discord.Object(int(os.environ["TEST_GUILD"])) async def sync(self) -> None: - current_commands = list(self.tree.walk_commands()) - if [c.to_dict() for c in self.prev_commands] == [c.to_dict() for c in current_commands]: + current_commands = list(self.tree.walk_commands(guild=self.test_guild)) + if sorted([c.to_dict() for c in self.prev_commands], key=lambda c: json.dumps(c)) == sorted( + [c.to_dict() for c in current_commands], key=lambda c: json.dumps(c) + ): self.logger.info("No changes, skipping sync") return self.prev_commands = current_commands diff --git a/src/db/gc_room.py b/src/db/gc_room.py new file mode 100644 index 0000000..353c2de --- /dev/null +++ b/src/db/gc_room.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from typing import Union + +from dataclasses_json import dataclass_json + + +@dataclass_json +@dataclass +class GlobalChatRoom: + id: str + name: str + channels: list[int] + owner: int + password: Union[str, None] + mute: list[int] + rule: dict[str, str] + slow: int + description: str + antispam: bool + + def except_channel(self, *channels: int) -> bool: + tmp_channels = self.channels.copy() + for channel in channels: + if channel in tmp_channels: + tmp_channels.remove(channel) + return tmp_channels diff --git a/src/embed.yml b/src/embed.yml new file mode 100644 index 0000000..5af1de9 --- /dev/null +++ b/src/embed.yml @@ -0,0 +1,31 @@ +chat_input: + ping: + embed: + color: sevenbot + global: + activate: + create_confirm: + color: prompt + create_success: + color: success + already_in: + color: error + join_confirm: + color: prompt + password_failed: + color: error + join_success: + color: success + join_announce: + color: success + deactivate: + deactivate_confirm: + color: prompt + deactivated: + color: success + deactivated_deleted: + color: success + leave_announce: + color: error + not_in: + color: error \ No newline at end of file diff --git a/src/exts/ex_events.py b/src/exts/ex_events.py new file mode 100644 index 0000000..f4a329a --- /dev/null +++ b/src/exts/ex_events.py @@ -0,0 +1,20 @@ +from typing import TYPE_CHECKING +import discord +from discord.ext import commands + +from ._common import Cog + +if TYPE_CHECKING: + from core import SevenBot + + +class ExtraEvents(Cog): + @commands.Cog.listener("on_message") + async def on_valid_message(self, message: discord.Message) -> None: + if message.author.bot: + return + self.bot.dispatch("valid_message", message) + + +async def setup(bot: "SevenBot"): + await bot.load_cog(ExtraEvents) diff --git a/src/exts/global_chat.py b/src/exts/global_chat.py index e413113..2b0ba84 100644 --- a/src/exts/global_chat.py +++ b/src/exts/global_chat.py @@ -1,7 +1,16 @@ -from typing import TYPE_CHECKING +import asyncio +import hashlib +import re +from secrets import token_urlsafe +from typing import TYPE_CHECKING, Optional import discord from discord import app_commands +from discord.ui import Modal, TextInput +from discord.ext import commands +from src.db.gc_room import GlobalChatRoom +from src.common.embed import LocaleEmbed +from src.common.confirm import ConfirmView from ._common import Cog, CogFlag @@ -9,17 +18,378 @@ from core import SevenBot +INVITE_PATTERN = re.compile(r"(?:https?://)?discord(?:app\.com/invite|\.gg)/[a-z0-9]+", re.I) +FILE_COLORS = { + "application": discord.Color.blurple(), + "audio": discord.Color.green(), + "font": discord.Color.dark_teal(), + "image": discord.Color.dark_orange(), + "text": discord.Color.dark_purple(), + "video": discord.Color.dark_red(), + "zip": discord.Color.dark_gold(), + "model": discord.Color.dark_magenta(), +} +SYSTEM_MESSAGE = "System" +LOGO = open("src/assets/global_chat.png", "rb").read() + + class GlobalChat(Cog): flag = CogFlag.production | CogFlag.development group = app_commands.Group(name="global", description="グローバルチャット") - @group.command(name="activate", description="Activate global chat") - async def activate(self, interaction: discord.Interaction): - await interaction.response.defer() + def __init__(self, bot: "SevenBot"): + super().__init__(bot) + self.channels_cache = set() + self.send_semaphore = asyncio.Semaphore(100) + + async def channels(self): + if self.channels_cache: + return self.channels_cache + async for gc_room in self.bot.db.gc_room.find(): + self.channels_cache.update(gc_room["channels"]) + return self.channels_cache - @group.command(name="deactivate", description="Deactivate global chat") + @group.command(name="activate", description="グローバルチャットに接続します。") + @app_commands.describe(gc_id="接続するグローバルチャットの名前。") + @app_commands.rename(gc_id="id") + async def activate(self, interaction: discord.Interaction, gc_id: str = "global"): + await interaction.response.defer(ephemeral=True) + current_room = await self.bot.db.gc_room.find_one({"channels": interaction.channel_id}) + if current_room is not None: + current_room = GlobalChatRoom.from_dict(current_room) + await interaction.followup.send( + embed=LocaleEmbed(interaction.text("already_in"), name=current_room.name, id=current_room.id), + ephemeral=True, + ) + return + gc_room = await self.bot.db.gc_room.find_one({"id": gc_id}) + if gc_room is None: + await self.create_gc_room(interaction, gc_id) + else: + await self.join_gc_room(interaction, GlobalChatRoom.from_dict(gc_room)) + + @group.command(name="deactivate", description="グローバルチャットから切断します。") async def deactivate(self, interaction: discord.Interaction): - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) + current_room = await self.bot.db.gc_room.find_one({"channels": interaction.channel_id}) + if current_room is None: + await interaction.followup.send( + embed=LocaleEmbed(interaction.text("not_in")), + ephemeral=True, + ) + return + current_room = GlobalChatRoom.from_dict(current_room) + confirm = ConfirmView(lang=interaction.locale, button_color=discord.ButtonStyle.danger) + await interaction.followup.send( + embed=LocaleEmbed(interaction.text("deactivate_confirm"), name=current_room.name, id=current_room.id), + view=confirm, + ephemeral=True, + ) + await confirm.wait() + if confirm.value is None: + return await interaction.followup.send( + interaction.text("common.timeouted"), + ephemeral=True, + ) + if not confirm.value: + return await confirm.interaction.response.send_message( + interaction.text("common.canceled"), + ephemeral=True, + ) + try: + self.channels_cache.remove(interaction.channel_id) + except KeyError: + pass + if len(current_room.channels) == 1: + await self.bot.db.gc_room.delete_one({"id": current_room.id}) + embed = LocaleEmbed(interaction.text("deactivated_deleted"), name=current_room.name, id=current_room.id) + else: + await self.bot.db.gc_room.update_one( + {"id": current_room.id}, {"$pull": {"channels": interaction.channel_id}} + ) + current_room.channels.remove(interaction.channel_id) + announce_embed = LocaleEmbed( + interaction.text("leave_announce"), name=interaction.guild.name, count=len(current_room.channels) + ) + if interaction.guild.icon: + announce_embed.set_thumbnail(url=interaction.guild.icon.url) + await self.multi_send( + current_room, + current_room.channels, + embed=announce_embed, + username=SYSTEM_MESSAGE, + ) + embed = LocaleEmbed(interaction.text("deactivated"), name=current_room.name, id=current_room.id) + webhook = await self.get_webhook(interaction.channel, current_room, create=False) + if webhook is not None: + await webhook.delete() + await interaction.edit_original_message( + embed=embed, + view=None, + ) + + async def create_gc_room(self, interaction: discord.Interaction, gc_id: str): + confirm = ConfirmView( + lang=interaction.locale, + button_color=discord.ButtonStyle.success, + ) + await interaction.edit_original_message( + embed=LocaleEmbed(interaction.text("create_confirm"), name=gc_id), + view=confirm, + ) + await confirm.wait() + if confirm.value is None: + return await interaction.followup.send( + interaction.text("common.timeouted"), + ephemeral=True, + ) + if not confirm.value: + return await confirm.interaction.response.send_message( + interaction.text("common.canceled"), + ephemeral=True, + ) + modal = CreateModal(confirm.interaction, gc_id) + await confirm.interaction.response.send_modal(modal) + await modal.wait() + modal_interaction = modal.interaction + response = {} + for component in modal_interaction.data["components"]: + response[component["components"][0]["custom_id"].split(":")[1]] = component["components"][0]["value"] + room = GlobalChatRoom( + id=gc_id, + name=response["name"], + channels=[interaction.channel_id], + owner=interaction.user.id, + password=hashlib.sha256(response["password"].encode("utf-8")).hexdigest() if response["password"] else None, + description=response["description"], + mute=[], + rule={}, + slow=0, + antispam=False, + ) + await modal_interaction.response.defer( + ephemeral=True, + ) + await self.bot.db.gc_room.insert_one(room.to_dict()) + self.channels_cache.add(interaction.channel_id) + await modal_interaction.edit_original_message( + embed=LocaleEmbed( + interaction.text("create_success"), + id=gc_id, + name=response["name"], + ), + view=None, + ) + + async def join_gc_room(self, interaction: discord.Interaction, gc_room: GlobalChatRoom): + confirm = ConfirmView( + lang=interaction.locale, + button_color=discord.ButtonStyle.success, + ) + await interaction.edit_original_message( + embed=LocaleEmbed( + interaction.text("join_confirm"), + name=gc_room.name, + id=gc_room.id, + description=gc_room.description, + ), + view=confirm, + ) + await confirm.wait() + if confirm.value is None: + return await interaction.followup.send( + interaction.text("common.timeouted"), + ephemeral=True, + ) + if not confirm.value: + return await confirm.interaction.response.send_message( + interaction.text("common.canceled"), + ephemeral=True, + ) + if gc_room.password: + modal = PasswordModal(confirm.interaction) + await confirm.interaction.response.send_modal(modal) + await modal.wait() + final_interaction = modal.interaction + password = final_interaction.data["components"][0]["components"][0]["value"] + if gc_room.password != hashlib.sha256(password.encode("utf8")).hexdigest(): + await final_interaction.response.send_message( + embed=LocaleEmbed(interaction.text("password_failed")), + ephemeral=True, + ) + return + else: + final_interaction = confirm.interaction + await final_interaction.response.defer( + ephemeral=True, + ) + await self.bot.db.gc_room.update_one( + {"id": gc_room.id}, + {"$addToSet": {"channels": interaction.channel_id}}, + ) + self.channels_cache.add(interaction.channel_id) + gc_room.channels.append(interaction.channel_id) + embed = LocaleEmbed(interaction.text("join_announce"), name=interaction.guild.name, count=len(gc_room.channels)) + if interaction.guild.icon: + embed.set_thumbnail(url=interaction.guild.icon.url) + await self.multi_send( + gc_room, gc_room.except_channel(interaction.channel_id), embed=embed, username=SYSTEM_MESSAGE + ) + await final_interaction.edit_original_message( + embed=LocaleEmbed(interaction.guild_text("join_success"), name=gc_room.name, id=gc_room.id), + view=None, + ) + + async def multi_send(self, gc_room: GlobalChatRoom, channels: list[int], **kwargs) -> list[discord.WebhookMessage]: + def get_coroutine(channel_id: int): + channel = self.bot.get_channel(channel_id) + if channel is None: + return + return self.single_send(gc_room, channel, **kwargs) + + return await asyncio.gather(*filter(lambda c: c, map(get_coroutine, channels))) + + async def single_send( + self, gc_room: GlobalChatRoom, channel: discord.TextChannel, **kwargs + ) -> discord.WebhookMessage: + webhook = await self.get_webhook(channel, gc_room, create=True) + if webhook is None: + return + async with self.send_semaphore: + return await webhook.send(**kwargs, allowed_mentions=discord.AllowedMentions.none(), wait=True) + + async def get_webhook( + self, channel: discord.TextChannel, gc_room: GlobalChatRoom, create: bool = False + ) -> Optional[discord.Webhook]: + webhook = await channel.webhooks() + name = f"sevenbot-global-webhook-{gc_room.id}" + for w in webhook: + if w.name == name: + return w + if not create: + return None + try: + return await channel.create_webhook(name=name, avatar=LOGO) + except discord.HTTPException: + return None + + @commands.Cog.listener("on_valid_message") + async def on_message_global(self, message: discord.Message): + if message.channel.id not in await self.channels(): + return + db_data = await self.bot.db.gc_room.find_one({"channels": message.channel.id}) + if db_data is None: + return + gc_room = GlobalChatRoom.from_dict(db_data) + name = str(message.author) + suffix = f"(From {message.guild.name}, ID: {message.author.id})" + if len(name + " " + suffix) > 80: + name = name[: 80 - len(suffix)] + name += " " + suffix + embeds = [] + for attachment in message.attachments: + if attachment.content_type: + content_type = attachment.content_type + else: + content_type = "application/octet-stream" + embed = discord.Embed( + title=attachment.name + " (" + self.get_size(attachment.size) + ")", + url=attachment.url, + color=FILE_COLORS[content_type.split("/")[0]], + ) + if content_type.startswith("image/"): + embed.set_image(url=attachment.url) + embeds.append(embed) + self.bot.loop.create_task(message.add_reaction(self.bot.emoji("clock"))) + await self.multi_send_filtered( + gc_room, + gc_room.except_channel(message.channel.id), + content=message.clean_content, + username=name, + avatar_url=message.author.display_avatar, + embeds=embeds, + ) + self.bot.loop.create_task(message.remove_reaction(self.bot.emoji("clock"), self.bot.user)) + self.bot.loop.create_task(message.add_reaction(self.bot.emoji("check"))) + await asyncio.sleep(3) + await message.remove_reaction(self.bot.emoji("check"), self.bot.user) + + def get_size(self, size: int): + if size < 1024: + return f"{size} Bytes" + elif size < 1048576: + return f"{size / 1024} KiB" + elif size < 1073741824: + return f"{size / 1048576} MiB" + else: + return f"{size / 1073741824} GiB" + + async def multi_send_filtered(self, gc_room: GlobalChatRoom, channels: list[int], content, **kwargs): + if len(content.splitlines()) > 10: + content = "\n".join(content.splitlines()[:10]) + "\n..." + content = INVITE_PATTERN.sub("", content) + return await self.multi_send(gc_room, channels, content=content, **kwargs) + + +class CreateModal(Modal): + def __init__(self, interaction: discord.Interaction, name: str): + command_texts = interaction.text("chat_input.global.activate.create") + super().__init__(title=command_texts("title")) + field_texts = command_texts("fields") + self.nonce: str = token_urlsafe(16) + self.interaction = None + self.add_item( + TextInput( + label=field_texts("name.name"), + placeholder=field_texts("name.placeholder"), + default=name, + custom_id=self.nonce + ":name", + ) + ) + self.add_item( + TextInput( + label=field_texts("description.name"), + placeholder=field_texts("description.placeholder"), + default="", + style=discord.TextStyle.paragraph, + custom_id=self.nonce + ":description", + ) + ) + self.add_item( + TextInput( + label=field_texts("password.name"), + placeholder=field_texts("password.placeholder"), + default="", + custom_id=self.nonce + ":password", + required=False, + ) + ) + + async def on_submit(self, interaction: discord.Interaction) -> None: + self.interaction = interaction + self.stop() + + +class PasswordModal(Modal): + def __init__(self, interaction: discord.Interaction): + command_texts = interaction.text("chat_input.global.activate.join_password") + super().__init__(title=command_texts("title")) + field_texts = command_texts("fields") + self.nonce: str = token_urlsafe(16) + self.interaction = None + self.add_item( + TextInput( + label=field_texts("password.name"), + placeholder=field_texts("password.placeholder"), + default="", + custom_id=self.nonce + ":password", + required=True, + ) + ) + + async def on_submit(self, interaction: discord.Interaction) -> None: + self.interaction = interaction + self.stop() async def setup(bot: "SevenBot"): diff --git a/src/exts/ping.py b/src/exts/ping.py index d657fc9..485ca60 100644 --- a/src/exts/ping.py +++ b/src/exts/ping.py @@ -3,6 +3,8 @@ import discord from discord import app_commands +from src.common.embed import LocaleEmbed + from ._common import Cog, CogFlag from src.const.color import Color @@ -16,10 +18,10 @@ class Ping(Cog): @app_commands.command(name="ping", description="Pong!") async def ping(self, interaction: discord.Interaction): await interaction.response.send_message( - embed=discord.Embed( - title=interaction.text("embed.title"), - description=interaction.text("embed.description", latency=round(self.bot.latency * 1000)), + embed=LocaleEmbed( + interaction.text("embed"), color=Color.sevenbot.value, + latency=round(self.bot.latency * 1000), ), ephemeral=True, ) diff --git a/src/lang.py b/src/lang.py index fe8ec19..62132ce 100644 --- a/src/lang.py +++ b/src/lang.py @@ -1,9 +1,65 @@ +from logging import getLogger import os +from typing import Any import discord import yaml +from src.common.missing import MISSING -def get_locale(locale: discord.Locale) -> str: + +logger = getLogger("lang") + + +class DefaultMap(dict): + def __missing__(self, key): + return key.join("{}") + + +class LocaleGroup: + def __init__(self, code: str, data: str, lang: str): + self.code = code + self.data = data + self.lang = lang + + def __call__(self, code: str, default: Any = MISSING, **formats) -> str: + try: + base = self.data + for part in code.split("."): + base = base[part] + except KeyError: + if default is MISSING: + logger.warning(f"Missing text in LocaleGroup: {self.lang}/{self.code}.{code}") + return LocaleText(f"*{self.code}.{code}*", self.lang) + return default + else: + if isinstance(base, str): + return LocaleText(base.format_map(DefaultMap(formats)), self.lang) + return LocaleGroup(self.code + "." + code, base, self.lang) + + +class LocaleText(str): + def __new__(cls, text: str, lang: str): + obj = str.__new__(cls, text) + obj.lang = lang + return obj + + @property + def code(self): + return self.replace("*", "") + + def __call__(self, code: str, default: Any = MISSING, **formats): + if default is not MISSING: + return default + logger.warning(f"Missing text in LocaleText: {self.lang}/{self.code}.{code}") + return LocaleText(f"*{self.code}.{code}*", self.lang) + + +def get_texts(locale: str) -> dict: + with open(get_locale(locale), "r", encoding="utf-8") as file: + return yaml.safe_load(file) + + +def get_locale(locale: str) -> str: locale = locale.value.split("-")[0] if os.path.exists(f"locale/{locale}.yml"): return f"locale/{locale}.yml" @@ -11,41 +67,44 @@ def get_locale(locale: discord.Locale) -> str: return "locale/en.yml" -def text(self: discord.Interaction, code: str, **formats) -> str: - try: - with open(get_locale(self.locale), "r", encoding="utf-8") as file: - texts = yaml.safe_load(file) - command = self.command - if isinstance(command, discord.app_commands.Command): - base = texts["chat_input"] - for name in command.qualified_name.split(" "): - base = base[name] - else: # Context - base = texts["context"][str(command)] - for code_key in code.split("."): - base = base[code_key] - return base.format(**formats) - except KeyError: - return f"*{code}*" +def interaction_text(self: discord.Interaction, code: str, **formats) -> str: + texts = get_texts(self.locale) + command = self.command + if code.split(".")[0] in texts: + pass + elif isinstance(command, discord.app_commands.Command): + code = "chat_input." + command.qualified_name.replace(" ", ".") + "." + code + else: # Context + code = "context." + str(command) + "." + code + return text(self.locale, code, **formats) + + +def interaction_guild_text(self: discord.Interaction, code: str, **formats) -> str: + texts = get_texts(self.guild_locale) + command = self.command + if code.split(".")[0] in texts: + pass + elif isinstance(command, discord.app_commands.Command): + code = "chat_input." + command.qualified_name.replace(" ", ".") + "." + code + else: # Context + code = "context." + str(command) + "." + code + return text(self.guild_locale, code, **formats) -def guild_text(self: discord.Interaction, code: str, **formats) -> str: +def text(lang: str, code: str, **formats) -> str: try: - with open(get_locale(self.guild_locale), "r", encoding="utf-8") as file: - texts = yaml.safe_load(file) - command = self.command - if isinstance(command, discord.app_commands.Command): - base = texts["chat_input"] - for name in command.qualified_name.split(" "): - base = base[name] - else: - base = texts["context"][str(self.command)] + texts = get_texts(lang) + base = texts for code_key in code.split("."): base = base[code_key] - return base.format(**formats) except KeyError: - return f"*{code}*" + logger.warning(f"Missing text: {lang}/{code}") + return LocaleText(f"*{code}*", lang) + else: + if isinstance(base, str): + return LocaleText(base.format_map(DefaultMap(formats)), lang) + return LocaleGroup(code, base, lang) -discord.Interaction.text = text # Monkey patch -discord.Interaction.guild_text = guild_text +discord.Interaction.text = interaction_text # Monkey patch +discord.Interaction.guild_text = interaction_guild_text