Stock-Tool/discord_bot/bot.py

534 lines
23 KiB
Python

#!/usr/bin/env python3
"""
Discord Sales & Support Bot — Ultimate Investment Tool
=======================================================
Features:
/setup → (Admin only) Posts the persistent sales embed with Purchase &
Support buttons into the current channel. Run this once in your
read-only sales channel.
Purchase button → Stripe Checkout (weekly $10 / monthly $25 / lifetime $150)
License key auto-generated + DM'd on successful payment
Role assigned automatically in the server
Support button → Modal prompts for a subject, then opens a private ticket
channel with a Close button and audit log
Setup: copy .env.example → .env and fill in all values before running.
"""
import asyncio
import datetime
import os
import uuid
import aiohttp
from aiohttp import web
import discord
from discord import app_commands
from discord.ext import commands
import requests
import stripe
from dotenv import load_dotenv
load_dotenv()
# ─────────────────────────────────────────────────────────────────────────────
# Configuration (all values from .env)
# ─────────────────────────────────────────────────────────────────────────────
DISCORD_TOKEN = os.getenv("DISCORD_TOKEN")
GUILD_ID = int(os.getenv("GUILD_ID", "0"))
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY")
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
SUPABASE_URL = os.getenv("SUPABASE_URL")
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
# Discord IDs — right-click channel/role → Copy ID (needs Developer Mode enabled)
TICKET_CATEGORY_ID = int(os.getenv("TICKET_CATEGORY_ID", "0"))
TICKET_LOG_CHANNEL_ID = int(os.getenv("TICKET_LOG_CHANNEL_ID", "0"))
ROLE_USER_ID = int(os.getenv("ROLE_USER_ID", "0"))
ROLE_LIFETIME_ID = int(os.getenv("ROLE_LIFETIME_ID", "0"))
WEBHOOK_PORT = int(os.getenv("WEBHOOK_PORT", "8080"))
# Pricing tiers
PRICES = {
"weekly": {"amount": 1000, "label": "$10 / week", "days": 7},
"monthly": {"amount": 2500, "label": "$25 / month", "days": 30},
"lifetime": {"amount": 15000, "label": "$150 lifetime", "days": None},
}
stripe.api_key = STRIPE_SECRET_KEY
_SUPABASE_HEADERS = {
"apikey": SUPABASE_SERVICE_KEY,
"Authorization": f"Bearer {SUPABASE_SERVICE_KEY}",
"Content-Type": "application/json",
"Prefer": "return=representation",
}
# ─────────────────────────────────────────────────────────────────────────────
# License helpers
# ─────────────────────────────────────────────────────────────────────────────
def _generate_key() -> str:
parts = [uuid.uuid4().hex[:8].upper() for _ in range(3)]
return "UIT-" + "-".join(parts)
def issue_license(tier: str, notes: str = "") -> str:
"""Create a new license in Supabase and return the key."""
days = PRICES[tier]["days"]
expiry = None
if days is not None:
expiry = (datetime.datetime.utcnow() + datetime.timedelta(days=days)).isoformat() + "Z"
key = _generate_key()
payload = {
"license_key": key,
"tier": tier,
"expiry_date": expiry,
"active": True,
"machines_allowed": 1,
"notes": notes,
}
resp = requests.post(
f"{SUPABASE_URL}/rest/v1/licenses",
headers=_SUPABASE_HEADERS,
json=payload,
timeout=10,
)
resp.raise_for_status()
return key
# ─────────────────────────────────────────────────────────────────────────────
# Stripe helpers
# ─────────────────────────────────────────────────────────────────────────────
def create_checkout_session(tier: str, discord_user_id: str, discord_username: str) -> str:
"""Create a Stripe Checkout Session and return its URL."""
price_info = PRICES[tier]
session = stripe.checkout.Session.create(
payment_method_types=["card"],
line_items=[{
"price_data": {
"currency": "usd",
"unit_amount": price_info["amount"],
"product_data": {
"name": f"Ultimate Investment Tool — {price_info['label']}",
},
},
"quantity": 1,
}],
mode="payment",
success_url=os.getenv("STRIPE_SUCCESS_URL", "https://discord.com/channels/@me"),
cancel_url=os.getenv("STRIPE_CANCEL_URL", "https://discord.com/channels/@me"),
metadata={
"discord_user_id": discord_user_id,
"discord_username": discord_username,
"tier": tier,
},
)
return session.url
# ─────────────────────────────────────────────────────────────────────────────
# Bot setup
# ─────────────────────────────────────────────────────────────────────────────
intents = discord.Intents.default()
intents.members = True
bot = commands.Bot(command_prefix="!", intents=intents)
tree = bot.tree
# ─────────────────────────────────────────────────────────────────────────────
# Ticket creation helper (shared by button and any future commands)
# ─────────────────────────────────────────────────────────────────────────────
async def create_ticket(interaction: discord.Interaction, subject: str):
guild = interaction.guild
category = guild.get_channel(TICKET_CATEGORY_ID)
overwrites = {
guild.default_role: discord.PermissionOverwrite(view_channel=False),
interaction.user: discord.PermissionOverwrite(
view_channel=True,
send_messages=True,
read_message_history=True,
),
}
for role in guild.roles:
if role.permissions.administrator or role.permissions.manage_channels:
overwrites[role] = discord.PermissionOverwrite(
view_channel=True,
send_messages=True,
read_message_history=True,
manage_channels=True,
)
safe_name = "".join(c for c in interaction.user.name if c.isalnum() or c in "-_")[:20]
channel_name = f"ticket-{safe_name}"
if category:
existing = discord.utils.get(category.text_channels, name=channel_name)
if existing:
await interaction.followup.send(
f"You already have an open ticket: {existing.mention}\n"
"Please use that channel or close it before opening a new one.",
ephemeral=True,
)
return
channel = await guild.create_text_channel(
name=channel_name,
category=category,
overwrites=overwrites,
topic=f"Ticket by {interaction.user} ({interaction.user.id}) | {subject}",
)
embed = discord.Embed(
title=f"Support Ticket — {subject}",
description=(
f"Welcome {interaction.user.mention}!\n\n"
"Please describe your issue in as much detail as possible.\n"
"A staff member will be with you shortly.\n\n"
"Press **🔒 Close Ticket** when your issue is resolved."
),
color=discord.Color.blurple(),
timestamp=discord.utils.utcnow(),
)
embed.set_footer(text=str(interaction.user), icon_url=interaction.user.display_avatar.url)
await channel.send(embed=embed, view=TicketCloseView())
log_channel = guild.get_channel(TICKET_LOG_CHANNEL_ID)
if log_channel:
log_embed = discord.Embed(
title="Ticket Opened",
description=(
f"**User:** {interaction.user.mention} (`{interaction.user.id}`)\n"
f"**Channel:** {channel.mention}\n"
f"**Subject:** {subject}"
),
color=discord.Color.green(),
timestamp=discord.utils.utcnow(),
)
await log_channel.send(embed=log_embed)
await interaction.followup.send(
f"Your ticket has been created: {channel.mention}", ephemeral=True
)
# ─────────────────────────────────────────────────────────────────────────────
# Persistent Views (survive bot restarts)
# ─────────────────────────────────────────────────────────────────────────────
class TicketCloseView(discord.ui.View):
"""Close button that lives permanently in ticket channels."""
def __init__(self):
super().__init__(timeout=None)
@discord.ui.button(
label="🔒 Close Ticket",
style=discord.ButtonStyle.danger,
custom_id="ticket_close",
)
async def close_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
channel = interaction.channel
guild = interaction.guild
await interaction.response.send_message(
f"Ticket closed by {interaction.user.mention}. Channel will be deleted in 5 seconds.",
ephemeral=False,
)
log_channel = guild.get_channel(TICKET_LOG_CHANNEL_ID)
if log_channel:
embed = discord.Embed(
title="Ticket Closed",
description=(
f"**Channel:** {channel.name}\n"
f"**Closed by:** {interaction.user.mention}\n"
f"**Topic:** {channel.topic or 'N/A'}"
),
color=discord.Color.red(),
timestamp=discord.utils.utcnow(),
)
await log_channel.send(embed=embed)
await asyncio.sleep(5)
await channel.delete(reason=f"Ticket closed by {interaction.user}")
class TierSelectView(discord.ui.View):
"""Three buttons for selecting a pricing tier — shown ephemerally after clicking Purchase."""
def __init__(self):
super().__init__(timeout=120)
async def _handle(self, interaction: discord.Interaction, tier: str):
await interaction.response.defer(ephemeral=True, thinking=True)
try:
url = create_checkout_session(
tier,
str(interaction.user.id),
str(interaction.user),
)
price_label = PRICES[tier]["label"]
embed = discord.Embed(
title="Complete Your Purchase",
description=(
f"You selected the **{price_label}** plan.\n\n"
f"[**→ Pay securely via Stripe**]({url})\n\n"
"Your license key will be **DM'd to you instantly** after payment.\n"
"The checkout link is valid for **24 hours**."
),
color=discord.Color.green(),
)
embed.set_footer(text="Powered by Stripe — we never store your card details.")
await interaction.followup.send(embed=embed, ephemeral=True)
except Exception as exc:
await interaction.followup.send(
f"❌ Could not create a checkout session. Please try again later.\n`{exc}`",
ephemeral=True,
)
@discord.ui.button(label="$10 / Week", style=discord.ButtonStyle.primary, custom_id="buy_weekly")
async def weekly(self, interaction: discord.Interaction, button: discord.ui.Button):
await self._handle(interaction, "weekly")
@discord.ui.button(label="$25 / Month", style=discord.ButtonStyle.primary, custom_id="buy_monthly")
async def monthly(self, interaction: discord.Interaction, button: discord.ui.Button):
await self._handle(interaction, "monthly")
@discord.ui.button(label="$150 Lifetime", style=discord.ButtonStyle.success, custom_id="buy_lifetime")
async def lifetime(self, interaction: discord.Interaction, button: discord.ui.Button):
await self._handle(interaction, "lifetime")
class TicketModal(discord.ui.Modal, title="Open a Support Ticket"):
"""Modal that collects a subject before creating the ticket channel."""
subject = discord.ui.TextInput(
label="Subject",
placeholder="Brief description of your issue",
max_length=100,
required=True,
)
async def on_submit(self, interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True, thinking=True)
await create_ticket(interaction, self.subject.value)
class SalesView(discord.ui.View):
"""
Persistent view with Purchase and Support buttons.
Posted once by /setup into the read-only sales channel.
"""
def __init__(self):
super().__init__(timeout=None)
@discord.ui.button(
label="Purchase",
style=discord.ButtonStyle.success,
custom_id="sales_purchase",
emoji="🛒",
)
async def purchase(self, interaction: discord.Interaction, button: discord.ui.Button):
embed = discord.Embed(
title="Ultimate Investment Tool — Pricing",
description=(
"Select a tier below. Payment is handled securely via **Stripe**.\n"
"Your license key will be delivered to your **DMs** the moment payment clears."
),
color=discord.Color.gold(),
)
embed.add_field(name="Weekly", value="**$10** / 7 days", inline=True)
embed.add_field(name="Monthly", value="**$25** / 30 days", inline=True)
embed.add_field(name="Lifetime", value="**$150** one-time", inline=True)
embed.set_footer(text="Having trouble? Click the Support button to open a ticket.")
await interaction.response.send_message(embed=embed, view=TierSelectView(), ephemeral=True)
@discord.ui.button(
label="Support",
style=discord.ButtonStyle.secondary,
custom_id="sales_support",
emoji="🎫",
)
async def support(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.send_modal(TicketModal())
# ─────────────────────────────────────────────────────────────────────────────
# Admin slash command — post the sales embed
# ─────────────────────────────────────────────────────────────────────────────
@tree.command(name="setup", description="Post the sales & support embed (admin only)")
@app_commands.checks.has_permissions(administrator=True)
async def setup(interaction: discord.Interaction):
embed = discord.Embed(
title="Ultimate Investment Tool",
description=(
"Click on the **Purchase** button to purchase a license. "
"Click on the **Support** button to create a support ticket. "
"Support tickets are checked by our staff, and can take time to get a response. "
"Please be patient when waiting for staff to respond."
),
color=discord.Color.gold(),
)
await interaction.channel.send(embed=embed, view=SalesView())
await interaction.response.send_message("Sales embed posted.", ephemeral=True)
@setup.error
async def setup_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
if isinstance(error, app_commands.MissingPermissions):
await interaction.response.send_message(
"You need Administrator permission to use this command.", ephemeral=True
)
# ─────────────────────────────────────────────────────────────────────────────
# On-ready
# ─────────────────────────────────────────────────────────────────────────────
@bot.event
async def on_ready():
# Re-register all persistent views so buttons work after restarts
bot.add_view(SalesView())
bot.add_view(TicketCloseView())
# Sync slash commands to the guild instantly (no 1-hour global wait)
guild_obj = discord.Object(id=GUILD_ID)
tree.copy_global_to(guild=guild_obj)
await tree.sync(guild=guild_obj)
print(f"[Bot] Online as {bot.user} | Guild {GUILD_ID} | Slash commands synced")
# ─────────────────────────────────────────────────────────────────────────────
# Stripe webhook (aiohttp server on WEBHOOK_PORT)
# ─────────────────────────────────────────────────────────────────────────────
async def _handle_stripe_webhook(request: web.Request) -> web.Response:
payload = await request.read()
sig_header = request.headers.get("stripe-signature", "")
try:
event = stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET)
except stripe.error.SignatureVerificationError:
print("[Webhook] Invalid Stripe signature — request rejected")
return web.Response(status=400, text="Invalid signature")
except Exception as exc:
print(f"[Webhook] Error parsing event: {exc}")
return web.Response(status=400, text=str(exc))
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
metadata = session.get("metadata", {})
discord_user_id = metadata.get("discord_user_id")
discord_username = metadata.get("discord_username", "Unknown")
tier = metadata.get("tier")
if not discord_user_id or tier not in PRICES:
print(f"[Webhook] Missing/invalid metadata: {metadata}")
return web.Response(status=200, text="OK")
# 1. Issue license in Supabase
try:
key = issue_license(
tier,
notes=f"Discord: {discord_username} ({discord_user_id})",
)
print(f"[License] Issued {key} ({tier}) for Discord user {discord_user_id}")
except Exception as exc:
print(f"[License] Failed to issue for {discord_user_id}: {exc}")
return web.Response(status=500, text="License issuance failed")
# 2. DM the license key to the buyer
price_info = PRICES[tier]
expiry_str = (
"Never (lifetime)"
if price_info["days"] is None
else f"{price_info['days']} days from today"
)
try:
user = await bot.fetch_user(int(discord_user_id))
embed = discord.Embed(
title="🎉 Purchase Confirmed — Ultimate Investment Tool",
description=(
f"Thank you for your purchase!\n\n"
f"**Your License Key:**\n```\n{key}\n```\n"
f"**Plan:** {price_info['label']}\n"
f"**Expires:** {expiry_str}\n\n"
"Paste this key into the application when prompted.\n"
"⚠️ This key is locked to one machine — keep it private."
),
color=discord.Color.green(),
)
embed.set_footer(text="Need help? Click the Support button in the server.")
await user.send(embed=embed)
print(f"[Bot] License DM'd to {discord_username} ({discord_user_id})")
except discord.Forbidden:
print(f"[Bot] Cannot DM {discord_user_id} — DMs may be disabled.")
except Exception as exc:
print(f"[Bot] DM error for {discord_user_id}: {exc}")
# 3. Assign roles in the guild
try:
guild = bot.get_guild(GUILD_ID)
if guild:
member = await guild.fetch_member(int(discord_user_id))
roles_to_add = []
user_role = guild.get_role(ROLE_USER_ID)
if user_role:
roles_to_add.append(user_role)
if tier == "lifetime":
lifetime_role = guild.get_role(ROLE_LIFETIME_ID)
if lifetime_role:
roles_to_add.append(lifetime_role)
if roles_to_add:
await member.add_roles(*roles_to_add, reason=f"Purchased {tier} via Stripe")
names = ", ".join(r.name for r in roles_to_add)
print(f"[Bot] Assigned roles '{names}' to {discord_username}")
else:
print(f"[Bot] No valid roles found to assign")
except discord.NotFound:
print(f"[Bot] Member {discord_user_id} not found in guild (may have left)")
except Exception as exc:
print(f"[Bot] Role assignment error for {discord_user_id}: {exc}")
return web.Response(status=200, text="OK")
async def _start_webhook_server():
app = web.Application()
app.router.add_post("/stripe/webhook", _handle_stripe_webhook)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", WEBHOOK_PORT)
await site.start()
print(f"[Webhook] Stripe webhook server listening on :{WEBHOOK_PORT}/stripe/webhook")
# ─────────────────────────────────────────────────────────────────────────────
# Entry point
# ─────────────────────────────────────────────────────────────────────────────
async def main():
async with bot:
await _start_webhook_server()
await bot.start(DISCORD_TOKEN)
if __name__ == "__main__":
asyncio.run(main())