Skip to content

Commit 8af4f14

Browse files
authored
feat: [FEATURE] Bot edit self post (#285)
* chore: update deps * refactor: bind dev-config.yml to env * add: docker compose * chore: format * add: edit post app command and modal * test: bot edit self post unit test * fix: typo * chore: remove unused import * chore: replace "Post edited" success message to "Success" for consistency
1 parent 8752890 commit 8af4f14

11 files changed

Lines changed: 311 additions & 45 deletions

File tree

.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,7 @@ api_ninja_key=
1515
currency_api_key=
1616

1717
# 14 character salt
18-
salt=
18+
salt=
19+
20+
log_channel=
21+
dev_help_forum_id=

config/config.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,11 @@ database:
99
password: !ENV "db_pass"
1010
port: !ENV "db_port"
1111

12-
1312
api:
1413
api_ninja: !ENV "api_ninja_key"
1514

1615
guild:
17-
staff_roles: [ 748554755265134635 ]
16+
staff_roles: [748554755265134635]
1817
dev_help_forum: 1020260384638259210
1918
log_channel: 1084008856633491557
2019

config/dev-config.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
bot:
22
prefix: "pph-"
33
token: !ENV "token"
4-
staff_roles: [ 748554755265134635, 809311700560904201, 1064179977639165952 ]
4+
staff_roles: [1062998739012296795]
55

66
database:
77
name: !ENV "db_name"
@@ -14,10 +14,10 @@ api:
1414
api_ninja: !ENV "api_ninja_key"
1515

1616
guild:
17-
staff_roles: [ 748554755265134635, 809311700560904201, 1064179977639165952 ]
18-
dev_help_forum: 1080527620434841731
19-
log_channel: 1084008856633491557
17+
staff_roles: [1062998739012296795] # [748554755265134635]
18+
dev_help_forum: !ENV "dev_help_forum_id"
19+
log_channel: !ENV "log_channel"
2020

2121
logger:
22-
log_channel: 1084008856633491557
23-
log_level: "DEBUG"
22+
log_channel: !ENV "log_channel"
23+
log_level: "DEBUG"

docker-compose.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
services:
2+
progphilbot-db:
3+
image: postgres:18
4+
container_name: progphilbot-db
5+
environment:
6+
POSTGRES_USER: postgres
7+
POSTGRES_PASSWORD: secret
8+
POSTGRES_DB: progphilbot-db
9+
ports:
10+
- "5434:5432"
11+
volumes:
12+
- progphilbot-volume:/var/lib/postgresql
13+
networks:
14+
- progphilbot-network
15+
16+
volumes:
17+
progphilbot-volume:
18+
19+
networks:
20+
progphilbot-network:

poetry.lock

Lines changed: 3 additions & 26 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ flake8 = "^7.1.0"
1919
asyncpg = "^0.29.0"
2020
requests = "^2.28.2"
2121
yoyo-migrations = "^8.2.0"
22-
psycopg2 = "^2.9.5"
2322
cloudscraper = "^1.2.69"
2423
bs4 = "^0.0.2"
2524
psycopg2-binary = "^2.9.5"

pyrightconfig.json

Lines changed: 0 additions & 4 deletions
This file was deleted.

src/cogs/admin/edit_post.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from discord import Interaction, NotFound
2+
from discord.app_commands import command
3+
from discord.app_commands.commands import describe
4+
from discord.ext.commands.bot import Bot
5+
from discord.ext.commands.cog import Cog
6+
7+
from data.admin.config_auto import Config
8+
from ui.modals.edit_post import EditPostModal
9+
from utils.decorators import is_staff
10+
11+
12+
class EditPost(Cog):
13+
def __init__(self, bot: Bot):
14+
self.bot = bot
15+
self.config = Config(self.bot.pool)
16+
17+
@is_staff()
18+
@command(
19+
name="post-edit",
20+
description="A command to allow staff to edit an existing post created by the PPH bot",
21+
)
22+
@describe(message_id="The ID of the post/message to edit")
23+
async def edit_post(self, interaction: Interaction, message_id: str):
24+
25+
if not interaction.channel_id:
26+
return
27+
28+
try:
29+
message = await interaction.channel.fetch_message(int(message_id))
30+
except (ValueError, NotFound):
31+
await interaction.response.send_message(
32+
"Message not found in this channel.", ephemeral=True
33+
)
34+
return
35+
36+
if self.bot.user is not None and message.author.id != self.bot.user.id:
37+
await interaction.response.send_message(
38+
"Cannot edit message that isn't from PPH bot!", ephemeral=True
39+
)
40+
return
41+
42+
post_id = message.id
43+
content = message.content
44+
edit_post_modal = EditPostModal(
45+
channel_id=interaction.channel_id,
46+
post_id=post_id,
47+
original_content=content,
48+
bot=self.bot,
49+
)
50+
51+
await interaction.response.send_modal(edit_post_modal)
52+
53+
54+
async def setup(bot: Bot):
55+
await bot.add_cog(EditPost(bot))

src/cogs/admin/test_edit_post.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import unittest
2+
from unittest.async_case import IsolatedAsyncioTestCase
3+
from unittest.mock import AsyncMock, MagicMock
4+
5+
from discord import Interaction, NotFound
6+
7+
from ui.modals.edit_post import EditPostModal
8+
9+
from src.cogs.admin.edit_post import EditPost
10+
11+
12+
class TestEditPost(IsolatedAsyncioTestCase):
13+
def setUp(self):
14+
self.mock_bot = MagicMock()
15+
self.mock_bot.pool = MagicMock()
16+
self.mock_bot.user = MagicMock()
17+
self.mock_bot.user.id = 123456789
18+
self.cog = EditPost(self.mock_bot)
19+
20+
def _make_interaction(self, *, channel_id=999, done=False):
21+
interaction = AsyncMock(spec=Interaction)
22+
interaction.response = AsyncMock()
23+
interaction.response.send_modal = AsyncMock()
24+
interaction.response.send_message = AsyncMock()
25+
interaction.response.is_done = MagicMock(return_value=done)
26+
interaction.channel = AsyncMock()
27+
interaction.channel_id = channel_id
28+
interaction.user = MagicMock()
29+
return interaction
30+
31+
async def test_edit_post_sends_modal_for_valid_bot_message(self):
32+
interaction = self._make_interaction()
33+
34+
mock_message = AsyncMock()
35+
mock_message.id = 42
36+
mock_message.content = "original content"
37+
mock_message.author = MagicMock()
38+
mock_message.author.id = self.mock_bot.user.id
39+
40+
interaction.channel.fetch_message = AsyncMock(return_value=mock_message)
41+
42+
await self.cog.edit_post.callback(
43+
self.cog,
44+
interaction=interaction,
45+
message_id="42",
46+
)
47+
48+
interaction.response.send_modal.assert_awaited_once()
49+
sent_modal = interaction.response.send_modal.call_args[0][0]
50+
self.assertIsInstance(sent_modal, EditPostModal)
51+
self.assertEqual(sent_modal.post_id, 42)
52+
self.assertEqual(sent_modal.channel_id, 999)
53+
self.assertEqual(sent_modal.message.default, "original content")
54+
55+
async def test_edit_post_message_not_found(self):
56+
interaction = self._make_interaction()
57+
interaction.channel.fetch_message = AsyncMock(
58+
side_effect=NotFound(MagicMock(), "not found")
59+
)
60+
61+
await self.cog.edit_post.callback(
62+
self.cog,
63+
interaction=interaction,
64+
message_id="42",
65+
)
66+
67+
interaction.response.send_message.assert_awaited_once_with(
68+
"Message not found in this channel.", ephemeral=True
69+
)
70+
interaction.response.send_modal.assert_not_awaited()
71+
72+
async def test_edit_post_invalid_message_id(self):
73+
interaction = self._make_interaction()
74+
75+
await self.cog.edit_post.callback(
76+
self.cog,
77+
interaction=interaction,
78+
message_id="not-an-int",
79+
)
80+
81+
interaction.response.send_message.assert_awaited_once_with(
82+
"Message not found in this channel.", ephemeral=True
83+
)
84+
interaction.response.send_modal.assert_not_awaited()
85+
86+
async def test_edit_post_refuses_non_bot_message(self):
87+
interaction = self._make_interaction()
88+
89+
mock_message = AsyncMock()
90+
mock_message.id = 42
91+
mock_message.content = "someone else's post"
92+
mock_message.author = MagicMock()
93+
mock_message.author.id = 999999999 # not the bot
94+
95+
interaction.channel.fetch_message = AsyncMock(return_value=mock_message)
96+
97+
await self.cog.edit_post.callback(
98+
self.cog,
99+
interaction=interaction,
100+
message_id="42",
101+
)
102+
103+
interaction.response.send_message.assert_awaited_once_with(
104+
"Cannot edit message that isn't from PPH bot!", ephemeral=True
105+
)
106+
interaction.response.send_modal.assert_not_awaited()
107+
108+
async def test_edit_post_returns_early_when_no_channel_id(self):
109+
interaction = self._make_interaction(channel_id=None)
110+
111+
await self.cog.edit_post.callback(
112+
self.cog,
113+
interaction=interaction,
114+
message_id="42",
115+
)
116+
117+
interaction.response.send_message.assert_not_awaited()
118+
interaction.response.send_modal.assert_not_awaited()
119+
120+
async def test_edit_post_refuses_non_bot_message_when_bot_user_is_none(self):
121+
self.mock_bot.user = None
122+
interaction = self._make_interaction()
123+
124+
mock_message = AsyncMock()
125+
mock_message.id = 42
126+
mock_message.content = "some content"
127+
mock_message.author = MagicMock()
128+
mock_message.author.id = 999999999
129+
130+
interaction.channel.fetch_message = AsyncMock(return_value=mock_message)
131+
132+
await self.cog.edit_post.callback(
133+
self.cog,
134+
interaction=interaction,
135+
message_id="42",
136+
)
137+
138+
# bot.user is None, so the author check is skipped, modal should open
139+
interaction.response.send_modal.assert_awaited_once()
140+
141+
142+
class TestEditPostModal(IsolatedAsyncioTestCase):
143+
async def test_modal_default_content_is_set(self):
144+
modal = EditPostModal(
145+
channel_id=999,
146+
post_id=42,
147+
original_content="edit me",
148+
bot=MagicMock(),
149+
)
150+
151+
self.assertEqual(modal.message.default, "edit me")
152+
self.assertEqual(modal.post_id, 42)
153+
self.assertEqual(modal.channel_id, 999)
154+
155+
async def test_on_submit_fetches_and_edits_message(self):
156+
mock_bot = MagicMock()
157+
mock_channel = AsyncMock()
158+
mock_message = AsyncMock()
159+
160+
mock_bot.fetch_channel = AsyncMock(return_value=mock_channel)
161+
mock_channel.fetch_message = AsyncMock(return_value=mock_message)
162+
mock_message.edit = AsyncMock()
163+
164+
mock_interaction = AsyncMock(spec=Interaction)
165+
mock_interaction.response = AsyncMock()
166+
mock_interaction.response.send_message = AsyncMock()
167+
168+
modal = EditPostModal(
169+
channel_id=999,
170+
post_id=42,
171+
original_content="old content",
172+
bot=mock_bot,
173+
)
174+
modal.message = MagicMock()
175+
modal.message.value = "new content"
176+
177+
await modal.on_submit(mock_interaction)
178+
179+
mock_bot.fetch_channel.assert_awaited_once_with(999)
180+
mock_channel.fetch_message.assert_awaited_once_with(42)
181+
mock_message.edit.assert_awaited_once_with(content="new content")
182+
mock_interaction.response.send_message.assert_awaited_once_with(
183+
"Success", ephemeral=True
184+
)
185+
186+
187+
if __name__ == "__main__":
188+
unittest.main()

0 commit comments

Comments
 (0)