Multi-Guild Support
This commit is contained in:
@@ -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
|
# 1.0.3
|
||||||
|
|
||||||
- Fixed: `ValueError: too many values to unpack (expected 2)`
|
- Fixed: `ValueError: too many values to unpack (expected 2)`
|
||||||
|
|||||||
237
app.py
237
app.py
@@ -22,7 +22,7 @@ def get_env_var(key: str, cast=str, required=True):
|
|||||||
|
|
||||||
TOKEN = get_env_var("DISCORD_TOKEN")
|
TOKEN = get_env_var("DISCORD_TOKEN")
|
||||||
PREFIX = os.getenv("PREFIX", "!") # Default to "!" if not set
|
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(",")]
|
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(",")]
|
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)
|
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_PASSWORD = get_env_var("DB_PASSWORD")
|
||||||
DB_NAME = get_env_var("DB_NAME")
|
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"
|
VERSION_URL = "https://raw.githubusercontent.com/PenguCCN/Jellycord/main/version.txt"
|
||||||
RELEASES_URL = "https://github.com/PenguCCN/Jellycord/releases"
|
RELEASES_URL = "https://github.com/PenguCCN/Jellycord/releases"
|
||||||
|
|
||||||
@@ -311,11 +311,30 @@ def delete_jellyseerr_user(js_id: str) -> bool:
|
|||||||
# =====================
|
# =====================
|
||||||
# DISCORD HELPERS
|
# 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):
|
def has_required_role(user: discord.User | discord.Member) -> bool:
|
||||||
return any(role.id in ADMIN_ROLE_IDS for role in member.roles)
|
"""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
|
# 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.")
|
await ctx.send(f"{ctx.author.mention} ❌ Please DM me to create your Jellyfin account.")
|
||||||
return
|
return
|
||||||
|
|
||||||
guild = bot.get_guild(GUILD_ID)
|
member = None
|
||||||
member = guild.get_member(ctx.author.id) if guild else 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):
|
if not member or not has_required_role(member):
|
||||||
await ctx.send(f"❌ {ctx.author.mention}, you don’t have the required role.")
|
await ctx.send(f"❌ {ctx.author.mention}, you don’t have the required role.")
|
||||||
return
|
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>"]))
|
await ctx.send(command_usage(f"{PREFIX}trialaccount", ["<username>", "<password>"]))
|
||||||
return
|
return
|
||||||
|
|
||||||
guild = bot.get_guild(GUILD_ID)
|
member = None
|
||||||
member = guild.get_member(ctx.author.id) if guild else 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):
|
if not member or not has_required_role(member):
|
||||||
await ctx.send(f"❌ {ctx.author.mention}, you don’t have the required role.")
|
await ctx.send(f"❌ {ctx.author.mention}, you don’t have the required role.")
|
||||||
return
|
return
|
||||||
@@ -585,17 +614,18 @@ async def deleteaccount(ctx, username: str = None):
|
|||||||
@bot.command()
|
@bot.command()
|
||||||
async def cleanup(ctx):
|
async def cleanup(ctx):
|
||||||
log_event(f"cleanup invoked by {ctx.author}")
|
log_event(f"cleanup invoked by {ctx.author}")
|
||||||
guild = bot.get_guild(GUILD_ID)
|
|
||||||
removed = []
|
removed = []
|
||||||
|
|
||||||
for row in get_accounts():
|
for discord_id, jf_username, jf_id, js_id in get_accounts():
|
||||||
discord_id = row[0]
|
member = None
|
||||||
jf_username = row[1]
|
for gid in GUILD_IDS:
|
||||||
jf_id = row[2] if len(row) > 2 else None
|
guild = bot.get_guild(gid)
|
||||||
js_id = row[3] if len(row) > 3 else None
|
if guild:
|
||||||
|
member = guild.get_member(discord_id)
|
||||||
|
if member:
|
||||||
|
break
|
||||||
|
|
||||||
m = guild.get_member(discord_id)
|
if member is None or not has_required_role(member):
|
||||||
if m is None or not has_required_role(m):
|
|
||||||
if delete_jellyfin_user(jf_username):
|
if delete_jellyfin_user(jf_username):
|
||||||
delete_account(discord_id)
|
delete_account(discord_id)
|
||||||
|
|
||||||
@@ -616,12 +646,59 @@ async def cleanup(ctx):
|
|||||||
|
|
||||||
await ctx.send("✅ Cleanup complete.")
|
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 don’t 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()
|
@bot.command()
|
||||||
async def lastcleanup(ctx):
|
async def lastcleanup(ctx):
|
||||||
log_event(f"lastcleanup invoked by {ctx.author}")
|
log_event(f"lastcleanup invoked by {ctx.author}")
|
||||||
member = ctx.guild.get_member(ctx.author.id)
|
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 don’t have permission to view the last cleanup.")
|
await ctx.send("❌ You don’t have permission to view the last cleanup.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -675,7 +752,7 @@ async def searchdiscord(ctx, user: discord.User = None):
|
|||||||
async def scanlibraries(ctx):
|
async def scanlibraries(ctx):
|
||||||
log_event(f"scanlibraries invoked by {ctx.author}")
|
log_event(f"scanlibraries invoked by {ctx.author}")
|
||||||
member = ctx.guild.get_member(ctx.author.id)
|
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 don’t have permission to use this command.")
|
await ctx.send("❌ You don’t have permission to use this command.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -734,7 +811,7 @@ async def setprefix(ctx, new_prefix: str = None):
|
|||||||
return
|
return
|
||||||
|
|
||||||
member = ctx.guild.get_member(ctx.author.id)
|
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 don’t have permission to use this command.")
|
await ctx.send("❌ You don’t have permission to use this command.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -761,7 +838,7 @@ async def setprefix(ctx, new_prefix: str = None):
|
|||||||
async def updates(ctx):
|
async def updates(ctx):
|
||||||
log_event(f"updates invoked by {ctx.author}")
|
log_event(f"updates invoked by {ctx.author}")
|
||||||
member = ctx.guild.get_member(ctx.author.id)
|
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 don’t have permission to use this command.")
|
await ctx.send("❌ You don’t have permission to use this command.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -779,7 +856,7 @@ async def updates(ctx):
|
|||||||
async def logging(ctx, state: str):
|
async def logging(ctx, state: str):
|
||||||
"""Admin-only: Enable or disable event logging."""
|
"""Admin-only: Enable or disable event logging."""
|
||||||
member = ctx.guild.get_member(ctx.author.id)
|
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 don’t have permission to use this command.")
|
await ctx.send("❌ You don’t have permission to use this command.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -813,7 +890,7 @@ async def logging(ctx, state: str):
|
|||||||
async def help_command(ctx):
|
async def help_command(ctx):
|
||||||
log_event(f"Command help invoked by {ctx.author}")
|
log_event(f"Command help invoked by {ctx.author}")
|
||||||
member = ctx.guild.get_member(ctx.author.id)
|
member = ctx.guild.get_member(ctx.author.id)
|
||||||
is_admin = has_admin_role(member)
|
is_admin = has_admin_role(ctx.author)
|
||||||
|
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title=f"📖 Jellyfin Bot Help {BOT_VERSION}",
|
title=f"📖 Jellyfin Bot Help {BOT_VERSION}",
|
||||||
@@ -838,6 +915,7 @@ async def help_command(ctx):
|
|||||||
if is_admin:
|
if is_admin:
|
||||||
embed.add_field(name="Admin Commands", value=(
|
embed.add_field(name="Admin Commands", value=(
|
||||||
f"`{PREFIX}cleanup` - Remove Jellyfin accounts from users without roles\n"
|
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}lastcleanup` - See Last cleanup time, and time remaining before next cleanup\n"
|
||||||
f"`{PREFIX}searchaccount <jellyfin_username>` - Find linked Discord user\n"
|
f"`{PREFIX}searchaccount <jellyfin_username>` - Find linked Discord user\n"
|
||||||
f"`{PREFIX}searchdiscord @user` - Find linked Jellyfin account\n"
|
f"`{PREFIX}searchdiscord @user` - Find linked Jellyfin account\n"
|
||||||
@@ -860,18 +938,59 @@ import datetime
|
|||||||
|
|
||||||
@tasks.loop(hours=24)
|
@tasks.loop(hours=24)
|
||||||
async def daily_check():
|
async def daily_check():
|
||||||
guild = bot.get_guild(GUILD_ID)
|
log_event("Running daily account cleanup check...")
|
||||||
removed = []
|
removed = []
|
||||||
|
|
||||||
# Normal accounts cleanup
|
# Normal accounts cleanup
|
||||||
for discord_id, jf_username, jf_id, js_id in get_accounts():
|
for row in get_accounts():
|
||||||
m = guild.get_member(discord_id)
|
# safe unpacking in case schema varies
|
||||||
if m is None or not has_required_role(m):
|
discord_id = row[0]
|
||||||
if delete_jellyfin_user(jf_username):
|
jf_username = row[1] if len(row) > 1 else None
|
||||||
delete_account(discord_id)
|
jf_id = row[2] if len(row) > 2 else None
|
||||||
removed.append(jf_username)
|
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(
|
conn = mysql.connector.connect(
|
||||||
host=DB_HOST, user=DB_USER, password=DB_PASSWORD, database=DB_NAME
|
host=DB_HOST, user=DB_USER, password=DB_PASSWORD, database=DB_NAME
|
||||||
)
|
)
|
||||||
@@ -880,20 +999,42 @@ async def daily_check():
|
|||||||
trials = cur.fetchall()
|
trials = cur.fetchall()
|
||||||
|
|
||||||
for trial in trials:
|
for trial in trials:
|
||||||
created_at = trial["trial_created_at"]
|
created_at = trial.get("trial_created_at") or trial.get("created_at") # compatibility
|
||||||
if created_at and datetime.datetime.utcnow() > created_at + datetime.timedelta(hours=24):
|
if not created_at:
|
||||||
# Delete from Jellyfin
|
continue
|
||||||
delete_jellyfin_user(trial["jellyfin_username"])
|
|
||||||
# Mark trial as expired
|
# 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"],))
|
cur.execute("UPDATE trial_accounts SET expired=1 WHERE discord_id=%s", (trial["discord_id"],))
|
||||||
conn.commit()
|
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()
|
cur.close()
|
||||||
conn.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(
|
conn = mysql.connector.connect(
|
||||||
host=DB_HOST, user=DB_USER, password=DB_PASSWORD, database=DB_NAME
|
host=DB_HOST, user=DB_USER, password=DB_PASSWORD, database=DB_NAME
|
||||||
)
|
)
|
||||||
@@ -902,9 +1043,19 @@ async def daily_check():
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.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:
|
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)
|
@tasks.loop(hours=1)
|
||||||
@@ -927,8 +1078,6 @@ async def check_for_updates():
|
|||||||
print(f"[Update Check] Failed: {e}")
|
print(f"[Update Check] Failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bot.event
|
@bot.event
|
||||||
async def on_ready():
|
async def on_ready():
|
||||||
print(f"Logged in as {bot.user}")
|
print(f"Logged in as {bot.user}")
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{ "version": "1.0.3" }
|
{ "version": "1.0.4" }
|
||||||
@@ -1 +1 @@
|
|||||||
1.0.3
|
1.0.4
|
||||||
Reference in New Issue
Block a user