discord-lnbits-bot/discord_lnbits_bot.py

267 lines
11 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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)