diff --git a/.env.example b/.env.example index c1cf886..1ab4ee1 100644 --- a/.env.example +++ b/.env.example @@ -15,4 +15,7 @@ api_ninja_key= currency_api_key= # 14 character salt -salt= \ No newline at end of file +salt= + +log_channel= +dev_help_forum_id= diff --git a/config/config.yml b/config/config.yml index 5cdda25..f278dab 100644 --- a/config/config.yml +++ b/config/config.yml @@ -9,12 +9,11 @@ database: password: !ENV "db_pass" port: !ENV "db_port" - api: api_ninja: !ENV "api_ninja_key" guild: - staff_roles: [ 748554755265134635 ] + staff_roles: [748554755265134635] dev_help_forum: 1020260384638259210 log_channel: 1084008856633491557 diff --git a/config/dev-config.yml b/config/dev-config.yml index cccd531..741abb2 100644 --- a/config/dev-config.yml +++ b/config/dev-config.yml @@ -1,7 +1,7 @@ bot: prefix: "pph-" token: !ENV "token" - staff_roles: [ 748554755265134635, 809311700560904201, 1064179977639165952 ] + staff_roles: [1062998739012296795] database: name: !ENV "db_name" @@ -14,10 +14,10 @@ api: api_ninja: !ENV "api_ninja_key" guild: - staff_roles: [ 748554755265134635, 809311700560904201, 1064179977639165952 ] - dev_help_forum: 1080527620434841731 - log_channel: 1084008856633491557 + staff_roles: [1062998739012296795] # [748554755265134635] + dev_help_forum: !ENV "dev_help_forum_id" + log_channel: !ENV "log_channel" logger: - log_channel: 1084008856633491557 - log_level: "DEBUG" \ No newline at end of file + log_channel: !ENV "log_channel" + log_level: "DEBUG" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..df39547 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + progphilbot-db: + image: postgres:18 + container_name: progphilbot-db + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: secret + POSTGRES_DB: progphilbot-db + ports: + - "5434:5432" + volumes: + - progphilbot-volume:/var/lib/postgresql + networks: + - progphilbot-network + +volumes: + progphilbot-volume: + +networks: + progphilbot-network: diff --git a/poetry.lock b/poetry.lock index 990bb09..3029634 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -1175,29 +1175,6 @@ files = [ {file = "propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168"}, ] -[[package]] -name = "psycopg2" -version = "2.9.9" -description = "psycopg2 - Python-PostgreSQL Database Adapter" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "psycopg2-2.9.9-cp310-cp310-win32.whl", hash = "sha256:38a8dcc6856f569068b47de286b472b7c473ac7977243593a288ebce0dc89516"}, - {file = "psycopg2-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:426f9f29bde126913a20a96ff8ce7d73fd8a216cfb323b1f04da402d452853c3"}, - {file = "psycopg2-2.9.9-cp311-cp311-win32.whl", hash = "sha256:ade01303ccf7ae12c356a5e10911c9e1c51136003a9a1d92f7aa9d010fb98372"}, - {file = "psycopg2-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:121081ea2e76729acfb0673ff33755e8703d45e926e416cb59bae3a86c6a4981"}, - {file = "psycopg2-2.9.9-cp312-cp312-win32.whl", hash = "sha256:d735786acc7dd25815e89cc4ad529a43af779db2e25aa7c626de864127e5a024"}, - {file = "psycopg2-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:a7653d00b732afb6fc597e29c50ad28087dcb4fbfb28e86092277a559ae4e693"}, - {file = "psycopg2-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:5e0d98cade4f0e0304d7d6f25bbfbc5bd186e07b38eac65379309c4ca3193efa"}, - {file = "psycopg2-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:7e2dacf8b009a1c1e843b5213a87f7c544b2b042476ed7755be813eaf4e8347a"}, - {file = "psycopg2-2.9.9-cp38-cp38-win32.whl", hash = "sha256:ff432630e510709564c01dafdbe996cb552e0b9f3f065eb89bdce5bd31fabf4c"}, - {file = "psycopg2-2.9.9-cp38-cp38-win_amd64.whl", hash = "sha256:bac58c024c9922c23550af2a581998624d6e02350f4ae9c5f0bc642c633a2d5e"}, - {file = "psycopg2-2.9.9-cp39-cp39-win32.whl", hash = "sha256:c92811b2d4c9b6ea0285942b2e7cac98a59e166d59c588fe5cfe1eda58e72d59"}, - {file = "psycopg2-2.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:de80739447af31525feddeb8effd640782cf5998e1a4e9192ebdf829717e3913"}, - {file = "psycopg2-2.9.9.tar.gz", hash = "sha256:d1454bde93fb1e224166811694d600e746430c006fbb031ea06ecc2ea41bf156"}, -] - [[package]] name = "psycopg2-binary" version = "2.9.9" @@ -1321,8 +1298,8 @@ files = [ annotated-types = ">=0.4.0" pydantic-core = "2.20.1" typing-extensions = [ - {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, {version = ">=4.6.1", markers = "python_version < \"3.13\""}, + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, ] [package.extras] @@ -1972,4 +1949,4 @@ test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.funct [metadata] lock-version = "2.1" python-versions = "^3.10.14" -content-hash = "95abbdf1ff1d02d6cd46821f237bbc457a4b626e154f264cee7666ef816b7494" +content-hash = "739dabcb3e829c460dfe532519aa9a55ec85aacfa3cfbef8d13d21131008601f" diff --git a/pyproject.toml b/pyproject.toml index 17596eb..03cad5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,6 @@ flake8 = "^7.1.0" asyncpg = "^0.29.0" requests = "^2.28.2" yoyo-migrations = "^8.2.0" -psycopg2 = "^2.9.5" cloudscraper = "^1.2.69" bs4 = "^0.0.2" psycopg2-binary = "^2.9.5" diff --git a/pyrightconfig.json b/pyrightconfig.json deleted file mode 100644 index 669307e..0000000 --- a/pyrightconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "venvPath": ".", - "venv": "C:/Users/benji/AppData/Local/pypoetry/Cache/virtualenvs/progphilbot-QxuRZnFx-py3.12" -} diff --git a/src/cogs/admin/edit_post.py b/src/cogs/admin/edit_post.py new file mode 100644 index 0000000..a8011ba --- /dev/null +++ b/src/cogs/admin/edit_post.py @@ -0,0 +1,55 @@ +from discord import Interaction, NotFound +from discord.app_commands import command +from discord.app_commands.commands import describe +from discord.ext.commands.bot import Bot +from discord.ext.commands.cog import Cog + +from data.admin.config_auto import Config +from ui.modals.edit_post import EditPostModal +from utils.decorators import is_staff + + +class EditPost(Cog): + def __init__(self, bot: Bot): + self.bot = bot + self.config = Config(self.bot.pool) + + @is_staff() + @command( + name="post-edit", + description="A command to allow staff to edit an existing post created by the PPH bot", + ) + @describe(message_id="The ID of the post/message to edit") + async def edit_post(self, interaction: Interaction, message_id: str): + + if not interaction.channel_id: + return + + try: + message = await interaction.channel.fetch_message(int(message_id)) + except (ValueError, NotFound): + await interaction.response.send_message( + "Message not found in this channel.", ephemeral=True + ) + return + + if self.bot.user is not None and message.author.id != self.bot.user.id: + await interaction.response.send_message( + "Cannot edit message that isn't from PPH bot!", ephemeral=True + ) + return + + post_id = message.id + content = message.content + edit_post_modal = EditPostModal( + channel_id=interaction.channel_id, + post_id=post_id, + original_content=content, + bot=self.bot, + ) + + await interaction.response.send_modal(edit_post_modal) + + +async def setup(bot: Bot): + await bot.add_cog(EditPost(bot)) diff --git a/src/cogs/admin/test_edit_post.py b/src/cogs/admin/test_edit_post.py new file mode 100644 index 0000000..0080a86 --- /dev/null +++ b/src/cogs/admin/test_edit_post.py @@ -0,0 +1,188 @@ +import unittest +from unittest.async_case import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, MagicMock + +from discord import Interaction, NotFound + +from ui.modals.edit_post import EditPostModal + +from src.cogs.admin.edit_post import EditPost + + +class TestEditPost(IsolatedAsyncioTestCase): + def setUp(self): + self.mock_bot = MagicMock() + self.mock_bot.pool = MagicMock() + self.mock_bot.user = MagicMock() + self.mock_bot.user.id = 123456789 + self.cog = EditPost(self.mock_bot) + + def _make_interaction(self, *, channel_id=999, done=False): + interaction = AsyncMock(spec=Interaction) + interaction.response = AsyncMock() + interaction.response.send_modal = AsyncMock() + interaction.response.send_message = AsyncMock() + interaction.response.is_done = MagicMock(return_value=done) + interaction.channel = AsyncMock() + interaction.channel_id = channel_id + interaction.user = MagicMock() + return interaction + + async def test_edit_post_sends_modal_for_valid_bot_message(self): + interaction = self._make_interaction() + + mock_message = AsyncMock() + mock_message.id = 42 + mock_message.content = "original content" + mock_message.author = MagicMock() + mock_message.author.id = self.mock_bot.user.id + + interaction.channel.fetch_message = AsyncMock(return_value=mock_message) + + await self.cog.edit_post.callback( + self.cog, + interaction=interaction, + message_id="42", + ) + + interaction.response.send_modal.assert_awaited_once() + sent_modal = interaction.response.send_modal.call_args[0][0] + self.assertIsInstance(sent_modal, EditPostModal) + self.assertEqual(sent_modal.post_id, 42) + self.assertEqual(sent_modal.channel_id, 999) + self.assertEqual(sent_modal.message.default, "original content") + + async def test_edit_post_message_not_found(self): + interaction = self._make_interaction() + interaction.channel.fetch_message = AsyncMock( + side_effect=NotFound(MagicMock(), "not found") + ) + + await self.cog.edit_post.callback( + self.cog, + interaction=interaction, + message_id="42", + ) + + interaction.response.send_message.assert_awaited_once_with( + "Message not found in this channel.", ephemeral=True + ) + interaction.response.send_modal.assert_not_awaited() + + async def test_edit_post_invalid_message_id(self): + interaction = self._make_interaction() + + await self.cog.edit_post.callback( + self.cog, + interaction=interaction, + message_id="not-an-int", + ) + + interaction.response.send_message.assert_awaited_once_with( + "Message not found in this channel.", ephemeral=True + ) + interaction.response.send_modal.assert_not_awaited() + + async def test_edit_post_refuses_non_bot_message(self): + interaction = self._make_interaction() + + mock_message = AsyncMock() + mock_message.id = 42 + mock_message.content = "someone else's post" + mock_message.author = MagicMock() + mock_message.author.id = 999999999 # not the bot + + interaction.channel.fetch_message = AsyncMock(return_value=mock_message) + + await self.cog.edit_post.callback( + self.cog, + interaction=interaction, + message_id="42", + ) + + interaction.response.send_message.assert_awaited_once_with( + "Cannot edit message that isn't from PPH bot!", ephemeral=True + ) + interaction.response.send_modal.assert_not_awaited() + + async def test_edit_post_returns_early_when_no_channel_id(self): + interaction = self._make_interaction(channel_id=None) + + await self.cog.edit_post.callback( + self.cog, + interaction=interaction, + message_id="42", + ) + + interaction.response.send_message.assert_not_awaited() + interaction.response.send_modal.assert_not_awaited() + + async def test_edit_post_refuses_non_bot_message_when_bot_user_is_none(self): + self.mock_bot.user = None + interaction = self._make_interaction() + + mock_message = AsyncMock() + mock_message.id = 42 + mock_message.content = "some content" + mock_message.author = MagicMock() + mock_message.author.id = 999999999 + + interaction.channel.fetch_message = AsyncMock(return_value=mock_message) + + await self.cog.edit_post.callback( + self.cog, + interaction=interaction, + message_id="42", + ) + + # bot.user is None, so the author check is skipped, modal should open + interaction.response.send_modal.assert_awaited_once() + + +class TestEditPostModal(IsolatedAsyncioTestCase): + async def test_modal_default_content_is_set(self): + modal = EditPostModal( + channel_id=999, + post_id=42, + original_content="edit me", + bot=MagicMock(), + ) + + self.assertEqual(modal.message.default, "edit me") + self.assertEqual(modal.post_id, 42) + self.assertEqual(modal.channel_id, 999) + + async def test_on_submit_fetches_and_edits_message(self): + mock_bot = MagicMock() + mock_channel = AsyncMock() + mock_message = AsyncMock() + + mock_bot.fetch_channel = AsyncMock(return_value=mock_channel) + mock_channel.fetch_message = AsyncMock(return_value=mock_message) + mock_message.edit = AsyncMock() + + mock_interaction = AsyncMock(spec=Interaction) + mock_interaction.response = AsyncMock() + mock_interaction.response.send_message = AsyncMock() + + modal = EditPostModal( + channel_id=999, + post_id=42, + original_content="old content", + bot=mock_bot, + ) + modal.message = MagicMock() + modal.message.value = "new content" + + await modal.on_submit(mock_interaction) + + mock_bot.fetch_channel.assert_awaited_once_with(999) + mock_channel.fetch_message.assert_awaited_once_with(42) + mock_message.edit.assert_awaited_once_with(content="new content") + mock_interaction.response.send_message.assert_awaited_once_with( + "Success", ephemeral=True + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/ui/modals/edit_post.py b/src/ui/modals/edit_post.py new file mode 100644 index 0000000..6bcff58 --- /dev/null +++ b/src/ui/modals/edit_post.py @@ -0,0 +1,29 @@ +from discord import Interaction +from discord.enums import TextStyle +from discord.ext.commands.bot import Bot +from discord.ui.modal import Modal +from discord.ui.text_input import TextInput + + +class EditPostModal(Modal, title="Edit post"): + message = TextInput( + label="Content", + placeholder="...", + required=True, + style=TextStyle.long, + max_length=4000, + ) + + def __init__(self, channel_id: int, post_id: int, original_content: str, bot: Bot): + super().__init__() + + self.bot = bot + self.channel_id = channel_id + self.post_id = post_id + self.message.default = original_content + + async def on_submit(self, interaction: Interaction, /) -> None: + channel = await self.bot.fetch_channel(self.channel_id) + message = await channel.fetch_message(self.post_id) + await message.edit(content=self.message.value) + await interaction.response.send_message("Success", ephemeral=True) diff --git a/src/utils/logging/discord_handler.py b/src/utils/logging/discord_handler.py index 02563e4..ffad6ad 100644 --- a/src/utils/logging/discord_handler.py +++ b/src/utils/logging/discord_handler.py @@ -1,7 +1,7 @@ -from logging import Handler, LogRecord, NOTSET -from discord.abc import GuildChannel - import asyncio +from logging import NOTSET, Handler, LogRecord + +from discord.abc import GuildChannel class DiscordHandler(Handler): @@ -9,7 +9,7 @@ class DiscordHandler(Handler): def __init__(self, log_channel: GuildChannel | None = None): super().__init__(level=NOTSET) - print(f'Initializing DiscordHandler for channel {log_channel}') + print(f"Initializing DiscordHandler for channel {log_channel}") self.log_channel = log_channel def emit(self, record: LogRecord) -> None: @@ -17,4 +17,4 @@ def emit(self, record: LogRecord) -> None: async def async_emit(self, record: LogRecord): log = self.format(record) - await self.log_channel.send(f'```{log}```') + await self.log_channel.send(f"```{log}```")