Compare commits

...

14 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
5 changed files with 342 additions and 63 deletions
+24 -7
View File
@@ -62,6 +62,7 @@ class RewardCampaign:
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,
@@ -71,6 +72,7 @@ class RewardCampaign:
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)
@@ -85,12 +87,28 @@ class RewardCampaign:
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
@@ -121,12 +139,13 @@ class RewardCampaign:
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"`ID: {reward.twitch_user_id or 'Unknown'}`.\n"
f"Internal ID `{reward.profileid}`"
),
inline=True,
@@ -135,10 +154,7 @@ class RewardCampaign:
# Earning field
embed.add_field(
name="Reward Earned",
value=(
f"{discord.utils.format_dt(reward.earned_at, 'F')}.\n"
f"{reward.earned_from}"
),
value=(f"{discord.utils.format_dt(reward.earned_at, 'F')}."),
inline=True,
)
@@ -154,12 +170,13 @@ class RewardCampaign:
)
# Fulfilled field
fluffed_emoji = "" if reward.fulfilled_at else "🔳"
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:
fluffed += "\n" + "Fluff Note: " + reward.fulfilled_note
else:
fluffed = "Not yet fluffed"
fluffed = f"{fluffed_emoji} Not yet fluffed"
embed.add_field(name="Fulfilled", value=fluffed)
+88 -7
View File
@@ -7,6 +7,7 @@ from discord.ext import commands as cmds
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
@@ -79,6 +80,7 @@ class CampaignCog(LionCog):
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
@@ -258,7 +260,7 @@ class CampaignCog(LionCog):
update_args["campaign_name"] = new_name
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 not None:
@@ -318,14 +320,18 @@ class CampaignCog(LionCog):
campaign_rewards_cmd.autocomplete("campaign_name")(_campaign_acmpl)
@campaign_group.command(
name="editreward",
description="Add or edit details for a given reward in a campaign (see also /campaign rewards)",
name="delreward",
description="Remove a campaign reward",
)
@appcmds.describe(
rewardid="Earned reward to edit",
rewardid="Earned reward to delete",
)
@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
# Will need to identify the reward with autocomplete. Can use the reward id directly..
if not rewardid.isdigit():
@@ -353,11 +359,84 @@ class CampaignCog(LionCog):
)
return
# Okay, this is our reward, and author has permission to modify it. Spin the modal.
modal = RewardEditor.from_reward(reward)
# 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 = {}
@@ -402,6 +481,7 @@ class CampaignCog(LionCog):
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
@@ -427,6 +507,7 @@ class CampaignCog(LionCog):
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()
+13 -6
View File
@@ -208,7 +208,6 @@ class RewardList(MessageUI):
"""
# Title is Reward #n earned by twitch_username
# 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:
fat = discord.utils.format_dt(reward.fulfilled_at, "F")
@@ -219,17 +218,25 @@ class RewardList(MessageUI):
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 At": discord.utils.format_dt(reward.earned_at, "F"),
"Earned From": reward.earned_from,
"Fulfilled At": fluf,
# "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()))
return (name, prop_table)
value = '\n'.join((
"> {reason}",
"{table}",
)).format(reason=reward.earned_from, table=prop_table)
return (name, value)
def _format_reward_option(self, reward: EarnedReward) -> SelectOption:
"""
+10
View File
@@ -63,6 +63,16 @@ class ThreadedWebhook(discord.Webhook):
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."
+187 -23
View File
@@ -1,9 +1,13 @@
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
@@ -24,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):
@@ -42,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.
@@ -58,11 +62,51 @@ 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:
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"(SUB 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}")
# @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())
@@ -73,17 +117,16 @@ class CampaignComponent(cmds.Component):
):
# Add a reward to the database with the correct info.
await campaign.add_reward(
profileid=event_row["profileid"],
earned_from=f"(SUB) User subscribed for {duration} months at tier {tier}",
event_id=event_row["event_id"],
twitch_user_id=data["chatter_user_id"],
twitch_user_name=data["chatter_user_name"],
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)
# 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()
# @cmds.Component.listener()
async def event_message(self, payload):
if not payload.text.startswith("%reward%"):
return
@@ -112,7 +155,7 @@ class CampaignComponent(cmds.Component):
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.
@@ -121,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): ...
@@ -130,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)
@@ -164,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()
@@ -261,9 +376,9 @@ class CampaignComponent(cmds.Component):
reward_cap = campaign.row.target_rewards
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:
response = f"Completed {name}: {rewards_earned} rewards earned so far!"
response = "Completed {name}: {given} rewards earned so far!"
formatted = response.format(
name=campaign.row.campaign_name,
given=rewards_earned,
@@ -278,6 +393,7 @@ class CampaignComponent(cmds.Component):
self,
ctx: cmds.Context,
user: twitchio.User,
*,
reward_reason: str,
):
"""Manually reward a target user, adjusting their points."""
@@ -310,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))