diff --git a/plugin/discord/cog.py b/plugin/discord/cog.py index dcc9c61..2fa6bb5 100644 --- a/plugin/discord/cog.py +++ b/plugin/discord/cog.py @@ -3,14 +3,17 @@ import asyncio import discord from discord.ext import commands as cmds -from discord import app_commands as appcmds +from discord import Forbidden, User, app_commands as appcmds from meta import LionBot, LionCog, LionContext +from meta.errors import SafeCancellation, UserInputError from meta.logger import log_wrap from utils.lib import utc_now -from ..data import CampaignData -from ..campaigns import CampaignRegistry +from ..data import CampaignData, EarnedReward +from ..campaign import CampaignRegistry, RewardCampaign +from ..lib import ThreadedWebhook +from .ui import RewardList, RewardEditor, CampaignDashboard class CampaignCog(LionCog): @@ -24,3 +27,442 @@ class CampaignCog(LionCog): await self.data.init() await self.bot.version_check(*self.data.VERSION) await self.campaigns.init() + + async def resolve_campaign( + self, cid: int, campaign_name: str | None + ) -> RewardCampaign: + campaign = None + if campaign_name is not None: + campaign = await self.campaigns.fetch_campaign_by_name(cid, campaign_name) + if campaign is None: + raise UserInputError( + f"Sorry, no campaign found named '{campaign_name}'" + ) + else: + # Find active campaign + active = await self.campaigns.fetch_campaigns(cid, active=True) + if len(active) > 1: + names = ", ".join(camp.row.campaign_name for camp in active) + raise UserInputError(f"Multiple active campaigns running: {names}") + elif not active: + raise UserInputError("No active campaigns running") + else: + campaign = active[0] + return campaign + + async def campaign_modcheck(self, campaign: RewardCampaign, member: discord.Member): + if member.guild_permissions.administrator: + return True + if campaign.row.moderator_role_id and campaign.row.moderator_role_id in [ + r.id for r in member.roles + ]: + return True + return False + + async def _campaign_acmpl( + self, interaction: discord.Interaction, partial: str + ) -> list[appcmds.Choice]: + """ + Generate a list of campaigns, with active campaigns listed first. + + Campaign values are their name. + """ + if not interaction.guild: + return [] + community = await self.bot.profiles.fetch_community( + interaction.guild, interaction=interaction + ) + cid = community.communityid + + campaigns = await self.campaigns.fetch_campaigns(cid, active=None) + sorted_campaigns = sorted( + campaigns, + key=lambda camp: (camp.is_active, camp.row.started_at, camp.row.created_at), + ) + names = [ + camp.row.campaign_name + for camp in sorted_campaigns + if partial.lower() in camp.row.campaign_name.lower() + ] + + choices = [appcmds.Choice(name=name[:100], value=name) for name in names] + return choices + + @cmds.hybrid_group( + name="campaign", description="Command group for administering reward campaigns" + ) + @appcmds.guild_only() + @appcmds.default_permissions(manage_guild=True) + async def campaign_group(self, ctx: LionContext): + """ + As a base command group this never gets executed. + """ + raise NotImplementedError + + @campaign_group.command( + name="dashboard", + description="Show summary dashboard for active or selected campaign.", + ) + @appcmds.describe(campaign_name="Name of the campaign to display") + @appcmds.rename(campaign_name="campaign") + async def campaign_dashboard_cmd( + self, ctx: LionContext, campaign_name: Optional[str] = None + ): + if not ctx.guild: + return + if not ctx.interaction: + return + + campaign = await self.resolve_campaign(ctx.community.communityid, campaign_name) + if not await self.campaign_modcheck(campaign, ctx.author): + await ctx.interaction.response.send_message( + "You need to be an administrator or have the configured modrole to use that!", + ephemeral=True, + ) + return + + # Open campaign UI + widget = CampaignDashboard(bot=self.bot, campaign=campaign, callerid=ctx.author.id) + await widget.run(ctx.interaction) + await widget.wait() + + campaign_dashboard_cmd.autocomplete("campaign_name")(_campaign_acmpl) + + @campaign_group.command(name="start", description="Setup and start a campaign") + @appcmds.describe( + campaign_name="Name of the campaign to create. Must be unique.", + rewards_cap="Optional maximum number of rewards to give.", + moderator_role="Optional discord role to allow to edit and moderate the campaign.", + logging_webhook="Optional discord webhook URL to log earned rewards to.", + ) + async def campaign_start_cmd( + self, + ctx: LionContext, + campaign_name: str, + rewards_cap: Optional[int] = None, + moderator_role: Optional[discord.Role] = None, + logging_webhook: Optional[str] = None, + ): + if not ctx.guild: + return + if not ctx.interaction: + return + + # Permission check + if not ctx.author.guild_permissions.administrator: + await ctx.interaction.response.send_message( + "You need to be an administrator to start a campaign!", ephemeral=True + ) + return + + # Check name clash + existing = await self.campaigns.fetch_campaign_by_name( + ctx.community.communityid, campaign_name + ) + if existing: + await ctx.reply("A campaign with that name already exists!") + return + + # Check active campaign + campaigns = await self.campaigns.fetch_campaigns( + ctx.community.communityid, active=True + ) + if campaigns: + existing_name = campaigns[0].row.campaign_name + await ctx.reply( + f"Your campaign '{existing_name}' is already active! " + "Sorry, we don't yet support multiple active campaigns, " + "please use '/campaign finish' to end your current campaign before starting a new one." + ) + return + + # If logging webhook is given, check that it works + if logging_webhook is not None: + webhook = ThreadedWebhook.from_url(logging_webhook) + try: + await webhook.test_webhook() + except (discord.HTTPException, discord.Forbidden): + await ctx.reply("Couldn't post to the logging webhook provided!") + return + + # Passed all the checks, now we can create + campaign = await self.campaigns.create_campaign( + cid=ctx.community.communityid, + campaign_name=campaign_name, + target_rewards=rewards_cap, + moderator_role_id=moderator_role.id if moderator_role else None, + logging_webhook_url=logging_webhook, + ) + + # Ack creation + # TODO: Can also show dashboard + await ctx.reply( + f"Setup and started your reward campaign {campaign.row.campaign_name}! Good luck" + ) + # Open campaign UI + widget = CampaignDashboard(bot=self.bot, campaign=campaign, callerid=ctx.author.id) + await widget.run(ctx.interaction) + await widget.wait() + + @campaign_group.command(name="configure", description="Update campaign settings") + @appcmds.describe( + campaign_name="Name of the campaign you want to update.", + new_name="New name for the campaign. Must be unique.", + new_cap="New cap for the number of rewards. Use 0 or negative for no cap.", + moderator_role="Optional discord role to allow to edit and moderate the campaign.", + logging_webhook="Optional discord webhook URL to log earned rewards to.", + ) + @appcmds.rename(campaign_name="campaign") + async def campaign_configure_cmd( + self, + ctx: LionContext, + campaign_name: Optional[str] = None, + new_name: Optional[str] = None, + new_cap: Optional[int] = None, + moderator_role: Optional[discord.Role] = None, + logging_webhook: Optional[str] = None, + ): + if not ctx.guild: + return + if not ctx.interaction: + return + + campaign = await self.resolve_campaign(ctx.community.communityid, campaign_name) + if not await self.campaign_modcheck(campaign, ctx.author): + await ctx.interaction.response.send_message( + "You need to be an administrator or have the configured modrole to use that!", + ephemeral=True, + ) + return + + # Verify each of the given settings to update + update_args = {} + + # Check name clash + if ( + new_name is not None + and new_name.lower() != campaign.row.campaign_name.lower() + ): + existing = await self.campaigns.fetch_campaign_by_name( + ctx.community.communityid, campaign_name + ) + if existing: + await ctx.reply("A campaign with that name already exists!") + return + update_args["campaign_name"] = new_name + + if new_cap is not None: + update_args["total_rewards"] = new_cap if new_cap > 0 else None + + # If logging webhook is given, check that it works + if logging_webhook is not None: + webhook = ThreadedWebhook.from_url(logging_webhook) + try: + await webhook.test_webhook() + except (discord.HTTPException, discord.Forbidden): + await ctx.reply("Couldn't post to the logging webhook provided!") + return + update_args["logging_webhook_url"] = logging_webhook + + if moderator_role is not None: + # TODO: Currently no way to actually unset this + update_args["moderator_role_id"] = moderator_role.id + + # Passed all the checks, now we can update + + campaign = await self.campaigns.update_campaign( + campaign.row.campaign_id, + **update_args, + ) + + # Ack creation + # TODO: Can also show dashboard + await ctx.reply( + f"Setup and started your reward campaign {campaign.row.campaign_name}! Good luck" + ) + + campaign_configure_cmd.autocomplete("campaign_name")(_campaign_acmpl) + + @campaign_group.command( + name="rewards", + description="View and edit the rewards earned so far this campaign", + ) + @appcmds.describe(campaign_name="Name of the campaign to display rewards for") + @appcmds.rename(campaign_name="campaign") + async def campaign_rewards_cmd( + self, + ctx: LionContext, + campaign_name: Optional[str] = None, + ): + if not ctx.guild: + return + if not ctx.interaction: + return + + campaign = await self.resolve_campaign(ctx.community.communityid, campaign_name) + if not await self.campaign_modcheck(campaign, ctx.author): + await ctx.interaction.response.send_message( + "You need to be an administrator or have the configured modrole to use that!", + ephemeral=True, + ) + return + + # Show rewardlist widget + widget = RewardList(bot=self.bot, campaign=campaign, callerid=ctx.author.id) + await widget.run(ctx.interaction) + await widget.wait() + + campaign_rewards_cmd.autocomplete("campaign_name")(_campaign_acmpl) + + @campaign_group.command( + name="editreward", + description="Add or edit details for a given reward in a campaign (see also /campaign rewards)", + ) + @appcmds.describe( + rewardid="Earned reward to edit", + ) + @appcmds.rename(rewardid="reward") + async def campaign_editreward_cmd(self, ctx: LionContext, rewardid: str): + # For this we'll just open the reward editor + # Will need to identify the reward with autocomplete. Can use the reward id directly.. + if not rewardid.isdigit(): + raise UserInputError( + "Please enter the reward number or select a reward from the argument menu" + ) + + reward = await EarnedReward.fetch(int(rewardid)) + if not reward: + raise UserInputError( + "Reward not found. Please enter the reward number or select a reward from the argument menu" + ) + + # Fetch campaign and then do a perm check + campaign = await self.campaigns.fetch_campaign(reward.campaign_id) + if campaign is None: + raise SafeCancellation("Something went wrong.. please try again soon.") + if campaign.row.communityid != ctx.community.communityid: + raise UserInputError("This reward doesn't belong to this community!") + + if not await self.campaign_modcheck(campaign, ctx.author): + await ctx.interaction.response.send_message( + "You need to be an administrator or have the configured modrole to use that!", + ephemeral=True, + ) + return + + # Okay, this is our reward, and author has permission to modify it. Spin the modal. + modal = RewardEditor.from_reward(reward) + + currently_fluffed = reward.fulfilled_at is not None + + @modal.submit_callback() + async def on_editor_submit(interaction: discord.Interaction): + update_args = {} + + if modal.flufbox.component.value and not currently_fluffed: + # Reward has been fluffed + update_args["fulfilled_at"] = utc_now() + elif currently_fluffed and not modal.flufbox.component.value: + # Reward has been unfluffed + update_args["fulfilled_at"] = None + + new_ref_value = modal.reference.value or None + if new_ref_value != reward.reference: + update_args["reference"] = new_ref_value + + new_notes_value = modal.notes.value or None + if new_notes_value != reward.modnote: + update_args["modnote"] = new_notes_value + + if update_args: + await interaction.response.defer(thinking=True, ephemeral=True) + await campaign.update_reward(reward.earned_id, **update_args) + await interaction.followup.send( + content="Reward updated!", ephemeral=True + ) + else: + await interaction.response.defer(thinking=False) + + def _reward_acmpl_format( + self, campaign: RewardCampaign, reward: EarnedReward + ) -> str: + """ + Format an EarnedReward for completion. + + #100: username in campaign + """ + ... + + @campaign_editreward_cmd.autocomplete("rewardid") + async def _reward_acmpl( + self, interaction: discord.Interaction, partial: str + ) -> list[appcmds.Choice]: + """ + Display list of rewards. + + If possible, use the active campaign. + If no matching choices, search all campaigns of the community. + + Values are the earned_id as a string + """ + if not interaction.guild: + return [] + community = await self.bot.profiles.fetch_community( + interaction.guild, interaction=interaction + ) + cid = community.communityid + + choices = [] + + campaigns = await self.campaigns.fetch_campaigns(cid, active=None) + sorted_campaigns = sorted( + campaigns, + key=lambda camp: (camp.is_active, camp.row.started_at, camp.row.created_at), + ) + for campaign in sorted_campaigns: + rewards = await campaign.get_rewards() + for reward in reversed(rewards): + formatted = self._reward_acmpl_format(campaign, reward) + if partial in formatted: + choices.append( + appcmds.Choice(name=formatted, value=str(reward.earned_id)) + ) + if len(choices) >= 20: + break + if len(choices) >= 20: + break + + return choices[:25] + + # @campaign_group.command( + # name="reward", + # description="Add a reward to a given user" + # ) + # async def campaign_reward_cmd(self, ctx: LionContext, twitch_username: str, reward_reason: str, campaign_name: Optional[str] = None): + # # TODO: This does + # ... + + # @campaign_group.command( + # name="start", description="Add or edit details for a given reward in a campaign" + # ) + # async def campaign_rewardnote_cmd(self, ctx: LionContext): ... + # + # @campaign_group.command(name="finish", description="Close a campaign") + # @appcmds.describe(campaign_name="Name of the campaign to mark as complete") + # @appcmds.rename(campaign_name='campaign') + # async def campaign_finish_cmd( + # self, ctx: LionContext, campaign_name: Optional[str] = None + # ): + # if not ctx.guild: + # return + # if not ctx.interaction: + # return + # + # campaign = await self.resolve_campaign(ctx.community.communityid, campaign_name) + # if not await self.campaign_modcheck(campaign, ctx.author): + # await ctx.interaction.response.send_message( + # "You need to be an administrator or have the configured modrole to use that!", + # ephemeral=True + # ) + # return + # campaign_finish_cmd.autocomplete('campaign_name')(_campaign_acmpl) diff --git a/plugin/discord/ui/__init__.py b/plugin/discord/ui/__init__.py index cf60a8c..8faa727 100644 --- a/plugin/discord/ui/__init__.py +++ b/plugin/discord/ui/__init__.py @@ -1 +1,2 @@ from .rewards import RewardList, RewardEditor +from .campaign import CampaignDashboard diff --git a/plugin/discord/ui/campaign.py b/plugin/discord/ui/campaign.py index e69de29..1515eac 100644 --- a/plugin/discord/ui/campaign.py +++ b/plugin/discord/ui/campaign.py @@ -0,0 +1,139 @@ +""" +Defines widgets for displaying a campaign +""" + +from typing import Optional, TYPE_CHECKING +import asyncio +import datetime as dt + +import discord +from discord.ui import Modal +from discord.ui.select import select, Select, SelectOption +from discord.ui.button import button, Button, ButtonStyle +from discord.ui.text_input import TextInput, TextStyle + +from meta import LionBot +from meta.errors import UserInputError +from meta.config import conf +from utils.lib import tabulate, utc_now, MessageArgs, parse_duration +from utils.ui import MessageUI, AButton, AsComponents, ConfigEditor +from utils.ui.micros import FastModal +from utils.ui.pagers import BasePager, Pager + +from ...campaign import RewardCampaign +from ...data import EarnedReward +from .. import logger + +from .rewards import RewardList + + +class CampaignDashboard(MessageUI): + def __init__(self, bot: LionBot, campaign: RewardCampaign, **kwargs): + super().__init__(**kwargs) + + self.bot = bot + self.campaign = campaign + + # UI state + + # ------ UI API ----- + + # ------ UI Components ----- + # Button to open rewards list + + @button(label="Rewards Earned") + async def rewards_list_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer() + widget = RewardList( + bot=self.bot, + campaign=self.campaign, + callerid=self._callerid, + ) + self._slaves.append(widget) + await widget.run(press) + await widget.wait() + self._slaves.remove(widget) + + @button(emoji=conf.emojis.cancel, style=ButtonStyle.red) + async def quit_button(self, press: discord.Interaction, pressed: Button): + """Close the UI and all children.""" + await press.response.defer(thinking=False) + await self.quit() + + @button(emoji=conf.emojis.refresh) + async def refresh_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer() + await self.refresh() + + # ------ UI Flow ----- + + async def refresh_layout(self): + # Nothing to refresh + pass + + async def make_message(self) -> MessageArgs: + embed = discord.Embed( + title=f"{self.campaign.row.campaign_name} Campaign Dashboard" + ) + # embed.set_footer with last update + + # Don't show the webhook url since it contains secrets, just show whether it is set + # Hard-coded channel/timer URL for now + campaign = self.campaign + started_at = ( + discord.utils.format_dt(campaign.row.started_at, "F") + if campaign.row.started_at + else "*Not Started*" + ) + + all_rewards = await campaign.get_rewards() + rewards_earned = len(all_rewards) + reward_cap = campaign.row.target_rewards + if reward_cap is not None: + rewards = f"{rewards_earned} out of {reward_cap}" + else: + rewards = f"{rewards_earned}" + + description = ( + f"Campaign running since {started_at} with {rewards} rewards given." + ) + + table = { + "Created at": discord.utils.format_dt(campaign.row.created_at, "F"), + "Started at": started_at, + "Moderator Role": f"<&@{campaign.row.moderator_role_id}>" if campaign.row.moderator_role_id else "*Not Set*", + "Logging webhook": "*Set Up*" if campaign.row.logging_webhook_url else "*Not Set*", + "Rewards Earned": str(rewards_earned), + "Rewards Cap": str(reward_cap), + } + if campaign.row.completed_at is not None: + table['Finished at'] = discord.utils.format_dt(campaign.row.completed_at, "F") + prop_table = '\n'.join(tabulate(*table.items())) + + embed.description = f"{description}\n\n{prop_table}" + + # Brief summary of rewards in columns. 12 per column. Empty titles? + rewardrows = [] + for reward in all_rewards: + name = reward.twitch_user_name or str(reward.twitch_user_id) or str(reward.profileid) + fluffed = reward.fulfilled_at is not None + fluffed_emoji = '✅' if fluffed else '🔳' + rewardrows.append(f"{fluffed_emoji} {name}") + + blocks = ['\n'.join(rewardrows[i:i+12]) for i in range(0, len(rewardrows), 12)] + + embed.add_field( + name="Rewards Summary", + value=blocks[0] or 'No Rewards Earned', + inline=True + ) + for block in blocks[1:]: + embed.add_field( + name="--", + value=block, + ) + + return MessageArgs(embed=embed) + + async def reload(self): + await self.campaign.row.refresh() diff --git a/plugin/discord/ui/rewards.py b/plugin/discord/ui/rewards.py index ed7a2da..a5d72cc 100644 --- a/plugin/discord/ui/rewards.py +++ b/plugin/discord/ui/rewards.py @@ -16,9 +16,8 @@ from meta import LionBot from meta.errors import UserInputError from meta.config import conf from utils.lib import tabulate, utc_now, MessageArgs, parse_duration -from utils.ui import MessageUI, AButton, AsComponents, ConfigEditor +from utils.ui import MessageUI from utils.ui.micros import FastModal -from utils.ui.pagers import BasePager, Pager from ...campaign import RewardCampaign from ...data import EarnedReward @@ -134,7 +133,38 @@ class RewardList(MessageUI): value = int(selected.values[0]) reward = next(r for r in self._rewards if r.earned_id == value) modal = RewardEditor.from_reward(reward) + + currently_fluffed = (reward.fulfilled_at is not None) + + @modal.submit_callback() + async def on_editor_submit(interaction: discord.Interaction): + update_args = {} + + if modal.flufbox.component.value and not currently_fluffed: + # Reward has been fluffed + update_args['fulfilled_at'] = utc_now() + elif currently_fluffed and not modal.flufbox.component.value: + # Reward has been unfluffed + update_args['fulfilled_at'] = None + + new_ref_value = modal.reference.value or None + if new_ref_value != reward.reference: + update_args['reference'] = new_ref_value + + new_notes_value = modal.notes.value or None + if new_notes_value != reward.modnote: + update_args['modnote'] = new_notes_value + + if update_args: + await interaction.response.defer(thinking=True, ephemeral=True) + await self.campaign.update_reward(reward.earned_id, **update_args) + await self.refresh(thinking=interaction) + else: + await interaction.response.defer(thinking=False) + + await selection.response.send_modal(modal) + await self.refresh() else: await selection.response.defer() @@ -200,7 +230,7 @@ class RewardList(MessageUI): 'Earned At': discord.utils.format_dt(reward.earned_at, 'F'), 'Earned From': reward.earned_from, 'Fulfilled At': fluf, - 'Reference': "*No reference set*", + 'Reference': reward.reference or "*No reference set*", 'Further notes': reward.modnote or "*No notes*", } prop_table = '\n'.join(tabulate(*table.items()))