generated from HoloTech/holotech-plugin-template
Compare commits
33 Commits
de64f9166b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 08fb0084fa | |||
| 1c5eb6ceeb | |||
| 97bdf8e8e3 | |||
| 14134d8831 | |||
| 1e8bab1334 | |||
| 252a8f1aa2 | |||
| da2122d375 | |||
| 6122603115 | |||
| a69d45c1c2 | |||
| e11fb8f071 | |||
| c54653322e | |||
| 8189eeec05 | |||
| f2e8852d45 | |||
| 5eb0c1a712 | |||
| 300f712bfd | |||
| 05c428bcc9 | |||
| 87aec03e57 | |||
| f61d0519ee | |||
| 99d1193be6 | |||
| 1cc93d732e | |||
| e700628403 | |||
| 299618ab1d | |||
| a681da058f | |||
| 5873b0fe65 | |||
| d8817d4473 | |||
| a18885511a | |||
| 0923f32a82 | |||
| 8fc0ff0667 | |||
| 9203a795e3 | |||
| ee70b80120 | |||
| a1dd04685b | |||
| d7447fc300 | |||
| bc231da61f |
@@ -2,12 +2,14 @@ BEGIN;
|
|||||||
|
|
||||||
-- Version dependency checks
|
-- Version dependency checks
|
||||||
DO $$
|
DO $$
|
||||||
ASSERT current_module_version('PROFILES') = 1, 'Dependency version mismatch: PROFILES';
|
BEGIN
|
||||||
ASSERT current_module_version('EVENT_TRACKER') = 2, 'Dependency version mismatch: EVENT_TRACKER';
|
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;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
-- Plugin version history
|
-- 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(
|
CREATE TABLE campaigns(
|
||||||
@@ -15,6 +17,8 @@ CREATE TABLE campaigns(
|
|||||||
communityid INTEGER NOT NULL REFERENCES communities(communityid) ON DELETE CASCADE ON UPDATE CASCADE,
|
communityid INTEGER NOT NULL REFERENCES communities(communityid) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
target_rewards INTEGER,
|
target_rewards INTEGER,
|
||||||
campaign_name TEXT NOT NULL,
|
campaign_name TEXT NOT NULL,
|
||||||
|
moderator_role_id BIGINT,
|
||||||
|
logging_webhook_url TEXT,
|
||||||
started_at TIMESTAMPTZ,
|
started_at TIMESTAMPTZ,
|
||||||
completed_at TIMESTAMPTZ,
|
completed_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
@@ -33,8 +37,10 @@ CREATE TABLE campaign_rewards_earned(
|
|||||||
fulfilled_at TIMESTAMPTZ,
|
fulfilled_at TIMESTAMPTZ,
|
||||||
fulfilled_note TEXT,
|
fulfilled_note TEXT,
|
||||||
modnote TEXT,
|
modnote TEXT,
|
||||||
|
reference TEXT,
|
||||||
|
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()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+157
-23
@@ -2,26 +2,45 @@ from typing import Any, Optional
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
from data import ORDER, Condition
|
from data import ORDER, Condition
|
||||||
from data.conditions import NULL, condition
|
from data.conditions import NULL, condition
|
||||||
from utils.lib import utc_now
|
from utils.lib import utc_now
|
||||||
|
|
||||||
from .lib import LOWER, asexpr
|
from .lib import LOWER, asexpr, ThreadedWebhook
|
||||||
from .data import (
|
from .data import (
|
||||||
CampaignData,
|
CampaignData,
|
||||||
Campaign,
|
Campaign,
|
||||||
EarnedReward,
|
EarnedReward,
|
||||||
)
|
)
|
||||||
|
from . import logger
|
||||||
|
|
||||||
|
|
||||||
class RewardCampaign:
|
class RewardCampaign:
|
||||||
def __init__(self, row: Campaign):
|
def __init__(self, row: Campaign, session: aiohttp.ClientSession | None = None):
|
||||||
self.row = row
|
self.row = row
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
self._webhook: ThreadedWebhook | None = None
|
||||||
|
self._cached_webhookurl: str | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_active(self):
|
def is_active(self):
|
||||||
return self.row.started_at is not None and self.row.completed_at is None
|
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):
|
async def start(self):
|
||||||
"""Start the reward campaign."""
|
"""Start the reward campaign."""
|
||||||
if self.row.started_at is not None:
|
if self.row.started_at is not None:
|
||||||
@@ -38,36 +57,144 @@ 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)
|
||||||
|
|
||||||
return 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]:
|
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):
|
||||||
|
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:
|
class CampaignRegistry:
|
||||||
VERSION = CampaignData.VERSION
|
VERSION = CampaignData.VERSION
|
||||||
|
|
||||||
def __init__(self, data: CampaignData):
|
def __init__(
|
||||||
|
self, data: CampaignData, session: aiohttp.ClientSession | None = None
|
||||||
|
):
|
||||||
self.data = data
|
self.data = data
|
||||||
|
# TODO: Actually pass in a session
|
||||||
|
self._session = aiohttp.ClientSession()
|
||||||
|
|
||||||
async def init(self):
|
async def init(self):
|
||||||
await self.data.init()
|
await self.data.init()
|
||||||
@@ -79,7 +206,7 @@ class CampaignRegistry:
|
|||||||
row = await Campaign.fetch(campaign_id)
|
row = await Campaign.fetch(campaign_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ValueError("Campign %s doesn't exist." % campaign_id)
|
raise ValueError("Campign %s doesn't exist." % campaign_id)
|
||||||
camp = RewardCampaign(row)
|
camp = RewardCampaign(row, session=self._session)
|
||||||
return camp
|
return camp
|
||||||
|
|
||||||
async def fetch_campaigns(
|
async def fetch_campaigns(
|
||||||
@@ -94,16 +221,18 @@ 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(
|
||||||
campaigns = [RewardCampaign(row) for row in rows]
|
condition,
|
||||||
|
)
|
||||||
|
campaigns = [RewardCampaign(row, session=self._session) for row in rows]
|
||||||
|
|
||||||
return campaigns
|
return campaigns
|
||||||
|
|
||||||
@@ -117,30 +246,35 @@ class CampaignRegistry:
|
|||||||
"""
|
"""
|
||||||
results = await Campaign.fetch_where(
|
results = await Campaign.fetch_where(
|
||||||
Condition._expression_equality(
|
Condition._expression_equality(
|
||||||
LOWER(Campaign.campaign_name.expr), LOWER(asexpr(name))
|
LOWER(Campaign.campaign_name), LOWER(asexpr(name))
|
||||||
),
|
),
|
||||||
campaignid=cid,
|
communityid=cid,
|
||||||
)
|
)
|
||||||
if results:
|
if results:
|
||||||
row = results[0]
|
row = results[0]
|
||||||
camp = RewardCampaign(row)
|
camp = RewardCampaign(row, session=self._session)
|
||||||
else:
|
else:
|
||||||
camp = None
|
camp = None
|
||||||
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,
|
|
||||||
target_rewards: Optional[int] = None,
|
|
||||||
) -> 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
|
||||||
target_rewards=target_rewards,
|
|
||||||
campaign_name=campaign_name,
|
|
||||||
)
|
)
|
||||||
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
@@ -54,7 +54,7 @@ async def prepare_campaign(campaign: RewardCampaign) -> CampaignPayload:
|
|||||||
|
|
||||||
|
|
||||||
class CampaignChannel(Channel):
|
class CampaignChannel(Channel):
|
||||||
name = "PlusCampaign"
|
name = "CampaignRewards"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, profiler: ProfilesRegistry, campaigns: CampaignRegistry, **kwargs
|
self, profiler: ProfilesRegistry, campaigns: CampaignRegistry, **kwargs
|
||||||
|
|||||||
+7
-1
@@ -13,6 +13,9 @@ class Campaign(RowModel):
|
|||||||
started_at = Timestamp()
|
started_at = Timestamp()
|
||||||
completed_at = Timestamp()
|
completed_at = Timestamp()
|
||||||
|
|
||||||
|
moderator_role_id = Integer()
|
||||||
|
logging_webhook_url = String()
|
||||||
|
|
||||||
created_at = Timestamp()
|
created_at = Timestamp()
|
||||||
_timestamp = Timestamp()
|
_timestamp = Timestamp()
|
||||||
|
|
||||||
@@ -34,13 +37,16 @@ class EarnedReward(RowModel):
|
|||||||
earned_at = Timestamp()
|
earned_at = Timestamp()
|
||||||
earned_from = String()
|
earned_from = String()
|
||||||
modnote = String()
|
modnote = String()
|
||||||
|
reference = String()
|
||||||
|
|
||||||
|
log_messageid = Integer()
|
||||||
|
|
||||||
_timestamp = Timestamp()
|
_timestamp = Timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class CampaignData(Registry):
|
class CampaignData(Registry):
|
||||||
VERSION = ("CAMPAIGN", 1)
|
VERSION = ("REWARDCAMPAIGN", 1)
|
||||||
|
|
||||||
Campaign = Campaign
|
Campaign = Campaign
|
||||||
campaigns = Campaign.table
|
campaigns = Campaign.table
|
||||||
|
|||||||
+535
-3
@@ -1,16 +1,21 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
from aiohttp import client
|
||||||
import discord
|
import discord
|
||||||
from discord.ext import commands as cmds
|
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 LionBot, LionCog, LionContext
|
||||||
|
from meta import logger
|
||||||
|
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
|
||||||
|
|
||||||
from ..data import CampaignData
|
from ..data import CampaignData, EarnedReward
|
||||||
from ..campaigns import CampaignRegistry
|
from ..campaign import CampaignRegistry, RewardCampaign
|
||||||
|
from ..lib import ThreadedWebhook
|
||||||
|
from .ui import RewardList, RewardEditor, CampaignDashboard
|
||||||
|
|
||||||
|
|
||||||
class CampaignCog(LionCog):
|
class CampaignCog(LionCog):
|
||||||
@@ -24,3 +29,530 @@ class CampaignCog(LionCog):
|
|||||||
await self.data.init()
|
await self.data.init()
|
||||||
await self.bot.version_check(*self.data.VERSION)
|
await self.bot.version_check(*self.data.VERSION)
|
||||||
await self.campaigns.init()
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
from .rewards import RewardList, RewardEditor
|
||||||
|
from .campaign import CampaignDashboard
|
||||||
@@ -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()
|
||||||
@@ -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 [[]]
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
import asyncio
|
||||||
|
import discord
|
||||||
from psycopg import sql
|
from psycopg import sql
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
from data import RawExpr, Expression
|
from data import RawExpr, Expression
|
||||||
|
|
||||||
@@ -14,8 +17,66 @@ 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)):
|
||||||
|
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
@@ -1,11 +1,16 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import datetime as dt
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import twitchio
|
import twitchio
|
||||||
from twitchio.ext import commands as cmds
|
from twitchio.ext import commands as cmds
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
from data.queries import JOINTYPE, ORDER
|
||||||
from meta import Bot
|
from meta import Bot
|
||||||
from meta.logger import log_wrap
|
from meta.logger import log_wrap
|
||||||
|
from meta.sockets import Channel, register_channel
|
||||||
from utils.lib import utc_now
|
from utils.lib import utc_now
|
||||||
|
|
||||||
from . import logger
|
from . import logger
|
||||||
@@ -23,7 +28,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):
|
||||||
@@ -41,7 +46,7 @@ class CampaignComponent(cmds.Component):
|
|||||||
|
|
||||||
# ------ Event Handlers -----
|
# ------ Event Handlers -----
|
||||||
@cmds.Component.listener()
|
@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.
|
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.
|
# Check the tier and duration of the subscription.
|
||||||
# Continue if tier = 3000 and duration is at least 3 months
|
# Continue if tier = 3000 and duration is at least 3 months
|
||||||
if (tier := detail_row["tier"]) == 3000 and (
|
if detail_row["tier"] == 3000 and detail_row["duration_months"] >= 3:
|
||||||
duration := detail_row["duration_months"]
|
|
||||||
) >= 3:
|
|
||||||
# Check if there is an ongoing campaign
|
# Check if there is an ongoing campaign
|
||||||
campaigns = await self.campaigns.fetch_campaigns(event_row["communityid"])
|
campaigns = await self.campaigns.fetch_campaigns(event_row["communityid"])
|
||||||
|
pid = event_row["profileid"]
|
||||||
|
|
||||||
for campaign in campaigns:
|
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 (
|
if (
|
||||||
campaign.row.target_rewards is None
|
campaign.row.target_rewards is None
|
||||||
@@ -73,17 +82,80 @@ 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 NOTICE): User subscribed for {duration} months at tier {tier}",
|
earned_from=f"(SUB NOTICE) {data.system_message}",
|
||||||
event_id=event_row["event_id"],
|
event_id=event_row["event_id"],
|
||||||
twitch_user_id=data["chatter_user_id"],
|
twitch_user_id=data.chatter.id,
|
||||||
twitch_user_name=data["chatter_user_name"],
|
twitch_user_name=data.chatter.name,
|
||||||
)
|
)
|
||||||
await self.dispatch_update(campaign)
|
await self.dispatch_update(campaign)
|
||||||
# TODO: Webhook logging maybe..
|
else:
|
||||||
# Or just general logging.
|
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()
|
@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
|
# 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.
|
# 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.
|
# 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
|
# # TODO: This logic should be done for completeness, but will postpone for now
|
||||||
# This could be done in subscription_message as well, but the
|
# This could be done in subscription_message as well, but the
|
||||||
# notice has slightly more self-contained metadata.
|
# 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()
|
@cmds.Component.listener()
|
||||||
async def event_safe_subscription(self, payload): ...
|
async def event_safe_subscription(self, payload): ...
|
||||||
@@ -101,8 +238,7 @@ class CampaignComponent(cmds.Component):
|
|||||||
async def event_safe_subscription_message(self, payload): ...
|
async def event_safe_subscription_message(self, payload): ...
|
||||||
|
|
||||||
# ------ Commands -----
|
# ------ Commands -----
|
||||||
@cmds.group(name="campaign", invoke_fallback=True)
|
@cmds.group(name="campaign", aliases=["ppp"], 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)
|
||||||
@@ -135,16 +271,24 @@ class CampaignComponent(cmds.Component):
|
|||||||
rewards_earned = len(all_rewards)
|
rewards_earned = len(all_rewards)
|
||||||
reward_cap = campaign.row.target_rewards
|
reward_cap = campaign.row.target_rewards
|
||||||
|
|
||||||
if reward_cap is not None:
|
response = (
|
||||||
response = "{name}: {given} rewards earned out of {cap}!"
|
"event details can be found here: {event_link} "
|
||||||
else:
|
"there are {remaining} sketch slots remaining! ♡♡ "
|
||||||
response = "{name}: {given} rewards earned so far!"
|
)
|
||||||
|
|
||||||
|
# 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(
|
formatted = response.format(
|
||||||
name=campaign.row.campaign_name,
|
name=campaign.row.campaign_name,
|
||||||
|
event_link="https://lilac.thewisewolf.dev/provides/partner_plus_poster2.jpg",
|
||||||
|
remaining=reward_cap - rewards_earned,
|
||||||
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()
|
||||||
@@ -175,12 +319,14 @@ class CampaignComponent(cmds.Component):
|
|||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
f"Your campaign '{existing_name}' is already active! "
|
f"Your campaign '{existing_name}' is already active! "
|
||||||
"Sorry, we don't yet support multiple active campaigns, "
|
"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
|
return
|
||||||
|
|
||||||
# Now can create
|
# 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
|
# Also start the campaign
|
||||||
await campaign.start()
|
await campaign.start()
|
||||||
@@ -238,16 +384,16 @@ class CampaignComponent(cmds.Component):
|
|||||||
given=rewards_earned,
|
given=rewards_earned,
|
||||||
cap=reward_cap,
|
cap=reward_cap,
|
||||||
)
|
)
|
||||||
await ctx.reply(formatte
|
await ctx.reply(formatted)
|
||||||
await self.dispatch_update(campaign)
|
await self.dispatch_update(campaign)
|
||||||
)
|
|
||||||
|
|
||||||
@group_campaign.command(name="reward")
|
@group_campaign.command(name="reward")
|
||||||
@cmds.is_moderator()
|
@cmds.is_moderator()
|
||||||
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."""
|
||||||
@@ -259,6 +405,8 @@ class CampaignComponent(cmds.Component):
|
|||||||
name = user.display_name or profile.nickname or "Unknown"
|
name = user.display_name or profile.nickname or "Unknown"
|
||||||
pid = profile.profileid
|
pid = profile.profileid
|
||||||
|
|
||||||
|
reason = f"(Given by {ctx.author.mention}) {reward_reason}"
|
||||||
|
|
||||||
campaigns = await self.campaigns.fetch_campaigns(cid, active=True)
|
campaigns = await self.campaigns.fetch_campaigns(cid, active=True)
|
||||||
if len(campaigns) > 1:
|
if len(campaigns) > 1:
|
||||||
names = ", ".join(camp.row.campaign_name for camp in campaigns)
|
names = ", ".join(camp.row.campaign_name for camp in campaigns)
|
||||||
@@ -270,7 +418,7 @@ class CampaignComponent(cmds.Component):
|
|||||||
|
|
||||||
await campaign.add_reward(
|
await campaign.add_reward(
|
||||||
pid,
|
pid,
|
||||||
reward_reason,
|
reason,
|
||||||
twitch_user_id=user.id,
|
twitch_user_id=user.id,
|
||||||
twitch_user_name=user.name,
|
twitch_user_name=user.name,
|
||||||
)
|
)
|
||||||
@@ -278,3 +426,51 @@ class CampaignComponent(cmds.Component):
|
|||||||
f"Successfully added campaign reward to {user.mention}'s account."
|
f"Successfully added campaign reward to {user.mention}'s account."
|
||||||
)
|
)
|
||||||
await self.dispatch_update(campaign)
|
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))
|
||||||
|
|||||||
Reference in New Issue
Block a user