Compare commits
41 Commits
Author | SHA1 | Date | |
---|---|---|---|
28affcc3c3 | |||
5510c07108 | |||
4b30c18310 | |||
4d2262e48f | |||
0433394e01 | |||
6705f7bff9 | |||
b4356f1182 | |||
41126309f8 | |||
a07cdecc32 | |||
22812b628d | |||
92c3213384 | |||
82209902d5 | |||
daeae200e7 | |||
37a1f363f8 | |||
afa18cb3bb | |||
c7c1aa3e7e | |||
30ced35e87 | |||
232a07629e | |||
f8e5bbcd75 | |||
e3100bd17f | |||
dd8abc227b | |||
10e51b3655 | |||
c914dc1753 | |||
3b646efdc8 | |||
6cc08dad0d | |||
f6cd2cb415 | |||
17c8861c45 | |||
0243671381 | |||
a1731290f0 | |||
cc606d4aa0 | |||
4edc7b673f | |||
29224cf51c | |||
0fd63cd8f1 | |||
ca865bd393 | |||
9117b13070 | |||
e04e14b647 | |||
a983fc0d87 | |||
d2e5d5a390 | |||
9bd999fc32 | |||
871a130eaf | |||
48197438b5 |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/data/secrets/
|
24
Dockerfile
Normal file
24
Dockerfile
Normal 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"]
|
39
README.md
39
README.md
@ -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 slash‐command 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
42
app.py
Normal 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)
|
12
config.json
12
config.json
@ -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"
|
||||
}
|
||||
|
@ -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
62
docker-compose.yml
Normal 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:
|
@ -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
59
setup.sh
Normal 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
28
templates/settings.html
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user