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 # Discord LNbits Bot
This bot allows users to purchase a **Discord role** using **Bitcoin Lightning payments** through LNbits. A full-stack Dockerized membership engine that lets users purchase Discord roles via Bitcoin Lightning (LNbits). It provides:
Users request an invoice via a **slash command** (configurable), and the bot automatically assigns the role **once payment is confirmed**. - 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 ## 🚀 Features
- ✅ Dynamic slashcommand name (configured via `command_name`) - 🔧 **One-click Docker Compose**: bot + web UI + database
- ✅ Generates a **Lightning invoice** via LNbits - ✅ **Dynamic slash-commands** for each plan (no code changes needed)
- ✅ Listens on LNbits WebSocket for paid invoices - ⚡ **Lightning invoices** via LNbits REST API
- ✅ Automatically assigns a **Discord role** on payment - 🌐 **WebSocket listener** for incoming payments
- ✅ Posts confirmations directly in your designated channel - 👤 **Automatic role assignment** on payment
- ⏰ **Scheduled cleanup** of expired subscriptions
- 📊 **Persistent audit log** in Postgres (raw JSON, timestamps, statuses)
--- ---
## 📋 Requirements ## 📋 Requirements
- **Ubuntu 24.04**
- **Python 3.10+**
- **A Discord Bot Token** (from the [Developer Portal](https://discord.com/developers/applications)) - **A Discord Bot Token** (from the [Developer Portal](https://discord.com/developers/applications))
- **LNbits Wallet Invoice Key** - **LNbits Wallet Invoice Key**
- **A running LNbits instance** (e.g., [Sats.Love](https://sats.love/)) - **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 cd discord-lnbits-bot
``` ```
### 3. Create & Activate Virtual Environment ### 3. Copy & Edit .env
```bash ```bash
python3 -m venv venv cp .env.example .env
source venv/bin/activate nano .env
``` ```
### 4. Install Dependencies ### 4. Buld & run
```bash ```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 ## 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 asyncio import os
import requests import asyncio
import json import json
import io import io
import qrcode import requests
import websockets import logging
import traceback from datetime import datetime
from discord import File, Embed from dateutil.relativedelta import relativedelta
from discord.ext import commands import qrcode
# --- 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, Text, Enum, ForeignKey
print("❌ Error: config.json is not valid JSON. Please check its syntax.") )
exit() from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.declarative import declarative_base
# Load values from sqlalchemy.orm import sessionmaker, relationship
TOKEN = config.get("discord_token")
GUILD_ID = int(config.get("guild_id", 0)) from apscheduler.schedulers.asyncio import AsyncIOScheduler
ROLE_ID = int(config.get("role_id", 0))
LNBITS_URL = config.get("lnbits_url") # ─── Logging ──────────────────────────────────────────────────────────────
LNBITS_API_KEY = config.get("lnbits_api_key") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s | %(message)s")
PRICE = config.get("price") logger = logging.getLogger("discord_lnbits_bot")
CHANNEL_ID = int(config.get("channelid", 0))
INVOICE_MESSAGE_TEMPLATE = config.get("invoicemessage", "Invoice for your purchase.") # ─── Database & Config Seeding ────────────────────────────────────────────
COMMAND_NAME = config.get("command_name", "support") DATABASE_URL = os.environ["DATABASE_URL"]
engine = create_engine(DATABASE_URL, future=True)
# Validate SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
if not LNBITS_URL or not LNBITS_API_KEY: Base = declarative_base()
print("❌ Error: LNBITS_URL or LNBITS_API_KEY is missing in config.json.")
exit() class Config(Base):
__tablename__ = "config"
clean_lnbits_http_url = LNBITS_URL.rstrip('/') key = Column(String, primary_key=True)
if clean_lnbits_http_url.startswith("https://"): value = Column(Text, nullable=False)
base_ws_url = clean_lnbits_http_url.replace("https://", "wss://", 1)
elif clean_lnbits_http_url.startswith("http://"): class Plan(Base):
base_ws_url = clean_lnbits_http_url.replace("http://", "ws://", 1) __tablename__ = "plans"
else: id = Column(Integer, primary_key=True)
print(f"❌ Error: Invalid LNBITS_URL scheme: {LNBITS_URL}.") name = Column(Text, nullable=False, unique=True)
exit() command_name = Column(Text, nullable=False, unique=True)
role_id = Column(String(64), nullable=False)
LNBITS_WEBSOCKET_URL = f"{base_ws_url}/api/v1/ws/{LNBITS_API_KEY}" channel_id = Column(String(64), nullable=False)
price_sats = Column(Integer, nullable=False)
if not all([TOKEN, GUILD_ID, ROLE_ID, PRICE, CHANNEL_ID, COMMAND_NAME]): invoice_message = Column(Text, nullable=False)
print("❌ Error: One or more essential configuration options are missing.") expiry_type = Column(Enum("fixed_date","rolling_days","calendar_month", name="expiry_type"), nullable=False)
exit() duration_days = Column(Integer, default=30)
subs = relationship("Subscription", back_populates="plan")
# Discord client
intents = discord.Intents.default() class Subscription(Base):
intents.members = True __tablename__ = "subscriptions"
bot = commands.Bot(command_prefix="!", intents=intents) id = Column(Integer, primary_key=True)
pending_invoices = {} plan_id = Column(Integer, ForeignKey("plans.id"), nullable=False)
user_id = Column(String(64), nullable=False)
async def assign_role_after_payment(payment_hash_received, payment_details_from_ws): invoice_hash = Column(String(256), unique=True, nullable=False)
print(f"DEBUG: ENTER assign_role_after_payment for hash: {payment_hash_received}") invoice_request = Column(Text, nullable=False)
status = Column(Enum("pending","active","expired","cancelled", name="sub_status"), nullable=False, default="pending")
if payment_hash_received not in pending_invoices: created_at = Column(DateTime, default=datetime.utcnow)
print(f" Hash {payment_hash_received} not found in pending_invoices.") paid_at = Column(DateTime)
return expires_at = Column(DateTime)
role_assigned_at = Column(DateTime)
user_id, original_interaction = pending_invoices.pop(payment_hash_received) role_removed_at = Column(DateTime)
print(f"DEBUG: Found pending invoice for user_id={user_id}, interaction_id={getattr(original_interaction, 'id', None)}") raw_invoice_json = Column(JSONB)
raw_payment_json = Column(JSONB)
guild = bot.get_guild(GUILD_ID) plan = relationship("Plan", back_populates="subs")
if not guild:
print(f"❌ Guild {GUILD_ID} not found.") Base.metadata.create_all(engine)
return logger.info("✅ Database schema ready.")
member = guild.get_member(user_id) # Seed Config keys (if missing)
if not member: session = SessionLocal()
try: for key in ("discord_token", "guild_id", "lnbits_url", "lnbits_api_key"):
member = await asyncio.wait_for(guild.fetch_member(user_id), timeout=10) if not session.get(Config, key):
except Exception as e: session.add(Config(key=key, value=""))
print(f"❌ Error fetching member: {e}") session.commit()
return session.close()
role = guild.get_role(ROLE_ID) # ─── Config helper ─────────────────────────────────────────────────────────
if not role: def get_cfg(k: str) -> str:
print(f"❌ Role ID {ROLE_ID} not found in guild.") db = SessionLocal()
return row = db.get(Config, k)
db.close()
invoice_channel = bot.get_channel(CHANNEL_ID) return row.value if row else ""
if not invoice_channel:
print(f"❌ Invoice channel ID {CHANNEL_ID} not found.") DISCORD_TOKEN = get_cfg("discord_token")
GUILD_ID = int(get_cfg("guild_id") or 0)
if role not in member.roles: LNBITS_URL = get_cfg("lnbits_url")
try: LNBITS_API_KEY = get_cfg("lnbits_api_key")
await asyncio.wait_for(member.add_roles(role, reason="Paid Lightning Invoice"), timeout=10)
if invoice_channel: for name, val in (("discord_token", DISCORD_TOKEN), ("guild_id", GUILD_ID),
await invoice_channel.send( ("lnbits_url", LNBITS_URL), ("lnbits_api_key", LNBITS_API_KEY)):
f"🎉 {member.mention} has paid {PRICE} sats and been granted the '{role.name}' role!" if not val:
) logger.critical(f"Config '{name}' is empty in DB. Fill via UI first.")
except Exception as e: exit(1)
print(f"❌ Error assigning role: {e}")
else: # Build WS URL
if invoice_channel: if LNBITS_URL.startswith("https://"):
await invoice_channel.send( ws_base = LNBITS_URL.replace("https://", "wss://", 1)
f"{member.mention}, payment confirmed! You already have the '{role.name}' role." elif LNBITS_URL.startswith("http://"):
) ws_base = LNBITS_URL.replace("http://", "ws://", 1)
else:
async def lnbits_websocket_listener(): logger.critical("LNBITS_URL must start with http:// or https://")
await bot.wait_until_ready() exit(1)
print(f"👂 Connecting to LNBits WS at {LNBITS_WEBSOCKET_URL}") LNBITS_WS_URL = f"{ws_base}/api/v1/ws/{LNBITS_API_KEY}"
while True: # ─── Discord Bot ──────────────────────────────────────────────────────────
try: intents = discord.Intents.default(); intents.members = True
async with websockets.connect(LNBITS_WEBSOCKET_URL) as ws: bot = commands.Bot(command_prefix="!", intents=intents)
print("✅ WS connected.")
while True: def compute_expiry(paid_at: datetime, plan: Plan) -> datetime:
try: if plan.expiry_type == "calendar_month":
msg = await ws.recv() first = paid_at.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
print(f"📬 WS message: {msg}") return first + relativedelta(months=1)
data = json.loads(msg) if plan.expiry_type == "rolling_days":
if isinstance(data.get("payment"), dict): return paid_at + relativedelta(days=plan.duration_days)
p = data["payment"] return paid_at
h = p.get("checking_id") or p.get("payment_hash")
amt = p.get("amount", 0) async def handle_plan_purchase(interaction, plan: Plan):
status = p.get("status") invoice_ch = bot.get_channel(int(plan.channel_id))
is_paid = (status == "success") or (p.get("paid") is True) or (p.get("pending") is False) if not invoice_ch:
return await interaction.response.send_message("❌ Invoice channel not found.", ephemeral=True)
if h and is_paid and amt > 0:
print(f"✅ Confirmed payment hash={h}, amount={amt}, status={status}") invoice_data = {"out": False, "amount": plan.price_sats, "memo": plan.invoice_message}
bot.loop.create_task( headers = {"X-Api-Key": LNBITS_API_KEY, "Content-Type": "application/json"}
assign_role_after_payment(h, p) loop = asyncio.get_running_loop()
) def create_invoice():
else: r = requests.post(f"{LNBITS_URL}/api/v1/payments", json=invoice_data, headers=headers, timeout=10)
print(f" Ignored update for hash={h}, status={status}, amount={amt}") r.raise_for_status()
else: return r.json()
print(f"⚠️ Unexpected WS format: {msg}") try:
except websockets.exceptions.ConnectionClosed: inv = await loop.run_in_executor(None, create_invoice)
print("⚠️ WS closed, reconnecting...") except Exception:
break logger.exception("Invoice creation failed")
except Exception as e: return await interaction.response.send_message("❌ Could not generate invoice.", ephemeral=True)
print(f"❌ Error handling WS message: {e}")
except Exception as e: pay_req, pay_hash = inv["bolt11"], inv["payment_hash"]
print(f"❌ WS connection error: {e}. Retrying in 15s...") db = SessionLocal()
await asyncio.sleep(15) sub = Subscription(plan_id=plan.id, user_id=str(interaction.user.id),
invoice_hash=pay_hash, invoice_request=pay_req,
@bot.event raw_invoice_json=inv)
async def on_ready(): db.add(sub); db.commit(); db.close()
print(f"✅ Bot ready as {bot.user} ({bot.user.id})")
bot.loop.create_task(lnbits_websocket_listener()) def make_qr_buf():
buf = io.BytesIO(); qrcode.make(pay_req).save(buf, format="PNG"); buf.seek(0); return buf
# Dynamic slash command name from config qr_buf = await loop.run_in_executor(None, make_qr_buf)
@bot.tree.command(name=COMMAND_NAME, description="Pay to get your role via Lightning.") qr_file= File(qr_buf, "invoice.png")
async def dynamic_command(interaction: discord.Interaction):
invoice_channel = bot.get_channel(CHANNEL_ID) embed = Embed(title=f"⚡ Pay {plan.price_sats} sats for {plan.name}",
if not invoice_channel: description=plan.invoice_message, color=discord.Color.gold())
await interaction.response.send_message("❌ Invoice channel misconfigured.", ephemeral=True) embed.add_field(name="Invoice", value=f"```{pay_req}```", inline=False)
return embed.set_image(url="attachment://invoice.png")
embed.set_footer(text=f"Hash: {pay_hash}")
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"} await invoice_ch.send(content=interaction.user.mention, embed=embed, file=qr_file)
loop = asyncio.get_running_loop() await interaction.response.send_message(f"✅ Invoice posted in {invoice_ch.mention}", ephemeral=True)
try:
resp = await loop.run_in_executor(None, lambda: requests.post( async def lnbits_ws_listener():
f"{clean_lnbits_http_url}/api/v1/payments", json=invoice_data, headers=headers await bot.wait_until_ready()
)) logger.info(f"Listening on WS {LNBITS_WS_URL}")
resp.raise_for_status() while True:
except Exception as e: try:
await interaction.response.send_message("❌ Could not create invoice. Try again later.", ephemeral=True) async with websockets.connect(LNBITS_WS_URL) as ws:
print(f"❌ Invoice creation error: {e}") async for msg in ws:
return data = json.loads(msg); pay = data.get("payment", {})
hsh = pay.get("checking_id") or pay.get("payment_hash")
if resp.status_code != 201: if not (hsh and pay.get("status")=="success"): continue
await interaction.response.send_message(f"❌ LNBits error ({resp.status_code}).", ephemeral=True)
print(f"❌ LNBits error response: {resp.text}") db = SessionLocal()
return sub= db.query(Subscription).filter_by(invoice_hash=hsh, status="pending").first()
if not sub: db.close(); continue
inv = resp.json()
pr = inv.get("bolt11") paid_at= datetime.utcnow()
h = inv.get("payment_hash") expires_at= compute_expiry(paid_at, sub.plan)
if not pr or not h: sub.status=sub.paid_at=paid_at; sub.expires_at=expires_at
await interaction.response.send_message("❌ Invalid invoice data.", ephemeral=True) sub.raw_payment_json=pay; db.commit(); db.close()
return
guild = bot.get_guild(GUILD_ID)
pending_invoices[h] = (interaction.user.id, interaction) member = guild.get_member(int(sub.user_id)) or await guild.fetch_member(int(sub.user_id))
print(f"DEBUG: Stored pending invoice {h} for user {interaction.user.id}") role = guild.get_role(int(sub.plan.role_id))
chan = bot.get_channel(int(sub.plan.channel_id))
buf = io.BytesIO()
try: await member.add_roles(role, reason="Subscription paid")
await loop.run_in_executor(None, lambda: qrcode.make(pr.upper()).save(buf, format="PNG")) await chan.send(f"🎉 {member.mention} paid **{sub.plan.price_sats} sats** "
buf.seek(0) f"for **{sub.plan.name}**, expires {expires_at:%Y-%m-%d}.")
qr_file = File(buf, filename="invoice_qr.png") except Exception:
except Exception as e: logger.exception("WS error, reconnecting in 10s"); await asyncio.sleep(10)
print(f"⚠️ QR generation failed: {e}")
qr_file = None async def cleanup_expired():
now= datetime.utcnow(); db= SessionLocal()
embed = Embed(title="⚡ Payment Required ⚡", description=INVOICE_MESSAGE_TEMPLATE) rows= db.query(Subscription).filter(Subscription.status=="active", Subscription.expires_at<=now).all()
embed.add_field(name="Invoice", value=f"```{pr}```", inline=False) for sub in rows:
embed.add_field(name="Amount", value=f"{PRICE} sats", inline=True) guild = bot.get_guild(GUILD_ID)
embed.set_footer(text=h) member = guild.get_member(int(sub.user_id))
if qr_file: role = guild.get_role(int(sub.plan.role_id))
embed.set_image(url="attachment://invoice_qr.png") if member and role in member.roles:
try:
try: await member.remove_roles(role, reason="Subscription expired")
await invoice_channel.send(content=interaction.user.mention, embed=embed, file=qr_file if qr_file else None) sub.status="expired"; sub.role_removed_at=now; db.commit()
await interaction.response.send_message("✅ Invoice posted!", ephemeral=True) logger.info(f"Removed expired role from {member.display_name}")
except Exception as e: except Exception:
print(f"❌ Error sending invoice message: {e}") logger.exception("Failed to remove expired role")
await interaction.response.send_message("❌ Failed to post invoice.", ephemeral=True) db.close()
if __name__ == "__main__": @bot.event
bot.run(TOKEN) async def on_ready():
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(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 discord.py>=2.0.0
requests websockets>=10.0
qrcode[pil] requests>=2.25.0
asyncio python-dotenv>=0.21.0
websockets 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>