Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions database/mongo.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,49 @@ async def update_user_balance(user_id: str, amount: int):
)


async def increment_user_balance(user_id: str, amount: int):
"""Atomically increments a user's balance using $inc."""
if db is None:
return
await db.users.update_one(
{"_id": str(user_id)}, {"$inc": {"balance": amount}}, upsert=True
)


# --- POLL REWARD HELPERS ---


async def is_poll_reward_processed(message_id: str) -> bool:
"""Checks if a poll reward has already been distributed for this message."""
if db is None:
return False
doc = await db.processed_poll_rewards.find_one({"_id": str(message_id)})
return doc is not None


async def mark_poll_reward_processed(
message_id: str,
admin_id: str,
answer_text: str,
amount: int,
voter_count: int,
):
"""Records that poll rewards were distributed for this message."""
if db is None:
return
await db.processed_poll_rewards.insert_one(
{
"_id": str(message_id),
"admin_id": str(admin_id),
"answer_text": answer_text,
"amount_per_user": amount,
"voter_count": voter_count,
"total_distributed": amount * voter_count,
"processed_at": datetime.utcnow(),
}
)


# --- LEVELING HELPERS ---


Expand Down
2 changes: 2 additions & 0 deletions features/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
GREEN_EVENT_CHANNEL_ID = 1419791220053643384
EVENT_STAFF_CHANNEL_ID = 811497227531845642
EVENT_ANNOUNCEMENTS_CHANNEL_ID = 863649858120450088
POLLS_CHANNEL_ID = 753118356743389194

EVENT_STAFF_ROLE_ID = 811495204346789938

Expand Down Expand Up @@ -232,6 +233,7 @@
GREEN_EVENT_CHANNEL_ID = 1450350503467815034
EVENT_STAFF_CHANNEL_ID = 1450350534279036979
EVENT_ANNOUNCEMENTS_CHANNEL_ID = 1450923458342162677
POLLS_CHANNEL_ID = 1510921417036927098

EVENT_STAFF_ROLE_ID = 1450350588209533019

Expand Down
228 changes: 226 additions & 2 deletions features/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
from datetime import datetime, time
import zoneinfo
import re
from database.mongo import get_user_balance, update_user_balance
from database.mongo import (
get_user_balance,
increment_user_balance,
is_poll_reward_processed,
mark_poll_reward_processed,
update_user_balance,
)

from features.config import (
ADMIN_ROLE_ID,
Expand All @@ -13,6 +19,7 @@
BLUE_EVENT_CHANNEL_ID,
GREEN_EVENT_CHANNEL_ID,
EVENT_STAFF_CHANNEL_ID,
POLLS_CHANNEL_ID,
EVENT_ANNOUNCEMENTS_CHANNEL_ID,
BOT_VERSION,
)
Expand Down Expand Up @@ -195,6 +202,97 @@ async def cancel(self, interaction: discord.Interaction, button: discord.ui.Butt
await interaction.response.edit_message(embed=embed, view=self)


class PollPayoutConfirmView(discord.ui.View):
def __init__(self, original_msg, voters, amount, answer_text, interaction_user):
super().__init__(timeout=60)
self.original_msg = original_msg
self.voters = voters
self.amount = amount
self.answer_text = answer_text
self.interaction_user = interaction_user
self.processed = False

async def interaction_check(self, interaction: discord.Interaction) -> bool:
if interaction.user.id != self.interaction_user.id:
await interaction.response.send_message(
"❌ This is not your command.", ephemeral=True
)
return False
return True

@discord.ui.button(
label="Confirm Payout", style=discord.ButtonStyle.green, emoji="✅"
)
async def confirm(
self, interaction: discord.Interaction, button: discord.ui.Button
):
if self.processed:
return
self.processed = True
await interaction.response.defer()

# Double-check idempotency before distributing
if await is_poll_reward_processed(str(self.original_msg.id)):
embed = interaction.message.embeds[0]
embed.title = "❌ Already Processed"
embed.color = discord.Color.red()
embed.description = "This poll has already been processed."
embed.clear_fields()
for child in self.children:
child.disabled = True
await interaction.edit_original_response(embed=embed, view=self)
return

# Distribute tokens atomically
for voter in self.voters:
await increment_user_balance(str(voter.id), self.amount)

# Record in DB
await mark_poll_reward_processed(
message_id=str(self.original_msg.id),
admin_id=str(interaction.user.id),
answer_text=self.answer_text,
amount=self.amount,
voter_count=len(self.voters),
)

# Mark original poll message with checkmark
try:
await self.original_msg.add_reaction("✅")
except Exception:
pass

# Update confirmation embed
total = self.amount * len(self.voters)
embed = interaction.message.embeds[0]
embed.title = "✅ Poll Rewards Complete"
embed.color = discord.Color.green()
embed.clear_fields()
embed.add_field(
name="Summary",
value=(
f"Distributed **{total}** tokens to **{len(self.voters)}** voters.\n"
f"(**{self.amount}** tokens each for option: *{self.answer_text}*)"
),
inline=False,
)
for child in self.children:
child.disabled = True
await interaction.edit_original_response(embed=embed, view=self)

@discord.ui.button(label="Cancel", style=discord.ButtonStyle.red, emoji="✖️")
async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
self.processed = True
embed = interaction.message.embeds[0]
embed.title = "❌ Poll Rewards Cancelled"
embed.color = discord.Color.red()
embed.description = "No tokens were distributed."
embed.clear_fields()
for child in self.children:
child.disabled = True
await interaction.response.edit_message(embed=embed, view=self)


class Events(commands.Cog):
def __init__(self, bot):
self.bot = bot
Expand Down Expand Up @@ -405,6 +503,130 @@ async def event_rewards(self, interaction: discord.Interaction, message_id: str)
view = PayoutConfirmView(target_message, matches, interaction.user)
await interaction.followup.send(embed=embed, view=view)

@app_commands.command(
name="poll-rewards",
description="ADMIN ONLY: Distribute tokens to voters of a poll option.",
)
@app_commands.describe(
message_id="The ID of the poll message in #polls",
answer="The poll option text to reward (case-insensitive match)",
amount="Flat token amount to give each voter",
)
async def poll_rewards(
self,
interaction: discord.Interaction,
message_id: str,
answer: str,
amount: int,
):
# 1. Permission Check - Admin only
if not interaction.user.get_role(ADMIN_ROLE_ID):
await interaction.response.send_message(
"❌ Permission Denied: Only Admins can process poll rewards.",
ephemeral=True,
)
return

# 2. Channel Check
if interaction.channel_id != EVENT_STAFF_CHANNEL_ID:
await interaction.response.send_message(
f"❌ Please use this command in <#{EVENT_STAFF_CHANNEL_ID}>.",
ephemeral=True,
)
return

# 3. Validate amount
if amount <= 0:
await interaction.response.send_message(
"❌ Amount must be a positive number.", ephemeral=True
)
return

await interaction.response.defer()

# 4. Check idempotency (DB-backed)
if await is_poll_reward_processed(message_id):
await interaction.followup.send(
"⚠️ This poll message has already been processed for rewards."
)
return

# 5. Fetch message from polls channel
polls_channel = self.bot.get_channel(POLLS_CHANNEL_ID)
if not polls_channel:
await interaction.followup.send("❌ Config Error: Polls channel not found.")
return

try:
target_message = await polls_channel.fetch_message(int(message_id))
except Exception:
await interaction.followup.send(
"❌ Could not find that message ID in the polls channel."
)
return

# 6. Validate poll exists
if not target_message.poll:
await interaction.followup.send("❌ That message does not contain a poll.")
return

poll = target_message.poll

# 7. Check poll is finalized
if not poll.is_finalized():
await interaction.followup.send(
"⚠️ This poll has not ended yet. Please wait for it to close."
)
return

# 8. Match answer text (case-insensitive)
matched_answer = None
for poll_answer in poll.answers:
if poll_answer.text and poll_answer.text.lower() == answer.lower():
matched_answer = poll_answer
break

if not matched_answer:
available = ", ".join(f'"{a.text}"' for a in poll.answers if a.text)
await interaction.followup.send(
f'❌ No poll option matches "{answer}".\nAvailable options: {available}'
)
return

# 9. Fetch all voters via async iterator
voters = []
async for voter in matched_answer.voters(limit=None):
voters.append(voter)

if not voters:
await interaction.followup.send(
f'⚠️ No voters found for option "{matched_answer.text}".'
)
return

# 10. Build confirmation embed
total = amount * len(voters)
embed = discord.Embed(
title="⚠️ Confirm Poll Rewards?",
description=(
f"**Poll Option:** {matched_answer.text}\n"
f"**Voters:** {len(voters)}\n"
f"**Amount Per Voter:** {amount} tokens\n"
f"**Total Payout:** {total} tokens"
),
color=discord.Color.orange(),
)

voter_preview = ", ".join(f"<@{v.id}>" for v in voters[:20])
if len(voters) > 20:
voter_preview += f"\n... and {len(voters) - 20} more"
embed.add_field(name="Voters", value=voter_preview, inline=False)

view = PollPayoutConfirmView(
target_message, voters, amount, matched_answer.text, interaction.user
)
await interaction.followup.send(embed=embed, view=view)

@app_commands.command(
name="event-staff-help",
description="STAFF ONLY: Guide for managing event channels.",
Expand Down Expand Up @@ -436,7 +658,9 @@ async def event_staff_help(self, interaction: discord.Interaction):
# --- Reward Distribution ---
reward_text = (
"`/event-rewards <message_id>` - Process token distribution from an announcement message.\n"
"*(Message must use `@User 500` format. Admin only.)*"
"*(Message must use `@User 500` format. Admin only.)*\n\n"
"`/poll-rewards <message_id> <answer> <amount>` - Distribute tokens to all voters of a poll option.\n"
"*(Poll must be finalized. Case-insensitive option match. Admin only.)*"
)
embed.add_field(name="🏆 Reward Distribution", value=reward_text, inline=False)

Expand Down
Loading