Update discord_lnbits_bot.py
This commit is contained in:
parent
9bd999fc32
commit
d2e5d5a390
@ -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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user