Update discord_lnbits_bot.py

This commit is contained in:
saulteafarmer 2025-05-14 13:39:58 +00:00
parent d2e5d5a390
commit a983fc0d87

View File

@ -22,7 +22,7 @@ from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
# ─── Environment & Logging ──────────────────────────────────────────────────── # ─── Environment & Logging ────────────────────────────────────────────────────
load_dotenv() # loads variables from .env into os.environ load_dotenv() # loads .env into os.environ
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@ -40,19 +40,19 @@ PRICE = int(os.getenv("PRICE", 0))
CHANNEL_ID = int(os.getenv("CHANNEL_ID", 0)) CHANNEL_ID = int(os.getenv("CHANNEL_ID", 0))
INVOICE_MESSAGE_TEMPLATE = os.getenv("INVOICE_MESSAGE", "Invoice for your purchase.") INVOICE_MESSAGE_TEMPLATE = os.getenv("INVOICE_MESSAGE", "Invoice for your purchase.")
COMMAND_NAME = os.getenv("COMMAND_NAME", "support") COMMAND_NAME = os.getenv("COMMAND_NAME", "support")
DATABASE_URL = os.getenv("DATABASE_URL") # e.g. postgres://user:pass@db:5432/bot DATABASE_URL = os.getenv("DATABASE_URL")
# Sanity checks # Ensure all required env vars are present
for var_name in ( for var in (
"DISCORD_TOKEN", "GUILD_ID", "ROLE_ID", "DISCORD_TOKEN", "GUILD_ID", "ROLE_ID",
"LNBITS_URL", "LNBITS_API_KEY", "PRICE", "LNBITS_URL", "LNBITS_API_KEY", "PRICE",
"CHANNEL_ID", "DATABASE_URL" "CHANNEL_ID", "DATABASE_URL"
): ):
if not globals()[var_name]: if not globals()[var]:
logger.critical(f"Environment variable {var_name} is missing.") logger.critical(f"Environment variable {var} is missing.")
exit(1) exit(1)
# Construct WS URL # Build WS URL from LNBITS_URL
if LNBITS_URL.startswith("https://"): if LNBITS_URL.startswith("https://"):
base_ws = LNBITS_URL.replace("https://", "wss://", 1) base_ws = LNBITS_URL.replace("https://", "wss://", 1)
elif LNBITS_URL.startswith("http://"): elif LNBITS_URL.startswith("http://"):
@ -60,27 +60,24 @@ elif LNBITS_URL.startswith("http://"):
else: else:
logger.critical("LNBITS_URL must start with http:// or https://") logger.critical("LNBITS_URL must start with http:// or https://")
exit(1) exit(1)
LNBITS_WS_URL = f"{base_ws}/api/v1/ws/{LNBITS_API_KEY}" LNBITS_WS_URL = f"{base_ws}/api/v1/ws/{LNBITS_API_KEY}"
# ─── Database Setup ─────────────────────────────────────────────────────────── # ─── Database Setup ───────────────────────────────────────────────────────────
Base = declarative_base() Base = declarative_base()
class Payment(Base): class Payment(Base):
__tablename__ = "payments" __tablename__ = "payments"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
payment_hash = Column(String(256), unique=True, nullable=False, index=True) payment_hash = Column(String(256), unique=True, nullable=False, index=True)
user_id = Column(String(64), nullable=False, index=True) user_id = Column(String(64), nullable=False, index=True)
amount = Column(Integer, nullable=False) amount = Column(Integer, nullable=False)
paid_at = Column(DateTime, default=datetime.utcnow, nullable=False) paid_at = Column(DateTime, default=datetime.utcnow, nullable=False)
engine = create_engine(DATABASE_URL, future=True) engine = create_engine(DATABASE_URL, future=True)
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False) SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
logger.info("✅ Database tables ensured.") logger.info("✅ Database tables ensured.")
# ─── Discord Bot Setup ──────────────────────────────────────────────────────── # ─── Discord Bot Setup ────────────────────────────────────────────────────────
intents = discord.Intents.default() intents = discord.Intents.default()
intents.members = True intents.members = True
@ -91,31 +88,34 @@ pending_invoices = {} # payment_hash -> (user_id, interaction)
# ─── Role Assignment Handler ────────────────────────────────────────────────── # ─── Role Assignment Handler ──────────────────────────────────────────────────
async def assign_role_after_payment(payment_hash: str, payment_obj: dict): async def assign_role_after_payment(payment_hash: str, payment_obj: dict):
logger.debug(f"ENTER assign_role_after_payment for hash={payment_hash}") logger.debug(f"ENTER assign_role_after_payment for hash={payment_hash}")
user_id, interaction = pending_invoices.pop(payment_hash, (None, None)) data = pending_invoices.pop(payment_hash, None)
if user_id is None: if data is None:
logger.info(f"No pending invoice for hash={payment_hash}") logger.info(f"No pending invoice for hash={payment_hash}")
return return
user_id, interaction = data
# Record to database # Convert from millisatoshis to sats
amt = payment_obj.get("amount", 0) raw_msat = payment_obj.get("amount", 0)
sat_amount = raw_msat // 1000
# Record payment in DB
session = SessionLocal() session = SessionLocal()
try: try:
payment = Payment( payment = Payment(
payment_hash=payment_hash, payment_hash=payment_hash,
user_id=str(user_id), user_id=str(user_id),
amount=amt, amount=sat_amount,
paid_at=datetime.utcnow() paid_at=datetime.utcnow()
) )
session.add(payment) session.add(payment)
session.commit() session.commit()
logger.info(f"💾 Logged payment {payment_hash} for user {user_id} ({amt} sats).") logger.info(f"💾 Logged payment {payment_hash} for user {user_id} ({sat_amount} sats).")
except Exception: except Exception:
session.rollback() session.rollback()
logger.exception("DB error saving payment") logger.exception("DB error saving payment")
finally: finally:
session.close() session.close()
# Fetch guild, member, role
guild = bot.get_guild(GUILD_ID) guild = bot.get_guild(GUILD_ID)
if not guild: if not guild:
logger.error(f"Guild {GUILD_ID} not found") logger.error(f"Guild {GUILD_ID} not found")
@ -123,11 +123,12 @@ async def assign_role_after_payment(payment_hash: str, payment_obj: dict):
member = guild.get_member(user_id) or await guild.fetch_member(user_id) 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: channel = bot.get_channel(CHANNEL_ID)
logger.error(f"Role {ROLE_ID} not found in guild {guild.id}")
if not all([member, role, channel]):
logger.error("Member, role, or channel not found")
return return
# Assign role if needed
if role not in member.roles: if role not in member.roles:
logger.debug(f"Adding role '{role.name}' to {member.display_name}") logger.debug(f"Adding role '{role.name}' to {member.display_name}")
try: try:
@ -135,22 +136,20 @@ async def assign_role_after_payment(payment_hash: str, payment_obj: dict):
member.add_roles(role, reason="Paid via Lightning"), member.add_roles(role, reason="Paid via Lightning"),
timeout=10 timeout=10
) )
channel = bot.get_channel(CHANNEL_ID)
await channel.send( await channel.send(
f"🎉 {member.mention} has paid **{amt} sats** and received the **{role.name}** role!" f"🎉 {member.mention} has paid **{sat_amount} sats** "
f"and received the **{role.name}** role!"
) )
logger.info(f"✅ Role '{role.name}' assigned to {member.display_name}") logger.info(f"✅ Role '{role.name}' assigned to {member.display_name}")
except Exception: except Exception:
logger.exception("Error assigning role or sending confirmation") logger.exception("Error assigning role or sending confirmation")
else: else:
# Already has role
channel = bot.get_channel(CHANNEL_ID)
await channel.send( await channel.send(
f" {member.mention} paid again (hash={payment_hash}), role **{role.name}** already assigned." f" {member.mention} paid again (hash={payment_hash}), "
f"role **{role.name}** already assigned."
) )
logger.info(f"User {member.display_name} already had role; notified channel.") logger.info(f"User {member.display_name} already had role; notified channel.")
# ─── LNbits WebSocket Listener ──────────────────────────────────────────────── # ─── LNbits WebSocket Listener ────────────────────────────────────────────────
async def lnbits_ws_listener(): async def lnbits_ws_listener():
await bot.wait_until_ready() await bot.wait_until_ready()
@ -166,15 +165,9 @@ async def lnbits_ws_listener():
pay = data.get("payment", {}) pay = data.get("payment", {})
hsh = pay.get("checking_id") or pay.get("payment_hash") hsh = pay.get("checking_id") or pay.get("payment_hash")
amt = pay.get("amount", 0) amt = pay.get("amount", 0)
# LNbits uses status="success" on paid
if hsh and pay.get("status") == "success" and amt > 0: if hsh and pay.get("status") == "success" and amt > 0:
logger.info( logger.info(f"💡 Payment received hash={hsh}, amt={amt}")
f"💡 Payment received hash={hsh}, amt={amt}" bot.loop.create_task(assign_role_after_payment(hsh, pay))
)
# schedule role assignment
bot.loop.create_task(
assign_role_after_payment(hsh, pay)
)
else: else:
logger.debug("Ignored WS update (not a paid invoice)") logger.debug("Ignored WS update (not a paid invoice)")
except json.JSONDecodeError: except json.JSONDecodeError:
@ -183,22 +176,17 @@ async def lnbits_ws_listener():
logger.exception("WS listener error; reconnecting in 10s") logger.exception("WS listener error; reconnecting in 10s")
await asyncio.sleep(10) await asyncio.sleep(10)
# ─── Slash Command Registration ─────────────────────────────────────────────── # ─── Slash Command Registration ───────────────────────────────────────────────
@bot.event @bot.event
async def on_ready(): async def on_ready():
logger.info(f"Logged in as {bot.user} (ID: {bot.user.id})") logger.info(f"Logged in as {bot.user} (ID: {bot.user.id})")
# sync commands
try: try:
synced = await bot.tree.sync() synced = await bot.tree.sync()
logger.info(f"✅ Synced {len(synced)} command(s).") logger.info(f"✅ Synced {len(synced)} command(s).")
except Exception: except Exception:
logger.exception("Failed to sync commands") logger.exception("Failed to sync commands")
# start WS listener
bot.loop.create_task(lnbits_ws_listener()) bot.loop.create_task(lnbits_ws_listener())
@bot.tree.command(name=COMMAND_NAME, description="Pay to get your role via Lightning") @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_ch = bot.get_channel(CHANNEL_ID) invoice_ch = bot.get_channel(CHANNEL_ID)
@ -209,11 +197,18 @@ async def dynamic_command(interaction: discord.Interaction):
) )
return return
# offload blocking REST call + QR gen
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
invoice_data = {"out": False, "amount": PRICE, "memo": f"Role for {interaction.user.display_name}"} invoice_data = {
headers = {"X-Api-Key": LNBITS_API_KEY, "Content-Type": "application/json"} "out": False,
"amount": PRICE,
"memo": f"Role for {interaction.user.display_name}"
}
headers = {
"X-Api-Key": LNBITS_API_KEY,
"Content-Type": "application/json"
}
# Create invoice
def create_invoice(): def create_invoice():
resp = requests.post( resp = requests.post(
f"{LNBITS_URL}/api/v1/payments", f"{LNBITS_URL}/api/v1/payments",
@ -224,10 +219,9 @@ async def dynamic_command(interaction: discord.Interaction):
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
# Create invoice
try: try:
invoice_json = await loop.run_in_executor(None, create_invoice) invoice_json = await loop.run_in_executor(None, create_invoice)
except Exception as e: except Exception:
logger.exception("Error creating LNbits invoice") logger.exception("Error creating LNbits invoice")
await interaction.response.send_message( await interaction.response.send_message(
"❌ Failed to generate invoice. Try again later.", "❌ Failed to generate invoice. Try again later.",
@ -235,32 +229,41 @@ async def dynamic_command(interaction: discord.Interaction):
) )
return return
pay_req = invoice_json["bolt11"] payment_request = invoice_json["bolt11"]
pay_hash = invoice_json["payment_hash"] payment_hash = invoice_json["payment_hash"]
pending_invoices[pay_hash] = (interaction.user.id, interaction) pending_invoices[payment_hash] = (interaction.user.id, interaction)
logger.info(f"Stored pending invoice {pay_hash} for user {interaction.user.id}") logger.info(f"Stored pending invoice {payment_hash} for user {interaction.user.id}")
# Generate QR # Generate QR code
qr_bytes = await loop.run_in_executor(None, lambda: qrcode.make(pay_req).get_image().tobytes()) def make_qr_buffer():
qr_file = File(io.BytesIO(qr_bytes), filename="invoice.png") img = qrcode.make(payment_request)
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return buf
qr_buffer = await loop.run_in_executor(None, make_qr_buffer)
qr_file = File(qr_buffer, filename="invoice.png")
embed = Embed( embed = Embed(
title="⚡ Please Pay ⚡", title="⚡ Please Pay ⚡",
description=INVOICE_MESSAGE_TEMPLATE, description=INVOICE_MESSAGE_TEMPLATE,
color=discord.Color.gold() color=discord.Color.gold()
) )
embed.add_field(name="Invoice", value=f"```{pay_req}```", inline=False) embed.add_field(name="Invoice", value=f"```{payment_request}```", 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_image(url="attachment://invoice.png") embed.set_image(url="attachment://invoice.png")
embed.set_footer(text=f"Hash: {pay_hash}") embed.set_footer(text=f"Hash: {payment_hash}")
# Send invoice & ack await invoice_ch.send(
await invoice_ch.send(content=interaction.user.mention, embed=embed, file=qr_file) content=interaction.user.mention,
embed=embed,
file=qr_file
)
await interaction.response.send_message( await interaction.response.send_message(
f"✅ Invoice posted in {invoice_ch.mention}", ephemeral=True f"✅ Invoice posted in {invoice_ch.mention}", ephemeral=True
) )
# ─── Run Bot ────────────────────────────────────────────────────────────────── # ─── Run Bot ──────────────────────────────────────────────────────────────────
if __name__ == "__main__": if __name__ == "__main__":
bot.run(DISCORD_TOKEN) bot.run(DISCORD_TOKEN)