Compare commits

..

22 Commits

Author SHA1 Message Date
conatum 97bdf8e8e3 tweak: Add inactive redeem event listener 2026-07-31 13:26:51 +03:00
conatum 14134d8831 fix(discord): configure command col name 2026-07-31 13:26:00 +03:00
conatum 1e8bab1334 fix(discord): Reverse campaign acmpl sort order 2026-07-30 18:13:33 +03:00
conatum 252a8f1aa2 fix(twitch): Typo in finish command 2026-07-30 17:17:24 +03:00
conatum da2122d375 feat: Add delreward command. 2026-07-30 16:57:14 +03:00
conatum 6122603115 fix: Deactivate testing event 2026-07-30 15:25:27 +03:00
conatum a69d45c1c2 tweak(campaign): Slight reformat to reward log 2026-07-30 14:43:13 +03:00
conatum e11fb8f071 fix(channel): Use correct channel name 2026-07-30 14:43:13 +03:00
conatum c54653322e Merge branch 'master' of thewisewolf.dev:CarmiCoven/pluscampaign-plugin 2026-07-30 14:41:29 +03:00
conatum 8189eeec05 feat(discord): Inline reward editing 2026-07-30 14:40:58 +03:00
conatum f2e8852d45 tweak(discord): Reform reward summary in list 2026-07-30 14:40:32 +03:00
conatum 5eb0c1a712 tweak: Add fluffed emoji to camp log 2026-07-30 14:39:22 +03:00
conatum 300f712bfd Merge branch 'master' of thewisewolf.dev:CarmiCoven/pluscampaign-plugin 2026-07-30 13:41:00 +03:00
conatum 05c428bcc9 fix(twitch): Converter on campaign reward cmd 2026-07-30 13:40:09 +03:00
conatum 87aec03e57 fix: Correct campaign condition 2026-07-30 13:39:06 +03:00
conatum f61d0519ee fix(discord): Actually send edit modal 2026-07-30 12:43:18 +03:00
conatum 99d1193be6 fix: Add edit support to ThreadedWebhook 2026-07-30 12:42:53 +03:00
conatum 1cc93d732e fix: Reward formatting and acmpl 2026-07-30 12:26:53 +03:00
conatum e700628403 tweak: Log formatting 2026-07-30 12:26:35 +03:00
conatum 299618ab1d fix: Rename logged_messageid column 2026-07-30 12:26:13 +03:00
conatum a681da058f fix: Add client to testing webhook 2026-07-30 11:59:50 +03:00
conatum 5873b0fe65 fix: Make 'earned_from' column name consistent 2026-07-30 11:58:35 +03:00
7 changed files with 341 additions and 152 deletions
+2 -2
View File
@@ -38,9 +38,9 @@ CREATE TABLE campaign_rewards_earned(
fulfilled_note TEXT, fulfilled_note TEXT,
modnote TEXT, modnote TEXT,
reference TEXT, reference TEXT,
logged_messageid BIGINT, log_messageid BIGINT,
earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
earned_reason TEXT NOT NULL, earned_from TEXT NOT NULL,
_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() _timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
+50 -39
View File
@@ -57,20 +57,22 @@ class RewardCampaign:
async def add_reward( async def add_reward(
self, self,
profileid: int, profileid: int,
earned_reason: str, earned_from: str,
event_id: Optional[int] = None, event_id: Optional[int] = None,
twitch_user_id: Optional[str] = None, twitch_user_id: Optional[str] = None,
twitch_user_name: Optional[str] = None, twitch_user_name: Optional[str] = None,
earned_at: Optional[datetime] = None, earned_at: Optional[datetime] = None,
**kwargs,
) -> EarnedReward: ) -> EarnedReward:
row = await EarnedReward.create( row = await EarnedReward.create(
campaign_id=self.row.campaign_id, campaign_id=self.row.campaign_id,
profileid=profileid, profileid=profileid,
earned_reason=earned_reason, earned_from=earned_from,
event_id=event_id, event_id=event_id,
twitch_user_id=twitch_user_id, twitch_user_id=twitch_user_id,
twitch_user_name=twitch_user_name, twitch_user_name=twitch_user_name,
earned_at=earned_at or utc_now(), earned_at=earned_at or utc_now(),
**kwargs,
) )
await self.try_to_log_reward(row) await self.try_to_log_reward(row)
@@ -85,14 +87,31 @@ class RewardCampaign:
await reward.update(**kwargs) await reward.update(**kwargs)
await self.try_to_log_reward(reward) await self.try_to_log_reward(reward)
async def delete_reward(self, rewardid: int):
reward = await EarnedReward.fetch(rewardid)
if reward is None:
raise ValueError("Reward doesn't exist")
await self.try_to_unlog_reward(reward)
await reward.delete()
async def get_rewards(self) -> list[EarnedReward]: async def get_rewards(self) -> list[EarnedReward]:
rows = await EarnedReward.fetch_where( rows = await EarnedReward.fetch_where(
campaign_id=self.row.campaign_id campaign_id=self.row.campaign_id
).order_by("earned_at", direction=ORDER.ASC) ).order_by("earned_at", direction=ORDER.ASC)
return rows return rows
async def try_to_unlog_reward(self, reward: EarnedReward):
import discord
try:
if self.webhook and reward.log_messageid:
await self.webhook.delete_message(reward.log_messageid)
except discord.HTTPException:
pass
async def try_to_log_reward(self, reward: EarnedReward): async def try_to_log_reward(self, reward: EarnedReward):
import discord import discord
if self.webhook: if self.webhook:
embed = await self._log_format_reward(reward) embed = await self._log_format_reward(reward)
if reward.log_messageid: if reward.log_messageid:
@@ -107,7 +126,9 @@ class RewardCampaign:
await reward.update(log_messageid=message.id) await reward.update(log_messageid=message.id)
except discord.HTTPException: except discord.HTTPException:
# Couldn't send, give up. # Couldn't send, give up.
logger.warning(f"Failed to log campaign reward {reward!r}", exc_info=True) logger.warning(
f"Failed to log campaign reward {reward!r}", exc_info=True
)
async def _log_format_reward(self, reward: EarnedReward): async def _log_format_reward(self, reward: EarnedReward):
""" """
@@ -118,51 +139,46 @@ class RewardCampaign:
embed = discord.Embed( embed = discord.Embed(
title=f"Reward #{reward.earned_id} in {self.row.campaign_name}", title=f"Reward #{reward.earned_id} in {self.row.campaign_name}",
) )
embed.description = f"> {reward.earned_from}"
# User Field # User Field
embed.add_field( embed.add_field(
name="User Information", name="User Information",
value=( value=(
f"Twitch user `{reward.twitch_user_name or 'Unknown'}` `(ID: {reward.twitch_user_id or 'Unknown'})`.\n" f"`{reward.twitch_user_name or 'Unknown'}`\n"
f"`ID: {reward.twitch_user_id or 'Unknown'}`.\n"
f"Internal ID `{reward.profileid}`" f"Internal ID `{reward.profileid}`"
), ),
inline=True inline=True,
) )
# Earning field # Earning field
embed.add_field( embed.add_field(
name="Reward Earned", name="Reward Earned",
value=( value=(f"{discord.utils.format_dt(reward.earned_at, 'F')}."),
f"Earned campaign reward at {discord.utils.format_dt(reward.earned_at, 'F')}.\n" inline=True,
f"Earned reason: '{reward.earned_from}'"
),
inline=True
) )
# Reference field # Reference field
embed.add_field( embed.add_field(
name="Reference", name="Reference",
value=reward.reference or "No Reference information saved.", value=reward.reference or "No Reference information saved.",
inline=False inline=False,
) )
# Modnote field # Modnote field
embed.add_field( embed.add_field(
name="Notes", name="Notes", value=reward.modnote or "No notes added", inline=False
value=reward.modnote or "No notes added",
inline=False
) )
# Fulfilled field # Fulfilled field
fluffed_emoji = "" if reward.fulfilled_at else "🔳"
if reward.fulfilled_at is not None: if reward.fulfilled_at is not None:
fluffed = f"Fluffed at {discord.utils.format_dt(reward.fulfilled_at, 'F')}" fluffed = f"{fluffed_emoji} Fluffed at {discord.utils.format_dt(reward.fulfilled_at, 'F')}"
if reward.fulfilled_note: if reward.fulfilled_note:
fluffed += '\n' + "Fluff Note: " + reward.fulfilled_note fluffed += "\n" + "Fluff Note: " + reward.fulfilled_note
else: else:
fluffed = "Not yet fluffed" fluffed = f"{fluffed_emoji} Not yet fluffed"
embed.add_field( embed.add_field(name="Fulfilled", value=fluffed)
name="Fulfilled",
value=fluffed
)
embed.set_footer(text="Last Updated") embed.set_footer(text="Last Updated")
embed.timestamp = utc_now() embed.timestamp = utc_now()
@@ -173,7 +189,9 @@ class RewardCampaign:
class CampaignRegistry: class CampaignRegistry:
VERSION = CampaignData.VERSION VERSION = CampaignData.VERSION
def __init__(self, data: CampaignData, session: aiohttp.ClientSession | None = None): def __init__(
self, data: CampaignData, session: aiohttp.ClientSession | None = None
):
self.data = data self.data = data
# TODO: Actually pass in a session # TODO: Actually pass in a session
self._session = aiohttp.ClientSession() self._session = aiohttp.ClientSession()
@@ -203,15 +221,17 @@ class CampaignRegistry:
condition = Campaign.communityid == cid condition = Campaign.communityid == cid
if active is not None: if active is not None:
active_condition = ( active_condition = (Campaign.started_at != NULL) & (
Campaign.started_at != NULL and Campaign.completed_at == NULL Campaign.completed_at == NULL
) )
if active: if active:
condition = condition and active_condition condition = condition & active_condition
else: else:
condition = condition and ~active_condition condition = condition & ~active_condition
rows = await Campaign.fetch_where(condition) rows = await Campaign.fetch_where(
condition,
)
campaigns = [RewardCampaign(row, session=self._session) for row in rows] campaigns = [RewardCampaign(row, session=self._session) for row in rows]
return campaigns return campaigns
@@ -238,27 +258,18 @@ class CampaignRegistry:
return camp return camp
async def create_campaign( async def create_campaign(
self, self, cid: int, campaign_name: str, **kwargs
cid: int,
campaign_name: str,
**kwargs
) -> RewardCampaign: ) -> RewardCampaign:
""" """
Create a new campaign. Create a new campaign.
The name must be unique (ignoring case) to facilitate easy lookup. The name must be unique (ignoring case) to facilitate easy lookup.
""" """
row = await Campaign.create( row = await Campaign.create(
communityid=cid, communityid=cid, campaign_name=campaign_name, **kwargs
campaign_name=campaign_name,
**kwargs
) )
return RewardCampaign(row, session=self._session) return RewardCampaign(row, session=self._session)
async def update_campaign( async def update_campaign(self, campaign_id: int, **kwargs) -> RewardCampaign:
self,
campaign_id: int,
**kwargs
) -> RewardCampaign:
""" """
Update a campaign with the given data args. Update a campaign with the given data args.
+123 -35
View File
@@ -7,6 +7,7 @@ from discord.ext import commands as cmds
from discord import Forbidden, User, app_commands as appcmds from discord import Forbidden, User, app_commands as appcmds
from meta import LionBot, LionCog, LionContext from meta import LionBot, LionCog, LionContext
from meta import logger
from meta.errors import SafeCancellation, UserInputError from meta.errors import SafeCancellation, UserInputError
from meta.logger import log_wrap from meta.logger import log_wrap
from utils.lib import utc_now from utils.lib import utc_now
@@ -79,6 +80,7 @@ class CampaignCog(LionCog):
sorted_campaigns = sorted( sorted_campaigns = sorted(
campaigns, campaigns,
key=lambda camp: (camp.is_active, camp.row.started_at, camp.row.created_at), key=lambda camp: (camp.is_active, camp.row.started_at, camp.row.created_at),
reverse=True,
) )
names = [ names = [
camp.row.campaign_name camp.row.campaign_name
@@ -123,7 +125,9 @@ class CampaignCog(LionCog):
return return
# Open campaign UI # Open campaign UI
widget = CampaignDashboard(bot=self.bot, campaign=campaign, callerid=ctx.author.id) widget = CampaignDashboard(
bot=self.bot, campaign=campaign, callerid=ctx.author.id
)
await widget.run(ctx.interaction) await widget.run(ctx.interaction)
await widget.wait() await widget.wait()
@@ -179,7 +183,7 @@ class CampaignCog(LionCog):
# If logging webhook is given, check that it works # If logging webhook is given, check that it works
if logging_webhook is not None: if logging_webhook is not None:
webhook = ThreadedWebhook.from_url(logging_webhook) webhook = ThreadedWebhook.from_url(logging_webhook, client=self.bot)
try: try:
await webhook.test_webhook() await webhook.test_webhook()
except (discord.HTTPException, discord.Forbidden): except (discord.HTTPException, discord.Forbidden):
@@ -202,7 +206,9 @@ class CampaignCog(LionCog):
f"Setup and started your reward campaign {campaign.row.campaign_name}! Good luck" f"Setup and started your reward campaign {campaign.row.campaign_name}! Good luck"
) )
# Open campaign UI # Open campaign UI
widget = CampaignDashboard(bot=self.bot, campaign=campaign, callerid=ctx.author.id) widget = CampaignDashboard(
bot=self.bot, campaign=campaign, callerid=ctx.author.id
)
await widget.run(ctx.interaction) await widget.run(ctx.interaction)
await widget.wait() await widget.wait()
@@ -254,7 +260,7 @@ class CampaignCog(LionCog):
update_args["campaign_name"] = new_name update_args["campaign_name"] = new_name
if new_cap is not None: if new_cap is not None:
update_args["total_rewards"] = new_cap if new_cap > 0 else None update_args["target_rewards"] = new_cap if new_cap > 0 else None
# If logging webhook is given, check that it works # If logging webhook is given, check that it works
if logging_webhook is not None: if logging_webhook is not None:
@@ -278,10 +284,7 @@ class CampaignCog(LionCog):
) )
# Ack creation # Ack creation
await ctx.reply( await ctx.reply("Updated your campaign, good luck!", ephemeral=True)
"Updated your campaign, good luck!",
ephemeral=True
)
campaign_configure_cmd.autocomplete("campaign_name")(_campaign_acmpl) campaign_configure_cmd.autocomplete("campaign_name")(_campaign_acmpl)
@@ -317,14 +320,18 @@ class CampaignCog(LionCog):
campaign_rewards_cmd.autocomplete("campaign_name")(_campaign_acmpl) campaign_rewards_cmd.autocomplete("campaign_name")(_campaign_acmpl)
@campaign_group.command( @campaign_group.command(
name="editreward", name="delreward",
description="Add or edit details for a given reward in a campaign (see also /campaign rewards)", description="Remove a campaign reward",
) )
@appcmds.describe( @appcmds.describe(
rewardid="Earned reward to edit", rewardid="Earned reward to delete",
) )
@appcmds.rename(rewardid="reward") @appcmds.rename(rewardid="reward")
async def campaign_editreward_cmd(self, ctx: LionContext, rewardid: str): async def campaign_delreward_cmd(
self,
ctx: LionContext,
rewardid: str,
):
# For this we'll just open the reward editor # For this we'll just open the reward editor
# Will need to identify the reward with autocomplete. Can use the reward id directly.. # Will need to identify the reward with autocomplete. Can use the reward id directly..
if not rewardid.isdigit(): if not rewardid.isdigit():
@@ -352,38 +359,113 @@ class CampaignCog(LionCog):
) )
return return
# Okay, this is our reward, and author has permission to modify it. Spin the modal. # Okay, they have permission, let's delete the reward.
modal = RewardEditor.from_reward(reward) await campaign.delete_reward(reward.earned_id)
# Ack
await ctx.reply(content="Campaign reward deleted!", ephemeral=True)
@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",
fulfilled="Whether this reward has been completed or not.",
reference="Reference information for this reward, e.g. image or message URL",
notes="Any additional notes",
)
@appcmds.rename(rewardid="reward")
async def campaign_editreward_cmd(
self,
ctx: LionContext,
rewardid: str,
fulfilled: Optional[bool] = None,
reference: Optional[str] = None,
notes: Optional[str] = None
):
# 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.
currently_fluffed = reward.fulfilled_at is not None currently_fluffed = reward.fulfilled_at is not None
@modal.submit_callback() update_args = {}
async def on_editor_submit(interaction: discord.Interaction): if fulfilled is not None:
update_args = {} if fulfilled and not currently_fluffed:
if modal.flufbox.component.value and not currently_fluffed:
# Reward has been fluffed # Reward has been fluffed
update_args["fulfilled_at"] = utc_now() update_args["fulfilled_at"] = utc_now()
elif currently_fluffed and not modal.flufbox.component.value: elif currently_fluffed and not fulfilled:
# Reward has been unfluffed # Reward has been unfluffed
update_args["fulfilled_at"] = None update_args["fulfilled_at"] = None
if reference is not None and reference != reward.reference:
update_args["reference"] = reference
if notes is not None and notes != reward.modnote:
update_args["modnote"] = notes
new_ref_value = modal.reference.value or None if update_args:
if new_ref_value != reward.reference: await ctx.interaction.response.defer(thinking=True, ephemeral=True)
update_args["reference"] = new_ref_value await campaign.update_reward(reward.earned_id, **update_args)
await ctx.interaction.followup.send(
content="Reward updated!"
)
else:
modal = RewardEditor.from_reward(reward)
new_notes_value = modal.notes.value or None @modal.submit_callback()
if new_notes_value != reward.modnote: async def on_editor_submit(interaction: discord.Interaction):
update_args["modnote"] = new_notes_value update_args = {}
if update_args: if modal.flufbox.component.value and not currently_fluffed:
await interaction.response.defer(thinking=True, ephemeral=True) # Reward has been fluffed
await campaign.update_reward(reward.earned_id, **update_args) update_args["fulfilled_at"] = utc_now()
await interaction.followup.send( elif currently_fluffed and not modal.flufbox.component.value:
content="Reward updated!", ephemeral=True # Reward has been unfluffed
) update_args["fulfilled_at"] = None
else:
await interaction.response.defer(thinking=False) new_ref_value = modal.reference.component.value or None
if new_ref_value != reward.reference:
update_args["reference"] = new_ref_value
new_notes_value = modal.notes.component.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)
await ctx.interaction.response.send_modal(modal)
def _reward_acmpl_format( def _reward_acmpl_format(
self, campaign: RewardCampaign, reward: EarnedReward self, campaign: RewardCampaign, reward: EarnedReward
@@ -393,8 +475,13 @@ class CampaignCog(LionCog):
#100: username in campaign #100: username in campaign
""" """
... return "#{rewardid}: {username} in {cname}".format(
rewardid=reward.earned_id,
username=reward.twitch_user_name or reward.twitch_user_id or "Unknown",
cname=campaign.row.campaign_name,
)
@campaign_delreward_cmd.autocomplete("rewardid")
@campaign_editreward_cmd.autocomplete("rewardid") @campaign_editreward_cmd.autocomplete("rewardid")
async def _reward_acmpl( async def _reward_acmpl(
self, interaction: discord.Interaction, partial: str self, interaction: discord.Interaction, partial: str
@@ -420,6 +507,7 @@ class CampaignCog(LionCog):
sorted_campaigns = sorted( sorted_campaigns = sorted(
campaigns, campaigns,
key=lambda camp: (camp.is_active, camp.row.started_at, camp.row.created_at), key=lambda camp: (camp.is_active, camp.row.started_at, camp.row.created_at),
reverse=True,
) )
for campaign in sorted_campaigns: for campaign in sorted_campaigns:
rewards = await campaign.get_rewards() rewards = await campaign.get_rewards()
+21 -9
View File
@@ -100,31 +100,43 @@ class CampaignDashboard(MessageUI):
table = { table = {
"Created at": discord.utils.format_dt(campaign.row.created_at, "F"), "Created at": discord.utils.format_dt(campaign.row.created_at, "F"),
"Started at": started_at, "Started at": started_at,
"Moderator Role": f"<&@{campaign.row.moderator_role_id}>" if campaign.row.moderator_role_id else "*Not Set*", "Moderator Role": f"<@&{campaign.row.moderator_role_id}>"
"Logging webhook": "*Set Up*" if campaign.row.logging_webhook_url else "*Not Set*", 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 Earned": str(rewards_earned),
"Rewards Cap": str(reward_cap), "Rewards Cap": str(reward_cap),
} }
if campaign.row.completed_at is not None: if campaign.row.completed_at is not None:
table['Finished at'] = discord.utils.format_dt(campaign.row.completed_at, "F") table["Finished at"] = discord.utils.format_dt(
prop_table = '\n'.join(tabulate(*table.items())) campaign.row.completed_at, "F"
)
prop_table = "\n".join(tabulate(*table.items()))
embed.description = f"{description}\n\n{prop_table}" embed.description = f"{description}\n\n{prop_table}"
# Brief summary of rewards in columns. 12 per column. Empty titles? # Brief summary of rewards in columns. 12 per column. Empty titles?
rewardrows = [] rewardrows = []
for reward in all_rewards: for reward in all_rewards:
name = reward.twitch_user_name or str(reward.twitch_user_id) or str(reward.profileid) name = (
reward.twitch_user_name
or str(reward.twitch_user_id)
or str(reward.profileid)
)
fluffed = reward.fulfilled_at is not None fluffed = reward.fulfilled_at is not None
fluffed_emoji = '' if fluffed else '🔳' fluffed_emoji = "" if fluffed else "🔳"
rewardrows.append(f"{fluffed_emoji} {name}") rewardrows.append(f"{fluffed_emoji} {name}")
blocks = ['\n'.join(rewardrows[i:i+12]) for i in range(0, len(rewardrows), 12)] blocks = [
"\n".join(rewardrows[i : i + 12]) for i in range(0, len(rewardrows), 12)
]
embed.add_field( embed.add_field(
name="Rewards Summary", name="Rewards Summary",
value=blocks[0] if blocks else 'No Rewards Earned', value=blocks[0] if blocks else "No Rewards Earned",
inline=True inline=True,
) )
for block in blocks[1:]: for block in blocks[1:]:
embed.add_field( embed.add_field(
+48 -47
View File
@@ -29,15 +29,13 @@ class RewardEditor(FastModal):
# Title is the reward we are editing # Title is the reward we are editing
# Block of text with dates and user info # Block of text with dates and user info
blurb = discord.ui.TextDisplay(content='placeholder') blurb = discord.ui.TextDisplay(content="placeholder")
# Fulfilled is a checkbox, and supports a fulfilled note # Fulfilled is a checkbox, and supports a fulfilled note
flufbox = discord.ui.Label( flufbox = discord.ui.Label(
text="Fulfilled", text="Fulfilled",
description="Whether this reward has been completed", description="Whether this reward has been completed",
component=discord.ui.Checkbox( component=discord.ui.Checkbox(default=False),
default=False
)
) )
# Reference and further notes need to be editable, # Reference and further notes need to be editable,
@@ -47,6 +45,7 @@ class RewardEditor(FastModal):
description="Reference URL or other information", description="Reference URL or other information",
component=discord.ui.TextInput( component=discord.ui.TextInput(
style=discord.TextStyle.long, style=discord.TextStyle.long,
required=False,
), ),
) )
notes = discord.ui.Label( notes = discord.ui.Label(
@@ -54,6 +53,7 @@ class RewardEditor(FastModal):
description="Further notes for this user/reward", description="Further notes for this user/reward",
component=discord.ui.TextInput( component=discord.ui.TextInput(
style=discord.TextStyle.long, style=discord.TextStyle.long,
required=False,
), ),
) )
@@ -64,19 +64,18 @@ class RewardEditor(FastModal):
# blurb # blurb
table = { table = {
'Earned At': discord.utils.format_dt(reward.earned_at, 'F'), "Earned At": discord.utils.format_dt(reward.earned_at, "F"),
'Earned From': reward.earned_from, "Earned From": reward.earned_from,
} }
prop_table = '\n'.join(tabulate(*table.items())) prop_table = "\n".join(tabulate(*table.items()))
self.blurb.content = prop_table self.blurb.content = prop_table
# Fulfilled # Fulfilled
self.flufbox.component.default = (reward.fulfilled_at is not None) self.flufbox.component.default = reward.fulfilled_at is not None
# Notes # Notes
self.reference.component.default = reward.reference or '' self.reference.component.default = reward.reference or ""
self.notes.component.default = reward.modnote or '' self.notes.component.default = reward.modnote or ""
return self return self
@@ -134,7 +133,7 @@ class RewardList(MessageUI):
reward = next(r for r in self._rewards if r.earned_id == value) reward = next(r for r in self._rewards if r.earned_id == value)
modal = RewardEditor.from_reward(reward) modal = RewardEditor.from_reward(reward)
currently_fluffed = (reward.fulfilled_at is not None) currently_fluffed = reward.fulfilled_at is not None
@modal.submit_callback() @modal.submit_callback()
async def on_editor_submit(interaction: discord.Interaction): async def on_editor_submit(interaction: discord.Interaction):
@@ -142,18 +141,18 @@ class RewardList(MessageUI):
if modal.flufbox.component.value and not currently_fluffed: if modal.flufbox.component.value and not currently_fluffed:
# Reward has been fluffed # Reward has been fluffed
update_args['fulfilled_at'] = utc_now() update_args["fulfilled_at"] = utc_now()
elif currently_fluffed and not modal.flufbox.component.value: elif currently_fluffed and not modal.flufbox.component.value:
# Reward has been unfluffed # Reward has been unfluffed
update_args['fulfilled_at'] = None update_args["fulfilled_at"] = None
new_ref_value = modal.reference.value or None new_ref_value = modal.reference.component.value or None
if new_ref_value != reward.reference: if new_ref_value != reward.reference:
update_args['reference'] = new_ref_value update_args["reference"] = new_ref_value
new_notes_value = modal.notes.value or None new_notes_value = modal.notes.component.value or None
if new_notes_value != reward.modnote: if new_notes_value != reward.modnote:
update_args['modnote'] = new_notes_value update_args["modnote"] = new_notes_value
if update_args: if update_args:
await interaction.response.defer(thinking=True, ephemeral=True) await interaction.response.defer(thinking=True, ephemeral=True)
@@ -162,7 +161,6 @@ class RewardList(MessageUI):
else: else:
await interaction.response.defer(thinking=False) await interaction.response.defer(thinking=False)
await selection.response.send_modal(modal) await selection.response.send_modal(modal)
await self.refresh() await self.refresh()
@@ -173,14 +171,10 @@ class RewardList(MessageUI):
menu = self.reward_menu menu = self.reward_menu
rewards = self.page rewards = self.page
if rewards: if rewards:
menu.options = [ menu.options = [self._format_reward_option(r) for r in rewards]
self._format_reward_option(r) for r in rewards
]
menu.disabled = False menu.disabled = False
else: else:
menu.options = [ menu.options = [SelectOption(label="DUMMY")]
SelectOption(label='DUMMY')
]
menu.disabled = True menu.disabled = True
# Meta buttons # Meta buttons
@@ -214,10 +208,9 @@ class RewardList(MessageUI):
""" """
# Title is Reward #n earned by twitch_username # Title is Reward #n earned by twitch_username
# Reward, Earned at, Earned by, Fulfilled at (not fulfilled/date), Ref, Added Notes # Reward, Earned at, Earned by, Fulfilled at (not fulfilled/date), Ref, Added Notes
name = f"Reward #{reward.earned_id} earned by {reward.twitch_user_name or reward.twitch_user_id}"
if reward.fulfilled_at is not None: if reward.fulfilled_at is not None:
fat = discord.utils.format_dt(reward.fulfilled_at, 'F') fat = discord.utils.format_dt(reward.fulfilled_at, "F")
if reward.fulfilled_note is not None: if reward.fulfilled_note is not None:
fluf = f"{fat} ({reward.fulfilled_note})" fluf = f"{fat} ({reward.fulfilled_note})"
else: else:
@@ -225,17 +218,25 @@ class RewardList(MessageUI):
else: else:
fluf = "*Not Fulfilled*" fluf = "*Not Fulfilled*"
table = { fluffed_emoji = "" if reward.fulfilled_at else "🔳"
'Reward': "Plus Campaign Sketch", earned = discord.utils.format_dt(reward.earned_at, "d")
'Earned At': discord.utils.format_dt(reward.earned_at, 'F'),
'Earned From': reward.earned_from,
'Fulfilled At': fluf,
'Reference': reward.reference or "*No reference set*",
'Further notes': reward.modnote or "*No notes*",
}
prop_table = '\n'.join(tabulate(*table.items()))
return (name, prop_table) name = f"{fluffed_emoji} #{reward.earned_id} earned by {reward.twitch_user_name or reward.twitch_user_id} at {earned}"
table = {
# "Reward": "Plus Campaign Sketch",
# "Earned From": reward.earned_from,
# "Fulfilled At": fluf,
"Reference": reward.reference or "*No reference set*",
"Further notes": reward.modnote or "*No notes*",
}
prop_table = "\n".join(tabulate(*table.items()))
value = '\n'.join((
"> {reason}",
"{table}",
)).format(reason=reward.earned_from, table=prop_table)
return (name, value)
def _format_reward_option(self, reward: EarnedReward) -> SelectOption: def _format_reward_option(self, reward: EarnedReward) -> SelectOption:
""" """
@@ -246,14 +247,10 @@ class RewardList(MessageUI):
name = f"Reward #{reward.earned_id} earned by {reward.twitch_user_name or reward.twitch_user_id}" name = f"Reward #{reward.earned_id} earned by {reward.twitch_user_name or reward.twitch_user_id}"
value = reward.earned_id value = reward.earned_id
return SelectOption( return SelectOption(label=name, value=str(value))
label=name, value=str(value)
)
async def refresh_layout(self): async def refresh_layout(self):
to_refresh = ( to_refresh = (self.reward_menu_refresh(),)
self.reward_menu_refresh(),
)
await asyncio.gather(*to_refresh) await asyncio.gather(*to_refresh)
if self.page_count <= 1: if self.page_count <= 1:
@@ -264,13 +261,18 @@ class RewardList(MessageUI):
self.next_page_button.disabled = False self.next_page_button.disabled = False
self.set_layout( self.set_layout(
(self.prev_page_button, self.refresh_button, self.next_page_button, self.quit_button), (
self.prev_page_button,
self.refresh_button,
self.next_page_button,
self.quit_button,
),
(self.reward_menu,), (self.reward_menu,),
) )
async def make_message(self) -> MessageArgs: async def make_message(self) -> MessageArgs:
embed = discord.Embed(title=f"{self.campaign.row.campaign_name} Reward List") embed = discord.Embed(title=f"{self.campaign.row.campaign_name} Reward List")
embed.set_footer(text='Last Update') embed.set_footer(text="Last Update")
embed.timestamp = utc_now() embed.timestamp = utc_now()
campaign = self.campaign campaign = self.campaign
@@ -284,7 +286,6 @@ class RewardList(MessageUI):
embed.description = description embed.description = description
for reward in self.page: for reward in self.page:
name, value = self._format_reward_section(reward) name, value = self._format_reward_section(reward)
embed.add_field(name=name, value=value, inline=False) embed.add_field(name=name, value=value, inline=False)
@@ -296,6 +297,6 @@ class RewardList(MessageUI):
# TODO: Consider filter by unfulfilled or reward type # TODO: Consider filter by unfulfilled or reward type
self.reward_blocks = [ self.reward_blocks = [
rewards[i:i+self.blocklen] rewards[i : i + self.blocklen]
for i in range(0, len(rewards), self.blocklen) for i in range(0, len(rewards), self.blocklen)
] or [[]] ] or [[]]
+19 -2
View File
@@ -17,12 +17,14 @@ def LOWER(expression: Expression) -> RawExpr:
return RawExpr(final_expr, final_values) return RawExpr(final_expr, final_values)
def asexpr(value: Any) -> RawExpr: def asexpr(value: Any) -> RawExpr:
""" """
Turn a value into an expression. Turn a value into an expression.
""" """
return RawExpr(sql.Placeholder(), (value,)) return RawExpr(sql.Placeholder(), (value,))
async def fire_and_forget(awaitable, do_in=1, ignorable=(discord.HTTPException)): async def fire_and_forget(awaitable, do_in=1, ignorable=(discord.HTTPException)):
await asyncio.sleep(do_in) await asyncio.sleep(do_in)
try: try:
@@ -33,6 +35,7 @@ async def fire_and_forget(awaitable, do_in=1, ignorable=(discord.HTTPException))
# TODO: Log unexpected exceptions # TODO: Log unexpected exceptions
pass pass
class ThreadedWebhook(discord.Webhook): class ThreadedWebhook(discord.Webhook):
__slots__ = ("thread_id",) __slots__ = ("thread_id",)
@@ -55,10 +58,24 @@ class ThreadedWebhook(discord.Webhook):
kwargs.setdefault("thread", discord.Object(self.thread_id)) kwargs.setdefault("thread", discord.Object(self.thread_id))
return await super().send(*args, **kwargs) return await super().send(*args, **kwargs)
async def edit_message(self, *args, **kwargs):
if self.thread_id is not None:
kwargs.setdefault("thread", discord.Object(self.thread_id))
return await super().edit_message(*args, **kwargs)
async def delete_message(self, *args, **kwargs):
if self.thread_id is not None:
kwargs.setdefault("thread", discord.Object(self.thread_id))
return await super().delete_message(*args, **kwargs)
async def fetch_message(self, *args, **kwargs):
if self.thread_id is not None:
kwargs.setdefault("thread", discord.Object(self.thread_id))
return await super().fetch_message(*args, **kwargs)
async def test_webhook(self): async def test_webhook(self):
embed = discord.Embed( embed = discord.Embed(
title="Testing", title="Testing", description="Testing logging webhook, feel free to delete."
description="Testing logging webhook, feel free to delete."
) )
result = await self.send(embed=embed, wait=True, silent=True) result = await self.send(embed=embed, wait=True, silent=True)
asyncio.create_task(fire_and_forget(result.delete())) asyncio.create_task(fire_and_forget(result.delete()))
+72 -12
View File
@@ -24,7 +24,7 @@ class CampaignComponent(cmds.Component):
self.campaigns = CampaignRegistry(self.data) self.campaigns = CampaignRegistry(self.data)
self.channel = CampaignChannel(self.bot.profiles.profiles, self.campaigns) self.channel = CampaignChannel(self.bot.profiles.profiles, self.campaigns)
register_channel("Campaign", self.channel) register_channel(self.channel.name, self.channel)
# ----- API ----- # ----- API -----
async def component_load(self): async def component_load(self):
@@ -74,14 +74,75 @@ class CampaignComponent(cmds.Component):
# Add a reward to the database with the correct info. # Add a reward to the database with the correct info.
await campaign.add_reward( await campaign.add_reward(
profileid=event_row["profileid"], profileid=event_row["profileid"],
earned_reason=f"(SUB) User subscribed for {duration} months at tier {tier}", earned_from=f"(SUB) User subscribed for {duration} months at tier {tier}",
event_id=event_row["event_id"], event_id=event_row["event_id"],
twitch_user_id=data["chatter_user_id"], twitch_user_id=data["chatter_user_id"],
twitch_user_name=data["chatter_user_name"], twitch_user_name=data["chatter_user_name"],
) )
await self.dispatch_update(campaign) await self.dispatch_update(campaign)
# TODO: Webhook logging maybe.. (do this in campaign)
# Or just general logging. (also do this in campaign, but we can do again here) # @cmds.Component.listener()
async def event_custom_redemption_add(self, payload):
if payload.reward.title not in (
"hi!",
"hydrate",
"stretch",
"save file",
"pet lilac",
):
return
community = await self.bot.profiles.fetch_community(payload.broadcaster)
cid = community.communityid
profile = await self.bot.profiles.fetch_profile(payload.user)
pid = profile.profileid
campaigns = await self.campaigns.fetch_campaigns(cid)
for campaign in campaigns:
reward_progress = len(await campaign.get_rewards())
if (
campaign.row.target_rewards is None
or reward_progress < campaign.row.target_rewards
):
# Add a reward to the database with the correct info.
await campaign.add_reward(
profileid=pid,
earned_from=f"(REDEEM) User redeemed {payload.reward.title}",
event_id=None,
twitch_user_id=payload.user.id,
twitch_user_name=payload.user.name,
reference=f"Redeem text: {payload.user_input}",
)
await self.dispatch_update(campaign)
# @cmds.Component.listener()
async def event_message(self, payload):
if not payload.text.startswith("%reward%"):
return
community = await self.bot.profiles.fetch_community(payload.broadcaster)
cid = community.communityid
profile = await self.bot.profiles.fetch_profile(payload.chatter)
pid = profile.profileid
campaigns = await self.campaigns.fetch_campaigns(cid)
for campaign in campaigns:
reward_progress = len(await campaign.get_rewards())
if (
campaign.row.target_rewards is None
or reward_progress < campaign.row.target_rewards
):
# Add a reward to the database with the correct info.
await campaign.add_reward(
profileid=pid,
earned_from=f"(MSG) User messaged in chat: {payload.text}",
event_id=None,
twitch_user_id=payload.chatter.id,
twitch_user_name=payload.chatter.name,
)
await self.dispatch_update(campaign)
@cmds.Component.listener() @cmds.Component.listener()
async def event_safe_event_chat_notice_resub(self, payload): async def event_safe_event_chat_notice_resub(self, payload):
@@ -103,7 +164,6 @@ class CampaignComponent(cmds.Component):
# ------ Commands ----- # ------ Commands -----
@cmds.group(name="campaign", invoke_fallback=True) @cmds.group(name="campaign", invoke_fallback=True)
@cmds.is_moderator()
async def group_campaign(self, ctx: cmds.Context, name: Optional[str] = None): async def group_campaign(self, ctx: cmds.Context, name: Optional[str] = None):
"""Status of the current or named campaign.""" """Status of the current or named campaign."""
community = await self.bot.profiles.fetch_community(ctx.broadcaster) community = await self.bot.profiles.fetch_community(ctx.broadcaster)
@@ -137,17 +197,16 @@ class CampaignComponent(cmds.Component):
reward_cap = campaign.row.target_rewards reward_cap = campaign.row.target_rewards
if reward_cap is not None: if reward_cap is not None:
response = ( response = "{name}: {given} rewards earned out of {cap}!"
f"{name}: {rewards_earned} rewards earned out of {reward_cap}!"
)
else: else:
response = f"{name}: {rewards_earned} rewards earned so far!" response = "{name}: {given} rewards earned so far!"
formatted = response.format( formatted = response.format(
name=campaign.row.campaign_name, name=campaign.row.campaign_name,
given=rewards_earned, given=rewards_earned,
cap=reward_cap, cap=reward_cap,
) )
await ctx.reply(formatted) await ctx.reply(formatted)
await self.dispatch_update(campaign)
@group_campaign.command(name="setup", aliases=["start"]) @group_campaign.command(name="setup", aliases=["start"])
@cmds.is_moderator() @cmds.is_moderator()
@@ -235,9 +294,9 @@ class CampaignComponent(cmds.Component):
reward_cap = campaign.row.target_rewards reward_cap = campaign.row.target_rewards
if reward_cap is not None: if reward_cap is not None:
response = f"Completed {name}: {rewards_earned} rewards earned out of {reward_cap}!" response = "Completed {name}: {given} rewards earned out of {cap}!"
else: else:
response = f"Completed {name}: {rewards_earned} rewards earned so far!" response = "Completed {name}: {given} rewards earned so far!"
formatted = response.format( formatted = response.format(
name=campaign.row.campaign_name, name=campaign.row.campaign_name,
given=rewards_earned, given=rewards_earned,
@@ -251,7 +310,8 @@ class CampaignComponent(cmds.Component):
async def cmd_campaign_reward( async def cmd_campaign_reward(
self, self,
ctx: cmds.Context, ctx: cmds.Context,
user: twitchio.PartialUser, user: twitchio.User,
*,
reward_reason: str, reward_reason: str,
): ):
"""Manually reward a target user, adjusting their points.""" """Manually reward a target user, adjusting their points."""