Update discord_lnbits_bot.py
This commit is contained in:
parent
d2e5d5a390
commit
a983fc0d87
@ -22,7 +22,7 @@ from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# ─── Environment & Logging ────────────────────────────────────────────────────
|
||||
load_dotenv() # loads variables from .env into os.environ
|
||||
load_dotenv() # loads .env into os.environ
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@ -40,19 +40,19 @@ PRICE = int(os.getenv("PRICE", 0))
|
||||
CHANNEL_ID = int(os.getenv("CHANNEL_ID", 0))
|
||||
INVOICE_MESSAGE_TEMPLATE = os.getenv("INVOICE_MESSAGE", "Invoice for your purchase.")
|
||||
COMMAND_NAME = os.getenv("COMMAND_NAME", "support")
|
||||
DATABASE_URL = os.getenv("DATABASE_URL") # e.g. postgres://user:pass@db:5432/bot
|
||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
|
||||
# Sanity checks
|
||||
for var_name in (
|
||||
# Ensure all required env vars are present
|
||||
for var in (
|
||||
"DISCORD_TOKEN", "GUILD_ID", "ROLE_ID",
|
||||
"LNBITS_URL", "LNBITS_API_KEY", "PRICE",
|
||||
"CHANNEL_ID", "DATABASE_URL"
|
||||
):
|
||||
if not globals()[var_name]:
|
||||
logger.critical(f"Environment variable {var_name} is missing.")
|
||||
if not globals()[var]:
|
||||
logger.critical(f"Environment variable {var} is missing.")
|
||||
exit(1)
|
||||
|
||||
# Construct WS URL
|
||||
# Build WS URL from LNBITS_URL
|
||||
if LNBITS_URL.startswith("https://"):
|
||||
base_ws = LNBITS_URL.replace("https://", "wss://", 1)
|
||||
elif LNBITS_URL.startswith("http://"):
|
||||
@ -60,10 +60,8 @@ elif LNBITS_URL.startswith("http://"):
|
||||
else:
|
||||
logger.critical("LNBITS_URL must start with http:// or https://")
|
||||
exit(1)
|
||||
|
||||
LNBITS_WS_URL = f"{base_ws}/api/v1/ws/{LNBITS_API_KEY}"
|
||||
|
||||
|
||||
# ─── Database Setup ───────────────────────────────────────────────────────────
|
||||
Base = declarative_base()
|
||||
|
||||
@ -80,7 +78,6 @@ SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
Base.metadata.create_all(engine)
|
||||
logger.info("✅ Database tables ensured.")
|
||||
|
||||
|
||||
# ─── Discord Bot Setup ────────────────────────────────────────────────────────
|
||||
intents = discord.Intents.default()
|
||||
intents.members = True
|
||||
@ -91,31 +88,34 @@ pending_invoices = {} # payment_hash -> (user_id, interaction)
|
||||
# ─── Role Assignment Handler ──────────────────────────────────────────────────
|
||||
async def assign_role_after_payment(payment_hash: str, payment_obj: dict):
|
||||
logger.debug(f"ENTER assign_role_after_payment for hash={payment_hash}")
|
||||
user_id, interaction = pending_invoices.pop(payment_hash, (None, None))
|
||||
if user_id is None:
|
||||
data = pending_invoices.pop(payment_hash, None)
|
||||
if data is None:
|
||||
logger.info(f"No pending invoice for hash={payment_hash}")
|
||||
return
|
||||
user_id, interaction = data
|
||||
|
||||
# Record to database
|
||||
amt = payment_obj.get("amount", 0)
|
||||
# Convert from millisatoshis to sats
|
||||
raw_msat = payment_obj.get("amount", 0)
|
||||
sat_amount = raw_msat // 1000
|
||||
|
||||
# Record payment in DB
|
||||
session = SessionLocal()
|
||||
try:
|
||||
payment = Payment(
|
||||
payment_hash=payment_hash,
|
||||
user_id=str(user_id),
|
||||
amount=amt,
|
||||
amount=sat_amount,
|
||||
paid_at=datetime.utcnow()
|
||||
)
|
||||
session.add(payment)
|
||||
session.commit()
|
||||
logger.info(f"💾 Logged payment {payment_hash} for user {user_id} ({amt} sats).")
|
||||
logger.info(f"💾 Logged payment {payment_hash} for user {user_id} ({sat_amount} sats).")
|
||||
except Exception:
|
||||
session.rollback()
|
||||
logger.exception("DB error saving payment")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# Fetch guild, member, role
|
||||
guild = bot.get_guild(GUILD_ID)
|
||||
if not guild:
|
||||
logger.error(f"Guild {GUILD_ID} not found")
|
||||
@ -123,11 +123,12 @@ async def assign_role_after_payment(payment_hash: str, payment_obj: dict):
|
||||
|
||||
member = guild.get_member(user_id) or await guild.fetch_member(user_id)
|
||||
role = guild.get_role(ROLE_ID)
|
||||
if not role:
|
||||
logger.error(f"Role {ROLE_ID} not found in guild {guild.id}")
|
||||
channel = bot.get_channel(CHANNEL_ID)
|
||||
|
||||
if not all([member, role, channel]):
|
||||
logger.error("Member, role, or channel not found")
|
||||
return
|
||||
|
||||
# Assign role if needed
|
||||
if role not in member.roles:
|
||||
logger.debug(f"Adding role '{role.name}' to {member.display_name}")
|
||||
try:
|
||||
@ -135,22 +136,20 @@ async def assign_role_after_payment(payment_hash: str, payment_obj: dict):
|
||||
member.add_roles(role, reason="Paid via Lightning"),
|
||||
timeout=10
|
||||
)
|
||||
channel = bot.get_channel(CHANNEL_ID)
|
||||
await channel.send(
|
||||
f"🎉 {member.mention} has paid **{amt} sats** and received the **{role.name}** role!"
|
||||
f"🎉 {member.mention} has paid **{sat_amount} sats** "
|
||||
f"and received the **{role.name}** role!"
|
||||
)
|
||||
logger.info(f"✅ Role '{role.name}' assigned to {member.display_name}")
|
||||
except Exception:
|
||||
logger.exception("Error assigning role or sending confirmation")
|
||||
else:
|
||||
# Already has role
|
||||
channel = bot.get_channel(CHANNEL_ID)
|
||||
await channel.send(
|
||||
f"ℹ️ {member.mention} paid again (hash={payment_hash}), role **{role.name}** already assigned."
|
||||
f"ℹ️ {member.mention} paid again (hash={payment_hash}), "
|
||||
f"role **{role.name}** already assigned."
|
||||
)
|
||||
logger.info(f"User {member.display_name} already had role; notified channel.")
|
||||
|
||||
|
||||
# ─── LNbits WebSocket Listener ────────────────────────────────────────────────
|
||||
async def lnbits_ws_listener():
|
||||
await bot.wait_until_ready()
|
||||
@ -166,15 +165,9 @@ async def lnbits_ws_listener():
|
||||
pay = data.get("payment", {})
|
||||
hsh = pay.get("checking_id") or pay.get("payment_hash")
|
||||
amt = pay.get("amount", 0)
|
||||
# LNbits uses status="success" on paid
|
||||
if hsh and pay.get("status") == "success" and amt > 0:
|
||||
logger.info(
|
||||
f"💡 Payment received hash={hsh}, amt={amt}"
|
||||
)
|
||||
# schedule role assignment
|
||||
bot.loop.create_task(
|
||||
assign_role_after_payment(hsh, pay)
|
||||
)
|
||||
logger.info(f"💡 Payment received hash={hsh}, amt={amt}")
|
||||
bot.loop.create_task(assign_role_after_payment(hsh, pay))
|
||||
else:
|
||||
logger.debug("Ignored WS update (not a paid invoice)")
|
||||
except json.JSONDecodeError:
|
||||
@ -183,22 +176,17 @@ async def lnbits_ws_listener():
|
||||
logger.exception("WS listener error; reconnecting in 10s")
|
||||
await asyncio.sleep(10)
|
||||
|
||||
|
||||
# ─── Slash Command Registration ───────────────────────────────────────────────
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
logger.info(f"Logged in as {bot.user} (ID: {bot.user.id})")
|
||||
# sync commands
|
||||
try:
|
||||
synced = await bot.tree.sync()
|
||||
logger.info(f"✅ Synced {len(synced)} command(s).")
|
||||
except Exception:
|
||||
logger.exception("Failed to sync commands")
|
||||
|
||||
# start WS listener
|
||||
bot.loop.create_task(lnbits_ws_listener())
|
||||
|
||||
|
||||
@bot.tree.command(name=COMMAND_NAME, description="Pay to get your role via Lightning")
|
||||
async def dynamic_command(interaction: discord.Interaction):
|
||||
invoice_ch = bot.get_channel(CHANNEL_ID)
|
||||
@ -209,11 +197,18 @@ async def dynamic_command(interaction: discord.Interaction):
|
||||
)
|
||||
return
|
||||
|
||||
# offload blocking REST call + QR gen
|
||||
loop = asyncio.get_running_loop()
|
||||
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"}
|
||||
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"
|
||||
}
|
||||
|
||||
# Create invoice
|
||||
def create_invoice():
|
||||
resp = requests.post(
|
||||
f"{LNBITS_URL}/api/v1/payments",
|
||||
@ -224,10 +219,9 @@ async def dynamic_command(interaction: discord.Interaction):
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# Create invoice
|
||||
try:
|
||||
invoice_json = await loop.run_in_executor(None, create_invoice)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
logger.exception("Error creating LNbits invoice")
|
||||
await interaction.response.send_message(
|
||||
"❌ Failed to generate invoice. Try again later.",
|
||||
@ -235,32 +229,41 @@ async def dynamic_command(interaction: discord.Interaction):
|
||||
)
|
||||
return
|
||||
|
||||
pay_req = invoice_json["bolt11"]
|
||||
pay_hash = invoice_json["payment_hash"]
|
||||
pending_invoices[pay_hash] = (interaction.user.id, interaction)
|
||||
logger.info(f"Stored pending invoice {pay_hash} for user {interaction.user.id}")
|
||||
payment_request = invoice_json["bolt11"]
|
||||
payment_hash = invoice_json["payment_hash"]
|
||||
pending_invoices[payment_hash] = (interaction.user.id, interaction)
|
||||
logger.info(f"Stored pending invoice {payment_hash} for user {interaction.user.id}")
|
||||
|
||||
# Generate QR
|
||||
qr_bytes = await loop.run_in_executor(None, lambda: qrcode.make(pay_req).get_image().tobytes())
|
||||
qr_file = File(io.BytesIO(qr_bytes), filename="invoice.png")
|
||||
# Generate QR code
|
||||
def make_qr_buffer():
|
||||
img = qrcode.make(payment_request)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
qr_buffer = await loop.run_in_executor(None, make_qr_buffer)
|
||||
qr_file = File(qr_buffer, filename="invoice.png")
|
||||
|
||||
embed = Embed(
|
||||
title="⚡ Please Pay ⚡",
|
||||
description=INVOICE_MESSAGE_TEMPLATE,
|
||||
color=discord.Color.gold()
|
||||
)
|
||||
embed.add_field(name="Invoice", value=f"```{pay_req}```", inline=False)
|
||||
embed.add_field(name="Invoice", value=f"```{payment_request}```", inline=False)
|
||||
embed.add_field(name="Amount", value=f"{PRICE} sats", inline=True)
|
||||
embed.set_image(url="attachment://invoice.png")
|
||||
embed.set_footer(text=f"Hash: {pay_hash}")
|
||||
embed.set_footer(text=f"Hash: {payment_hash}")
|
||||
|
||||
# Send invoice & ack
|
||||
await invoice_ch.send(content=interaction.user.mention, embed=embed, file=qr_file)
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
# ─── Run Bot ──────────────────────────────────────────────────────────────────
|
||||
if __name__ == "__main__":
|
||||
bot.run(DISCORD_TOKEN)
|
||||
|
Loading…
x
Reference in New Issue
Block a user