discord-lnbits-bot/discord_lnbits_bot.py

267 lines
11 KiB
Python
Raw Normal View History

2025-05-11 00:05:59 +00:00
#!/usr/bin/env python3
import os
import asyncio
import json
import io
import qrcode
import requests
import logging
from datetime import datetime
from dotenv import load_dotenv
import discord
from discord import File, Embed
from discord.ext import commands
import websockets
from sqlalchemy import (
create_engine, Column, String, Integer, DateTime
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
# ─── Environment & Logging ────────────────────────────────────────────────────
load_dotenv() # loads variables from .env into os.environ
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)7s %(name)s | %(message)s"
)
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:
logger.critical("LNBITS_URL must start with http:// or https://")
exit(1)
LNBITS_WS_URL = f"{base_ws}/api/v1/ws/{LNBITS_API_KEY}"
# ─── 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.members = True
bot = commands.Bot(command_prefix="!", intents=intents)
pending_invoices = {} # payment_hash -> (user_id, interaction)
# ─── Role Assignment Handler ──────────────────────────────────────────────────
async def assign_role_after_payment(payment_hash: str, payment_obj: dict):
logger.debug(f"ENTER assign_role_after_payment for hash={payment_hash}")
user_id, interaction = pending_invoices.pop(payment_hash, (None, None))
if user_id is None:
logger.info(f"No pending invoice for hash={payment_hash}")
return
# Record to database
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)
if not guild:
logger.error(f"Guild {GUILD_ID} not found")
return
member = guild.get_member(user_id) or await guild.fetch_member(user_id)
role = guild.get_role(ROLE_ID)
if not role:
logger.error(f"Role {ROLE_ID} not found in guild {guild.id}")
return
# Assign role if needed
if role not in member.roles:
logger.debug(f"Adding role '{role.name}' to {member.display_name}")
try:
await asyncio.wait_for(
member.add_roles(role, reason="Paid via Lightning"),
timeout=10
)
channel = bot.get_channel(CHANNEL_ID)
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:
# Already has role
channel = bot.get_channel(CHANNEL_ID)
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.")
# ─── LNbits WebSocket Listener ────────────────────────────────────────────────
async def lnbits_ws_listener():
await bot.wait_until_ready()
logger.info(f"👂 Connecting to LNbits WS at {LNBITS_WS_URL}")
while True:
try:
async with websockets.connect(LNBITS_WS_URL) as ws:
logger.info("✅ WS connected")
async for msg in ws:
logger.debug(f"WS ► {msg}")
try:
data = json.loads(msg)
pay = data.get("payment", {})
hsh = pay.get("checking_id") or pay.get("payment_hash")
amt = pay.get("amount", 0)
# LNbits uses status="success" on paid
if hsh and pay.get("status") == "success" and amt > 0:
logger.info(
f"💡 Payment received hash={hsh}, amt={amt}"
)
# schedule role assignment
bot.loop.create_task(
assign_role_after_payment(hsh, pay)
)
else:
logger.debug("Ignored WS update (not a paid invoice)")
except json.JSONDecodeError:
logger.warning("WS ► Non-JSON payload")
except Exception:
logger.exception("WS listener error; reconnecting in 10s")
await asyncio.sleep(10)
# ─── Slash Command Registration ───────────────────────────────────────────────
@bot.event
async def on_ready():
logger.info(f"Logged in as {bot.user} (ID: {bot.user.id})")
# sync commands
try:
synced = await bot.tree.sync()
logger.info(f"✅ Synced {len(synced)} command(s).")
except Exception:
logger.exception("Failed to sync commands")
# start WS listener
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):
invoice_ch = bot.get_channel(CHANNEL_ID)
if invoice_ch is None:
await interaction.response.send_message(
"❌ Invoice channel not found. Check your config.",
ephemeral=True
)
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)