diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ea051a0..322810b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -60,6 +60,7 @@ async def setup(bot): **IMPORTANT**: When creating new cogs or updating existing ones, always use the async setup pattern shown above. This is the modern Red-bot convention and ensures compatibility with the bot framework. ### Key Cogs +- **albion_auth/**: Albion Online authentication and daily verification system (requires: httpx>=0.14.1) - **albion_regear/**: Albion Online regear cost calculator (requires: httpx>=0.14.1) - **assign_roles/**: Role management system (no external deps) - **empty_voices/**: Voice channel management (no external deps) diff --git a/albion_auth/README.md b/albion_auth/README.md new file mode 100644 index 0000000..f986371 --- /dev/null +++ b/albion_auth/README.md @@ -0,0 +1,165 @@ +# Albion Auth + +Authenticate and verify Discord users with their Albion Online character names. + +## Features + +- **User Authentication**: Users can authenticate with their Albion Online character name +- **Automatic Nickname Change**: Discord nickname is automatically set to match the Albion character name +- **Role Assignment**: Optional role assignment upon successful authentication +- **Daily Verification**: Background task that automatically verifies users once per day +- **Mismatch Reporting**: Bot owner receives DM reports when user nicknames don't match their Albion names +- **Manual Checking**: Admins can manually check specific users + +## Commands + +### User Commands + +#### `.auth ` +Authenticate with your Albion Online character name. + +The bot will search for the player name in Albion Online and rename you to match. If an auth role is configured, it will also be assigned to you. + +**Example:** +``` +.auth MyCharacter +``` + +### Admin Commands + +#### `.authset authrole [@role]` +Set the role to assign when someone authenticates. If no role is provided, clears the current auth role setting. + +**Permissions Required:** Manage Server + +**Examples:** +``` +.authset authrole @Verified +.authset authrole +``` + +#### `.authset dailycheck ` +Enable or disable daily name verification checks for this server. + +When enabled, the bot will automatically check verified users once per day to ensure their Discord nickname still matches their Albion Online name. The bot owner will receive a DM report of any mismatches found. + +**Permissions Required:** Manage Server + +**Examples:** +``` +.authset dailycheck true +.authset dailycheck false +``` + +#### `.authset checkuser @user` +Manually check a specific user's name against the Albion API. + +This will immediately verify if the user's Discord nickname matches their Albion Online character name. + +**Permissions Required:** Manage Server + +**Example:** +``` +.authset checkuser @JohnDoe +``` + +## How Daily Verification Works + +1. **Background Task**: A background task runs every hour +2. **Role-Based Discovery**: The bot checks all members with the configured auth role +3. **Auto-Registration**: Users with the auth role who aren't in the verified users list are automatically added +4. **24-Hour Interval**: Users are checked approximately once every 24 hours +5. **Staggered Checks**: Users are checked in batches to avoid rate limiting +6. **Mismatch Detection**: The bot compares the user's Discord nickname with their current Albion Online name +7. **Report Generation**: If mismatches are found, a detailed report is sent to the bot owner via DM + +**Note**: The bot will automatically discover and track users who were verified before this feature was added, as long as they have the configured auth role. + +### Mismatch Scenarios + +The bot will report mismatches in the following cases: +- Discord nickname doesn't match the current Albion Online character name +- Player is no longer found in the Albion Online API + +### Report Format + +The bot owner receives a DM report with: +- Timestamp of the check +- Total number of mismatches +- For each mismatch: + - Guild name + - User Discord tag and ID + - Current Discord nickname + - Stored Albion name + - Current Albion API name (if found) + - Issue description + +## Configuration + +### Enable/Disable Daily Checks + +Daily checks are **enabled by default** for all servers. Admins can disable them per server: + +``` +.authset dailycheck false +``` + +### Setting an Auth Role + +Configure a role to be automatically assigned when users authenticate: + +``` +.authset authrole @Verified +``` + +## Requirements + +- `httpx>=0.14.1` + +## Installation + +1. Install the cog using Red's downloader: + ``` + [p]repo add psykzz-cogs https://github.com/psykzz/cogs + [p]cog install psykzz-cogs albion_auth + ``` + +2. Load the cog: + ``` + [p]load albion_auth + ``` + +## Technical Details + +### Data Storage + +The cog stores the following data per guild using Red's Config system: +- `auth_role`: Role ID to assign upon authentication (optional) +- `verified_users`: Dictionary mapping user IDs to their verification data: + - `discord_id`: The Discord user ID + - `albion_id`: The Albion Online player ID + - `name`: The Albion Online character name + - `last_checked`: Timestamp of the last verification check +- `enable_daily_check`: Boolean flag to enable/disable daily verification + +### API Usage + +The cog uses the Albion Online official game info API: +- Endpoint: `https://gameinfo-ams.albiononline.com/api/gameinfo/search` +- Rate limiting protection: 2-second delay between user checks +- Retry logic: Up to 3 attempts per request with exponential backoff + +### Background Task + +The background task: +- Starts when the cog is loaded (`cog_load`) +- Runs every hour +- Discovers all guild members with the configured auth role +- Automatically adds any missing users to the verified users list +- Checks users that haven't been verified in the last 24 hours +- Queries the Albion API to verify current character names +- Cancels gracefully when the cog is unloaded (`cog_unload`) + +## Support + +For issues or feature requests, please visit the [GitHub repository](https://github.com/psykzz/cogs). diff --git a/albion_auth/auth.py b/albion_auth/auth.py index 2892616..0a21d63 100644 --- a/albion_auth/auth.py +++ b/albion_auth/auth.py @@ -1,5 +1,7 @@ import asyncio import logging +from datetime import datetime, timezone +from typing import Dict, List import discord import httpx @@ -42,7 +44,24 @@ class AlbionAuth(commands.Cog): def __init__(self, bot): self.bot = bot self.config = Config.get_conf(self, identifier=73601, force_registration=True) - self.config.register_guild(auth_role=None) + self.config.register_guild( + auth_role=None, + # {user_id: {"discord_id": int, "albion_id": str, "name": str, "last_checked": timestamp}} + verified_users={}, + enable_daily_check=True + ) + self._check_task = None + + async def cog_load(self): + """Start the background task when cog loads""" + self._check_task = self.bot.loop.create_task(self._daily_check_loop()) + log.info("Started daily name check task") + + async def cog_unload(self): + """Cancel the background task when cog unloads""" + if self._check_task: + self._check_task.cancel() + log.info("Cancelled daily name check task") async def search_player(self, name): """Search for a player by name""" @@ -59,6 +78,237 @@ async def search_player(self, name): log.warning(f"Player '{name}' not found in search results") return None + async def _daily_check_loop(self): + """Background task to check verified users periodically""" + await self.bot.wait_until_ready() + log.info("Daily check loop started") + + while True: + try: + # Run check every hour + await asyncio.sleep(3600) + await self._check_users_batch() + except asyncio.CancelledError: + log.info("Daily check loop cancelled") + break + except Exception as e: + log.error(f"Error in daily check loop: {e}", exc_info=True) + await asyncio.sleep(3600) # Wait an hour before retrying on error + + async def _check_users_batch(self): + """Check a batch of users (approximately 1/24th of users per hour)""" + log.info("Starting user batch check") + all_mismatches: List[Dict] = [] + + for guild in self.bot.guilds: + try: + enabled = await self.config.guild(guild).enable_daily_check() + if not enabled: + log.debug(f"Daily check disabled for guild {guild.name}") + continue + + # Get the auth role + auth_role_id = await self.config.guild(guild).auth_role() + if not auth_role_id: + log.debug(f"No auth role configured for guild {guild.name}") + continue + + auth_role = guild.get_role(auth_role_id) + if not auth_role: + log.warning(f"Configured auth role ID {auth_role_id} not found in guild {guild.name}") + continue + + # Get all members with the auth role + members_with_role = [member for member in guild.members if auth_role in member.roles] + if not members_with_role: + log.debug(f"No members with auth role in guild {guild.name}") + continue + + verified_users = await self.config.guild(guild).verified_users() + now = datetime.now(timezone.utc).timestamp() + users_to_check = [] + + # Build list of users to check, adding missing ones to config + for member in members_with_role: + user_id_str = str(member.id) + + # If user not in verified_users, add them with their current nickname + if user_id_str not in verified_users: + log.info( + f"Adding previously verified user {member} to config " + f"with nickname {member.display_name}" + ) + # Search for the player to get their Albion ID + player = await self.search_player(member.display_name) + albion_id = player.get("Id") if player else None + albion_name = player.get("Name") if player else member.display_name + + async with self.config.guild(guild).verified_users() as verified_users_dict: + verified_users_dict[user_id_str] = { + "discord_id": member.id, + "albion_id": albion_id, + "name": albion_name, + "last_checked": 0 # Set to 0 to ensure they get checked + } + # Refresh verified_users after update + verified_users = await self.config.guild(guild).verified_users() + # Small delay to avoid rate limiting when adding users + await asyncio.sleep(2) + + user_data = verified_users[user_id_str] + last_checked = user_data.get("last_checked", 0) + + # Check if it's been at least 24 hours + if now - last_checked >= 86400: # 24 hours in seconds + users_to_check.append((user_id_str, user_data)) + + if not users_to_check: + log.debug(f"No users need checking in guild {guild.name}") + continue + + log.info(f"Checking {len(users_to_check)} users in guild {guild.name}") + + # Check each user and collect mismatches + for user_id_str, user_data in users_to_check: + try: + mismatch = await self._check_single_user(guild, user_id_str, user_data) + if mismatch: + all_mismatches.append(mismatch) + + # Small delay between checks to avoid rate limiting + await asyncio.sleep(2) + except Exception as e: + log.error(f"Error checking user {user_id_str}: {e}", exc_info=True) + + except Exception as e: + log.error(f"Error checking guild {guild.name}: {e}", exc_info=True) + + # Send report if there are any mismatches + if all_mismatches: + await self._send_mismatch_report(all_mismatches) + + async def _check_single_user(self, guild: discord.Guild, user_id_str: str, user_data: Dict) -> Dict: + """Check a single user's name against Albion API + + Returns a mismatch dict if there's an issue, None otherwise + """ + user_id = int(user_id_str) + stored_name = user_data.get("name") + + # Get the member from guild + member = guild.get_member(user_id) + if not member: + log.debug(f"User {user_id} not found in guild {guild.name}") + # Update last_checked timestamp even if user not found + async with self.config.guild(guild).verified_users() as verified_users: + if user_id_str in verified_users: + verified_users[user_id_str]["last_checked"] = datetime.now(timezone.utc).timestamp() + return None + + # Search for player in Albion API + player = await self.search_player(stored_name) + + # Update last_checked timestamp + async with self.config.guild(guild).verified_users() as verified_users: + if user_id_str in verified_users: + verified_users[user_id_str]["last_checked"] = datetime.now(timezone.utc).timestamp() + + if not player: + # Player not found in API + log.warning(f"Player {stored_name} no longer found in Albion API") + return { + "guild_name": guild.name, + "user_id": user_id, + "user_tag": str(member), + "discord_nick": member.display_name, + "stored_name": stored_name, + "current_api_name": None, + "issue": "Player not found in Albion API" + } + + current_api_name = player.get("Name") + + # Check if names match + if member.display_name != current_api_name: + log.info(f"Name mismatch for user {member}: '{member.display_name}' vs '{current_api_name}'") + return { + "guild_name": guild.name, + "user_id": user_id, + "user_tag": str(member), + "discord_nick": member.display_name, + "stored_name": stored_name, + "current_api_name": current_api_name, + "issue": "Discord nickname doesn't match Albion name" + } + + log.debug(f"User {member} name matches: {current_api_name}") + return None + + async def _send_mismatch_report(self, mismatches: List[Dict]): + """Send a DM to the bot owner with the mismatch report""" + try: + app_info = await self.bot.application_info() + owner = app_info.owner + + if not owner: + log.error("Could not determine bot owner") + return + + # Build the report message + report_lines = [ + "# Albion Auth Daily Check Report", + f"**Date:** {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}", + f"**Total Mismatches:** {len(mismatches)}", + "", + "## Details", + "" + ] + + for mismatch in mismatches: + report_lines.append(f"**Guild:** {mismatch['guild_name']}") + report_lines.append(f"**User:** {mismatch['user_tag']} (ID: {mismatch['user_id']})") + report_lines.append(f"**Discord Nick:** {mismatch['discord_nick']}") + report_lines.append(f"**Stored Name:** {mismatch['stored_name']}") + if mismatch['current_api_name']: + report_lines.append(f"**Current Albion Name:** {mismatch['current_api_name']}") + report_lines.append(f"**Issue:** {mismatch['issue']}") + report_lines.append("") + + report = "\n".join(report_lines) + + # Send as DM (split if too long) + if len(report) <= 2000: + await owner.send(report) + else: + # Split into chunks + chunks = [] + current_chunk = [] + current_length = 0 + + for line in report_lines: + line_length = len(line) + 1 # +1 for newline + if current_length + line_length > 1900: # Leave some margin + chunks.append("\n".join(current_chunk)) + current_chunk = [line] + current_length = line_length + else: + current_chunk.append(line) + current_length += line_length + + if current_chunk: + chunks.append("\n".join(current_chunk)) + + for chunk in chunks: + await owner.send(chunk) + await asyncio.sleep(1) # Rate limit protection + + log.info(f"Sent mismatch report to bot owner with {len(mismatches)} mismatches") + + except discord.Forbidden: + log.error("Cannot send DM to bot owner - DMs may be disabled") + except Exception as e: + log.error(f"Error sending mismatch report: {e}", exc_info=True) + @commands.guild_only() @commands.hybrid_command(name="auth") async def auth(self, ctx, name: str): @@ -89,6 +339,17 @@ async def auth(self, ctx, name: str): try: await ctx.author.edit(nick=player_name) log.info(f"Successfully renamed {ctx.author} to {player_name}") + + # Store verified user information + async with self.config.guild(ctx.guild).verified_users() as verified_users: + verified_users[str(ctx.author.id)] = { + "discord_id": ctx.author.id, + "albion_id": player_id, + "name": player_name, + "last_checked": datetime.now(timezone.utc).timestamp() + } + log.info(f"Stored verified user: {ctx.author.id} -> {player_name} (Albion ID: {player_id})") + success_msg = ( f"✅ Successfully authenticated! " f"Your nickname has been changed to **{player_name}**." @@ -158,3 +419,83 @@ async def authset_authrole(self, ctx, role: discord.Role = None): await self.config.guild(ctx.guild).auth_role.set(role.id) log.info(f"Auth role set to {role.name} (ID: {role.id}) for guild {ctx.guild.name}") await ctx.send(f"✅ Auth role set to **{role.name}**. This role will be assigned when users authenticate.") + + @authset.command(name="dailycheck") + async def authset_dailycheck(self, ctx, enabled: bool): + """Enable or disable daily name verification checks + + When enabled, the bot will automatically check verified users once per day + to ensure their Discord nickname still matches their Albion Online name. + The bot owner will receive a DM report of any mismatches found. + + Usage: .authset dailycheck + Example: .authset dailycheck true + """ + await self.config.guild(ctx.guild).enable_daily_check.set(enabled) + log.info(f"Daily check {'enabled' if enabled else 'disabled'} for guild {ctx.guild.name}") + + if enabled: + await ctx.send( + "✅ Daily name verification checks **enabled**. " + "Verified users will be checked once per day, and the bot owner will receive " + "a DM report of any mismatches." + ) + else: + await ctx.send( + "✅ Daily name verification checks **disabled**. " + "Automatic checking has been turned off for this server." + ) + + @authset.command(name="checkuser") + async def authset_checkuser(self, ctx, user: discord.Member): + """Manually check a specific user's name against Albion API + + This will immediately verify if the user's Discord nickname matches + their Albion Online character name. + + Usage: .authset checkuser @user + Example: .authset checkuser @JohnDoe + """ + verified_users = await self.config.guild(ctx.guild).verified_users() + user_id_str = str(user.id) + + if user_id_str not in verified_users: + await ctx.send(f"❌ {user.mention} is not in the verified users list.") + return + + user_data = verified_users[user_id_str] + stored_name = user_data.get("name") + + async with ctx.typing(): + # Search for player in Albion API + player = await self.search_player(stored_name) + + if not player: + await ctx.send( + f"⚠️ **Mismatch Found!**\n" + f"User: {user.mention}\n" + f"Discord Nick: {user.display_name}\n" + f"Stored Name: {stored_name}\n" + f"Issue: Player not found in Albion API" + ) + return + + current_api_name = player.get("Name") + + if user.display_name != current_api_name: + await ctx.send( + f"⚠️ **Mismatch Found!**\n" + f"User: {user.mention}\n" + f"Discord Nick: {user.display_name}\n" + f"Stored Name: {stored_name}\n" + f"Current Albion Name: {current_api_name}\n" + f"Issue: Discord nickname doesn't match Albion name" + ) + else: + await ctx.send( + f"✅ **No Issues**\n" + f"User: {user.mention}\n" + f"Discord Nick: {user.display_name}\n" + f"Albion Name: {current_api_name}\n" + f"Status: Names match correctly" + ) diff --git a/albion_auth/info.json b/albion_auth/info.json index 4a09964..566151d 100644 --- a/albion_auth/info.json +++ b/albion_auth/info.json @@ -1,14 +1,15 @@ { "author": ["PsyKzz"], "name": "Albion Auth", - "short": "Authenticate and rename users with Albion Online player names", - "description": "Allows users to authenticate their Discord identity with their Albion Online character name. The bot will search for the player and rename them if found. Admins can configure a role to be assigned upon successful authentication.", + "short": "Authenticate and verify users with Albion Online player names", + "description": "Allows users to authenticate their Discord identity with their Albion Online character name. The bot will search for the player and rename them if found. Admins can configure a role to be assigned upon successful authentication. Features automatic daily verification to ensure Discord nicknames still match Albion Online names, with mismatch reports sent to the bot owner.", "requirements": [ "httpx>=0.14.1" ], "tags": [ "Albion Online", "Authentication", - "API" + "API", + "Verification" ] }