Compare commits
No commits in common. "docker" and "main" have entirely different histories.
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +0,0 @@
|
|||||||
/data/secrets/
|
|
24
Dockerfile
24
Dockerfile
@ -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"]
|
|
39
README.md
39
README.md
@ -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 slash‐command 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
42
app.py
@ -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
12
config.json
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
@ -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:
|
|
@ -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
|
|
59
setup.sh
59
setup.sh
@ -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"
|
|
@ -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>
|
|
Loading…
x
Reference in New Issue
Block a user