Compare commits

...

41 Commits
main ... docker

Author SHA1 Message Date
28affcc3c3 Update docker-compose.yml 2025-05-14 18:26:09 +00:00
5510c07108 Update Dockerfile 2025-05-14 16:53:47 +00:00
4b30c18310 Update docker-compose.yml 2025-05-14 16:47:49 +00:00
4d2262e48f Update setup.sh 2025-05-14 16:39:31 +00:00
0433394e01 Update docker-compose.yml 2025-05-14 16:39:12 +00:00
6705f7bff9 Update docker-compose.yml 2025-05-14 16:27:12 +00:00
b4356f1182 Update docker-compose.yml 2025-05-14 16:26:51 +00:00
41126309f8 Update setup.sh 2025-05-14 16:17:35 +00:00
a07cdecc32 Update discord_lnbits_bot.py 2025-05-14 16:15:24 +00:00
22812b628d Update app.py 2025-05-14 16:14:22 +00:00
92c3213384 Delete .env.example 2025-05-14 16:13:45 +00:00
82209902d5 Update Dockerfile 2025-05-14 16:12:39 +00:00
daeae200e7 Update docker-compose.yml 2025-05-14 16:12:15 +00:00
37a1f363f8 Update setup.sh 2025-05-14 16:11:40 +00:00
afa18cb3bb Update app.py 2025-05-14 16:08:22 +00:00
c7c1aa3e7e Add .gitignore 2025-05-14 15:58:50 +00:00
30ced35e87 Update docker-compose.yml 2025-05-14 15:58:19 +00:00
232a07629e Add setup.sh 2025-05-14 15:57:34 +00:00
f8e5bbcd75 Update .env.example 2025-05-14 15:51:32 +00:00
e3100bd17f Update .env.example 2025-05-14 15:48:42 +00:00
dd8abc227b Update README.md 2025-05-14 15:38:46 +00:00
10e51b3655 Update discord_lnbits_bot.py 2025-05-14 15:12:23 +00:00
c914dc1753 Update requirements.txt 2025-05-14 15:11:15 +00:00
3b646efdc8 Update docker-compose.yml 2025-05-14 15:10:17 +00:00
6cc08dad0d Add templates/settings.html 2025-05-14 15:03:07 +00:00
f6cd2cb415 Add app.py 2025-05-14 15:02:34 +00:00
17c8861c45 Update .env 2025-05-14 15:02:03 +00:00
0243671381 Update requirements.txt 2025-05-14 15:01:36 +00:00
a1731290f0 Update Dockerfile 2025-05-14 15:01:18 +00:00
cc606d4aa0 Update docker-compose.yml 2025-05-14 15:00:16 +00:00
4edc7b673f Update discord_lnbits_bot.py 2025-05-14 14:59:35 +00:00
29224cf51c Delete config.json 2025-05-14 14:59:14 +00:00
0fd63cd8f1 Update .env 2025-05-14 14:32:15 +00:00
ca865bd393 Update docker-compose.yml 2025-05-14 13:53:43 +00:00
9117b13070 Update .env 2025-05-14 13:50:23 +00:00
e04e14b647 Update requirements.txt 2025-05-14 13:42:01 +00:00
a983fc0d87 Update discord_lnbits_bot.py 2025-05-14 13:39:58 +00:00
d2e5d5a390 Update discord_lnbits_bot.py 2025-05-11 00:05:59 +00:00
9bd999fc32 Add .env 2025-05-11 00:02:12 +00:00
871a130eaf Add docker-compose.yml 2025-05-11 00:01:04 +00:00
48197438b5 Add Dockerfile 2025-05-10 23:59:55 +00:00
10 changed files with 478 additions and 241 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/data/secrets/

24
Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM python:3.10-slim
# Set working directory
WORKDIR /app
# Install dependencies (including PostgreSQL client for pg_isready)
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
postgresql-client \
--no-install-recommends && \
rm -rf /var/lib/apt/lists/*
# Copy requirements first to leverage Docker caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy the full application code
COPY . .
# Default command (or override via docker-compose)
CMD ["sh", "-c", "until pg_isready -h db -p 5432; do echo '[web] waiting for db…'; sleep 1; done && python app.py"]

View File

@ -1,27 +1,32 @@
# Discord LNbits Bot
This bot allows users to purchase a **Discord role** using **Bitcoin Lightning payments** through LNbits.
Users request an invoice via a **slash command** (configurable), and the bot automatically assigns the role **once payment is confirmed**.
A full-stack Dockerized membership engine that lets users purchase Discord roles via Bitcoin Lightning (LNbits). It provides:
- A Discord bot that dynamically registers slash-commands per membership “plan”
- A Flask web UI (port 3000) for configuring your Discord/LNbits credentials and creating/editing membership plans
- A Postgres backend to persist plans, subscriptions, and payment logs
- Automated expiry logic (calendar-month, rolling-days, fixed-date) with scheduled role revocation
---
## 🚀 Features
- ✅ Dynamic slashcommand name (configured via `command_name`)
- ✅ Generates a **Lightning invoice** via LNbits
- ✅ Listens on LNbits WebSocket for paid invoices
- ✅ Automatically assigns a **Discord role** on payment
- ✅ Posts confirmations directly in your designated channel
- 🔧 **One-click Docker Compose**: bot + web UI + database
- ✅ **Dynamic slash-commands** for each plan (no code changes needed)
- ⚡ **Lightning invoices** via LNbits REST API
- 🌐 **WebSocket listener** for incoming payments
- 👤 **Automatic role assignment** on payment
- ⏰ **Scheduled cleanup** of expired subscriptions
- 📊 **Persistent audit log** in Postgres (raw JSON, timestamps, statuses)
---
## 📋 Requirements
- **Ubuntu 24.04**
- **Python 3.10+**
- **A Discord Bot Token** (from the [Developer Portal](https://discord.com/developers/applications))
- **LNbits Wallet Invoice Key**
- **A running LNbits instance** (e.g., [Sats.Love](https://sats.love/))
- **Docker Engine (v20+)**
- **Docker Compose (v1.29+ or the built-in docker compose)**
---
@ -86,19 +91,25 @@ git clone https://code.rustysats.com/saulteafarmer/discord-lnbits-bot
cd discord-lnbits-bot
```
### 3. Create & Activate Virtual Environment
### 3. Copy & Edit .env
```bash
python3 -m venv venv
source venv/bin/activate
cp .env.example .env
nano .env
```
### 4. Install Dependencies
### 4. Buld & run
```bash
pip install -r requirements.txt
docker-compose up -d --build
```
### 5. Open the web UI
Visit: http://localhost:3000
- Configure your Discord Bot Token, Guild ID, LNbits URL, LNbits Invoice Key
- Define membership plans (command name, role ID, channel ID, price, expiry policy)
---
## 3⃣ Find Your Discord Guild ID & Role ID

42
app.py Normal file
View File

@ -0,0 +1,42 @@
#!/usr/bin/env python3
import os
from flask import Flask, render_template, request, redirect, flash
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# import ORM models & Base from your bot module
from discord_lnbits_bot import Base, Config, Plan
# Flask setup
app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET"]
# Database setup
DATABASE_URL = os.environ["DATABASE_URL"]
engine = create_engine(DATABASE_URL, future=True)
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
Base.metadata.create_all(engine)
@app.route("/", methods=["GET", "POST"])
def settings():
db = SessionLocal()
if request.method == "POST":
# save core config keys
for key in ("discord_token", "guild_id", "lnbits_url", "lnbits_api_key"):
val = request.form.get(key, "").strip()
cfg = db.get(Config, key)
if cfg:
cfg.value = val
else:
db.add(Config(key=key, value=val))
db.commit()
flash("Configuration saved.", "success")
return redirect("/")
configs = db.query(Config).all()
plans = db.query(Plan).all()
db.close()
return render_template("settings.html", configs=configs, plans=plans)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=3000)

View File

@ -1,12 +0,0 @@
{
"discord_token": "YOUR_DISCORD_BOT_TOKEN",
"guild_id": "YOUR_GUILD_ID",
"role_id": "YOUR_ROLE_ID",
"channelid": "YOUR_CHANNELID_FOR_PURCHASE_ROOM",
"lnbits_url": "https://sats.love",
"lnbits_api_key": "YOUR_INVOICE_READ_KEY",
"price": 1000,
"invoicemessage": "Invoice for your purchase.",
"command_name": "support"
}

View File

@ -1,210 +1,225 @@
import discord
#!/usr/bin/env python3
import os
import asyncio
import requests
import json
import io
import requests
import logging
from datetime import datetime
from dateutil.relativedelta import relativedelta
import qrcode
import websockets
import traceback
import discord
from discord import File, Embed
from discord.ext import commands
# --- Configuration Loading ---
try:
with open("config.json", "r") as f:
config = json.load(f)
except FileNotFoundError:
print("❌ Error: config.json not found. Please create it and fill in the details.")
exit()
except json.JSONDecodeError:
print("❌ Error: config.json is not valid JSON. Please check its syntax.")
exit()
import websockets
# Load values
TOKEN = config.get("discord_token")
GUILD_ID = int(config.get("guild_id", 0))
ROLE_ID = int(config.get("role_id", 0))
LNBITS_URL = config.get("lnbits_url")
LNBITS_API_KEY = config.get("lnbits_api_key")
PRICE = config.get("price")
CHANNEL_ID = int(config.get("channelid", 0))
INVOICE_MESSAGE_TEMPLATE = config.get("invoicemessage", "Invoice for your purchase.")
COMMAND_NAME = config.get("command_name", "support")
from sqlalchemy import (
create_engine, Column, String, Integer, DateTime, Text, Enum, ForeignKey
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, relationship
# Validate
if not LNBITS_URL or not LNBITS_API_KEY:
print("❌ Error: LNBITS_URL or LNBITS_API_KEY is missing in config.json.")
exit()
from apscheduler.schedulers.asyncio import AsyncIOScheduler
clean_lnbits_http_url = LNBITS_URL.rstrip('/')
if clean_lnbits_http_url.startswith("https://"):
base_ws_url = clean_lnbits_http_url.replace("https://", "wss://", 1)
elif clean_lnbits_http_url.startswith("http://"):
base_ws_url = clean_lnbits_http_url.replace("http://", "ws://", 1)
# ─── Logging ──────────────────────────────────────────────────────────────
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s | %(message)s")
logger = logging.getLogger("discord_lnbits_bot")
# ─── Database & Config Seeding ────────────────────────────────────────────
DATABASE_URL = os.environ["DATABASE_URL"]
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)
expiry_type = Column(Enum("fixed_date","rolling_days","calendar_month", name="expiry_type"), nullable=False)
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)
status = Column(Enum("pending","active","expired","cancelled", name="sub_status"), nullable=False, default="pending")
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)
raw_invoice_json = Column(JSONB)
raw_payment_json = Column(JSONB)
plan = relationship("Plan", back_populates="subs")
Base.metadata.create_all(engine)
logger.info("✅ Database schema ready.")
# Seed Config keys (if missing)
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()
# ─── Config helper ─────────────────────────────────────────────────────────
def get_cfg(k: str) -> str:
db = SessionLocal()
row = db.get(Config, k)
db.close()
return row.value if row else ""
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")
for name, val in (("discord_token", DISCORD_TOKEN), ("guild_id", GUILD_ID),
("lnbits_url", LNBITS_URL), ("lnbits_api_key", LNBITS_API_KEY)):
if not val:
logger.critical(f"Config '{name}' is empty in DB. Fill via UI first.")
exit(1)
# Build WS URL
if LNBITS_URL.startswith("https://"):
ws_base = LNBITS_URL.replace("https://", "wss://", 1)
elif LNBITS_URL.startswith("http://"):
ws_base = LNBITS_URL.replace("http://", "ws://", 1)
else:
print(f"❌ Error: Invalid LNBITS_URL scheme: {LNBITS_URL}.")
exit()
logger.critical("LNBITS_URL must start with http:// or https://")
exit(1)
LNBITS_WS_URL = f"{ws_base}/api/v1/ws/{LNBITS_API_KEY}"
LNBITS_WEBSOCKET_URL = f"{base_ws_url}/api/v1/ws/{LNBITS_API_KEY}"
if not all([TOKEN, GUILD_ID, ROLE_ID, PRICE, CHANNEL_ID, COMMAND_NAME]):
print("❌ Error: One or more essential configuration options are missing.")
exit()
# Discord client
intents = discord.Intents.default()
intents.members = True
# ─── Discord Bot ──────────────────────────────────────────────────────────
intents = discord.Intents.default(); intents.members = True
bot = commands.Bot(command_prefix="!", intents=intents)
pending_invoices = {}
async def assign_role_after_payment(payment_hash_received, payment_details_from_ws):
print(f"DEBUG: ENTER assign_role_after_payment for hash: {payment_hash_received}")
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)
if plan.expiry_type == "rolling_days":
return paid_at + relativedelta(days=plan.duration_days)
return paid_at
if payment_hash_received not in pending_invoices:
print(f" Hash {payment_hash_received} not found in pending_invoices.")
return
async def handle_plan_purchase(interaction, plan: Plan):
invoice_ch = bot.get_channel(int(plan.channel_id))
if not invoice_ch:
return await interaction.response.send_message("❌ Invoice channel not found.", ephemeral=True)
user_id, original_interaction = pending_invoices.pop(payment_hash_received)
print(f"DEBUG: Found pending invoice for user_id={user_id}, interaction_id={getattr(original_interaction, 'id', None)}")
invoice_data = {"out": False, "amount": plan.price_sats, "memo": plan.invoice_message}
headers = {"X-Api-Key": LNBITS_API_KEY, "Content-Type": "application/json"}
loop = asyncio.get_running_loop()
def create_invoice():
r = requests.post(f"{LNBITS_URL}/api/v1/payments", json=invoice_data, headers=headers, timeout=10)
r.raise_for_status()
return r.json()
try:
inv = await loop.run_in_executor(None, create_invoice)
except Exception:
logger.exception("Invoice creation failed")
return await interaction.response.send_message("❌ Could not generate invoice.", ephemeral=True)
pay_req, pay_hash = inv["bolt11"], inv["payment_hash"]
db = SessionLocal()
sub = Subscription(plan_id=plan.id, user_id=str(interaction.user.id),
invoice_hash=pay_hash, invoice_request=pay_req,
raw_invoice_json=inv)
db.add(sub); db.commit(); db.close()
def make_qr_buf():
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")
embed = Embed(title=f"⚡ Pay {plan.price_sats} sats for {plan.name}",
description=plan.invoice_message, color=discord.Color.gold())
embed.add_field(name="Invoice", value=f"```{pay_req}```", inline=False)
embed.set_image(url="attachment://invoice.png")
embed.set_footer(text=f"Hash: {pay_hash}")
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)
async def lnbits_ws_listener():
await bot.wait_until_ready()
logger.info(f"Listening on WS {LNBITS_WS_URL}")
while True:
try:
async with websockets.connect(LNBITS_WS_URL) as ws:
async for msg in ws:
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
db = SessionLocal()
sub= db.query(Subscription).filter_by(invoice_hash=hsh, status="pending").first()
if not sub: db.close(); continue
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()
guild = bot.get_guild(GUILD_ID)
if not guild:
print(f"❌ Guild {GUILD_ID} not found.")
return
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))
member = guild.get_member(user_id)
if not member:
await member.add_roles(role, reason="Subscription paid")
await chan.send(f"🎉 {member.mention} paid **{sub.plan.price_sats} sats** "
f"for **{sub.plan.name}**, expires {expires_at:%Y-%m-%d}.")
except Exception:
logger.exception("WS error, reconnecting in 10s"); await asyncio.sleep(10)
async def cleanup_expired():
now= datetime.utcnow(); db= SessionLocal()
rows= db.query(Subscription).filter(Subscription.status=="active", Subscription.expires_at<=now).all()
for sub in rows:
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:
member = await asyncio.wait_for(guild.fetch_member(user_id), timeout=10)
except Exception as e:
print(f"❌ Error fetching member: {e}")
return
role = guild.get_role(ROLE_ID)
if not role:
print(f"❌ Role ID {ROLE_ID} not found in guild.")
return
invoice_channel = bot.get_channel(CHANNEL_ID)
if not invoice_channel:
print(f"❌ Invoice channel ID {CHANNEL_ID} not found.")
if role not in member.roles:
try:
await asyncio.wait_for(member.add_roles(role, reason="Paid Lightning Invoice"), timeout=10)
if invoice_channel:
await invoice_channel.send(
f"🎉 {member.mention} has paid {PRICE} sats and been granted the '{role.name}' role!"
)
except Exception as e:
print(f"❌ Error assigning role: {e}")
else:
if invoice_channel:
await invoice_channel.send(
f"{member.mention}, payment confirmed! You already have the '{role.name}' role."
)
async def lnbits_websocket_listener():
await bot.wait_until_ready()
print(f"👂 Connecting to LNBits WS at {LNBITS_WEBSOCKET_URL}")
while True:
try:
async with websockets.connect(LNBITS_WEBSOCKET_URL) as ws:
print("✅ WS connected.")
while True:
try:
msg = await ws.recv()
print(f"📬 WS message: {msg}")
data = json.loads(msg)
if isinstance(data.get("payment"), dict):
p = data["payment"]
h = p.get("checking_id") or p.get("payment_hash")
amt = p.get("amount", 0)
status = p.get("status")
is_paid = (status == "success") or (p.get("paid") is True) or (p.get("pending") is False)
if h and is_paid and amt > 0:
print(f"✅ Confirmed payment hash={h}, amount={amt}, status={status}")
bot.loop.create_task(
assign_role_after_payment(h, p)
)
else:
print(f" Ignored update for hash={h}, status={status}, amount={amt}")
else:
print(f"⚠️ Unexpected WS format: {msg}")
except websockets.exceptions.ConnectionClosed:
print("⚠️ WS closed, reconnecting...")
break
except Exception as e:
print(f"❌ Error handling WS message: {e}")
except Exception as e:
print(f"❌ WS connection error: {e}. Retrying in 15s...")
await asyncio.sleep(15)
await member.remove_roles(role, reason="Subscription expired")
sub.status="expired"; sub.role_removed_at=now; db.commit()
logger.info(f"Removed expired role from {member.display_name}")
except Exception:
logger.exception("Failed to remove expired role")
db.close()
@bot.event
async def on_ready():
print(f"✅ Bot ready as {bot.user} ({bot.user.id})")
bot.loop.create_task(lnbits_websocket_listener())
# Dynamic slash command name from config
@bot.tree.command(name=COMMAND_NAME, description="Pay to get your role via Lightning.")
async def dynamic_command(interaction: discord.Interaction):
invoice_channel = bot.get_channel(CHANNEL_ID)
if not invoice_channel:
await interaction.response.send_message("❌ Invoice channel misconfigured.", ephemeral=True)
return
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"}
loop = asyncio.get_running_loop()
try:
resp = await loop.run_in_executor(None, lambda: requests.post(
f"{clean_lnbits_http_url}/api/v1/payments", json=invoice_data, headers=headers
))
resp.raise_for_status()
except Exception as e:
await interaction.response.send_message("❌ Could not create invoice. Try again later.", ephemeral=True)
print(f"❌ Invoice creation error: {e}")
return
if resp.status_code != 201:
await interaction.response.send_message(f"❌ LNBits error ({resp.status_code}).", ephemeral=True)
print(f"❌ LNBits error response: {resp.text}")
return
inv = resp.json()
pr = inv.get("bolt11")
h = inv.get("payment_hash")
if not pr or not h:
await interaction.response.send_message("❌ Invalid invoice data.", ephemeral=True)
return
pending_invoices[h] = (interaction.user.id, interaction)
print(f"DEBUG: Stored pending invoice {h} for user {interaction.user.id}")
buf = io.BytesIO()
try:
await loop.run_in_executor(None, lambda: qrcode.make(pr.upper()).save(buf, format="PNG"))
buf.seek(0)
qr_file = File(buf, filename="invoice_qr.png")
except Exception as e:
print(f"⚠️ QR generation failed: {e}")
qr_file = None
embed = Embed(title="⚡ Payment Required ⚡", description=INVOICE_MESSAGE_TEMPLATE)
embed.add_field(name="Invoice", value=f"```{pr}```", inline=False)
embed.add_field(name="Amount", value=f"{PRICE} sats", inline=True)
embed.set_footer(text=h)
if qr_file:
embed.set_image(url="attachment://invoice_qr.png")
try:
await invoice_channel.send(content=interaction.user.mention, embed=embed, file=qr_file if qr_file else None)
await interaction.response.send_message("✅ Invoice posted!", ephemeral=True)
except Exception as e:
print(f"❌ Error sending invoice message: {e}")
await interaction.response.send_message("❌ Failed to post invoice.", ephemeral=True)
logger.info(f"Logged in as {bot.user}")
db = SessionLocal(); plans = db.query(Plan).all()
for plan in plans:
@bot.tree.command(name=plan.command_name, description=f"Buy {plan.name}")
async def _cmd(interaction, plan=plan):
await handle_plan_purchase(interaction, plan)
db.close()
await bot.tree.sync()
bot.loop.create_task(lnbits_ws_listener())
sched= AsyncIOScheduler(timezone="UTC")
sched.add_job(cleanup_expired, "cron", hour=0, minute=0)
sched.start()
if __name__=="__main__":
bot.run(TOKEN)
bot.run(DISCORD_TOKEN)

62
docker-compose.yml Normal file
View File

@ -0,0 +1,62 @@
version: "3.9"
services:
db:
image: postgres:15
restart: always
environment:
POSTGRES_USER: "${DB_USER:-postgres}"
POSTGRES_PASSWORD: "${DB_PASS:-}"
POSTGRES_DB: "${DB_NAME:-lnbitsdb}"
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 5s
timeout: 2s
retries: 5
web:
build:
context: .
dockerfile: Dockerfile
restart: on-failure
depends_on:
db:
condition: service_healthy
env_file:
- .env
ports:
- "3000:3000"
# wait for the DB, then launch the Flask app under Gunicorn
command: >
sh -c "
until pg_isready -h db -U \$DB_USER; do
echo '[web] waiting for db…';
sleep 1;
done &&
exec gunicorn -b 0.0.0.0:3000 app:app
"
bot:
build:
context: .
dockerfile: Dockerfile
restart: on-failure
depends_on:
db:
condition: service_healthy
env_file:
- .env
# wait for the DB, then run your bot loop
command: >
sh -c "
until pg_isready -h db -U \$DB_USER; do
echo '[bot] waiting for db…';
sleep 1;
done &&
exec python discord_lnbits_bot.py
"
volumes:
db_data:

View File

@ -1,5 +1,12 @@
discord.py
requests
qrcode[pil]
asyncio
websockets
discord.py>=2.0.0
websockets>=10.0
requests>=2.25.0
python-dotenv>=0.21.0
SQLAlchemy>=1.4.0
psycopg2-binary>=2.9.0
qrcode>=7.3
Pillow>=9.0.0
python-dateutil>=2.8.2
APScheduler>=3.8.0
Flask>=2.0.0
gunicorn>=20.0.0

59
setup.sh Normal file
View File

@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$REPO_DIR/.env"
if [ -f "$ENV_FILE" ]; then
echo "♻️ Found existing $ENV_FILE — reusing."
else
echo "🔐 Generating new $ENV_FILE"
# ── Database defaults
DB_USER="postgres"
DB_PASS="$(openssl rand -hex 16)"
DB_NAME="lnbitsdb"
DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@db:5432/${DB_NAME}"
# ── Flask UI secret
FLASK_SECRET="$(openssl rand -hex 32)"
# ── Blank placeholders for the Web UI to fill later
cat > "$ENV_FILE" <<EOF
# ── Database
DB_USER=${DB_USER}
DB_PASS=${DB_PASS}
DB_NAME=${DB_NAME}
DATABASE_URL=${DATABASE_URL}
# ── Flask UI
FLASK_SECRET=${FLASK_SECRET}
# ── Discord Bot (edit via Web UI or manually here)
DISCORD_TOKEN=
GUILD_ID=
ROLE_ID=
CHANNEL_ID=
LNBITS_URL=
LNBITS_API_KEY=
PRICE=1000
COMMAND_NAME=support
INVOICE_MESSAGE=Thank you for supporting us!
EOF
echo "✅ Wrote defaults to $ENV_FILE"
fi
echo
echo "🚀 Bringing up all services…"
docker-compose up -d --build
echo
echo "🔐 Your DB credentials (in .env):"
echo " DB_USER: $DB_USER"
echo " DB_PASS: $DB_PASS"
echo " DB_NAME: $DB_NAME"
echo
echo "🔑 Your Flask secret: $FLASK_SECRET"
echo
echo "🌐 Web UI available at http://localhost:3000"

28
templates/settings.html Normal file
View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<title>Bot Configuration</title>
</head>
<body>
<h1>Core Settings</h1>
<form method="POST">
{% for row in config %}
<label>{{ row.key }}:</label>
<input name="{{ row.key }}" value="{{ row.value }}" /><br/>
{% endfor %}
<button type="submit">Save Settings</button>
</form>
<h1>Membership Plans</h1>
<a href="/plans/new">+ New Plan</a>
<ul>
{% for p in plans %}
<li>
{{ p.name }} (/{{ p.command_name }}) — {{ p.price_sats }} sats —
Expires: {{ p.expiry_type }}
<a href="/plans/{{ p.id }}/edit">Edit</a>
</li>
{% endfor %}
</ul>
</body>
</html>