From d2e5d5a3904315ff78002ed984a6d14f9fde9ada Mon Sep 17 00:00:00 2001 From: saulteafarmer Date: Sun, 11 May 2025 00:05:59 +0000 Subject: [PATCH] Update discord_lnbits_bot.py --- discord_lnbits_bot.py | 476 +++++++++++++++++++++++------------------- 1 file changed, 266 insertions(+), 210 deletions(-) diff --git a/discord_lnbits_bot.py b/discord_lnbits_bot.py index dce64ae..df15b56 100644 --- a/discord_lnbits_bot.py +++ b/discord_lnbits_bot.py @@ -1,210 +1,266 @@ -import discord -import asyncio -import requests -import json -import io -import qrcode -import websockets -import traceback -from discord import File, Embed -from discord.ext import commands - -# --- Configuration Loading --- -try: - with open("config.json", "r") as f: - config = json.load(f) -except FileNotFoundError: - print("❌ Error: config.json not found. Please create it and fill in the details.") - exit() -except json.JSONDecodeError: - print("❌ Error: config.json is not valid JSON. Please check its syntax.") - exit() - -# Load values -TOKEN = config.get("discord_token") -GUILD_ID = int(config.get("guild_id", 0)) -ROLE_ID = int(config.get("role_id", 0)) -LNBITS_URL = config.get("lnbits_url") -LNBITS_API_KEY = config.get("lnbits_api_key") -PRICE = config.get("price") -CHANNEL_ID = int(config.get("channelid", 0)) -INVOICE_MESSAGE_TEMPLATE = config.get("invoicemessage", "Invoice for your purchase.") -COMMAND_NAME = config.get("command_name", "support") - -# Validate -if not LNBITS_URL or not LNBITS_API_KEY: - print("❌ Error: LNBITS_URL or LNBITS_API_KEY is missing in config.json.") - exit() - -clean_lnbits_http_url = LNBITS_URL.rstrip('/') -if clean_lnbits_http_url.startswith("https://"): - base_ws_url = clean_lnbits_http_url.replace("https://", "wss://", 1) -elif clean_lnbits_http_url.startswith("http://"): - base_ws_url = clean_lnbits_http_url.replace("http://", "ws://", 1) -else: - print(f"❌ Error: Invalid LNBITS_URL scheme: {LNBITS_URL}.") - exit() - -LNBITS_WEBSOCKET_URL = f"{base_ws_url}/api/v1/ws/{LNBITS_API_KEY}" - -if not all([TOKEN, GUILD_ID, ROLE_ID, PRICE, CHANNEL_ID, COMMAND_NAME]): - print("❌ Error: One or more essential configuration options are missing.") - exit() - -# Discord client -intents = discord.Intents.default() -intents.members = True -bot = commands.Bot(command_prefix="!", intents=intents) -pending_invoices = {} - -async def assign_role_after_payment(payment_hash_received, payment_details_from_ws): - print(f"DEBUG: ENTER assign_role_after_payment for hash: {payment_hash_received}") - - if payment_hash_received not in pending_invoices: - print(f"ℹ️ Hash {payment_hash_received} not found in pending_invoices.") - return - - user_id, original_interaction = pending_invoices.pop(payment_hash_received) - print(f"DEBUG: Found pending invoice for user_id={user_id}, interaction_id={getattr(original_interaction, 'id', None)}") - - guild = bot.get_guild(GUILD_ID) - if not guild: - print(f"❌ Guild {GUILD_ID} not found.") - return - - member = guild.get_member(user_id) - if not member: - try: - member = await asyncio.wait_for(guild.fetch_member(user_id), timeout=10) - except Exception as e: - print(f"❌ Error fetching member: {e}") - return - - role = guild.get_role(ROLE_ID) - if not role: - print(f"❌ Role ID {ROLE_ID} not found in guild.") - return - - invoice_channel = bot.get_channel(CHANNEL_ID) - if not invoice_channel: - print(f"❌ Invoice channel ID {CHANNEL_ID} not found.") - - if role not in member.roles: - try: - await asyncio.wait_for(member.add_roles(role, reason="Paid Lightning Invoice"), timeout=10) - if invoice_channel: - await invoice_channel.send( - f"🎉 {member.mention} has paid {PRICE} sats and been granted the '{role.name}' role!" - ) - except Exception as e: - print(f"❌ Error assigning role: {e}") - else: - if invoice_channel: - await invoice_channel.send( - f"✅ {member.mention}, payment confirmed! You already have the '{role.name}' role." - ) - -async def lnbits_websocket_listener(): - await bot.wait_until_ready() - print(f"👂 Connecting to LNBits WS at {LNBITS_WEBSOCKET_URL}") - - while True: - try: - async with websockets.connect(LNBITS_WEBSOCKET_URL) as ws: - print("✅ WS connected.") - while True: - try: - msg = await ws.recv() - print(f"📬 WS message: {msg}") - data = json.loads(msg) - if isinstance(data.get("payment"), dict): - p = data["payment"] - h = p.get("checking_id") or p.get("payment_hash") - amt = p.get("amount", 0) - status = p.get("status") - is_paid = (status == "success") or (p.get("paid") is True) or (p.get("pending") is False) - - if h and is_paid and amt > 0: - print(f"✅ Confirmed payment hash={h}, amount={amt}, status={status}") - bot.loop.create_task( - assign_role_after_payment(h, p) - ) - else: - print(f"ℹ️ Ignored update for hash={h}, status={status}, amount={amt}") - else: - print(f"⚠️ Unexpected WS format: {msg}") - except websockets.exceptions.ConnectionClosed: - print("⚠️ WS closed, reconnecting...") - break - except Exception as e: - print(f"❌ Error handling WS message: {e}") - except Exception as e: - print(f"❌ WS connection error: {e}. Retrying in 15s...") - await asyncio.sleep(15) - -@bot.event -async def on_ready(): - print(f"✅ Bot ready as {bot.user} ({bot.user.id})") - bot.loop.create_task(lnbits_websocket_listener()) - -# Dynamic slash command name from config -@bot.tree.command(name=COMMAND_NAME, description="Pay to get your role via Lightning.") -async def dynamic_command(interaction: discord.Interaction): - invoice_channel = bot.get_channel(CHANNEL_ID) - if not invoice_channel: - await interaction.response.send_message("❌ Invoice channel misconfigured.", ephemeral=True) - return - - invoice_data = {"out": False, "amount": PRICE, "memo": f"Role for {interaction.user.display_name}"} - headers = {"X-Api-Key": LNBITS_API_KEY, "Content-Type": "application/json"} - loop = asyncio.get_running_loop() - try: - resp = await loop.run_in_executor(None, lambda: requests.post( - f"{clean_lnbits_http_url}/api/v1/payments", json=invoice_data, headers=headers - )) - resp.raise_for_status() - except Exception as e: - await interaction.response.send_message("❌ Could not create invoice. Try again later.", ephemeral=True) - print(f"❌ Invoice creation error: {e}") - return - - if resp.status_code != 201: - await interaction.response.send_message(f"❌ LNBits error ({resp.status_code}).", ephemeral=True) - print(f"❌ LNBits error response: {resp.text}") - return - - inv = resp.json() - pr = inv.get("bolt11") - h = inv.get("payment_hash") - if not pr or not h: - await interaction.response.send_message("❌ Invalid invoice data.", ephemeral=True) - return - - pending_invoices[h] = (interaction.user.id, interaction) - print(f"DEBUG: Stored pending invoice {h} for user {interaction.user.id}") - - buf = io.BytesIO() - try: - await loop.run_in_executor(None, lambda: qrcode.make(pr.upper()).save(buf, format="PNG")) - buf.seek(0) - qr_file = File(buf, filename="invoice_qr.png") - except Exception as e: - print(f"⚠️ QR generation failed: {e}") - qr_file = None - - embed = Embed(title="⚡ Payment Required ⚡", description=INVOICE_MESSAGE_TEMPLATE) - embed.add_field(name="Invoice", value=f"```{pr}```", inline=False) - embed.add_field(name="Amount", value=f"{PRICE} sats", inline=True) - embed.set_footer(text=h) - if qr_file: - embed.set_image(url="attachment://invoice_qr.png") - - try: - await invoice_channel.send(content=interaction.user.mention, embed=embed, file=qr_file if qr_file else None) - await interaction.response.send_message("✅ Invoice posted!", ephemeral=True) - except Exception as e: - print(f"❌ Error sending invoice message: {e}") - await interaction.response.send_message("❌ Failed to post invoice.", ephemeral=True) - -if __name__ == "__main__": - bot.run(TOKEN) +#!/usr/bin/env python3 +import os +import asyncio +import json +import io +import qrcode +import requests +import logging +from datetime import datetime +from dotenv import load_dotenv + +import discord +from discord import File, Embed +from discord.ext import commands + +import websockets + +from sqlalchemy import ( + create_engine, Column, String, Integer, DateTime +) +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +# ─── Environment & Logging ──────────────────────────────────────────────────── +load_dotenv() # loads variables from .env into os.environ + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)7s %(name)s | %(message)s" +) +logger = logging.getLogger("discord_lnbits_bot") + +# ─── Config from ENV ────────────────────────────────────────────────────────── +DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") +GUILD_ID = int(os.getenv("GUILD_ID", 0)) +ROLE_ID = int(os.getenv("ROLE_ID", 0)) +LNBITS_URL = os.getenv("LNBITS_URL", "").rstrip("/") +LNBITS_API_KEY = os.getenv("LNBITS_API_KEY", "") +PRICE = int(os.getenv("PRICE", 0)) +CHANNEL_ID = int(os.getenv("CHANNEL_ID", 0)) +INVOICE_MESSAGE_TEMPLATE = os.getenv("INVOICE_MESSAGE", "Invoice for your purchase.") +COMMAND_NAME = os.getenv("COMMAND_NAME", "support") +DATABASE_URL = os.getenv("DATABASE_URL") # e.g. postgres://user:pass@db:5432/bot + +# Sanity checks +for var_name in ( + "DISCORD_TOKEN", "GUILD_ID", "ROLE_ID", + "LNBITS_URL", "LNBITS_API_KEY", "PRICE", + "CHANNEL_ID", "DATABASE_URL" +): + if not globals()[var_name]: + logger.critical(f"Environment variable {var_name} is missing.") + exit(1) + +# Construct WS URL +if LNBITS_URL.startswith("https://"): + base_ws = LNBITS_URL.replace("https://", "wss://", 1) +elif LNBITS_URL.startswith("http://"): + base_ws = LNBITS_URL.replace("http://", "ws://", 1) +else: + logger.critical("LNBITS_URL must start with http:// or https://") + exit(1) + +LNBITS_WS_URL = f"{base_ws}/api/v1/ws/{LNBITS_API_KEY}" + + +# ─── Database Setup ─────────────────────────────────────────────────────────── +Base = declarative_base() + +class Payment(Base): + __tablename__ = "payments" + id = Column(Integer, primary_key=True, autoincrement=True) + payment_hash = Column(String(256), unique=True, nullable=False, index=True) + user_id = Column(String(64), nullable=False, index=True) + amount = Column(Integer, nullable=False) + paid_at = Column(DateTime, default=datetime.utcnow, nullable=False) + +engine = create_engine(DATABASE_URL, future=True) +SessionLocal = sessionmaker(bind=engine, expire_on_commit=False) +Base.metadata.create_all(engine) +logger.info("✅ Database tables ensured.") + + +# ─── Discord Bot Setup ──────────────────────────────────────────────────────── +intents = discord.Intents.default() +intents.members = True + +bot = commands.Bot(command_prefix="!", intents=intents) +pending_invoices = {} # payment_hash -> (user_id, interaction) + +# ─── Role Assignment Handler ────────────────────────────────────────────────── +async def assign_role_after_payment(payment_hash: str, payment_obj: dict): + logger.debug(f"ENTER assign_role_after_payment for hash={payment_hash}") + user_id, interaction = pending_invoices.pop(payment_hash, (None, None)) + if user_id is None: + logger.info(f"No pending invoice for hash={payment_hash}") + return + + # Record to database + amt = payment_obj.get("amount", 0) + session = SessionLocal() + try: + payment = Payment( + payment_hash=payment_hash, + user_id=str(user_id), + amount=amt, + paid_at=datetime.utcnow() + ) + session.add(payment) + session.commit() + logger.info(f"💾 Logged payment {payment_hash} for user {user_id} ({amt} sats).") + except Exception: + session.rollback() + logger.exception("DB error saving payment") + finally: + session.close() + + # Fetch guild, member, role + guild = bot.get_guild(GUILD_ID) + if not guild: + logger.error(f"Guild {GUILD_ID} not found") + return + + member = guild.get_member(user_id) or await guild.fetch_member(user_id) + role = guild.get_role(ROLE_ID) + if not role: + logger.error(f"Role {ROLE_ID} not found in guild {guild.id}") + return + + # Assign role if needed + if role not in member.roles: + logger.debug(f"Adding role '{role.name}' to {member.display_name}") + try: + await asyncio.wait_for( + member.add_roles(role, reason="Paid via Lightning"), + timeout=10 + ) + channel = bot.get_channel(CHANNEL_ID) + await channel.send( + f"🎉 {member.mention} has paid **{amt} sats** and received the **{role.name}** role!" + ) + logger.info(f"✅ Role '{role.name}' assigned to {member.display_name}") + except Exception: + logger.exception("Error assigning role or sending confirmation") + else: + # Already has role + channel = bot.get_channel(CHANNEL_ID) + await channel.send( + f"ℹ️ {member.mention} paid again (hash={payment_hash}), role **{role.name}** already assigned." + ) + logger.info(f"User {member.display_name} already had role; notified channel.") + + +# ─── LNbits WebSocket Listener ──────────────────────────────────────────────── +async def lnbits_ws_listener(): + await bot.wait_until_ready() + logger.info(f"👂 Connecting to LNbits WS at {LNBITS_WS_URL}") + while True: + try: + async with websockets.connect(LNBITS_WS_URL) as ws: + logger.info("✅ WS connected") + async for msg in ws: + logger.debug(f"WS ► {msg}") + try: + data = json.loads(msg) + pay = data.get("payment", {}) + hsh = pay.get("checking_id") or pay.get("payment_hash") + amt = pay.get("amount", 0) + # LNbits uses status="success" on paid + if hsh and pay.get("status") == "success" and amt > 0: + logger.info( + f"💡 Payment received hash={hsh}, amt={amt}" + ) + # schedule role assignment + bot.loop.create_task( + assign_role_after_payment(hsh, pay) + ) + else: + logger.debug("Ignored WS update (not a paid invoice)") + except json.JSONDecodeError: + logger.warning("WS ► Non-JSON payload") + except Exception: + logger.exception("WS listener error; reconnecting in 10s") + await asyncio.sleep(10) + + +# ─── Slash Command Registration ─────────────────────────────────────────────── +@bot.event +async def on_ready(): + logger.info(f"Logged in as {bot.user} (ID: {bot.user.id})") + # sync commands + try: + synced = await bot.tree.sync() + logger.info(f"✅ Synced {len(synced)} command(s).") + except Exception: + logger.exception("Failed to sync commands") + + # start WS listener + bot.loop.create_task(lnbits_ws_listener()) + + +@bot.tree.command(name=COMMAND_NAME, description="Pay to get your role via Lightning") +async def dynamic_command(interaction: discord.Interaction): + invoice_ch = bot.get_channel(CHANNEL_ID) + if invoice_ch is None: + await interaction.response.send_message( + "❌ Invoice channel not found. Check your config.", + ephemeral=True + ) + return + + # offload blocking REST call + QR gen + loop = asyncio.get_running_loop() + invoice_data = {"out": False, "amount": PRICE, "memo": f"Role for {interaction.user.display_name}"} + headers = {"X-Api-Key": LNBITS_API_KEY, "Content-Type": "application/json"} + + def create_invoice(): + resp = requests.post( + f"{LNBITS_URL}/api/v1/payments", + json=invoice_data, + headers=headers, + timeout=10 + ) + resp.raise_for_status() + return resp.json() + + # Create invoice + try: + invoice_json = await loop.run_in_executor(None, create_invoice) + except Exception as e: + logger.exception("Error creating LNbits invoice") + await interaction.response.send_message( + "❌ Failed to generate invoice. Try again later.", + ephemeral=True + ) + return + + pay_req = invoice_json["bolt11"] + pay_hash = invoice_json["payment_hash"] + pending_invoices[pay_hash] = (interaction.user.id, interaction) + logger.info(f"Stored pending invoice {pay_hash} for user {interaction.user.id}") + + # Generate QR + qr_bytes = await loop.run_in_executor(None, lambda: qrcode.make(pay_req).get_image().tobytes()) + qr_file = File(io.BytesIO(qr_bytes), filename="invoice.png") + + embed = Embed( + title="⚡ Please Pay ⚡", + description=INVOICE_MESSAGE_TEMPLATE, + color=discord.Color.gold() + ) + embed.add_field(name="Invoice", value=f"```{pay_req}```", inline=False) + embed.add_field(name="Amount", value=f"{PRICE} sats", inline=True) + embed.set_image(url="attachment://invoice.png") + embed.set_footer(text=f"Hash: {pay_hash}") + + # Send invoice & ack + await invoice_ch.send(content=interaction.user.mention, embed=embed, file=qr_file) + await interaction.response.send_message( + f"✅ Invoice posted in {invoice_ch.mention}", ephemeral=True + ) + + +# ─── Run Bot ────────────────────────────────────────────────────────────────── +if __name__ == "__main__": + bot.run(DISCORD_TOKEN)