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:
parent
5a3a1dd09f
commit
1a09e1404d
30
README.md
30
README.md
@ -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 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**
|
- **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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
|
||||||
|
@ -2,3 +2,4 @@ discord.py
|
|||||||
requests
|
requests
|
||||||
qrcode[pil]
|
qrcode[pil]
|
||||||
asyncio
|
asyncio
|
||||||
|
websockets
|
Loading…
x
Reference in New Issue
Block a user