diff --git a/discord_lnbits_bot.py b/discord_lnbits_bot.py index df15b56..f491bcd 100644 --- a/discord_lnbits_bot.py +++ b/discord_lnbits_bot.py @@ -22,7 +22,7 @@ from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker # ─── Environment & Logging ──────────────────────────────────────────────────── -load_dotenv() # loads variables from .env into os.environ +load_dotenv() # loads .env into os.environ logging.basicConfig( level=logging.INFO, @@ -40,19 +40,19 @@ 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 +DATABASE_URL = os.getenv("DATABASE_URL") -# Sanity checks -for var_name in ( +# Ensure all required env vars are present +for var in ( "DISCORD_TOKEN", "GUILD_ID", "ROLE_ID", - "LNBITS_URL", "LNBITS_API_KEY", "PRICE", + "LNBITS_URL", "LNBITS_API_KEY", "PRICE", "CHANNEL_ID", "DATABASE_URL" ): - if not globals()[var_name]: - logger.critical(f"Environment variable {var_name} is missing.") + if not globals()[var]: + logger.critical(f"Environment variable {var} is missing.") exit(1) -# Construct WS URL +# Build WS URL from LNBITS_URL if LNBITS_URL.startswith("https://"): base_ws = LNBITS_URL.replace("https://", "wss://", 1) elif LNBITS_URL.startswith("http://"): @@ -60,27 +60,24 @@ elif LNBITS_URL.startswith("http://"): 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) + 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 @@ -91,31 +88,34 @@ 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: + data = pending_invoices.pop(payment_hash, None) + if data is None: logger.info(f"No pending invoice for hash={payment_hash}") return + user_id, interaction = data - # Record to database - amt = payment_obj.get("amount", 0) + # Convert from millisatoshis to sats + raw_msat = payment_obj.get("amount", 0) + sat_amount = raw_msat // 1000 + + # Record payment in DB session = SessionLocal() try: payment = Payment( payment_hash=payment_hash, user_id=str(user_id), - amount=amt, + amount=sat_amount, paid_at=datetime.utcnow() ) session.add(payment) 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: 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") @@ -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) role = guild.get_role(ROLE_ID) - if not role: - logger.error(f"Role {ROLE_ID} not found in guild {guild.id}") + channel = bot.get_channel(CHANNEL_ID) + + if not all([member, role, channel]): + logger.error("Member, role, or channel not found") return - # Assign role if needed if role not in member.roles: logger.debug(f"Adding role '{role.name}' to {member.display_name}") 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"), timeout=10 ) - channel = bot.get_channel(CHANNEL_ID) 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}") 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." + 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.") - # ─── LNbits WebSocket Listener ──────────────────────────────────────────────── async def lnbits_ws_listener(): await bot.wait_until_ready() @@ -166,15 +165,9 @@ async def lnbits_ws_listener(): 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) - ) + logger.info(f"💡 Payment received hash={hsh}, amt={amt}") + bot.loop.create_task(assign_role_after_payment(hsh, pay)) else: logger.debug("Ignored WS update (not a paid invoice)") except json.JSONDecodeError: @@ -183,22 +176,17 @@ async def lnbits_ws_listener(): 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) @@ -209,11 +197,18 @@ async def dynamic_command(interaction: discord.Interaction): ) 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"} + 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" + } + # Create invoice def create_invoice(): resp = requests.post( f"{LNBITS_URL}/api/v1/payments", @@ -224,10 +219,9 @@ async def dynamic_command(interaction: discord.Interaction): resp.raise_for_status() return resp.json() - # Create invoice try: invoice_json = await loop.run_in_executor(None, create_invoice) - except Exception as e: + except Exception: logger.exception("Error creating LNbits invoice") await interaction.response.send_message( "❌ Failed to generate invoice. Try again later.", @@ -235,32 +229,41 @@ async def dynamic_command(interaction: discord.Interaction): ) 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}") + payment_request = invoice_json["bolt11"] + payment_hash = invoice_json["payment_hash"] + pending_invoices[payment_hash] = (interaction.user.id, interaction) + logger.info(f"Stored pending invoice {payment_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") + # Generate QR code + def make_qr_buffer(): + 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( 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="Invoice", value=f"```{payment_request}```", 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}") + embed.set_footer(text=f"Hash: {payment_hash}") - # Send invoice & ack - await invoice_ch.send(content=interaction.user.mention, embed=embed, file=qr_file) + 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)