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 logging
from datetime import datetime
from dateutil.relativedelta import relativedelta
import qrcode import qrcode
import websockets
import discord import traceback
from discord import File, Embed from discord import File, Embed
from discord.ext import commands from discord.ext import commands
import websockets # --- 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()
from sqlalchemy import ( # Load values
create_engine, Column, String, Integer, DateTime, Text, Enum, ForeignKey TOKEN = config.get("discord_token")
) GUILD_ID = int(config.get("guild_id", 0))
from sqlalchemy.dialects.postgresql import JSONB ROLE_ID = int(config.get("role_id", 0))
from sqlalchemy.ext.declarative import declarative_base LNBITS_URL = config.get("lnbits_url")
from sqlalchemy.orm import sessionmaker, relationship 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 apscheduler.schedulers.asyncio import AsyncIOScheduler # Validate
if not LNBITS_URL or not LNBITS_API_KEY:
print("❌ Error: LNBITS_URL or LNBITS_API_KEY is missing in config.json.")
exit()
# ─── Logging ────────────────────────────────────────────────────────────── clean_lnbits_http_url = LNBITS_URL.rstrip('/')
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s | %(message)s") if clean_lnbits_http_url.startswith("https://"):
logger = logging.getLogger("discord_lnbits_bot") base_ws_url = clean_lnbits_http_url.replace("https://", "wss://", 1)
elif clean_lnbits_http_url.startswith("http://"):
# ─── Database & Config Seeding ──────────────────────────────────────────── base_ws_url = clean_lnbits_http_url.replace("http://", "ws://", 1)
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: else:
logger.critical("LNBITS_URL must start with http:// or https://") print(f"❌ Error: Invalid LNBITS_URL scheme: {LNBITS_URL}.")
exit(1) exit()
LNBITS_WS_URL = f"{ws_base}/api/v1/ws/{LNBITS_API_KEY}"
# ─── Discord Bot ────────────────────────────────────────────────────────── LNBITS_WEBSOCKET_URL = f"{base_ws_url}/api/v1/ws/{LNBITS_API_KEY}"
intents = discord.Intents.default(); intents.members = True
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
bot = commands.Bot(command_prefix="!", intents=intents) bot = commands.Bot(command_prefix="!", intents=intents)
pending_invoices = {}
def compute_expiry(paid_at: datetime, plan: Plan) -> datetime: async def assign_role_after_payment(payment_hash_received, payment_details_from_ws):
if plan.expiry_type == "calendar_month": print(f"DEBUG: ENTER assign_role_after_payment for hash: {payment_hash_received}")
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
async def handle_plan_purchase(interaction, plan: Plan): if payment_hash_received not in pending_invoices:
invoice_ch = bot.get_channel(int(plan.channel_id)) print(f" Hash {payment_hash_received} not found in pending_invoices.")
if not invoice_ch: return
return await interaction.response.send_message("❌ Invoice channel not found.", ephemeral=True)
invoice_data = {"out": False, "amount": plan.price_sats, "memo": plan.invoice_message} user_id, original_interaction = pending_invoices.pop(payment_hash_received)
headers = {"X-Api-Key": LNBITS_API_KEY, "Content-Type": "application/json"} print(f"DEBUG: Found pending invoice for user_id={user_id}, interaction_id={getattr(original_interaction, 'id', None)}")
loop = asyncio.get_running_loop()
def create_invoice(): guild = bot.get_guild(GUILD_ID)
r = requests.post(f"{LNBITS_URL}/api/v1/payments", json=invoice_data, headers=headers, timeout=10) if not guild:
r.raise_for_status() print(f"❌ Guild {GUILD_ID} not found.")
return r.json() return
member = guild.get_member(user_id)
if not member:
try: try:
inv = await loop.run_in_executor(None, create_invoice) member = await asyncio.wait_for(guild.fetch_member(user_id), timeout=10)
except Exception: except Exception as e:
logger.exception("Invoice creation failed") print(f"❌ Error fetching member: {e}")
return await interaction.response.send_message("❌ Could not generate invoice.", ephemeral=True) return
pay_req, pay_hash = inv["bolt11"], inv["payment_hash"] role = guild.get_role(ROLE_ID)
db = SessionLocal() if not role:
sub = Subscription(plan_id=plan.id, user_id=str(interaction.user.id), print(f"❌ Role ID {ROLE_ID} not found in guild.")
invoice_hash=pay_hash, invoice_request=pay_req, return
raw_invoice_json=inv)
db.add(sub); db.commit(); db.close()
def make_qr_buf(): invoice_channel = bot.get_channel(CHANNEL_ID)
buf = io.BytesIO(); qrcode.make(pay_req).save(buf, format="PNG"); buf.seek(0); return buf if not invoice_channel:
qr_buf = await loop.run_in_executor(None, make_qr_buf) print(f"❌ Invoice channel ID {CHANNEL_ID} not found.")
qr_file= File(qr_buf, "invoice.png")
embed = Embed(title=f"⚡ Pay {plan.price_sats} sats for {plan.name}", if role not in member.roles:
description=plan.invoice_message, color=discord.Color.gold()) try:
embed.add_field(name="Invoice", value=f"```{pay_req}```", inline=False) await asyncio.wait_for(member.add_roles(role, reason="Paid Lightning Invoice"), timeout=10)
embed.set_image(url="attachment://invoice.png") if invoice_channel:
embed.set_footer(text=f"Hash: {pay_hash}") 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."
)
await invoice_ch.send(content=interaction.user.mention, embed=embed, file=qr_file) async def lnbits_websocket_listener():
await interaction.response.send_message(f"✅ Invoice posted in {invoice_ch.mention}", ephemeral=True)
async def lnbits_ws_listener():
await bot.wait_until_ready() await bot.wait_until_ready()
logger.info(f"Listening on WS {LNBITS_WS_URL}") print(f"👂 Connecting to LNBits WS at {LNBITS_WEBSOCKET_URL}")
while True: while True:
try: try:
async with websockets.connect(LNBITS_WS_URL) as ws: async with websockets.connect(LNBITS_WEBSOCKET_URL) as ws:
async for msg in ws: print("✅ WS connected.")
data = json.loads(msg); pay = data.get("payment", {}) while True:
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)
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")
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: try:
await member.remove_roles(role, reason="Subscription expired") msg = await ws.recv()
sub.status="expired"; sub.role_removed_at=now; db.commit() print(f"📬 WS message: {msg}")
logger.info(f"Removed expired role from {member.display_name}") data = json.loads(msg)
except Exception: if isinstance(data.get("payment"), dict):
logger.exception("Failed to remove expired role") p = data["payment"]
db.close() 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)
@bot.event @bot.event
async def on_ready(): async def on_ready():
logger.info(f"Logged in as {bot.user}") print(f"✅ Bot ready as {bot.user} ({bot.user.id})")
db = SessionLocal(); plans = db.query(Plan).all() bot.loop.create_task(lnbits_websocket_listener())
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__": # Dynamic slash command name from config
bot.run(DISCORD_TOKEN) @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)
if __name__ == "__main__":
bot.run(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>