Update plugin to 0.2.0

Update LNBits to work with v1
Integrate channel messages instead of DMs
Utilize websockets instead of post
This commit is contained in:
saulteafarmer 2025-05-10 18:02:58 +00:00
parent 5a3a1dd09f
commit 1a09e1404d
4 changed files with 207 additions and 128 deletions

View File

@ -1,17 +1,21 @@
# Discord LNbits Bot # 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**. 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**.
## 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.
--- ---
## Requirements ## 🚀 Features
- ✅ Dynamic slashcommand 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** - **Ubuntu 24.04**
- **Python 3.10+** - **Python 3.10+**
@ -132,10 +136,10 @@ sudo nano config.json
"role_id": "YOUR_ROLE_ID", "role_id": "YOUR_ROLE_ID",
"lnbits_url": "https://sats.love", "lnbits_url": "https://sats.love",
"lnbits_api_key": "YOUR_INVOICE_READ_KEY", "lnbits_api_key": "YOUR_INVOICE_READ_KEY",
"channelid": "YOUR_CHANNELID_FOR_PURCHASE_ROOM",
"price": 1000, "price": 1000,
"command_name": "support", "command_name": "support",
"check_interval": 30, "invoicemessage": "Invoice for your purchase."
"max_checks": 20
} }
``` ```
@ -148,8 +152,8 @@ sudo nano config.json
- **`lnbits_api_key`** → **Invoice-only key** from LNbits (⚠️ NOT an admin key) - **`lnbits_api_key`** → **Invoice-only key** from LNbits (⚠️ NOT an admin key)
- **`price`** → Price in **satoshis** (e.g., `1000` = 1000 sats) - **`price`** → Price in **satoshis** (e.g., `1000` = 1000 sats)
- **`command_name`** → Name of the command (default: `support`) - **`command_name`** → Name of the command (default: `support`)
- **`check_interval`** → How often (in seconds) the bot checks for payments - **`invoicemessage`** → Message to be sent with invoice
- **`max_checks`** → How many times the bot will check before stopping - **`channelid`** → Discord channel where invoices and confirmations post
--- ---

View File

@ -2,10 +2,11 @@
"discord_token": "YOUR_DISCORD_BOT_TOKEN", "discord_token": "YOUR_DISCORD_BOT_TOKEN",
"guild_id": "YOUR_GUILD_ID", "guild_id": "YOUR_GUILD_ID",
"role_id": "YOUR_ROLE_ID", "role_id": "YOUR_ROLE_ID",
"channelid": "YOUR_CHANNELID_FOR_PURCHASE_ROOM",
"lnbits_url": "https://sats.love", "lnbits_url": "https://sats.love",
"lnbits_api_key": "YOUR_INVOICE_READ_KEY", "lnbits_api_key": "YOUR_INVOICE_READ_KEY",
"price": 1000, "price": 1000,
"command_name": "support", "invoicemessage": "Invoice for your purchase.",
"check_interval": 30, "command_name": "support"
"max_checks": 20
} }

View File

@ -4,134 +4,207 @@ import requests
import json import json
import io import io
import qrcode import qrcode
import websockets
import traceback
from discord import File, Embed from discord import File, Embed
from discord.ext import commands, tasks from discord.ext import commands
with open("config.json", "r") as f: # --- Configuration Loading ---
config = json.load(f) 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"] # Load values
GUILD_ID = int(config["guild_id"]) TOKEN = config.get("discord_token")
ROLE_ID = int(config["role_id"]) GUILD_ID = int(config.get("guild_id", 0))
LNBITS_URL = config["lnbits_url"] ROLE_ID = int(config.get("role_id", 0))
LNBITS_API_KEY = config["lnbits_api_key"] LNBITS_URL = config.get("lnbits_url")
PRICE = config["price"] LNBITS_API_KEY = config.get("lnbits_api_key")
CHECK_INTERVAL = config["check_interval"] PRICE = config.get("price")
MAX_CHECKS = config["max_checks"] 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 = discord.Intents.default()
intents.members = True intents.members = True
intents.message_content = True
bot = commands.Bot(command_prefix="!", intents=intents) bot = commands.Bot(command_prefix="!", intents=intents)
pending_invoices = {} 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 @bot.event
async def on_ready(): async def on_ready():
"""Called when the bot successfully logs in.""" print(f"✅ Bot ready as {bot.user} ({bot.user.id})")
print(f"✅ Logged in as {bot.user}") bot.loop.create_task(lnbits_websocket_listener())
print("Bot is in the following guilds:")
for g in bot.guilds:
print(f" - {g.name} (ID: {g.id})")
synced_cmds = await bot.tree.sync() # Dynamic slash command name from config
print("✅ Synced global commands:", synced_cmds) @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...") invoice_data = {"out": False, "amount": PRICE, "memo": f"Role for {interaction.user.display_name}"}
check_invoices.start() 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!") if resp.status_code != 201:
async def support_command(interaction: discord.Interaction): await interaction.response.send_message(f"❌ LNBits error ({resp.status_code}).", ephemeral=True)
user_id = interaction.user.id print(f"❌ LNBits error response: {resp.text}")
return
invoice_data = { inv = resp.json()
"out": False, pr = inv.get("bolt11")
"amount": PRICE, h = inv.get("payment_hash")
"memo": "Lightning Payment" if not pr or not h:
} await interaction.response.send_message("❌ Invalid invoice data.", ephemeral=True)
headers = { return
"X-Api-Key": LNBITS_API_KEY,
"Content-Type": "application/json"
}
resp = requests.post(f"{LNBITS_URL}/api/v1/payments", json=invoice_data, headers=headers) pending_invoices[h] = (interaction.user.id, interaction)
if resp.status_code == 201: print(f"DEBUG: Stored pending invoice {h} for user {interaction.user.id}")
invoice_json = resp.json()
payment_request = invoice_json["payment_request"]
payment_hash = invoice_json["payment_hash"]
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() embed = Embed(title="⚡ Payment Required ⚡", description=INVOICE_MESSAGE_TEMPLATE)
qrcode.make(payment_request).save(qr_buffer, format="PNG") embed.add_field(name="Invoice", value=f"```{pr}```", inline=False)
qr_buffer.seek(0) embed.add_field(name="Amount", value=f"{PRICE} sats", inline=True)
qr_file = File(fp=qr_buffer, filename="invoice_qr.png") embed.set_footer(text=h)
if qr_file:
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.set_image(url="attachment://invoice_qr.png") embed.set_image(url="attachment://invoice_qr.png")
await interaction.user.send( try:
content=f"{interaction.user.display_name}, please pay **{PRICE} sats** using the Lightning Network.", await invoice_channel.send(content=interaction.user.mention, embed=embed, file=qr_file if qr_file else None)
embed=embed, await interaction.response.send_message("✅ Invoice posted!", ephemeral=True)
file=qr_file 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) if __name__ == "__main__":
bot.run(TOKEN)
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)

View File

@ -2,3 +2,4 @@ discord.py
requests requests
qrcode[pil] qrcode[pil]
asyncio asyncio
websockets