diff --git a/README.md b/README.md index de1b3f8..1846cd6 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,21 @@ # Discord LNbits Bot -This bot allows users to purchase a **Discord role** using **Bitcoin Lightning payments** through LNbits. Users request an invoice via a **slash command**, and the bot automatically assigns the role **once payment is confirmed**. - -## Features - -βœ… Users request an invoice using `/support` (configurable). -βœ… The bot generates a **Lightning invoice** via LNbits. -βœ… Once paid, the bot assigns a **Discord role**. -βœ… Configurable check intervals & max attempts to verify payments. +This bot allows users to purchase a **Discord role** using **Bitcoin Lightning payments** through LNbits. +Users request an invoice via a **slash command** (configurable), and the bot automatically assigns the role **once payment is confirmed**. --- -## Requirements +## πŸš€ Features + +- βœ… Dynamic slash‐command name (configured via `command_name`) +- βœ… Generates a **Lightning invoice** via LNbits +- βœ… Listens on LNbits WebSocket for paid invoices +- βœ… Automatically assigns a **Discord role** on payment +- βœ… Posts confirmations directly in your designated channel + +--- + +## πŸ“‹ Requirements - **Ubuntu 24.04** - **Python 3.10+** @@ -132,10 +136,10 @@ sudo nano config.json "role_id": "YOUR_ROLE_ID", "lnbits_url": "https://sats.love", "lnbits_api_key": "YOUR_INVOICE_READ_KEY", + "channelid": "YOUR_CHANNELID_FOR_PURCHASE_ROOM", "price": 1000, "command_name": "support", - "check_interval": 30, - "max_checks": 20 + "invoicemessage": "Invoice for your purchase." } ``` @@ -148,8 +152,8 @@ sudo nano config.json - **`lnbits_api_key`** β†’ **Invoice-only key** from LNbits (⚠️ NOT an admin key) - **`price`** β†’ Price in **satoshis** (e.g., `1000` = 1000 sats) - **`command_name`** β†’ Name of the command (default: `support`) -- **`check_interval`** β†’ How often (in seconds) the bot checks for payments -- **`max_checks`** β†’ How many times the bot will check before stopping +- **`invoicemessage`** β†’ Message to be sent with invoice +- **`channelid`** β†’ Discord channel where invoices and confirmations post --- diff --git a/config.json b/config.json index c2745eb..0876d2f 100644 --- a/config.json +++ b/config.json @@ -2,10 +2,11 @@ "discord_token": "YOUR_DISCORD_BOT_TOKEN", "guild_id": "YOUR_GUILD_ID", "role_id": "YOUR_ROLE_ID", + "channelid": "YOUR_CHANNELID_FOR_PURCHASE_ROOM", "lnbits_url": "https://sats.love", "lnbits_api_key": "YOUR_INVOICE_READ_KEY", "price": 1000, - "command_name": "support", - "check_interval": 30, - "max_checks": 20 - } \ No newline at end of file + "invoicemessage": "Invoice for your purchase.", + "command_name": "support" + } + \ No newline at end of file diff --git a/discord_lnbits_bot.py b/discord_lnbits_bot.py index d8517df..dce64ae 100644 --- a/discord_lnbits_bot.py +++ b/discord_lnbits_bot.py @@ -4,134 +4,207 @@ import requests import json import io import qrcode +import websockets +import traceback from discord import File, Embed -from discord.ext import commands, tasks +from discord.ext import commands -with open("config.json", "r") as f: - config = json.load(f) +# --- 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() -TOKEN = config["discord_token"] -GUILD_ID = int(config["guild_id"]) -ROLE_ID = int(config["role_id"]) -LNBITS_URL = config["lnbits_url"] -LNBITS_API_KEY = config["lnbits_api_key"] -PRICE = config["price"] -CHECK_INTERVAL = config["check_interval"] -MAX_CHECKS = config["max_checks"] +# 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 -intents.message_content = 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(): - """Called when the bot successfully logs in.""" - print(f"βœ… Logged in as {bot.user}") - print("Bot is in the following guilds:") - for g in bot.guilds: - print(f" - {g.name} (ID: {g.id})") + print(f"βœ… Bot ready as {bot.user} ({bot.user.id})") + bot.loop.create_task(lnbits_websocket_listener()) - synced_cmds = await bot.tree.sync() - print("βœ… Synced global commands:", synced_cmds) +# 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 - print(f"πŸ”„ Checking invoices every {CHECK_INTERVAL} seconds...") - check_invoices.start() + 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 -@bot.tree.command(name="support", description="Pay a Lightning invoice to get your role!") -async def support_command(interaction: discord.Interaction): - user_id = interaction.user.id + 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 - invoice_data = { - "out": False, - "amount": PRICE, - "memo": "Lightning Payment" - } - headers = { - "X-Api-Key": LNBITS_API_KEY, - "Content-Type": "application/json" - } + 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 - resp = requests.post(f"{LNBITS_URL}/api/v1/payments", json=invoice_data, headers=headers) - if resp.status_code == 201: - invoice_json = resp.json() - payment_request = invoice_json["payment_request"] - payment_hash = invoice_json["payment_hash"] + pending_invoices[h] = (interaction.user.id, interaction) + print(f"DEBUG: Stored pending invoice {h} for user {interaction.user.id}") - pending_invoices[payment_hash] = (user_id, 0) + 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 - qr_buffer = io.BytesIO() - qrcode.make(payment_request).save(qr_buffer, format="PNG") - qr_buffer.seek(0) - qr_file = File(fp=qr_buffer, filename="invoice_qr.png") - - embed = Embed( - title="Payment Invoice", - description="Please pay the following Lightning invoice to complete your purchase." - ) - embed.add_field(name="Invoice", value=f"```{payment_request}```", inline=False) - embed.add_field(name="Amount", value=f"{PRICE} sats", inline=True) - embed.add_field(name="Requesting User", value=f"{interaction.user.display_name}", inline=True) + 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") - await interaction.user.send( - content=f"{interaction.user.display_name}, please pay **{PRICE} sats** using the Lightning Network.", - embed=embed, - file=qr_file - ) + 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) - await interaction.response.send_message("βœ… I've sent you a payment invoice via DM!", ephemeral=True) - - else: - await interaction.response.send_message("❌ Failed to generate invoice. Try again later.", ephemeral=True) - print(f"❌ LNbits Error: {resp.text}") - -@tasks.loop(seconds=CHECK_INTERVAL) -async def check_invoices(): - if not pending_invoices: - return - - guild = discord.utils.get(bot.guilds, id=GUILD_ID) - if guild is None: - print(f"⚠️ Bot not in server with ID {GUILD_ID}") - return - - headers = { - "X-Api-Key": LNBITS_API_KEY, - "Content-Type": "application/json" - } - - invoices_to_remove = [] - - for payment_hash, (user_id, attempts) in pending_invoices.items(): - if attempts >= MAX_CHECKS: - invoices_to_remove.append(payment_hash) - continue - - status_resp = requests.get(f"{LNBITS_URL}/api/v1/payments/{payment_hash}", headers=headers) - - if status_resp.status_code == 200: - payment_status = status_resp.json() - if payment_status.get("paid", False): - member = guild.get_member(user_id) - if member: - role = guild.get_role(ROLE_ID) - if role: - await member.add_roles(role) - await member.send("βœ… **Thank you! Your role has been assigned.**") - print(f"πŸŽ‰ Role assigned to {member.name}") - invoices_to_remove.append(payment_hash) - else: - print(f"❌ Role ID {ROLE_ID} not found!") - else: - print(f"❌ Member {user_id} not found!") - - pending_invoices[payment_hash] = (user_id, attempts + 1) - - for done_hash in invoices_to_remove: - del pending_invoices[done_hash] - -bot.run(TOKEN) +if __name__ == "__main__": + bot.run(TOKEN) diff --git a/requirements.txt b/requirements.txt index b17f6e2..bb1d8eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ discord.py requests qrcode[pil] -asyncio \ No newline at end of file +asyncio +websockets \ No newline at end of file