Skip to content

[FEATURE] Mark as Solution #271

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions migrations/21_add_enable_accept_solutions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- depends: 18_post_assist
ALTER TABLE pph_post_assist_config
ADD COLUMN IF NOT EXISTS enable_accept_solutions BOOLEAN DEFAULT FALSE;

CREATE TABLE
IF NOT EXISTS pph_post_assist_config_accept_solutions (
id SERIAL PRIMARY KEY,
thread_id BIGINT NOT NULL,
post_assist_config_id SERIAL NOT NULL,
user_id BIGINT NOT NULL,
message_id BIGINT NOT NULL,
FOREIGN KEY (post_assist_config_id) REFERENCES pph_post_assist_config (id) ON DELETE CASCADE
);

ALTER TABLE pph_post_assist_config_accept_solutions
ADD COLUMN IF NOT EXISTS message_id BIGINT NOT NULL;
2 changes: 2 additions & 0 deletions migrations/22_drop_accept_solutions_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- depends: 21_add_enable_accept_solutions
DROP TABLE IF EXISTS pph_post_assist_config_accept_solutions;
204 changes: 190 additions & 14 deletions src/cogs/forum/auto_tagging.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
from discord import Forbidden, Guild, HTTPException
from discord import Interaction, Thread, Member, Role
from discord.app_commands import command, describe
from logging import Logger

from discord import (
Forbidden,
ForumChannel,
Guild,
HTTPException,
Interaction,
Member,
Message,
Role,
Thread,
)
from discord.app_commands import ContextMenu, command, describe
from discord.enums import AppCommandType
from discord.ext.commands import Bot, Cog, GroupCog

from src.data.forum.post_assist import PostAssistDB
from src.data.admin.config_auto import Config
from src.utils.decorators import is_staff
from src.data.forum.post_assist import PostAssistDB
from src.ui.views.dev_help import DevHelpTagDB, DevHelpViewsDB, PersistentSolverView
from src.ui.views.mark_as_solution import MarkAsSolution
from src.ui.views.post_assist import (
ConfigurePostAssist,
ConfigurationPagination,
ConfigurePostAssist,
PostAssistMessage,
PostAssistState,
format_data,
)
from src.utils.decorators import is_staff

AUTHOR_PLACEHOLDER = "[[@author]]"

Expand All @@ -31,8 +45,18 @@ def _getter(guild: Guild, entry: dict) -> Member | Role:
class ForumAssist(GroupCog):
def __init__(self, bot: Bot):
self.bot = bot
self.db = PostAssistDB(self.bot.pool)
self.config = Config(self.bot.pool)
self.logger: Logger = self.bot.logger # type: ignore
self.db = PostAssistDB(self.bot.pool) # type: ignore
self.dev_help_tag_db = DevHelpTagDB(self.bot.pool) # type: ignore
self.dev_help_views_db = DevHelpViewsDB(self.bot.pool) # type: ignore
self.config = Config(self.bot.pool) # type: ignore
ctx_menu = ContextMenu(
name="Accept Solution",
callback=self.accept_solution,
type=AppCommandType.message,
)
self.bot.tree.add_command(ctx_menu)
self.ctx_menu = ctx_menu

@Cog.listener()
async def on_thread_create(self, thread: Thread):
Expand All @@ -53,8 +77,7 @@ async def on_thread_create(self, thread: Thread):
thread_msg = thread.get_partial_message(thread.id)

if AUTHOR_PLACEHOLDER in reply:
reply = reply.replace(AUTHOR_PLACEHOLDER,
thread_msg.thread.owner.mention)
reply = reply.replace(AUTHOR_PLACEHOLDER, thread_msg.thread.owner.mention)

# Sometimes the pinning and reply fails
# when the thread is created and the bot
Expand Down Expand Up @@ -112,6 +135,7 @@ async def create_new(self, interaction: Interaction):
reply = view.state.custom_msg
tags = view.state.tag_list
tag_message = view.state.tag_message
enable_accept_solutions = view.state.enable_accept_solutions

if await self.db.config_by_forum(forum):
return await interaction.followup.send(
Expand All @@ -132,6 +156,7 @@ async def create_new(self, interaction: Interaction):
entities=tags,
entity_tag_message=tag_message,
reply=reply,
enable_accept_solutions=enable_accept_solutions,
)
return

Expand Down Expand Up @@ -193,9 +218,7 @@ async def edit(self, interaction: Interaction, config_id: int):
try:
config = await self.db.get_config(config_id)
if not config:
self.bot.logger.info(
f"Configuration ID: {config_id} may not exist."
)
self.bot.logger.info(f"Configuration ID: {config_id} may not exist.")
return await interaction.response.send_message(
f"Configuration ID: {config_id} may not exist.",
ephemeral=True,
Expand All @@ -220,7 +243,7 @@ async def edit(self, interaction: Interaction, config_id: int):
forum=forum_id,
tag_message=tag_message,
custom_msg=custom_message,
existing_tags=existing_tags
existing_tags=existing_tags,
)

modal = PostAssistMessage(state)
Expand Down Expand Up @@ -250,6 +273,159 @@ async def edit(self, interaction: Interaction, config_id: int):

await interaction.followup.send("Cancelled.", ephemeral=True)

async def cog_unload(self) -> None:
self.bot.tree.remove_command(
self.ctx_menu.name, type=self.ctx_menu.type
) # remove it on unload

async def _get_current_solution_from_pins(self, thread: Thread) -> Message | None:
"""Check pinned messages to find the current accepted solution.

Returns the pinned message that has a ✅ reaction from the bot,
or None if no solution is currently accepted.

Note: We need to fetch full message objects because pins() returns
incomplete reaction data according to Discord API documentation.
"""
try:
pinned_messages = await thread.pins()
self.logger.info(
f"Found {len(pinned_messages)} pinned messages in thread {thread.id}"
)

for pinned_msg in pinned_messages:
# Skip the original thread message (it's always pinned)
if pinned_msg.id == thread.id:
continue

try:
full_message = await thread.fetch_message(pinned_msg.id)

for reaction in full_message.reactions:
emoji_str = str(reaction.emoji)

if emoji_str not in ["✅", "✔️", "☑️"]:
continue

if self.bot.user:
async for user in reaction.users():
if user.id == self.bot.user.id:
self.logger.info(
f"Found existing solution: message {full_message.id}"
)
return full_message

except (Forbidden, HTTPException) as e:
self.logger.warning(f"Failed to fetch message {pinned_msg.id}: {e}")
continue

self.logger.info("No existing solution found in pinned messages")

except (Forbidden, HTTPException) as e:
self.logger.warning(
f"Failed to access pinned messages in thread {thread.id}: {e}"
)

return None

async def accept_solution(self, interaction: Interaction, message: Message) -> None:
thread = interaction.channel
is_thread = isinstance(thread, Thread)
thread_id = thread.id if is_thread else None
thread_author_id = thread.owner_id if is_thread else None
message_id = message.id
forum = thread.parent if is_thread else None
staff_roles: list[int] = self.bot.config.guild.staff_roles # type: ignore

if (
not is_thread
or not thread_author_id
or not thread_id
or not forum
or not isinstance(forum, ForumChannel)
):
return await interaction.response.send_message(
"This command can only be used in threads.", ephemeral=True
)

if thread.archived:
return await interaction.response.send_message(
"This thread is archived.", ephemeral=True
)

if message.author.bot:
return await interaction.response.send_message(
"Cannot mark a bot's post as a solution.", ephemeral=True
)

if message_id == thread_id:
return await interaction.response.send_message(
"Cannot mark the original post as a solution.", ephemeral=True
)

mark_as_solution_config = await self.db.is_mark_as_solution_enabled(forum.id)
is_enabled = mark_as_solution_config[1]

if not is_enabled:
return await interaction.response.send_message(
"Accept Solution is not enabled here.", ephemeral=True
)

mark_as_solution_view = MarkAsSolution(thread=thread, message=message)
await interaction.response.send_message(
"Are you sure you want to mark this as a solution?",
view=mark_as_solution_view,
ephemeral=True,
)
await mark_as_solution_view.wait()

if not mark_as_solution_view.confirmed:
return

current_solution_message = await self._get_current_solution_from_pins(thread)

if current_solution_message:
try:
await current_solution_message.unpin()
except (Forbidden, HTTPException) as e:
self.logger.warning(
f"Failed to unpin previous solution {current_solution_message.id}: {e}"
)

if self.bot.user:
try:
await current_solution_message.remove_reaction("✅", self.bot.user)
self.logger.info(
f"Removed ✅ from previous solution: {current_solution_message.id}"
)
except (Forbidden, HTTPException) as e:
self.logger.warning(
f"Failed to remove ✅ from previous solution {current_solution_message.id}: {e}"
)

try:
await message.add_reaction("✅")
await message.pin()
self.logger.info(f"Marked message {message.id} as solution")
except (Forbidden, HTTPException) as e:
self.logger.error(f"Failed to mark message {message.id} as solution: {e}")

mark_as_solved_button = PersistentSolverView(
thread_id,
thread_author_id,
self.dev_help_views_db,
self.dev_help_tag_db,
forum,
staff_roles,
self.logger,
)

await interaction.followup.send(
f"{interaction.user.mention} marked this as a solution.",
view=mark_as_solved_button,
ephemeral=False,
)


async def setup(bot: Bot):
await bot.add_cog(ForumAssist(bot))
Loading
Loading