discord-lnbits-bot/discord_lnbits_bot.py

226 lines
9.9 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 requests
import logging
from datetime import datetime
2025-05-14 14:59:35 +00:00
from dateutil.relativedelta import relativedelta
2025-05-14 15:12:23 +00:00
import qrcode
2025-05-11 00:05:59 +00:00
import discord
from discord import File, Embed
from discord.ext import commands
import websockets
from sqlalchemy import (
2025-05-14 14:59:35 +00:00
create_engine, Column, String, Integer, DateTime, Text, Enum, ForeignKey
2025-05-11 00:05:59 +00:00
)
2025-05-14 15:12:23 +00:00
from sqlalchemy.dialects.postgresql import JSONB
2025-05-11 00:05:59 +00:00
from sqlalchemy.ext.declarative import declarative_base
2025-05-14 14:59:35 +00:00
from sqlalchemy.orm import sessionmaker, relationship
2025-05-11 00:05:59 +00:00
2025-05-14 14:59:35 +00:00
from apscheduler.schedulers.asyncio import AsyncIOScheduler
2025-05-11 00:05:59 +00:00
2025-05-14 16:15:24 +00:00
# ─── Logging ──────────────────────────────────────────────────────────────
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s | %(message)s")
2025-05-11 00:05:59 +00:00
logger = logging.getLogger("discord_lnbits_bot")
2025-05-14 16:15:24 +00:00
# ─── Database & Config Seeding ────────────────────────────────────────────
DATABASE_URL = os.environ["DATABASE_URL"]
2025-05-14 14:59:35 +00:00
engine = create_engine(DATABASE_URL, future=True)
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
Base = declarative_base()
class Config(Base):
__tablename__ = "config"
key = Column(String, primary_key=True)
value = Column(Text, nullable=False)
class Plan(Base):
__tablename__ = "plans"
id = Column(Integer, primary_key=True)
name = Column(Text, nullable=False, unique=True)
command_name = Column(Text, nullable=False, unique=True)
role_id = Column(String(64), nullable=False)
channel_id = Column(String(64), nullable=False)
price_sats = Column(Integer, nullable=False)
invoice_message = Column(Text, nullable=False)
2025-05-14 16:15:24 +00:00
expiry_type = Column(Enum("fixed_date","rolling_days","calendar_month", name="expiry_type"), nullable=False)
2025-05-14 14:59:35 +00:00
duration_days = Column(Integer, default=30)
subs = relationship("Subscription", back_populates="plan")
class Subscription(Base):
__tablename__ = "subscriptions"
id = Column(Integer, primary_key=True)
plan_id = Column(Integer, ForeignKey("plans.id"), nullable=False)
user_id = Column(String(64), nullable=False)
invoice_hash = Column(String(256), unique=True, nullable=False)
invoice_request = Column(Text, nullable=False)
2025-05-14 16:15:24 +00:00
status = Column(Enum("pending","active","expired","cancelled", name="sub_status"), nullable=False, default="pending")
2025-05-14 14:59:35 +00:00
created_at = Column(DateTime, default=datetime.utcnow)
paid_at = Column(DateTime)
expires_at = Column(DateTime)
role_assigned_at = Column(DateTime)
role_removed_at = Column(DateTime)
2025-05-14 15:12:23 +00:00
raw_invoice_json = Column(JSONB)
raw_payment_json = Column(JSONB)
2025-05-14 14:59:35 +00:00
plan = relationship("Plan", back_populates="subs")
Base.metadata.create_all(engine)
logger.info("✅ Database schema ready.")
2025-05-14 16:15:24 +00:00
# Seed Config keys (if missing)
2025-05-14 15:12:23 +00:00
session = SessionLocal()
for key in ("discord_token", "guild_id", "lnbits_url", "lnbits_api_key"):
if not session.get(Config, key):
session.add(Config(key=key, value=""))
session.commit()
session.close()
2025-05-14 16:15:24 +00:00
# ─── Config helper ─────────────────────────────────────────────────────────
def get_cfg(k: str) -> str:
2025-05-14 15:12:23 +00:00
db = SessionLocal()
2025-05-14 16:15:24 +00:00
row = db.get(Config, k)
2025-05-14 15:12:23 +00:00
db.close()
return row.value if row else ""
2025-05-14 14:59:35 +00:00
DISCORD_TOKEN = get_cfg("discord_token")
GUILD_ID = int(get_cfg("guild_id") or 0)
LNBITS_URL = get_cfg("lnbits_url")
LNBITS_API_KEY = get_cfg("lnbits_api_key")
2025-05-14 16:15:24 +00:00
for name, val in (("discord_token", DISCORD_TOKEN), ("guild_id", GUILD_ID),
("lnbits_url", LNBITS_URL), ("lnbits_api_key", LNBITS_API_KEY)):
2025-05-14 14:59:35 +00:00
if not val:
2025-05-14 16:15:24 +00:00
logger.critical(f"Config '{name}' is empty in DB. Fill via UI first.")
2025-05-11 00:05:59 +00:00
exit(1)
2025-05-14 16:15:24 +00:00
# Build WS URL
2025-05-11 00:05:59 +00:00
if LNBITS_URL.startswith("https://"):
2025-05-14 14:59:35 +00:00
ws_base = LNBITS_URL.replace("https://", "wss://", 1)
2025-05-11 00:05:59 +00:00
elif LNBITS_URL.startswith("http://"):
2025-05-14 14:59:35 +00:00
ws_base = LNBITS_URL.replace("http://", "ws://", 1)
2025-05-11 00:05:59 +00:00
else:
logger.critical("LNBITS_URL must start with http:// or https://")
exit(1)
2025-05-14 14:59:35 +00:00
LNBITS_WS_URL = f"{ws_base}/api/v1/ws/{LNBITS_API_KEY}"
2025-05-11 00:05:59 +00:00
2025-05-14 16:15:24 +00:00
# ─── Discord Bot ──────────────────────────────────────────────────────────
intents = discord.Intents.default(); intents.members = True
2025-05-11 00:05:59 +00:00
bot = commands.Bot(command_prefix="!", intents=intents)
2025-05-14 14:59:35 +00:00
def compute_expiry(paid_at: datetime, plan: Plan) -> datetime:
if plan.expiry_type == "calendar_month":
first = paid_at.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
return first + relativedelta(months=1)
2025-05-14 15:12:23 +00:00
if plan.expiry_type == "rolling_days":
2025-05-14 14:59:35 +00:00
return paid_at + relativedelta(days=plan.duration_days)
2025-05-14 16:15:24 +00:00
return paid_at
2025-05-14 14:59:35 +00:00
2025-05-14 16:15:24 +00:00
async def handle_plan_purchase(interaction, plan: Plan):
2025-05-14 14:59:35 +00:00
invoice_ch = bot.get_channel(int(plan.channel_id))
if not invoice_ch:
2025-05-14 16:15:24 +00:00
return await interaction.response.send_message("❌ Invoice channel not found.", ephemeral=True)
2025-05-11 00:05:59 +00:00
2025-05-14 16:15:24 +00:00
invoice_data = {"out": False, "amount": plan.price_sats, "memo": plan.invoice_message}
headers = {"X-Api-Key": LNBITS_API_KEY, "Content-Type": "application/json"}
2025-05-14 14:59:35 +00:00
loop = asyncio.get_running_loop()
2025-05-11 00:05:59 +00:00
def create_invoice():
2025-05-14 16:15:24 +00:00
r = requests.post(f"{LNBITS_URL}/api/v1/payments", json=invoice_data, headers=headers, timeout=10)
2025-05-14 15:12:23 +00:00
r.raise_for_status()
return r.json()
2025-05-11 00:05:59 +00:00
try:
2025-05-14 14:59:35 +00:00
inv = await loop.run_in_executor(None, create_invoice)
2025-05-14 13:39:58 +00:00
except Exception:
2025-05-14 14:59:35 +00:00
logger.exception("Invoice creation failed")
2025-05-14 16:15:24 +00:00
return await interaction.response.send_message("❌ Could not generate invoice.", ephemeral=True)
2025-05-14 14:59:35 +00:00
2025-05-14 16:15:24 +00:00
pay_req, pay_hash = inv["bolt11"], inv["payment_hash"]
2025-05-14 14:59:35 +00:00
db = SessionLocal()
2025-05-14 16:15:24 +00:00
sub = Subscription(plan_id=plan.id, user_id=str(interaction.user.id),
invoice_hash=pay_hash, invoice_request=pay_req,
raw_invoice_json=inv)
2025-05-14 15:12:23 +00:00
db.add(sub); db.commit(); db.close()
2025-05-11 00:05:59 +00:00
2025-05-14 14:59:35 +00:00
def make_qr_buf():
2025-05-14 16:15:24 +00:00
buf = io.BytesIO(); qrcode.make(pay_req).save(buf, format="PNG"); buf.seek(0); return buf
qr_buf = await loop.run_in_executor(None, make_qr_buf)
qr_file= File(qr_buf, "invoice.png")
2025-05-14 13:39:58 +00:00
2025-05-14 16:15:24 +00:00
embed = Embed(title=f"⚡ Pay {plan.price_sats} sats for {plan.name}",
description=plan.invoice_message, color=discord.Color.gold())
2025-05-14 14:59:35 +00:00
embed.add_field(name="Invoice", value=f"```{pay_req}```", inline=False)
2025-05-11 00:05:59 +00:00
embed.set_image(url="attachment://invoice.png")
2025-05-14 14:59:35 +00:00
embed.set_footer(text=f"Hash: {pay_hash}")
2025-05-11 00:05:59 +00:00
2025-05-14 14:59:35 +00:00
await invoice_ch.send(content=interaction.user.mention, embed=embed, file=qr_file)
2025-05-14 16:15:24 +00:00
await interaction.response.send_message(f"✅ Invoice posted in {invoice_ch.mention}", ephemeral=True)
2025-05-11 00:05:59 +00:00
2025-05-14 14:59:35 +00:00
async def lnbits_ws_listener():
await bot.wait_until_ready()
2025-05-14 16:15:24 +00:00
logger.info(f"Listening on WS {LNBITS_WS_URL}")
2025-05-14 14:59:35 +00:00
while True:
try:
async with websockets.connect(LNBITS_WS_URL) as ws:
async for msg in ws:
2025-05-14 16:15:24 +00:00
data = json.loads(msg); pay = data.get("payment", {})
hsh = pay.get("checking_id") or pay.get("payment_hash")
if not (hsh and pay.get("status")=="success"): continue
2025-05-14 15:12:23 +00:00
2025-05-14 16:15:24 +00:00
db = SessionLocal()
sub= db.query(Subscription).filter_by(invoice_hash=hsh, status="pending").first()
if not sub: db.close(); continue
2025-05-14 15:12:23 +00:00
2025-05-14 16:15:24 +00:00
paid_at= datetime.utcnow()
expires_at= compute_expiry(paid_at, sub.plan)
sub.status=sub.paid_at=paid_at; sub.expires_at=expires_at
sub.raw_payment_json=pay; db.commit(); db.close()
2025-05-14 15:12:23 +00:00
guild = bot.get_guild(GUILD_ID)
member = guild.get_member(int(sub.user_id)) or await guild.fetch_member(int(sub.user_id))
role = guild.get_role(int(sub.plan.role_id))
chan = bot.get_channel(int(sub.plan.channel_id))
await member.add_roles(role, reason="Subscription paid")
2025-05-14 16:15:24 +00:00
await chan.send(f"🎉 {member.mention} paid **{sub.plan.price_sats} sats** "
f"for **{sub.plan.name}**, expires {expires_at:%Y-%m-%d}.")
2025-05-14 14:59:35 +00:00
except Exception:
2025-05-14 16:15:24 +00:00
logger.exception("WS error, reconnecting in 10s"); await asyncio.sleep(10)
2025-05-14 14:59:35 +00:00
async def cleanup_expired():
2025-05-14 16:15:24 +00:00
now= datetime.utcnow(); db= SessionLocal()
rows= db.query(Subscription).filter(Subscription.status=="active", Subscription.expires_at<=now).all()
2025-05-14 15:12:23 +00:00
for sub in rows:
2025-05-14 14:59:35 +00:00
guild = bot.get_guild(GUILD_ID)
member = guild.get_member(int(sub.user_id))
role = guild.get_role(int(sub.plan.role_id))
if member and role in member.roles:
try:
await member.remove_roles(role, reason="Subscription expired")
2025-05-14 16:15:24 +00:00
sub.status="expired"; sub.role_removed_at=now; db.commit()
logger.info(f"Removed expired role from {member.display_name}")
2025-05-14 14:59:35 +00:00
except Exception:
2025-05-14 16:15:24 +00:00
logger.exception("Failed to remove expired role")
2025-05-14 14:59:35 +00:00
db.close()
@bot.event
async def on_ready():
2025-05-14 16:15:24 +00:00
logger.info(f"Logged in as {bot.user}")
db = SessionLocal(); plans = db.query(Plan).all()
2025-05-14 14:59:35 +00:00
for plan in plans:
2025-05-14 16:15:24 +00:00
@bot.tree.command(name=plan.command_name, description=f"Buy {plan.name}")
async def _cmd(interaction, plan=plan):
2025-05-14 14:59:35 +00:00
await handle_plan_purchase(interaction, plan)
db.close()
await bot.tree.sync()
bot.loop.create_task(lnbits_ws_listener())
2025-05-14 16:15:24 +00:00
sched= AsyncIOScheduler(timezone="UTC")
2025-05-14 15:12:23 +00:00
sched.add_job(cleanup_expired, "cron", hour=0, minute=0)
sched.start()
2025-05-14 14:59:35 +00:00
2025-05-14 16:15:24 +00:00
if __name__=="__main__":
2025-05-11 00:05:59 +00:00
bot.run(DISCORD_TOKEN)