Multi-Guild Support

This commit is contained in:
2025-09-06 22:04:15 -05:00
parent 9acbbe5644
commit bfb16c503a
4 changed files with 216 additions and 63 deletions

View File

@@ -1,3 +1,7 @@
# 1.0.4
- Multi-Guild support (As long as a user has a required role or admin role in one server, they are able to use the bot and Jellyfin)
# 1.0.3
- Fixed: `ValueError: too many values to unpack (expected 2)`

237
app.py
View File

@@ -22,7 +22,7 @@ def get_env_var(key: str, cast=str, required=True):
TOKEN = get_env_var("DISCORD_TOKEN")
PREFIX = os.getenv("PREFIX", "!") # Default to "!" if not set
GUILD_ID = get_env_var("GUILD_ID", int)
GUILD_IDS = [int(x.strip()) for x in get_env_var("GUILD_IDS").split(",")]
REQUIRED_ROLE_IDS = [int(x) for x in get_env_var("REQUIRED_ROLE_IDS").split(",")]
ADMIN_ROLE_IDS = [int(x) for x in get_env_var("ADMIN_ROLE_IDS").split(",")]
SYNC_LOG_CHANNEL_ID = get_env_var("SYNC_LOG_CHANNEL_ID", int)
@@ -40,7 +40,7 @@ DB_USER = get_env_var("DB_USER")
DB_PASSWORD = get_env_var("DB_PASSWORD")
DB_NAME = get_env_var("DB_NAME")
BOT_VERSION = "1.0.3"
BOT_VERSION = "1.0.4"
VERSION_URL = "https://raw.githubusercontent.com/PenguCCN/Jellycord/main/version.txt"
RELEASES_URL = "https://github.com/PenguCCN/Jellycord/releases"
@@ -311,11 +311,30 @@ def delete_jellyseerr_user(js_id: str) -> bool:
# =====================
# DISCORD HELPERS
# =====================
def has_required_role(member):
return any(role.id in REQUIRED_ROLE_IDS for role in member.roles)
def has_admin_role(member):
return any(role.id in ADMIN_ROLE_IDS for role in member.roles)
def has_required_role(user: discord.User | discord.Member) -> bool:
"""Check if the user has any of the required roles across all configured guilds."""
for gid in GUILD_IDS:
guild = bot.get_guild(gid)
if not guild:
continue
member = guild.get_member(user.id)
if member and any(role.id in REQUIRED_ROLE_IDS for role in member.roles):
return True
return False
def has_admin_role(user: discord.User | discord.Member) -> bool:
"""Check if the user has any of the admin roles across all configured guilds."""
for gid in GUILD_IDS:
guild = bot.get_guild(gid)
if not guild:
continue
member = guild.get_member(user.id)
if member and any(role.id in ADMIN_ROLE_IDS for role in member.roles):
return True
return False
# =====================
# BOT HELPERS
@@ -418,8 +437,14 @@ async def createaccount(ctx, username: str = None, password: str = None):
await ctx.send(f"{ctx.author.mention} ❌ Please DM me to create your Jellyfin account.")
return
guild = bot.get_guild(GUILD_ID)
member = guild.get_member(ctx.author.id) if guild else None
member = None
for gid in GUILD_IDS:
guild = bot.get_guild(gid)
if guild:
member = guild.get_member(ctx.author.id)
if member and has_required_role(member):
break
if not member or not has_required_role(member):
await ctx.send(f"{ctx.author.mention}, you dont have the required role.")
return
@@ -474,10 +499,14 @@ async def trialaccount(ctx, username: str = None, password: str = None):
await ctx.send(command_usage(f"{PREFIX}trialaccount", ["<username>", "<password>"]))
return
guild = bot.get_guild(GUILD_ID)
member = guild.get_member(ctx.author.id) if guild else None
member = None
for gid in GUILD_IDS:
guild = bot.get_guild(gid)
if guild:
member = guild.get_member(ctx.author.id)
if member and has_required_role(member):
break
# Check required server role
if not member or not has_required_role(member):
await ctx.send(f"{ctx.author.mention}, you dont have the required role.")
return
@@ -585,17 +614,18 @@ async def deleteaccount(ctx, username: str = None):
@bot.command()
async def cleanup(ctx):
log_event(f"cleanup invoked by {ctx.author}")
guild = bot.get_guild(GUILD_ID)
removed = []
for row in get_accounts():
discord_id = row[0]
jf_username = row[1]
jf_id = row[2] if len(row) > 2 else None
js_id = row[3] if len(row) > 3 else None
for discord_id, jf_username, jf_id, js_id in get_accounts():
member = None
for gid in GUILD_IDS:
guild = bot.get_guild(gid)
if guild:
member = guild.get_member(discord_id)
if member:
break
m = guild.get_member(discord_id)
if m is None or not has_required_role(m):
if member is None or not has_required_role(member):
if delete_jellyfin_user(jf_username):
delete_account(discord_id)
@@ -616,12 +646,59 @@ async def cleanup(ctx):
await ctx.send("✅ Cleanup complete.")
@bot.command()
async def listvalidusers(ctx):
"""Admin-only: List how many registered users have a valid role."""
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
accounts = get_accounts()
valid_users = []
invalid_users = []
for discord_id, jf_username, jf_id, js_id in accounts:
user = await bot.fetch_user(discord_id)
if has_required_role(user):
valid_users.append(user)
else:
invalid_users.append(user)
embed = discord.Embed(
title="📊 Registered User Role Status",
color=discord.Color.green()
)
embed.add_field(
name="✅ Valid Users",
value=f"{len(valid_users)} users",
inline=True
)
embed.add_field(
name="❌ Invalid Users",
value=f"{len(invalid_users)} users",
inline=True
)
if len(valid_users) > 0:
embed.add_field(
name="Valid Users List",
value="\n".join([u.mention for u in valid_users[:20]]) + ("..." if len(valid_users) > 20 else ""),
inline=False
)
if len(invalid_users) > 0:
embed.add_field(
name="Invalid Users List",
value="\n".join([u.mention for u in invalid_users[:20]]) + ("..." if len(invalid_users) > 20 else ""),
inline=False
)
await ctx.send(embed=embed)
@bot.command()
async def lastcleanup(ctx):
log_event(f"lastcleanup invoked by {ctx.author}")
member = ctx.guild.get_member(ctx.author.id)
if not has_admin_role(member):
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to view the last cleanup.")
return
@@ -675,7 +752,7 @@ async def searchdiscord(ctx, user: discord.User = None):
async def scanlibraries(ctx):
log_event(f"scanlibraries invoked by {ctx.author}")
member = ctx.guild.get_member(ctx.author.id)
if not has_admin_role(member):
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
@@ -734,7 +811,7 @@ async def setprefix(ctx, new_prefix: str = None):
return
member = ctx.guild.get_member(ctx.author.id)
if not member or not has_admin_role(member):
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
@@ -761,7 +838,7 @@ async def setprefix(ctx, new_prefix: str = None):
async def updates(ctx):
log_event(f"updates invoked by {ctx.author}")
member = ctx.guild.get_member(ctx.author.id)
if not has_admin_role(member):
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
@@ -779,7 +856,7 @@ async def updates(ctx):
async def logging(ctx, state: str):
"""Admin-only: Enable or disable event logging."""
member = ctx.guild.get_member(ctx.author.id)
if not member or not has_admin_role(member):
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
@@ -813,7 +890,7 @@ async def logging(ctx, state: str):
async def help_command(ctx):
log_event(f"Command help invoked by {ctx.author}")
member = ctx.guild.get_member(ctx.author.id)
is_admin = has_admin_role(member)
is_admin = has_admin_role(ctx.author)
embed = discord.Embed(
title=f"📖 Jellyfin Bot Help {BOT_VERSION}",
@@ -838,6 +915,7 @@ async def help_command(ctx):
if is_admin:
embed.add_field(name="Admin Commands", value=(
f"`{PREFIX}cleanup` - Remove Jellyfin accounts from users without roles\n"
f"`{PREFIX}listvalidusers` - Show number of valid and invalid accounts\n"
f"`{PREFIX}lastcleanup` - See Last cleanup time, and time remaining before next cleanup\n"
f"`{PREFIX}searchaccount <jellyfin_username>` - Find linked Discord user\n"
f"`{PREFIX}searchdiscord @user` - Find linked Jellyfin account\n"
@@ -860,18 +938,59 @@ import datetime
@tasks.loop(hours=24)
async def daily_check():
guild = bot.get_guild(GUILD_ID)
log_event("Running daily account cleanup check...")
removed = []
# Normal accounts cleanup
for discord_id, jf_username, jf_id, js_id in get_accounts():
m = guild.get_member(discord_id)
if m is None or not has_required_role(m):
if delete_jellyfin_user(jf_username):
delete_account(discord_id)
removed.append(jf_username)
for row in get_accounts():
# safe unpacking in case schema varies
discord_id = row[0]
jf_username = row[1] if len(row) > 1 else None
jf_id = row[2] if len(row) > 2 else None
js_id = row[3] if len(row) > 3 else None
# Trial accounts cleanup
# find the member across configured guilds
member = None
for gid in GUILD_IDS:
guild = bot.get_guild(gid)
if not guild:
continue
candidate = guild.get_member(discord_id)
if candidate:
member = candidate
break
# if no member found or member doesn't have a required role -> delete account
if member is None or not has_required_role(member):
if jf_username:
try:
if delete_jellyfin_user(jf_username):
log_event(f"Deleted Jellyfin user {jf_username} for Discord ID {discord_id}")
else:
log_event(f"Failed to delete Jellyfin user {jf_username} for Discord ID {discord_id}")
except Exception as e:
print(f"[Cleanup] Error deleting Jellyfin user {jf_username}: {e}")
# remove DB entry for normal account
try:
delete_account(discord_id)
except Exception as e:
print(f"[Cleanup] Error removing DB entry for Discord ID {discord_id}: {e}")
# remove from Jellyseerr if we have an id and integration enabled
if JELLYSEERR_ENABLED and js_id:
try:
if delete_jellyseerr_user(js_id):
log_event(f"Deleted Jellyseerr user {js_id} for Discord ID {discord_id}")
else:
log_event(f"Failed to delete Jellyseerr user {js_id} for Discord ID {discord_id}")
except Exception as e:
print(f"[Cleanup] Failed to delete Jellyseerr user {js_id}: {e}")
removed.append(jf_username or f"{discord_id}")
# Trial accounts cleanup (persistent history table)
try:
conn = mysql.connector.connect(
host=DB_HOST, user=DB_USER, password=DB_PASSWORD, database=DB_NAME
)
@@ -880,20 +999,42 @@ async def daily_check():
trials = cur.fetchall()
for trial in trials:
created_at = trial["trial_created_at"]
if created_at and datetime.datetime.utcnow() > created_at + datetime.timedelta(hours=24):
# Delete from Jellyfin
delete_jellyfin_user(trial["jellyfin_username"])
# Mark trial as expired
created_at = trial.get("trial_created_at") or trial.get("created_at") # compatibility
if not created_at:
continue
# created_at is a datetime from the DB (cursor dictionary=True)
if datetime.datetime.utcnow() > created_at + datetime.timedelta(hours=24):
# delete from Jellyfin (best-effort)
try:
delete_jellyfin_user(trial.get("jellyfin_username"))
except Exception as e:
print(f"[Trial Cleanup] Error deleting trial Jellyfin user {trial.get('jellyfin_username')}: {e}")
# mark trial as expired
try:
cur.execute("UPDATE trial_accounts SET expired=1 WHERE discord_id=%s", (trial["discord_id"],))
conn.commit()
removed.append(f"{trial['jellyfin_username']} (trial)")
except Exception as e:
print(f"[Trial Cleanup] Error marking trial expired for {trial['discord_id']}: {e}")
removed.append(f"{trial.get('jellyfin_username')} (trial)")
except Exception as e:
print(f"[Trial Cleanup] Error reading trial accounts: {e}")
finally:
try:
cur.close()
conn.close()
except Exception:
pass
# Record cleanup run
# record last run in metadata and cleanup_logs
try:
set_metadata("last_cleanup", datetime.datetime.utcnow().isoformat())
except Exception as e:
print(f"[Cleanup] Failed to set last_cleanup metadata: {e}")
try:
conn = mysql.connector.connect(
host=DB_HOST, user=DB_USER, password=DB_PASSWORD, database=DB_NAME
)
@@ -902,9 +1043,19 @@ async def daily_check():
conn.commit()
cur.close()
conn.close()
except Exception as e:
print(f"[Cleanup] Failed to insert cleanup_logs: {e}")
# post results to sync channel if anything removed
if removed:
print(f"Cleanup removed {len(removed)} accounts: {removed}")
msg = f"🧹 Removed {len(removed)} Jellyfin accounts: {', '.join(removed)}"
print(msg)
try:
log_channel = bot.get_channel(SYNC_LOG_CHANNEL_ID)
if log_channel:
await log_channel.send(msg)
except Exception as e:
print(f"[Cleanup] Failed to send removed message to sync channel: {e}")
@tasks.loop(hours=1)
@@ -927,8 +1078,6 @@ async def check_for_updates():
print(f"[Update Check] Failed: {e}")
@bot.event
async def on_ready():
print(f"Logged in as {bot.user}")

View File

@@ -1 +1 @@
{ "version": "1.0.3" }
{ "version": "1.0.4" }

View File

@@ -1 +1 @@
1.0.3
1.0.4