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 os
import asyncio import asyncio
import requests
import json import json
import io import io
import qrcode import qrcode
import websockets import requests
import traceback import logging
from datetime import datetime
from dotenv import load_dotenv
import discord
from discord import File, Embed from discord import File, Embed
from discord.ext import commands from discord.ext import commands
# --- Configuration Loading --- import websockets
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 from sqlalchemy import (
TOKEN = config.get("discord_token") create_engine, Column, String, Integer, DateTime
GUILD_ID = int(config.get("guild_id", 0)) )
ROLE_ID = int(config.get("role_id", 0)) from sqlalchemy.ext.declarative import declarative_base
LNBITS_URL = config.get("lnbits_url") from sqlalchemy.orm import sessionmaker
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 # ─── Environment & Logging ────────────────────────────────────────────────────
if not LNBITS_URL or not LNBITS_API_KEY: load_dotenv() # loads variables from .env into os.environ
print("❌ Error: LNBITS_URL or LNBITS_API_KEY is missing in config.json.")
exit()
clean_lnbits_http_url = LNBITS_URL.rstrip('/') logging.basicConfig(
if clean_lnbits_http_url.startswith("https://"): level=logging.INFO,
base_ws_url = clean_lnbits_http_url.replace("https://", "wss://", 1) format="%(asctime)s %(levelname)7s %(name)s | %(message)s"
elif clean_lnbits_http_url.startswith("http://"): )
base_ws_url = clean_lnbits_http_url.replace("http://", "ws://", 1) 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: else:
print(f"❌ Error: Invalid LNBITS_URL scheme: {LNBITS_URL}.") logger.critical("LNBITS_URL must start with http:// or https://")
exit() exit(1)
LNBITS_WEBSOCKET_URL = f"{base_ws_url}/api/v1/ws/{LNBITS_API_KEY}" LNBITS_WS_URL = f"{base_ws}/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 # ─── 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 = discord.Intents.default()
intents.members = True intents.members = True
bot = commands.Bot(command_prefix="!", intents=intents) bot = commands.Bot(command_prefix="!", intents=intents)
pending_invoices = {} pending_invoices = {} # payment_hash -> (user_id, interaction)
async def assign_role_after_payment(payment_hash_received, payment_details_from_ws): # ─── Role Assignment Handler ──────────────────────────────────────────────────
print(f"DEBUG: ENTER assign_role_after_payment for hash: {payment_hash_received}") async def assign_role_after_payment(payment_hash: str, payment_obj: dict):
logger.debug(f"ENTER assign_role_after_payment for hash={payment_hash}")
if payment_hash_received not in pending_invoices: user_id, interaction = pending_invoices.pop(payment_hash, (None, None))
print(f" Hash {payment_hash_received} not found in pending_invoices.") if user_id is None:
logger.info(f"No pending invoice for hash={payment_hash}")
return return
user_id, original_interaction = pending_invoices.pop(payment_hash_received) # Record to database
print(f"DEBUG: Found pending invoice for user_id={user_id}, interaction_id={getattr(original_interaction, 'id', None)}") 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) guild = bot.get_guild(GUILD_ID)
if not guild: if not guild:
print(f"❌ Guild {GUILD_ID} not found.") logger.error(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 return
member = guild.get_member(user_id) or await guild.fetch_member(user_id)
role = guild.get_role(ROLE_ID) role = guild.get_role(ROLE_ID)
if not role: if not role:
print(f"❌ Role ID {ROLE_ID} not found in guild.") logger.error(f"Role {ROLE_ID} not found in guild {guild.id}")
return return
invoice_channel = bot.get_channel(CHANNEL_ID) # Assign role if needed
if not invoice_channel:
print(f"❌ Invoice channel ID {CHANNEL_ID} not found.")
if role not in member.roles: if role not in member.roles:
logger.debug(f"Adding role '{role.name}' to {member.display_name}")
try: try:
await asyncio.wait_for(member.add_roles(role, reason="Paid Lightning Invoice"), timeout=10) await asyncio.wait_for(
if invoice_channel: member.add_roles(role, reason="Paid via Lightning"),
await invoice_channel.send( timeout=10
f"🎉 {member.mention} has paid {PRICE} sats and been granted the '{role.name}' role!"
) )
except Exception as e: channel = bot.get_channel(CHANNEL_ID)
print(f"❌ Error assigning role: {e}") 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: else:
if invoice_channel: # Already has role
await invoice_channel.send( channel = bot.get_channel(CHANNEL_ID)
f"{member.mention}, payment confirmed! You already have the '{role.name}' role." 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.")
async def lnbits_websocket_listener():
# ─── LNbits WebSocket Listener ────────────────────────────────────────────────
async def lnbits_ws_listener():
await bot.wait_until_ready() await bot.wait_until_ready()
print(f"👂 Connecting to LNBits WS at {LNBITS_WEBSOCKET_URL}") logger.info(f"👂 Connecting to LNbits WS at {LNBITS_WS_URL}")
while True: while True:
try: try:
async with websockets.connect(LNBITS_WEBSOCKET_URL) as ws: async with websockets.connect(LNBITS_WS_URL) as ws:
print("✅ WS connected.") logger.info("✅ WS connected")
while True: async for msg in ws:
logger.debug(f"WS ► {msg}")
try: try:
msg = await ws.recv()
print(f"📬 WS message: {msg}")
data = json.loads(msg) data = json.loads(msg)
if isinstance(data.get("payment"), dict): pay = data.get("payment", {})
p = data["payment"] hsh = pay.get("checking_id") or pay.get("payment_hash")
h = p.get("checking_id") or p.get("payment_hash") amt = pay.get("amount", 0)
amt = p.get("amount", 0) # LNbits uses status="success" on paid
status = p.get("status") if hsh and pay.get("status") == "success" and amt > 0:
is_paid = (status == "success") or (p.get("paid") is True) or (p.get("pending") is False) logger.info(
f"💡 Payment received hash={hsh}, amt={amt}"
if h and is_paid and amt > 0: )
print(f"✅ Confirmed payment hash={h}, amount={amt}, status={status}") # schedule role assignment
bot.loop.create_task( bot.loop.create_task(
assign_role_after_payment(h, p) assign_role_after_payment(hsh, pay)
) )
else: else:
print(f" Ignored update for hash={h}, status={status}, amount={amt}") logger.debug("Ignored WS update (not a paid invoice)")
else: except json.JSONDecodeError:
print(f"⚠️ Unexpected WS format: {msg}") logger.warning("WS ► Non-JSON payload")
except websockets.exceptions.ConnectionClosed: except Exception:
print("⚠️ WS closed, reconnecting...") logger.exception("WS listener error; reconnecting in 10s")
break await asyncio.sleep(10)
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)
# ─── Slash Command Registration ───────────────────────────────────────────────
@bot.event @bot.event
async def on_ready(): async def on_ready():
print(f"✅ Bot ready as {bot.user} ({bot.user.id})") logger.info(f"Logged in as {bot.user} (ID: {bot.user.id})")
bot.loop.create_task(lnbits_websocket_listener()) # sync commands
try:
synced = await bot.tree.sync()
logger.info(f"✅ Synced {len(synced)} command(s).")
except Exception:
logger.exception("Failed to sync commands")
# Dynamic slash command name from config # start WS listener
@bot.tree.command(name=COMMAND_NAME, description="Pay to get your role via Lightning.") 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): async def dynamic_command(interaction: discord.Interaction):
invoice_channel = bot.get_channel(CHANNEL_ID) invoice_ch = bot.get_channel(CHANNEL_ID)
if not invoice_channel: if invoice_ch is None:
await interaction.response.send_message("❌ Invoice channel misconfigured.", ephemeral=True) await interaction.response.send_message(
"❌ Invoice channel not found. Check your config.",
ephemeral=True
)
return 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}"} 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"} headers = {"X-Api-Key": LNBITS_API_KEY, "Content-Type": "application/json"}
loop = asyncio.get_running_loop()
try: def create_invoice():
resp = await loop.run_in_executor(None, lambda: requests.post( resp = requests.post(
f"{clean_lnbits_http_url}/api/v1/payments", json=invoice_data, headers=headers f"{LNBITS_URL}/api/v1/payments",
)) json=invoice_data,
headers=headers,
timeout=10
)
resp.raise_for_status() resp.raise_for_status()
except Exception as e: return resp.json()
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: # Create invoice
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: try:
await loop.run_in_executor(None, lambda: qrcode.make(pr.upper()).save(buf, format="PNG")) invoice_json = await loop.run_in_executor(None, create_invoice)
buf.seek(0)
qr_file = File(buf, filename="invoice_qr.png")
except Exception as e: except Exception as e:
print(f"⚠️ QR generation failed: {e}") logger.exception("Error creating LNbits invoice")
qr_file = None await interaction.response.send_message(
"❌ Failed to generate invoice. Try again later.",
ephemeral=True
)
return
embed = Embed(title="⚡ Payment Required ⚡", description=INVOICE_MESSAGE_TEMPLATE) pay_req = invoice_json["bolt11"]
embed.add_field(name="Invoice", value=f"```{pr}```", inline=False) 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.add_field(name="Amount", value=f"{PRICE} sats", inline=True)
embed.set_footer(text=h) embed.set_image(url="attachment://invoice.png")
if qr_file: embed.set_footer(text=f"Hash: {pay_hash}")
embed.set_image(url="attachment://invoice_qr.png")
try: # Send invoice & ack
await invoice_channel.send(content=interaction.user.mention, embed=embed, file=qr_file if qr_file else None) await invoice_ch.send(content=interaction.user.mention, embed=embed, file=qr_file)
await interaction.response.send_message("✅ Invoice posted!", ephemeral=True) await interaction.response.send_message(
except Exception as e: f"✅ Invoice posted in {invoice_ch.mention}", ephemeral=True
print(f"❌ Error sending invoice message: {e}") )
await interaction.response.send_message("❌ Failed to post invoice.", ephemeral=True)
# ─── Run Bot ──────────────────────────────────────────────────────────────────
if __name__ == "__main__": if __name__ == "__main__":
bot.run(TOKEN) bot.run(DISCORD_TOKEN)