Compare commits

..

No commits in common. "docker" and "main" have entirely different histories.
docker ... main

10 changed files with 241 additions and 478 deletions

1
.gitignore vendored
View File

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

View File

@ -1,24 +0,0 @@
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,32 +1,27 @@
# Discord LNbits Bot # Discord LNbits Bot
A full-stack Dockerized membership engine that lets users purchase Discord roles via Bitcoin Lightning (LNbits). It provides: This bot allows users to purchase a **Discord role** using **Bitcoin Lightning payments** through LNbits.
- A Discord bot that dynamically registers slash-commands per membership “plan” Users request an invoice via a **slash command** (configurable), and the bot automatically assigns the role **once payment is confirmed**.
- 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
- 🔧 **One-click Docker Compose**: bot + web UI + database - ✅ Dynamic slashcommand name (configured via `command_name`)
- ✅ **Dynamic slash-commands** for each plan (no code changes needed) - ✅ Generates a **Lightning invoice** via LNbits
- ⚡ **Lightning invoices** via LNbits REST API - ✅ Listens on LNbits WebSocket for paid invoices
- 🌐 **WebSocket listener** for incoming payments - ✅ Automatically assigns a **Discord role** on payment
- 👤 **Automatic role assignment** on payment - ✅ Posts confirmations directly in your designated channel
- ⏰ **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)**
--- ---
@ -91,25 +86,19 @@ git clone https://code.rustysats.com/saulteafarmer/discord-lnbits-bot
cd discord-lnbits-bot cd discord-lnbits-bot
``` ```
### 3. Copy & Edit .env ### 3. Create & Activate Virtual Environment
```bash ```bash
cp .env.example .env python3 -m venv venv
nano .env source venv/bin/activate
``` ```
### 4. Buld & run ### 4. Install Dependencies
```bash ```bash
docker-compose up -d --build pip install -r requirements.txt
``` ```
### 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
View File

@ -1,42 +0,0 @@
#!/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)

12
config.json Normal file
View File

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

View File

@ -1,62 +0,0 @@
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,12 +1,5 @@
discord.py>=2.0.0 discord.py
websockets>=10.0 requests
requests>=2.25.0 qrcode[pil]
python-dotenv>=0.21.0 asyncio
SQLAlchemy>=1.4.0 websockets
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

View File

@ -1,59 +0,0 @@
#!/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"

View File

@ -1,28 +0,0 @@
<!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>