Compare commits

...

33 Commits

Author SHA1 Message Date
conatum 08fb0084fa Adaptions for ppp event.
Fix several typos in event handler
Rewrite sub and resub event handlers to expect resub events
Add ppp alias to campaign command.
Add ppp test command for testing last sub calculator
2026-08-02 17:18:20 +03:00
conatum 1c5eb6ceeb fix(twitch): Correct payload attr names for event 2026-07-31 13:51:15 +03:00
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
conatum d8817d4473 fix: Component string typos 2026-07-30 10:32:40 +03:00
conatum a18885511a fix: Many typos 2026-07-30 09:45:03 +03:00
conatum 0923f32a82 (twitch): Fix some typos 2026-07-29 15:49:06 +03:00
conatum 8fc0ff0667 (discord): Fill in discord interfaces 2026-07-29 15:48:47 +03:00
conatum 9203a795e3 (campaign): Add reward webhook logging 2026-07-29 15:47:58 +03:00
conatum ee70b80120 (channel): Change name to 'CampaignRewards' 2026-07-29 15:47:35 +03:00
conatum a1dd04685b (lib): Add ThreadedWebhook for threaded webhooks 2026-07-29 15:47:01 +03:00
conatum d7447fc300 data: Add webhook logging and modrole 2026-07-29 15:46:13 +03:00
conatum bc231da61f (discord): Rough reward list and editor UI 2026-07-29 08:34:27 +03:00
10 changed files with 1446 additions and 57 deletions
+10 -4
View File
@@ -2,12 +2,14 @@ BEGIN;
-- Version dependency checks
DO $$
ASSERT current_module_version('PROFILES') = 1, 'Dependency version mismatch: PROFILES';
ASSERT current_module_version('EVENT_TRACKER') = 2, 'Dependency version mismatch: EVENT_TRACKER';
BEGIN
ASSERT current_module_version('PROFILES') = 1, 'Dependency version mismatch: PROFILES';
ASSERT current_module_version('EVENT_TRACKER') = 2, 'Dependency version mismatch: EVENT_TRACKER';
END;
$$ LANGUAGE plpgsql;
-- Plugin version history
INSERT INTO version_history (component, from_version, to_version, author) VALUES ('PLUSCAMPAIGN', 0, 1, 'Initial Creation');
INSERT INTO version_history (component, from_version, to_version, author) VALUES ('REWARDCAMPAIGN', 0, 1, 'Initial Creation');
CREATE TABLE campaigns(
@@ -15,6 +17,8 @@ CREATE TABLE campaigns(
communityid INTEGER NOT NULL REFERENCES communities(communityid) ON DELETE CASCADE ON UPDATE CASCADE,
target_rewards INTEGER,
campaign_name TEXT NOT NULL,
moderator_role_id BIGINT,
logging_webhook_url TEXT,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -33,8 +37,10 @@ CREATE TABLE campaign_rewards_earned(
fulfilled_at TIMESTAMPTZ,
fulfilled_note TEXT,
modnote TEXT,
reference TEXT,
log_messageid BIGINT,
earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
earned_reason TEXT NOT NULL,
earned_from TEXT NOT NULL,
_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
+157 -23
View File
@@ -2,26 +2,45 @@ from typing import Any, Optional
import datetime as dt
from datetime import datetime, timedelta
import aiohttp
from data import ORDER, Condition
from data.conditions import NULL, condition
from utils.lib import utc_now
from .lib import LOWER, asexpr
from .lib import LOWER, asexpr, ThreadedWebhook
from .data import (
CampaignData,
Campaign,
EarnedReward,
)
from . import logger
class RewardCampaign:
def __init__(self, row: Campaign):
def __init__(self, row: Campaign, session: aiohttp.ClientSession | None = None):
self.row = row
self._session = session
self._webhook: ThreadedWebhook | None = None
self._cached_webhookurl: str | None = None
@property
def is_active(self):
return self.row.started_at is not None and self.row.completed_at is None
@property
def webhook(self):
if self._cached_webhookurl != self.row.logging_webhook_url:
url = self._cached_webhookurl = self.row.logging_webhook_url
if url is not None:
# TODO: I don't know if these needs a client
# Might be hard if so given we need to run this from Twitch client as well
self._webhook = ThreadedWebhook.from_url(url, session=self._session)
else:
self._webhook = None
return self._webhook
async def start(self):
"""Start the reward campaign."""
if self.row.started_at is not None:
@@ -38,36 +57,144 @@ class RewardCampaign:
async def add_reward(
self,
profileid: int,
earned_reason: str,
earned_from: str,
event_id: Optional[int] = None,
twitch_user_id: Optional[str] = None,
twitch_user_name: Optional[str] = None,
earned_at: Optional[datetime] = None,
**kwargs,
) -> EarnedReward:
row = await EarnedReward.create(
campaign_id=self.row.campaign_id,
profileid=profileid,
earned_reason=earned_reason,
earned_from=earned_from,
event_id=event_id,
twitch_user_id=twitch_user_id,
twitch_user_name=twitch_user_name,
earned_at=earned_at or utc_now(),
**kwargs,
)
await self.try_to_log_reward(row)
return row
async def update_reward(self, rewardid: int, **kwargs):
# In particular, log or update the logged message
if kwargs:
reward = await EarnedReward.fetch(rewardid)
if reward is None:
raise ValueError("Reward doesn't exist")
await reward.update(**kwargs)
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]:
rows = await EarnedReward.fetch_where(
campaign_id=self.row.campaign_id
).order_by("earned_at", direction=ORDER.ASC)
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):
import discord
if self.webhook:
embed = await self._log_format_reward(reward)
if reward.log_messageid:
# Try and edit message
try:
await self.webhook.edit_message(reward.log_messageid, embed=embed)
except discord.HTTPException:
await reward.update(log_messageid=None)
if not reward.log_messageid:
try:
message = await self.webhook.send(embed=embed, wait=True)
await reward.update(log_messageid=message.id)
except discord.HTTPException:
# Couldn't send, give up.
logger.warning(
f"Failed to log campaign reward {reward!r}", exc_info=True
)
async def _log_format_reward(self, reward: EarnedReward):
"""
Quick and ugly embed format for the webhook.
"""
import discord
embed = discord.Embed(
title=f"Reward #{reward.earned_id} in {self.row.campaign_name}",
)
embed.description = f"> {reward.earned_from}"
# User Field
embed.add_field(
name="User Information",
value=(
f"`{reward.twitch_user_name or 'Unknown'}`\n"
f"`ID: {reward.twitch_user_id or 'Unknown'}`.\n"
f"Internal ID `{reward.profileid}`"
),
inline=True,
)
# Earning field
embed.add_field(
name="Reward Earned",
value=(f"{discord.utils.format_dt(reward.earned_at, 'F')}."),
inline=True,
)
# Reference field
embed.add_field(
name="Reference",
value=reward.reference or "No Reference information saved.",
inline=False,
)
# Modnote field
embed.add_field(
name="Notes", value=reward.modnote or "No notes added", inline=False
)
# Fulfilled field
fluffed_emoji = "" if reward.fulfilled_at else "🔳"
if reward.fulfilled_at is not None:
fluffed = f"{fluffed_emoji} Fluffed at {discord.utils.format_dt(reward.fulfilled_at, 'F')}"
if reward.fulfilled_note:
fluffed += "\n" + "Fluff Note: " + reward.fulfilled_note
else:
fluffed = f"{fluffed_emoji} Not yet fluffed"
embed.add_field(name="Fulfilled", value=fluffed)
embed.set_footer(text="Last Updated")
embed.timestamp = utc_now()
return embed
class CampaignRegistry:
VERSION = CampaignData.VERSION
def __init__(self, data: CampaignData):
def __init__(
self, data: CampaignData, session: aiohttp.ClientSession | None = None
):
self.data = data
# TODO: Actually pass in a session
self._session = aiohttp.ClientSession()
async def init(self):
await self.data.init()
@@ -79,7 +206,7 @@ class CampaignRegistry:
row = await Campaign.fetch(campaign_id)
if row is None:
raise ValueError("Campign %s doesn't exist." % campaign_id)
camp = RewardCampaign(row)
camp = RewardCampaign(row, session=self._session)
return camp
async def fetch_campaigns(
@@ -94,16 +221,18 @@ class CampaignRegistry:
condition = Campaign.communityid == cid
if active is not None:
active_condition = (
Campaign.started_at != NULL and Campaign.completed_at == NULL
active_condition = (Campaign.started_at != NULL) & (
Campaign.completed_at == NULL
)
if active:
condition = condition and active_condition
condition = condition & active_condition
else:
condition = condition and ~active_condition
condition = condition & ~active_condition
rows = await Campaign.fetch_where(condition)
campaigns = [RewardCampaign(row) for row in rows]
rows = await Campaign.fetch_where(
condition,
)
campaigns = [RewardCampaign(row, session=self._session) for row in rows]
return campaigns
@@ -117,30 +246,35 @@ class CampaignRegistry:
"""
results = await Campaign.fetch_where(
Condition._expression_equality(
LOWER(Campaign.campaign_name.expr), LOWER(asexpr(name))
LOWER(Campaign.campaign_name), LOWER(asexpr(name))
),
campaignid=cid,
communityid=cid,
)
if results:
row = results[0]
camp = RewardCampaign(row)
camp = RewardCampaign(row, session=self._session)
else:
camp = None
return camp
async def create_campaign(
self,
cid: int,
campaign_name: str,
target_rewards: Optional[int] = None,
self, cid: int, campaign_name: str, **kwargs
) -> RewardCampaign:
"""
Create a new campaign.
The name must be unique (ignoring case) to facilitate easy lookup.
"""
row = await Campaign.create(
communityid=cid,
target_rewards=target_rewards,
campaign_name=campaign_name,
communityid=cid, campaign_name=campaign_name, **kwargs
)
return RewardCampaign(row)
return RewardCampaign(row, session=self._session)
async def update_campaign(self, campaign_id: int, **kwargs) -> RewardCampaign:
"""
Update a campaign with the given data args.
Registry handles this for caching and dispatch reasons.
"""
campaign = await Campaign.fetch(campaign_id)
await campaign.update(**kwargs)
return RewardCampaign(campaign, session=self._session)
+1 -1
View File
@@ -54,7 +54,7 @@ async def prepare_campaign(campaign: RewardCampaign) -> CampaignPayload:
class CampaignChannel(Channel):
name = "PlusCampaign"
name = "CampaignRewards"
def __init__(
self, profiler: ProfilesRegistry, campaigns: CampaignRegistry, **kwargs
+7 -1
View File
@@ -13,6 +13,9 @@ class Campaign(RowModel):
started_at = Timestamp()
completed_at = Timestamp()
moderator_role_id = Integer()
logging_webhook_url = String()
created_at = Timestamp()
_timestamp = Timestamp()
@@ -34,13 +37,16 @@ class EarnedReward(RowModel):
earned_at = Timestamp()
earned_from = String()
modnote = String()
reference = String()
log_messageid = Integer()
_timestamp = Timestamp()
class CampaignData(Registry):
VERSION = ("CAMPAIGN", 1)
VERSION = ("REWARDCAMPAIGN", 1)
Campaign = Campaign
campaigns = Campaign.table
+535 -3
View File
@@ -1,16 +1,21 @@
from typing import Optional
import asyncio
from aiohttp import client
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 import logger
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 +29,530 @@ 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),
reverse=True,
)
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, client=self.bot)
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,
)
await campaign.start()
# 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["target_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, client=self.bot)
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
await ctx.reply("Updated your campaign, good luck!", ephemeral=True)
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="delreward",
description="Remove a campaign reward",
)
@appcmds.describe(
rewardid="Earned reward to delete",
)
@appcmds.rename(rewardid="reward")
async def campaign_delreward_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, they have permission, let's delete the 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
update_args = {}
if fulfilled is not None:
if fulfilled and not currently_fluffed:
# Reward has been fluffed
update_args["fulfilled_at"] = utc_now()
elif currently_fluffed and not fulfilled:
# Reward has been unfluffed
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
if update_args:
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
await campaign.update_reward(reward.earned_id, **update_args)
await ctx.interaction.followup.send(
content="Reward updated!"
)
else:
modal = RewardEditor.from_reward(reward)
@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.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(
self, campaign: RewardCampaign, reward: EarnedReward
) -> str:
"""
Format an EarnedReward for completion.
#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")
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),
reverse=True,
)
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)
+2
View File
@@ -0,0 +1,2 @@
from .rewards import RewardList, RewardEditor
from .campaign import CampaignDashboard
+150
View File
@@ -0,0 +1,150 @@
"""
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
from utils.ui.micros import FastModal
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)
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] if blocks else "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()
+302
View File
@@ -0,0 +1,302 @@
"""
Defines widgets for displaying a campaign's rewards.
"""
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
from utils.ui.micros import FastModal
from ...campaign import RewardCampaign
from ...data import EarnedReward
from .. import logger
class RewardEditor(FastModal):
# Title is the reward we are editing
# Block of text with dates and user info
blurb = discord.ui.TextDisplay(content="placeholder")
# Fulfilled is a checkbox, and supports a fulfilled note
flufbox = discord.ui.Label(
text="Fulfilled",
description="Whether this reward has been completed",
component=discord.ui.Checkbox(default=False),
)
# Reference and further notes need to be editable,
# and need to set the default text correctly
reference = discord.ui.Label(
text="Reference",
description="Reference URL or other information",
component=discord.ui.TextInput(
style=discord.TextStyle.long,
required=False,
),
)
notes = discord.ui.Label(
text="Notes",
description="Further notes for this user/reward",
component=discord.ui.TextInput(
style=discord.TextStyle.long,
required=False,
),
)
@classmethod
def from_reward(cls, reward: EarnedReward):
title = f"Reward #{reward.earned_id} for {reward.twitch_user_name or reward.twitch_user_id}"
self = cls(title=title)
# blurb
table = {
"Earned At": discord.utils.format_dt(reward.earned_at, "F"),
"Earned From": reward.earned_from,
}
prop_table = "\n".join(tabulate(*table.items()))
self.blurb.content = prop_table
# Fulfilled
self.flufbox.component.default = reward.fulfilled_at is not None
# Notes
self.reference.component.default = reward.reference or ""
self.notes.component.default = reward.modnote or ""
return self
class RewardList(MessageUI):
blocklen = 10
def __init__(self, bot: LionBot, campaign: RewardCampaign, **kwargs):
super().__init__(**kwargs)
self.bot = bot
self.campaign = campaign
# UI state
self.pagen = 0
self.pages = []
self.reward_blocks = [[]]
self._rewards: list[EarnedReward] = []
# self._reward_viewer: Optional[RewardViewer] = None
@property
def page(self):
self.pagen %= self.page_count
return self.reward_blocks[self.pagen]
@property
def page_count(self):
return len(self.reward_blocks)
# ----- UI API -----
# ----- UI Components -----
@select(
cls=Select,
placeholder="Select reward to edit",
min_values=0,
max_values=1,
)
async def reward_menu(self, selection: discord.Interaction, selected):
"""
Select reward to view and edit.
Maybe just edit for a start?
"""
if selected.values:
# Hopefully this is a list of reminderids
values = selected.values
# Create detail widget, add to children
# Spawn editor modal for this reward, callback to refresh detail widget
# Also want to refresh this widget
# Show detail widget as followup
# TODO: Dedicated editor maybe, could e.g. embed refs. Maybe.
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.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 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()
async def reward_menu_refresh(self):
menu = self.reward_menu
rewards = self.page
if rewards:
menu.options = [self._format_reward_option(r) for r in rewards]
menu.disabled = False
else:
menu.options = [SelectOption(label="DUMMY")]
menu.disabled = True
# Meta buttons
@button(emoji=conf.emojis.getemoji("forward"))
async def next_page_button(self, press: discord.Interaction, pressed):
await press.response.defer()
self.pagen += 1
await self.refresh()
@button(emoji=conf.emojis.getemoji("backward"))
async def prev_page_button(self, press: discord.Interaction, pressed):
await press.response.defer()
self.pagen -= 1
await self.refresh()
@button(emoji=conf.emojis.cancel)
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 -----
def _format_reward_section(self, reward: EarnedReward) -> tuple[str, str]:
"""
Format the earned reward as an embed section.
"""
# Title is Reward #n earned by twitch_username
# Reward, Earned at, Earned by, Fulfilled at (not fulfilled/date), Ref, Added Notes
if reward.fulfilled_at is not None:
fat = discord.utils.format_dt(reward.fulfilled_at, "F")
if reward.fulfilled_note is not None:
fluf = f"{fat} ({reward.fulfilled_note})"
else:
fluf = fat
else:
fluf = "*Not Fulfilled*"
fluffed_emoji = "" if reward.fulfilled_at else "🔳"
earned = discord.utils.format_dt(reward.earned_at, "d")
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:
"""
Format the earned reward as a selectable option
"""
# Reward #n earned by twitch_username
# Value is the reward id
name = f"Reward #{reward.earned_id} earned by {reward.twitch_user_name or reward.twitch_user_id}"
value = reward.earned_id
return SelectOption(label=name, value=str(value))
async def refresh_layout(self):
to_refresh = (self.reward_menu_refresh(),)
await asyncio.gather(*to_refresh)
if self.page_count <= 1:
self.prev_page_button.disabled = True
self.next_page_button.disabled = True
else:
self.prev_page_button.disabled = False
self.next_page_button.disabled = False
self.set_layout(
(
self.prev_page_button,
self.refresh_button,
self.next_page_button,
self.quit_button,
),
(self.reward_menu,),
)
async def make_message(self) -> MessageArgs:
embed = discord.Embed(title=f"{self.campaign.row.campaign_name} Reward List")
embed.set_footer(text="Last Update")
embed.timestamp = utc_now()
campaign = self.campaign
all_rewards = await campaign.get_rewards()
rewards_earned = len(all_rewards)
reward_cap = campaign.row.target_rewards
if reward_cap is not None:
description = f"{rewards_earned} earned out of {reward_cap} available"
else:
description = f"{rewards_earned} earned so far"
embed.description = description
for reward in self.page:
name, value = self._format_reward_section(reward)
embed.add_field(name=name, value=value, inline=False)
return MessageArgs(embed=embed)
async def reload(self):
rewards = self._rewards = await self.campaign.get_rewards()
# TODO: Consider filter by unfulfilled or reward type
self.reward_blocks = [
rewards[i : i + self.blocklen]
for i in range(0, len(rewards), self.blocklen)
] or [[]]
+61
View File
@@ -1,5 +1,8 @@
from typing import Any
import asyncio
import discord
from psycopg import sql
from urllib.parse import urlparse, parse_qs
from data import RawExpr, Expression
@@ -14,8 +17,66 @@ def LOWER(expression: Expression) -> RawExpr:
return RawExpr(final_expr, final_values)
def asexpr(value: Any) -> RawExpr:
"""
Turn a value into an expression.
"""
return RawExpr(sql.Placeholder(), (value,))
async def fire_and_forget(awaitable, do_in=1, ignorable=(discord.HTTPException)):
await asyncio.sleep(do_in)
try:
await awaitable
except ignorable:
pass
except Exception as e:
# TODO: Log unexpected exceptions
pass
class ThreadedWebhook(discord.Webhook):
__slots__ = ("thread_id",)
def __init__(self, *args, thread_id=None, **kwargs):
super().__init__(*args, **kwargs)
self.thread_id = thread_id
@classmethod
def from_url(cls, url: str, *args, **kwargs):
self = super().from_url(url, *args, **kwargs)
parse = urlparse(url)
if parse.query:
args = parse_qs(parse.query)
if "thread_id" in args:
self.thread_id = int(args["thread_id"][0])
return self
async def send(self, *args, **kwargs):
if self.thread_id is not None:
kwargs.setdefault("thread", discord.Object(self.thread_id))
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):
embed = discord.Embed(
title="Testing", description="Testing logging webhook, feel free to delete."
)
result = await self.send(embed=embed, wait=True, silent=True)
asyncio.create_task(fire_and_forget(result.delete()))
return result
+221 -25
View File
@@ -1,11 +1,16 @@
from typing import Optional
import asyncio
import datetime as dt
from datetime import datetime, timedelta
import twitchio
from twitchio.ext import commands as cmds
from dateutil.relativedelta import relativedelta
from data.queries import JOINTYPE, ORDER
from meta import Bot
from meta.logger import log_wrap
from meta.sockets import Channel, register_channel
from utils.lib import utc_now
from . import logger
@@ -23,7 +28,7 @@ class CampaignComponent(cmds.Component):
self.campaigns = CampaignRegistry(self.data)
self.channel = CampaignChannel(self.bot.profiles.profiles, self.campaigns)
register_channel("Campaign", self.channel)
register_channel(self.channel.name, self.channel)
# ----- API -----
async def component_load(self):
@@ -41,7 +46,7 @@ class CampaignComponent(cmds.Component):
# ------ Event Handlers -----
@cmds.Component.listener()
async def event_safe_event_chat_notice_sub(self, payload):
async def event_safe_chat_notice_sub(self, payload):
"""
This is our most important notice for this event.
@@ -57,14 +62,18 @@ class CampaignComponent(cmds.Component):
# Check the tier and duration of the subscription.
# Continue if tier = 3000 and duration is at least 3 months
if (tier := detail_row["tier"]) == 3000 and (
duration := detail_row["duration_months"]
) >= 3:
if detail_row["tier"] == 3000 and detail_row["duration_months"] >= 3:
# Check if there is an ongoing campaign
campaigns = await self.campaigns.fetch_campaigns(event_row["communityid"])
pid = event_row["profileid"]
for campaign in campaigns:
reward_progress = len(await campaign.get_rewards())
rewards = await campaign.get_rewards()
reward_progress = len(rewards)
existing = any(reward.profileid == pid for reward in rewards)
if existing:
continue
if (
campaign.row.target_rewards is None
@@ -73,17 +82,80 @@ class CampaignComponent(cmds.Component):
# Add a reward to the database with the correct info.
await campaign.add_reward(
profileid=event_row["profileid"],
earned_reason=f"(SUB NOTICE): User subscribed for {duration} months at tier {tier}",
earned_from=f"(SUB NOTICE) {data.system_message}",
event_id=event_row["event_id"],
twitch_user_id=data["chatter_user_id"],
twitch_user_name=data["chatter_user_name"],
twitch_user_id=data.chatter.id,
twitch_user_name=data.chatter.name,
)
await self.dispatch_update(campaign)
# TODO: Webhook logging maybe..
# Or just general logging.
else:
logger.info(f"Campaigns ignoring sub notice event: {event_row}")
# @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()
async def event_safe_event_chat_notice_resub(self, payload):
async def event_safe_chat_notice_resub(self, payload):
# Check that the end of the sub is past the threshold by adding duration to the previous sub
# Threshold being now + 3 months, by calendar date.
# Or even if the sub ends on at least the month that is past the three month region.
@@ -92,7 +164,72 @@ class CampaignComponent(cmds.Component):
# # TODO: This logic should be done for completeness, but will postpone for now
# This could be done in subscription_message as well, but the
# notice has slightly more self-contained metadata.
...
event_row, detail_row, data = payload
# Check the tier and duration of the subscription.
# Continue if tier = 3000 and duration is at least 3 months
if detail_row["tier"] == 3000 and detail_row["duration_months"] >= 3:
# Check if there is an ongoing campaign
campaigns = await self.campaigns.fetch_campaigns(event_row["communityid"])
pid = event_row["profileid"]
# Get user's last sub date
last_sub_date = await self.get_sub_start(
channelid=event_row["channel_id"],
userid=event_row["user_id"],
tier=3000,
)
if last_sub_date is None:
logger.error(f"T3 rsub with no history: {event_row!r}")
return
forecast_end = last_sub_date + relativedelta(
months=detail_row["duration_months"]
)
if forecast_end < dt.datetime(2026, 11, 1, tzinfo=dt.UTC):
logger.warning(f"T3 sub with forecast end too short: {event_row!r}")
return
for campaign in campaigns:
rewards = await campaign.get_rewards()
reward_progress = len(rewards)
existing = any(reward.profileid == pid for reward in rewards)
if existing:
continue
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=event_row["profileid"],
earned_from=f"(RESUB NOTICE) {data.system_message}",
event_id=event_row["event_id"],
twitch_user_id=data.chatter.id,
twitch_user_name=data.chatter.name,
)
await self.dispatch_update(campaign)
else:
logger.info(f"Campaigns ignoring sub notice event: {event_row}")
async def get_sub_start(self, channelid, userid, tier=3000):
event_tracker = self.bot.get_component("TrackerComponent")
query = event_tracker.data.events.select_where(
channel_id=channelid,
user_id=userid,
tier=tier,
)
query.join("subscribe_events", using=("event_id",), join_type=JOINTYPE.INNER)
query.order_by("created_at", direction=ORDER.DESC)
query.select("created_at")
query.limit(1)
query.with_no_adapter()
rows = await query
if rows:
return rows[0]["created_at"]
else:
return None
@cmds.Component.listener()
async def event_safe_subscription(self, payload): ...
@@ -101,8 +238,7 @@ class CampaignComponent(cmds.Component):
async def event_safe_subscription_message(self, payload): ...
# ------ Commands -----
@cmds.group(name="campaign", invoke_fallback=True)
@cmds.is_moderator()
@cmds.group(name="campaign", aliases=["ppp"], invoke_fallback=True)
async def group_campaign(self, ctx: cmds.Context, name: Optional[str] = None):
"""Status of the current or named campaign."""
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
@@ -135,16 +271,24 @@ class CampaignComponent(cmds.Component):
rewards_earned = len(all_rewards)
reward_cap = campaign.row.target_rewards
if reward_cap is not None:
response = "{name}: {given} rewards earned out of {cap}!"
else:
response = "{name}: {given} rewards earned so far!"
response = (
"event details can be found here: {event_link} "
"there are {remaining} sketch slots remaining! ♡♡ "
)
# if reward_cap is not None:
# response = "{name}: {given} rewards earned out of {cap}!"
# else:
# response = "{name}: {given} rewards earned so far!"
formatted = response.format(
name=campaign.row.campaign_name,
event_link="https://lilac.thewisewolf.dev/provides/partner_plus_poster2.jpg",
remaining=reward_cap - rewards_earned,
given=rewards_earned,
cap=reward_cap,
)
await ctx.reply(formatted)
await self.dispatch_update(campaign)
@group_campaign.command(name="setup", aliases=["start"])
@cmds.is_moderator()
@@ -175,12 +319,14 @@ class CampaignComponent(cmds.Component):
await ctx.reply(
f"Your campaign '{existing_name}' is already active! "
"Sorry, we don't yet support multiple active campaigns, "
"please finish your campaign before starting a new one."
"please use '!campaign finish' to end your current campaign before starting a new one."
)
return
# Now can create
campaign = await self.campaigns.create_campaign(cid, name, rewards)
campaign = await self.campaigns.create_campaign(
cid, name, target_rewards=rewards
)
# Also start the campaign
await campaign.start()
@@ -238,16 +384,16 @@ class CampaignComponent(cmds.Component):
given=rewards_earned,
cap=reward_cap,
)
await ctx.reply(formatte
await ctx.reply(formatted)
await self.dispatch_update(campaign)
)
@group_campaign.command(name="reward")
@cmds.is_moderator()
async def cmd_campaign_reward(
self,
ctx: cmds.Context,
user: twitchio.PartialUser,
user: twitchio.User,
*,
reward_reason: str,
):
"""Manually reward a target user, adjusting their points."""
@@ -259,6 +405,8 @@ class CampaignComponent(cmds.Component):
name = user.display_name or profile.nickname or "Unknown"
pid = profile.profileid
reason = f"(Given by {ctx.author.mention}) {reward_reason}"
campaigns = await self.campaigns.fetch_campaigns(cid, active=True)
if len(campaigns) > 1:
names = ", ".join(camp.row.campaign_name for camp in campaigns)
@@ -270,7 +418,7 @@ class CampaignComponent(cmds.Component):
await campaign.add_reward(
pid,
reward_reason,
reason,
twitch_user_id=user.id,
twitch_user_name=user.name,
)
@@ -278,3 +426,51 @@ class CampaignComponent(cmds.Component):
f"Successfully added campaign reward to {user.mention}'s account."
)
await self.dispatch_update(campaign)
@group_campaign.command(name="test")
@cmds.is_moderator()
async def cmd_campaign_test(
self,
ctx: cmds.Context,
user: twitchio.User,
channel: twitchio.User,
tier: int = 3000,
):
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
cid = community.communityid
profile = await self.bot.profiles.fetch_profile(user)
name = user.display_name or profile.nickname or "Unknown"
pid = profile.profileid
campaigns = await self.campaigns.fetch_campaigns(cid)
campaign = campaigns[0]
# Get user's last sub date
last_sub_date = await self.get_sub_start(
channelid=channel.id,
userid=user.id,
tier=tier,
)
lines = []
if last_sub_date is None:
subline = f"Last {tier} sub not found"
lines.append(subline)
else:
subline = f"Last {tier} sub at {last_sub_date}"
lines.append(subline)
forecast_end = last_sub_date + relativedelta(months=3)
lines.append(f"Three months after: {forecast_end}")
if forecast_end > dt.datetime(2026, 11, 1, tzinfo=dt.UTC):
lines.append("Which is after the end of the event")
else:
lines.append("Which is not after the end of the event")
rewards = await campaign.get_rewards()
existing = any(reward.profileid == pid for reward in rewards)
if existing:
lines.append(f"This user has already been rewarded.")
else:
lines.append(f"This user might yet earn a reward")
await ctx.reply("; ".join(lines))