534 lines
23 KiB
Python
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())
|