Update discord_lnbits_bot.py

This commit is contained in:
saulteafarmer 2025-05-11 00:05:59 +00:00
parent 9bd999fc32
commit d2e5d5a390

View File

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