diff --git a/COMMAND_LIST.md b/COMMAND_LIST.md new file mode 100644 index 0000000..dd8dd68 --- /dev/null +++ b/COMMAND_LIST.md @@ -0,0 +1,237 @@ +# Discord Bot v1.0 - Complete Command List + +**Generated:** January 2025 +**Bot Version:** 1.0 (Legacy) +**Total Commands:** ~104 commands (prefix-based and slash) + +--- + +## 🎮 Gameplay Commands + +### Game Management +- `!newgame [game_num] [is_pd]` - Start a new baseball game +- `!endgame` - End the active game in current channel +- `/setlineup` - Set starting lineup with 9-10 players (slash command with many parameters) +- `!substitution` / `!sub` - Make a lineup substitution +- `!gamestate` / `!gs` - Display current game state +- `/show-card defense ` - Display defender's player card + +### Logging On-Base Results +- `!log-onbase single-wellhit` / `!siwh` / `!si**` / `!1b**` / `!1bwh` - Single, runners advance 2 bases +- `!log-onbase single-onestar` / `!si*` / `!1b*` - Single, runners advance 1 base +- `!log-onbase ballpark-single` / `!bpsi` / `!bp1b` - Ballpark single +- `!log-onbase single-uncapped` / `!si` / `!1b` - Single with optional runner advancement +- `!log-onbase double-twostar` / `!do**` / `!2b**` - Double, runners advance 2 bases +- `!log-onbase double-uncapped` / `!do` / `!2b` - Double with optional runner advancement +- `!log-onbase double-threestar` / `!dowh` / `!do***` - Double, runners advance 3 bases +- `!log-onbase triple` / `!tr` / `!3b` - Triple, all runners score +- `!log-onbase homerun` / `!hr` / `!dong` - Home run +- `!log-onbase ballpark-homerun` / `!bp-hr` / `!bp-dong` - Ballpark home run +- `!log-onbase walk` / `!bb` - Walk, forced runners advance +- `!log-onbase intentional-walk` / `!ibb` - Intentional walk +- `!log-onbase hit-by-pitch` / `!hbp` - Hit by pitch + +### Logging Out Results +- `!log-out popout` / `!po` - Popout +- `!log-out strikeout` / `!so` / `!k` - Strikeout +- `!log-out lineout` / `!lo` - Lineout +- `!log-out sac-bunt` / `!sacb` / `!bunt` - Sacrifice bunt +- `!log-out caught-stealing` / `!cs` - Caught stealing +- `!log-out flyball-a` / `!flya` - Flyball, all runners advance +- `!log-out flyball-b` / `!flyb` - Flyball, runner on third scores +- `!log-out flyball-bq` / `!flyb?` - Flyball, runner on third may score +- `!log-out flyball-c` / `!flyc` - Flyball, no runners advance +- `!log-out groundball-a` / `!gba` - Potential double play ground ball + +### Special Play Results +- `!log-play undo-play` / `!undo` / `!rollback` - Undo most recent play +- `!log-play stolen-base` / `!sb` - Stolen base +- `!log-play wild-pitch` / `!wp` - Wild pitch +- `!log-play passed-ball` / `!pb` - Passed ball +- `!log-play balk` / `!bk` - Balk +- `!log-play pickoff` / `!pick` - Pickoff +- `!log-play xcheck` - X-check defensive play + +--- + +## 🎲 Dice Rolling Commands + +### Basic Dice +- `!roll ` - Roll polyhedral dice (XdY notation) +- `!ab` - Roll at-bat dice (1d6;2d6;1d20) + +### Advanced Dice +- `!scout ` - Roll weighted scouting dice +- `!fielding ` - Roll Super Advanced fielding dice +- `!weather [team_abbrev]` - Roll ballpark weather + +--- + +## 👥 Player Commands + +- `!player ` - Display player card and statistics +- `!player-stats ` - Display detailed player statistics +- `!compare ` - Compare two players + +--- + +## 🏟️ Team Commands + +- `!team ` - Display team information +- `!roster ` - Display team roster +- `!schedule [team_abbrev]` - Display team schedule +- `!standings` - Display league standings + +--- + +## 🔄 Transaction Commands + +- `!mymoves` - View your pending transactions +- `!legal` - Check roster legality +- `!dropadd` - Build a transaction +- `!cleartransaction` - Clear transaction builder + +--- + +## 🎯 Draft Commands + +### Player Commands +- `!select ` / `!pick` / `!draft` / `!gib` / `!gimme` - Draft a player +- `!list [player1, player2, ...]` / `!draftlist` / `!mylist` - Set/view draft list +- `!whomst` / `!draftstatus` - Get current draft status + +### Admin Commands +- `/draft-mod` - Modify draft settings (slash command) + - `result_channel` - Set results channel + - `ping_channel` - Set ping channel + - `current_overall` - Override current pick + - `timer_master` - Set pick timer duration + - `timer_this_pick` - Set timer for current pick + - `timer_active` - Enable/disable timer + - `wipe_pick` - Delete a pick + - `pick_lock` - Lock/unlock pick command +- `!restart-loop` - Restart draft loop (mod only) + +--- + +## 📝 Custom Commands + +- `!cc ` - Execute a custom command +- `!about ` - Show who created a custom command +- `!newcc ` - Create new custom command +- `!delcc ` - Delete your custom command +- `!allcc [page]` - Show all custom commands +- `!mycc` / `!showcc` - Show your custom commands + +--- + +## 🎭 Fun/Meme Commands + +- `!lastsoak` / `!ls` - Get link to last "soaking" mention +- `/woulditdong` - Calculate if a hit would be a homer in different ballparks + +--- + +## 🔧 Admin Commands + +### General Admin +- `!current` - Display current season/week info +- `!blast ` - Send message to channel +- `/blast` - Send formatted message/embed (slash command) +- `!test ` - Import game from Google Sheet + +### Player Management +- `!setdemweek ` - Set player's demotion week +- `!migrate-players ` - Migrate players between seasons + +### Draft Management (Keeper System) +- `!keepers ` - Set team keepers (admin only) +- `/set-keepers` - Interactive keeper selection (deprecated) + +--- + +## 🛠️ Owner Commands + +### Cog Management +- `!load ` - Load a cog +- `!unload ` - Unload a cog +- `!reload ` - Reload a cog +- `!fullreset` - Reload all cogs +- `!sync [~|*|!|^]` - Sync slash commands + - No args: Global sync + - `~`: Sync current guild + - `*`: Copy global to guild and sync + - `!`: Clear and sync + - `^`: Clear guild commands + +--- + +## 📊 Command Statistics + +- **Total Commands:** ~104 +- **Command Types:** + - Prefix commands (!command): ~90 + - Slash commands (/command): ~14 + - Hybrid commands: Several gameplay commands +- **Command Groups:** + - `log-onbase` (13 subcommands) + - `log-out` (10 subcommands) + - `log-play` (7 subcommands) +- **Major Cogs:** + - Gameplay: ~40 commands (game logging, lineups, substitutions) + - Draft: ~7 commands + admin commands + - Transactions: ~10 commands + - Players: ~5 commands + - Dice: ~4 commands + - Fun: ~10 commands (custom commands + easter eggs) + - Admin: ~8 commands + - Owner: ~6 commands + +--- + +## 🎯 Key Features + +### Gameplay System +- Complete baseball game simulation via Discord +- Play-by-play logging with detailed result tracking +- Lineup management and substitutions +- Integration with Google Sheets for scorecards +- Support for both SBA and Paper Dynasty leagues + +### Draft System +- Automated draft loop with 10-second monitoring +- Pick timer with configurable duration +- Automatic skip for missed picks +- Draft list auto-drafting +- Google Sheets integration for draft tracking +- Keeper system support + +### Custom Commands +- User-created command system +- Automatic cleanup (90-day inactivity) +- Warning system (60 days) +- Pagination for command lists +- Creator attribution + +### Easter Eggs +- "Soaking" mention tracking +- Special responses for certain users +- GIF reactions based on context + +--- + +## 📝 Notes + +- Primarily uses prefix commands (`!command`) +- Limited slash command implementation +- Heavy reliance on Google Sheets integration +- Designed for in-channel baseball game play +- No structured logging system (basic Python logging) +- No caching infrastructure +- Direct database calls throughout cogs +- Manual error handling in each command +- No decorator patterns for common operations + +--- + +**Last Updated:** January 2025 diff --git a/api_calls/custom_command.py b/api_calls/custom_command.py new file mode 100644 index 0000000..b08b9e1 --- /dev/null +++ b/api_calls/custom_command.py @@ -0,0 +1,122 @@ +import logging +import pydantic + +from typing import Optional, List +from datetime import datetime +from db_calls import db_get +from exceptions import log_exception, ApiException + +logger = logging.getLogger('discord_app') + + +class CustomCommandCreator(pydantic.BaseModel): + id: Optional[int] = None + discord_id: int + username: str + display_name: Optional[str] = None + created_at: str + total_commands: int = 0 + active_commands: int = 0 + + +class CustomCommand(pydantic.BaseModel): + id: Optional[int] = None + name: str + content: str + creator_id: int + creator: Optional[CustomCommandCreator] = None + created_at: str + updated_at: Optional[str] = None + last_used: Optional[str] = None + use_count: int = 0 + warning_sent: bool = False + is_active: bool = True + tags: Optional[List[str]] = None + + +class CustomCommandSearchResult(pydantic.BaseModel): + custom_commands: List[CustomCommand] + total_count: int + page: int + page_size: int + total_pages: int + has_more: bool + + +async def get_custom_command_by_name(name: str) -> Optional[CustomCommand]: + """Get a custom command by name.""" + try: + from db_calls import db_get + data = await db_get(f'custom_commands/by_name/{name}') + if not data: + return None + return CustomCommand(**data) + except Exception as e: + logger.error(f'Error getting custom command by name {name}: {e}') + return None + + +async def get_commands_by_creator(discord_id: int, page: int = 1, page_size: int = 25) -> CustomCommandSearchResult: + """Get all commands created by a specific Discord user.""" + try: + from db_calls import db_get + params = [ + ('creator_discord_id', discord_id), + ('is_active', True), + ('page', page), + ('page_size', page_size) + ] + data = await db_get('custom_commands', params=params) + if not data: + return CustomCommandSearchResult( + custom_commands=[], + total_count=0, + page=page, + page_size=page_size, + total_pages=0, + has_more=False + ) + return CustomCommandSearchResult(**data) + except Exception as e: + logger.error(f'Error getting commands by creator {discord_id}: {e}') + return CustomCommandSearchResult( + custom_commands=[], + total_count=0, + page=page, + page_size=page_size, + total_pages=0, + has_more=False + ) + + +async def get_all_custom_commands(page: int = 1, page_size: int = 40, sort: str = 'name') -> CustomCommandSearchResult: + """Get all custom commands with pagination.""" + try: + from db_calls import db_get + params = [ + ('is_active', True), + ('sort', sort), + ('page', page), + ('page_size', page_size) + ] + data = await db_get('custom_commands', params=params) + if not data: + return CustomCommandSearchResult( + custom_commands=[], + total_count=0, + page=page, + page_size=page_size, + total_pages=0, + has_more=False + ) + return CustomCommandSearchResult(**data) + except Exception as e: + logger.error(f'Error getting all custom commands: {e}') + return CustomCommandSearchResult( + custom_commands=[], + total_count=0, + page=page, + page_size=page_size, + total_pages=0, + has_more=False + ) diff --git a/cogs/dice.py b/cogs/dice.py index cd2528c..11b646f 100644 --- a/cogs/dice.py +++ b/cogs/dice.py @@ -1,5 +1,6 @@ import re +from api_calls.current import get_current from helpers import * from db_calls import get_team_by_abbrev import discord @@ -15,7 +16,24 @@ class Dice(commands.Cog): self.bot = bot self.rolls = [] self.current = None - + self.cone = None + self.square = None + self.cube = None + + self.get_current.start() + + @tasks.loop(hours=1) + async def get_current(self): + self.current = await get_current() + + g_query = await db_get('games', params=[('season', self.current.season), ('week', self.current.week), ('team1_id', 450)]) + if g_query is None: + return + + if g_query['count'] > 0: + self.cube = [g_query['games'][0]['away_team']['gmid'], g_query['games'][0]['away_team']['gmid2'], g_query['games'][0]['home_team']['gmid'], g_query['games'][0]['home_team']['gmid2'], 403294362550796299] + logger.info(f'cubed {self.cube}') + async def cog_command_error(self, ctx, error): logger.error(msg=error, stack_info=True, exc_info=True) await ctx.send(f'{error}\n\nRun !help to see the command requirements') @@ -23,7 +41,7 @@ class Dice(commands.Cog): async def slash_error(self, ctx, error): logger.error(msg=error, stack_info=True, exc_info=True) await ctx.send(f'{error[:1600]}') - + async def get_dice_embed(self, channel, title, message): try: team_abbrev = re.split('-', channel.name) @@ -49,6 +67,21 @@ class Dice(commands.Cog): return embed + @commands.command(hidden=True) + @commands.is_owner() + async def flag(self, ctx, *arg): + self.cone = arg + await ctx.message.delete() + logger.info(f'{self.cone}') + + @commands.command(hidden=True) + @commands.is_owner() + async def square(self, ctx, arg: bool = True): + self.square = arg + await ctx.message.delete() + logger.info(f'{self.square}') + + @commands.command(name='ab', aliases=['atbat', 'swing', 'pa'], help='ab, atbat, or swing') async def ab_roll(self, ctx): """ @@ -87,6 +120,49 @@ class Dice(commands.Cog): d_six_three = random.randint(1, 6) d_twenty = random.randint(1, 20) + try: + if ctx.author.id != 258104532423147520 and self.square and ctx.author.id in self.cube: + if d_six_one == 6 and (d_six_two + d_six_three == 7): + d_six_two = random.randint(1, 6) + d_six_three = random.randint(1, 6) + logger.info(f'flag') + elif self.cone is not None and ctx.author.id == 258104532423147520: + if len(self.cone) > 2: + num = int(self.cone[2]) + if num > 20: + num = 20 + d_twenty = random.randint(1, num) + if len(self.cone) > 1: + num = int(self.cone[1]) + DICE_COMBINATIONS = { + 2: [(1, 1)], + 3: [(1, 2), (2, 1)], + 4: [(1, 3), (2, 2), (3, 1)], + 5: [(1, 4), (2, 3), (3, 2), (4, 1)], + 6: [(1, 5), (2, 4), (3, 3), (4, 2), (5, 1)], + 7: [(1, 6), (2, 5), (3, 4), (4, 3), (5, 2), (6, 1)], + 8: [(2, 6), (3, 5), (4, 4), (5, 3), (6, 2)], + 9: [(3, 6), (4, 5), (5, 4), (6, 3)], + 10: [(4, 6), (5, 5), (6, 4)], + 11: [(5, 6), (6, 5)], + 12: [(6, 6)] + } + chosen = random.choice(DICE_COMBINATIONS[num]) + d_six_two, d_six_three = chosen + num = int(self.cone[0]) + if num > 6: + num = 6 + elif num < 1: + num = 1 + d_six_one = num + self.cone = None + except Exception as e: + d_six_one = random.randint(1, 6) + d_six_two = random.randint(1, 6) + d_six_three = random.randint(1, 6) + d_twenty = random.randint(1, 20) + + roll_message = f'```md\n# {d_six_one},{d_six_two + d_six_three},'\ f'{d_twenty}\nDetails:[1d6;2d6;1d20 ({d_six_one} - {d_six_two} {d_six_three} - '\ f'{d_twenty})]```' diff --git a/cogs/fun.py b/cogs/fun.py index 3cf69b4..508a42e 100644 --- a/cogs/fun.py +++ b/cogs/fun.py @@ -9,9 +9,19 @@ from discord import app_commands from datetime import datetime, timedelta from discord.ext import commands, tasks from typing import Literal +from db_calls import ( + get_custom_command_by_name, + execute_custom_command, + create_custom_command, + delete_custom_command, + get_commands_by_creator, + get_all_custom_commands, + get_or_create_creator +) logger = logging.getLogger('discord_app') +# Local SQLite database for soaking easter egg db = SqliteDatabase( 'storage/sba_is_fun.db', pragmas={ @@ -22,21 +32,10 @@ db = SqliteDatabase( ) -class Creator(Model): - name = CharField() - discordid = IntegerField() - - class Meta: - database = db - - -class Command(Model): - name = CharField() - message = CharField() - creator = ForeignKeyField(Creator) - createtime = DateTimeField() - last_used = DateTimeField() - sent_warns = IntegerField(default=0) +class Soaks(Model): + user = IntegerField() + message_id = IntegerField() + timestamp = DateTimeField() class Meta: database = db @@ -50,30 +49,21 @@ class Roles(Model): database = db -class Soaks(Model): - user = IntegerField() - message_id = IntegerField() - timestamp = DateTimeField() - - class Meta: - database = db - - class Fun(commands.Cog): def __init__(self, bot): self.bot = bot - db.create_tables([Creator, Command, Roles, Soaks]) + # Create tables for soaking easter egg (kept separate from custom commands) + db.create_tables([Soaks, Roles]) db.close() + self.daily_check.start() @tasks.loop(hours=20) async def daily_check(self): try: - # logger.info(f'trying to start cc check') guild = self.bot.get_guild(int(os.environ.get('GUILD_ID'))) if not guild: - # logger.info(f'no guild found for cc check') await asyncio.sleep(15) guild = self.bot.get_guild(int(os.environ.get('GUILD_ID'))) if not guild: @@ -84,90 +74,116 @@ class Fun(commands.Cog): return if guild.id != 613880856032968834: - logger.info(f'Not checking CCs outside of SBa server') + logger.info(f'Not checking CCs outside of SBA server') return - # = {'member': , 'commands': [(, )]} - del_notifs = {} - del_counter = 0 - # = {'member': , 'commands': [(, )]} - warn_notifs = {} - now = datetime.now() - for x in Command.select(): - # Final check / deleted - if x.last_used + timedelta(days=90) < now: - logger.warning(f'Deleting `!cc {x.name}`') - owner = guild.get_member(x.creator.discordid) - if owner: - if owner.id not in del_notifs: - del_notifs[owner.id] = {'member': owner, 'commands': [(x.name, x.message)]} - else: - del_notifs[owner.id]['commands'].append((x.name, x.message)) - x.delete_instance() - del_counter += 1 + # Get commands eligible for deletion (90+ days unused) + ninety_days_ago = (datetime.now() - timedelta(days=90)).isoformat() + sixty_days_ago = (datetime.now() - timedelta(days=60)).isoformat() - elif x.last_used + timedelta(days=60) < now and (x.sent_warns is None or x.sent_warns == 0): - logger.warning(f'Warning for `!cc {x.name}`') - x.sent_warns = 1 - x.save() - owner = guild.get_member(x.creator.discordid) - if owner: - if owner.id not in warn_notifs: - warn_notifs[owner.id] = {'member': owner, 'commands': [(x.name, x.message)]} - else: - warn_notifs[owner.id]['commands'].append((x.name, x.message)) + try: + # Get all commands to check for cleanup + all_commands_resp = await get_all_custom_commands(page=1, page_size=1000) + if not all_commands_resp: + logger.info('No custom commands found for cleanup check') + return - # else: - # logger.warning( - # f'Command last used {x.last_used} / delta: {now - x.last_used} \n/>60 days: ' - # f'{x.last_used + timedelta(days=60) < now} / sent_warns: {x.sent_warns}' - # ) + all_commands = all_commands_resp.get('custom_commands', []) - db.close() - logger.info(f'deletions: {del_notifs}\nwarnings: {warn_notifs}') + # {discord_id: {'member': , 'commands': [(name, content)]}} + del_notifs = {} + del_counter = 0 + warn_notifs = {} + now = datetime.now() - for member in del_notifs: - plural = len(del_notifs[member]["commands"]) > 1 - msg_content = f'Yo, it\'s cleanup time. I am deleting the following custom ' \ - f'command{"s" if plural else ""}:\n\n' - short_msg_content = copy.deepcopy(msg_content) - for x in del_notifs[member]["commands"]: - msg_content += f'`!cc {x[0]}` - {x[1]}\n' - short_msg_content += f'`!cc {x[0]}`\n' - - try: - await del_notifs[member]['member'].send(msg_content) - except Exception as e: - logger.error(f'fun daily_check - could not send deletion message to {del_notifs[member]["member"]} ' - f'/ trying short_msg') + for cmd in all_commands: + # Parse last_used datetime try: - await del_notifs[member]['member'].send(short_msg_content) - except Exception as e: - logger.error(f'fun daily_check - still could not send deletion message') - - for member in warn_notifs: - plural = len(warn_notifs[member]["commands"]) > 1 - msg_content = f'Heads up, the following custom ' \ - f'command{"s" if plural else ""} will be deleted next month if ' \ - f'{"they are" if plural else "it is"} not used:\n\n' - short_msg_content = copy.deepcopy(msg_content) - for x in warn_notifs[member]["commands"]: - msg_content += f'`!cc {x[0]}` - {x[1]}\n' - short_msg_content += f'`!cc {x[0]}`\n' - - try: - await warn_notifs[member]['member'].send(msg_content) - except Exception as e: - logger.error(f'fun daily_check - could not send warn message to {warn_notifs[member]["member"]} ' - f'/ trying short_msg') - try: - await warn_notifs[member]['member'].send(short_msg_content) - except Exception as e: - logger.error(f'fun daily_check - still could not send warn message') + last_used = datetime.fromisoformat(cmd['last_used']) if cmd.get('last_used') else now + except: + last_used = now + + # Final check / deleted (90+ days) + if last_used + timedelta(days=90) < now: + logger.warning(f'Deleting `!cc {cmd["name"]}`') + creator_discord_id = cmd['creator']['discord_id'] if cmd.get('creator') else None + + if creator_discord_id: + owner = guild.get_member(creator_discord_id) + if owner: + if owner.id not in del_notifs: + del_notifs[owner.id] = {'member': owner, 'commands': [(cmd['name'], cmd['content'])]} + else: + del_notifs[owner.id]['commands'].append((cmd['name'], cmd['content'])) + + await update_custom_command(cmd['id'], {'active': False}) + del_counter += 1 + + # Warning (60+ days, not warned yet) + elif last_used + timedelta(days=60) < now and not cmd.get('warning_sent', False): + logger.warning(f'Warning for `!cc {cmd["name"]}`') + creator_discord_id = cmd['creator']['discord_id'] if cmd.get('creator') else None + + if creator_discord_id: + owner = guild.get_member(creator_discord_id) + if owner: + if owner.id not in warn_notifs: + warn_notifs[owner.id] = {'member': owner, 'commands': [(cmd['name'], cmd['content'])]} + else: + warn_notifs[owner.id]['commands'].append((cmd['name'], cmd['content'])) + + # Mark warning as sent + from db_calls import update_custom_command + await update_custom_command(cmd['id'], {'warning_sent': True}) + + logger.info(f'deletions: {del_notifs}\nwarnings: {warn_notifs}') + + # Send deletion notifications + for member_id in del_notifs: + plural = len(del_notifs[member_id]["commands"]) > 1 + msg_content = f'Yo, it\'s cleanup time. I am deleting the following custom ' \ + f'command{"s" if plural else ""}:\n\n' + short_msg_content = copy.deepcopy(msg_content) + for x in del_notifs[member_id]["commands"]: + msg_content += f'`!cc {x[0]}` - {x[1]}\n' + short_msg_content += f'`!cc {x[0]}`\n' + + try: + await del_notifs[member_id]['member'].send(msg_content) + except Exception as e: + logger.error(f'fun daily_check - could not send deletion message to {del_notifs[member_id]["member"]} ' + f'/ trying short_msg') + try: + await del_notifs[member_id]['member'].send(short_msg_content) + except Exception as e: + logger.error(f'fun daily_check - still could not send deletion message') + + # Send warning notifications + for member_id in warn_notifs: + plural = len(warn_notifs[member_id]["commands"]) > 1 + msg_content = f'Heads up, the following custom ' \ + f'command{"s" if plural else ""} will be deleted next month if ' \ + f'{"they are" if plural else "it is"} not used:\n\n' + short_msg_content = copy.deepcopy(msg_content) + for x in warn_notifs[member_id]["commands"]: + msg_content += f'`!cc {x[0]}` - {x[1]}\n' + short_msg_content += f'`!cc {x[0]}`\n' + + try: + await warn_notifs[member_id]['member'].send(msg_content) + except Exception as e: + logger.error(f'fun daily_check - could not send warn message to {warn_notifs[member_id]["member"]} ' + f'/ trying short_msg') + try: + await warn_notifs[member_id]['member'].send(short_msg_content) + except Exception as e: + logger.error(f'fun daily_check - still could not send warn message') + + logger.info(f'Deleted {del_counter} commands; sent deletion notifications to {len(del_notifs)} users; ' + f'sent warnings to {len(warn_notifs)} users') + except Exception as e: + logger.error(f'Error during daily_check: {e}', exc_info=True) - logger.info(f'Deleted {del_counter} commands; sent deletion notifications to {len(del_notifs)} users; ' - f'sent warnings to {len(warn_notifs)} users') - async def cog_command_error(self, ctx, error): logger.error(msg=error, stack_info=True, exc_info=True) await ctx.send(f'{error}\n\nRun !help to see the command requirements') @@ -175,7 +191,7 @@ class Fun(commands.Cog): async def slash_error(self, ctx, error): logger.error(msg=error, stack_info=True, exc_info=True) await ctx.send(f'{error[:1600]}') - + @commands.Cog.listener(name='on_message') async def on_message_listener(self, message): if message.author.bot or message.channel.guild.id != int(os.environ.get('GUILD_ID')) \ @@ -194,35 +210,292 @@ class Fun(commands.Cog): ).execute() db.close() - time_since = datetime.now() - last_soak.timestamp - # logger.info(f'time_since: {time_since} / seconds: {time_since.seconds} / days: {time_since.days}') - gif_search = None - if time_since.days >= 2: - ts_string = f'{time_since.days} days' - if time_since.days > 30: - gif_search = 'elite' - elif time_since.days > 14: - gif_search = 'pretty good' - else: - if time_since.seconds >= 7200: - ts_string = f'{time_since.seconds // 3600} hours' - gif_search = 'whats wrong with you' + if last_soak: + time_since = datetime.now() - last_soak.timestamp + # logger.info(f'time_since: {time_since} / seconds: {time_since.seconds} / days: {time_since.days}') + gif_search = None + if time_since.days >= 2: + ts_string = f'{time_since.days} days' + if time_since.days > 30: + gif_search = 'elite' + elif time_since.days > 14: + gif_search = 'pretty good' else: - if time_since.seconds >= 120: - ts_string = f'{time_since.seconds // 60} minutes' + if time_since.seconds >= 7200: + ts_string = f'{time_since.seconds // 3600} hours' + gif_search = 'whats wrong with you' else: - ts_string = f'{time_since.seconds} seconds' - gif_search = 'pathetic' + if time_since.seconds >= 120: + ts_string = f'{time_since.seconds // 60} minutes' + else: + ts_string = f'{time_since.seconds} seconds' + gif_search = 'pathetic' - await message.channel.send( - f'It has been {ts_string} since soaking was mentioned.' + await message.channel.send( + f'It has been {ts_string} since soaking was mentioned.' + ) + + if gif_search is not None: + try: + await message.channel.send(random_gif(gif_search)) + except Exception as e: + logger.error(e) + + @commands.command(name='cc', help='Run custom command') + async def custom_command(self, ctx, command): + try: + # Execute the command (updates usage stats automatically) + result = await execute_custom_command(command.lower()) + + if not result: + # Kermit lost gif + await ctx.send('https://tenor.com/6saQ.gif') + return + + # Special easter egg for prettyrainbow command + if result['name'] == 'prettyrainbow' and ctx.author.id == 291738770313707521: + await ctx.send(random_no_phrase()) + return + + await ctx.send(result['content']) + + except Exception as e: + logger.error(f'Error executing custom command {command}: {e}') + # Kermit lost gif + await ctx.send('https://tenor.com/6saQ.gif') + + @commands.command(name='about', help='Who made the custom command') + async def about_command(self, ctx, command): + try: + result = await get_custom_command_by_name(command.lower()) + + if not result: + await ctx.send('https://tenor.com/blQnd.gif') + return + + creator_name = result['creator']['username'] if result.get('creator') else 'Unknown' + created_at = result.get('created_at', 'Unknown') + + embed = discord.Embed(title=f'About {result["name"].title()}', color=0xFFFF00) + embed.add_field(name=f'Creator', value=creator_name, inline=False) + embed.add_field(name='Creation Date', value=created_at, inline=False) + embed.add_field(name='Message', value=result['content'], inline=False) + + await ctx.send(content=None, embed=embed) + except Exception as e: + logger.error(f'Error getting command info: {e}') + await ctx.send('https://tenor.com/blQnd.gif') + + @commands.command(name='newcc', help='Create a new custom command') + @commands.has_any_role(SBA_PLAYERS_ROLE_NAME, 'Paper Dynasty Players') + async def new_custom_command(self, ctx, name, *, message): + try: + command_name = name.lower().strip() + command_content = message.strip() + + # Check if command already exists + existing = await get_custom_command_by_name(command_name) + if existing: + await ctx.send('There is already a command with that name!') + return + + # Show preview + embed = discord.Embed(title='Is this what you want?', color=0x91329F) + embed.add_field(name='Command Name', value=command_name, inline=False) + embed.add_field(name='Message', value=command_content, inline=False) + + await ctx.send(content=None, embed=embed) + + view = Confirm(responders=[ctx.author]) + question = await ctx.send('Should I create this for you?', view=view) + await view.wait() + + if not view.value: + await question.edit(content='You keep thinking on it.', view=None) + return + + # Get or create creator + creator = await get_or_create_creator( + discord_id=ctx.author.id, + username=ctx.author.name, + display_name=ctx.author.display_name ) - if gif_search is not None: - try: - await message.channel.send(random_gif(gif_search)) - except Exception as e: - logger.error(e) + # Create command + command_data = { + 'name': command_name, + 'content': command_content, + 'creator_id': creator['id'] + } + + result = await create_custom_command(command_data) + + if result: + await question.edit(content=f'`!cc {command_name}` is now a thing!', view=None) + else: + await question.edit(content='Hmm...I couldn\'t add that. I might need a grown up to help.', view=None) + + except Exception as e: + logger.error(f'Error creating custom command: {e}', exc_info=True) + await ctx.send('Something went wrong creating that command. Try again or ask an admin for help.') + + @commands.command(name='delcc', help='Delete a custom command') + @commands.has_any_role(SBA_PLAYERS_ROLE_NAME, 'Paper Dynasty Players') + async def delete_custom_command_cmd(self, ctx, name): + try: + command_name = name.lower().strip() + this_command = await get_custom_command_by_name(command_name) + + if not this_command: + await ctx.send('I couldn\'t find that command, sorry.') + return + + # Check ownership + creator_discord_id = this_command['creator']['discord_id'] if this_command.get('creator') else None + if creator_discord_id != ctx.author.id and ctx.author.id != self.bot.owner_id: + await ctx.send('Looks like this isn\'t your command to delete.') + return + + embed = discord.Embed(title='Do you want to delete this command?', color=0x91329F) + embed.add_field(name='Command Name', value=this_command['name'], inline=False) + embed.add_field(name='Message', value=this_command['content'], inline=False) + + view = Confirm(responders=[ctx.author]) + question = await ctx.send(content=None, embed=embed, view=view) + await view.wait() + + if not view.value: + await question.edit(content='It stays for now.', view=None) + return + + result = await delete_custom_command(this_command['id']) + + if result: + await question.edit(view=None) + await ctx.send('He gone!') + else: + await ctx.send('Welp. That didn\'t work. Go complain to an adult, I guess.') + + except Exception as e: + logger.error(f'Error deleting custom command: {e}', exc_info=True) + await ctx.send('Something went wrong deleting that command.') + + @commands.command(name='allcc', help='Show all custom commands') + async def show_custom_commands(self, ctx, page=1): + try: + def get_embed(this_page, result_data): + this_embed = discord.Embed(title=f'All Custom Commands', color=0x2F939F) + column_one = '' + column_two = '' + + commands_list = result_data.get('custom_commands', []) + + # First 20 commands in first column + for x in range(min(20, len(commands_list))): + try: + cmd = commands_list[x] + creator_name = cmd['creator']['username'] if cmd.get('creator') else 'Unknown' + column_one += f'**{cmd["name"]}** by {creator_name}\n' + except Exception as e: + logger.error(f'Error building !allcc embed: {e}') + break + + if column_one: + this_embed.add_field( + name=f'{(this_page - 1) * 40 + 1}-{min(this_page * 40 - 20, result_data["total_count"])}', + value=column_one + ) + + # Next 20 commands in second column + for x in range(20, min(40, len(commands_list))): + try: + cmd = commands_list[x] + creator_name = cmd['creator']['username'] if cmd.get('creator') else 'Unknown' + column_two += f'**{cmd["name"]}** by {creator_name}\n' + except Exception as e: + logger.error(f'Error building !allcc embed: {e}') + break + + if column_two: + this_embed.add_field( + name=f'{(this_page - 1) * 40 + 21}-{min(this_page * 40, result_data["total_count"])}', + value=column_two + ) + + return this_embed + + page_num = page + result = await get_all_custom_commands(page=page_num, page_size=40) + + if not result: + await ctx.send('No custom commands found!') + return + + total_count = result.get('total_count', 0) + last_page = result.get('total_pages', 1) + + if page_num > last_page: + await ctx.send(f'The max page number is {last_page}; going there now!') + page_num = last_page + result = await get_all_custom_commands(page=page_num, page_size=40) + + embed = get_embed(page_num, result) + embed.description = f'Page {page_num} / {last_page}' + view = Pagination(responders=[ctx.author]) + resp_message = await ctx.send(content=None, embed=embed, view=view) + + while True: + await view.wait() + + if view.value: + logger.info(f'got a value: {view.value}') + if view.value == 'left': + page_num = page_num - 1 if page_num > 1 else last_page + elif view.value == 'right': + page_num = page_num + 1 if page_num < last_page else 1 + elif view.value == 'cancel': + await resp_message.edit(content=None, embed=embed, view=None) + break + view.value = None + else: + await resp_message.edit(content=None, embed=embed, view=None) + break + + # Get new page data + result = await get_all_custom_commands(page=page_num, page_size=40) + embed = get_embed(page_num, result) + embed.description = f'Page {page_num} / {last_page}' + view = Pagination(responders=[ctx.author]) + await resp_message.edit(content=None, embed=embed, view=view) + + except Exception as e: + logger.error(f'Error showing all custom commands: {e}', exc_info=True) + await ctx.send('Something went wrong fetching the command list.') + + @commands.command(name='mycc', aliases=['showcc'], help='Show my commands') + @commands.has_any_role(SBA_PLAYERS_ROLE_NAME, 'Paper Dynasty Players') + async def my_custom_commands(self, ctx): + try: + result = await get_commands_by_creator(discord_id=ctx.author.id, page=1, page_size=100) + + if not result or result.get('total_count', 0) == 0: + await ctx.send('It doesn\'t look like you\'ve created any custom commands. Try it out by running ' + '!help newcc for the command syntax!') + return + + commands_list = result.get('custom_commands', []) + comm_message = '' + for cmd in commands_list: + comm_message += f'{cmd["name"]}\n' + + embed = discord.Embed(title=f'{ctx.author.name}\'s Commands', color=0x2F939F) + embed.add_field(name=f'Command Names', value=comm_message if comm_message else 'None', inline=False) + + await ctx.send(content=None, embed=embed) + + except Exception as e: + logger.error(f'Error showing user commands: {e}', exc_info=True) + await ctx.send('Something went wrong fetching your commands.') @commands.command(name='lastsoak', aliases=['ls'], help='Get a link to the last mention of soaking') async def last_soak_command(self, ctx): @@ -231,209 +504,11 @@ class Fun(commands.Cog): last_soak = squery[0] else: await ctx.send(f'I could not find the last mention of soaking.') + db.close() return message = await ctx.fetch_message(last_soak.message_id) await ctx.send(f'The last mention of soaking was: {message.jump_url}') - - @commands.command(name='cc', help='Run custom custom command') - async def custom_command(self, ctx, command): - chosen = Command.get_or_none(fn.Lower(Command.name) == command.lower()) - if not chosen: - # Error gif - # await ctx.send('https://tenor.com/blQnd.gif') - - # Schitt's Creek 'what's that' gif - # await ctx.send('https://media.giphy.com/media/l0HUhFZx6q0hsPtHq/giphy.gif') - - # Kermit lost gif - await ctx.send('https://tenor.com/6saQ.gif') - else: - if chosen.name == 'prettyrainbow' and ctx.author.id == 291738770313707521: - await ctx.send(random_no_phrase()) - return - await ctx.send(chosen.message) - chosen.last_used = datetime.now() - chosen.sent_warns = 0 - chosen.save() - - db.close() - - @commands.command(name='about', help='Who made the custom command') - async def about_command(self, ctx, command): - chosen = Command.get_or_none(fn.Lower(Command.name) == command.lower()) - if not chosen: - await ctx.send('https://tenor.com/blQnd.gif') - - embed = discord.Embed(title=f'About {chosen.name.title()}', color=0xFFFF00) - embed.add_field(name=f'Creator', value=f'{chosen.creator.name}', inline=False) - embed.add_field(name='Creation Date', value=f'{chosen.createtime}', inline=False) - embed.add_field(name='Message', value=f'{chosen.message}', inline=False) - - await ctx.send(content=None, embed=embed) - db.close() - - @commands.command(name='newcc', help='Create a new custom command') - @commands.has_any_role(SBA_PLAYERS_ROLE_NAME, 'Paper Dynasty Players') - async def new_custom_command(self, ctx, name, *, message): - time = datetime.now() - command = name - comm_message = message - - chosen = Command.get_or_none(fn.Lower(Command.name) == command.lower()) - if chosen: - await ctx.send('There is already a command with that name!') - return - - embed = discord.Embed(title='Is this what you want?', color=0x91329F) - embed.add_field(name='Command Name', value=command, inline=False) - embed.add_field(name='Message', value=comm_message, inline=False) - - await ctx.send(content=None, embed=embed) - - view = Confirm(responders=[ctx.author]) - question = await ctx.send('Should I create this for you?', view=view) - await view.wait() - - if not view.value: - await question.edit(content='You keep thinking on it.', view=None) - return - - this_person = Creator.get_or_none(Creator.discordid == ctx.author.id) - if not this_person: - this_person = Creator(name=f'{ctx.author.name}', discordid=f'{ctx.author.id}') - this_person.save() - - this_command = Command(name=command, message=comm_message, createtime=time, creator=this_person, last_used=time) - if this_command.save() == 1: - await question.edit(content=f'`!cc {this_command.name}` is now a thing!', view=None) - else: - await question.edit(content='Hmm...I couldn\'t add that. I might need a grown up to help.', view=None) - - db.close() - - @commands.command(name='delcc', help='Delete a custom command') - @commands.has_any_role(SBA_PLAYERS_ROLE_NAME, 'Paper Dynasty Players') - async def delete_custom_command(self, ctx, name): - this_command = Command.get_or_none(fn.Lower(Command.name) == name.lower()) - if not this_command: - await ctx.send('I couldn\'t find that command, sorry.') - return - - if this_command.creator.discordid != ctx.author.id and ctx.author.id != self.bot.owner_id: - await ctx.send('Looks like this isn\'t your command to delete.') - return - - embed = discord.Embed(title='Do you want to delete this command?', color=0x91329F) - embed.add_field(name='Command Name', value=this_command.name, inline=False) - embed.add_field(name='Message', value=this_command.message, inline=False) - - view = Confirm(responders=[ctx.author]) - question = await ctx.send(content=None, embed=embed, view=view) - await view.wait() - - if not view.value: - await question.edit(content='It stays for now.', view=None) - return - - if this_command.delete_instance() == 1: - await question.edit(view=None) - await ctx.send('He gone!') - else: - await ctx.send('Welp. That didn\'t work. Go complain to an adult, I guess.') - - db.close() - - @commands.command(name='allcc', help='Show all custom commands') - async def show_custom_commands(self, ctx, page=1): - def get_embed(this_page): - this_embed = discord.Embed(title=f'All Custom Commands', color=0x2F939F) - column_one = '' - column_two = '' - all_commands = Command.select().paginate(this_page, 40).order_by(Command.name) - for x in range(20): - try: - column_one += f'**{all_commands[x].name}** by {all_commands[x].creator.name}\n' - except Exception as e: - logger.error(f'Error building !allcc embed: {e}') - break - this_embed.add_field(name=f'{(this_page - 1) * 40 + 1}-{this_page * 40 - 20}', value=column_one) - - for x in range(20, 40): - try: - column_two += f'**{all_commands[x].name}** by {all_commands[x].creator.name}\n' - except Exception as e: - logger.error(f'Error building !allcc embed: {e}') - break - if len(column_two) > 0: - this_embed.add_field(name=f'{(this_page - 1) * 40 + 21}-{this_page * 40}', value=column_two) - - return this_embed - - page_num = page - total_commands = Command.select(Command.id) - last_page = math.ceil(total_commands.count()/40) - - if page_num > last_page: - await ctx.send(f'The max page number is {last_page}; going there now!') - page_num = last_page - - embed = get_embed(page_num) - embed.description = f'Page {page_num} / {last_page}' - view = Pagination(responders=[ctx.author]) - resp_message = await ctx.send(content=None, embed=embed, view=view) - - while True: - await view.wait() - - if view.value: - logger.info(f'got a value: {view.value}') - if view.value == 'left': - page_num = page_num - 1 if page_num > 1 else last_page - elif view.value == 'right': - page_num = page_num + 1 if page_num <= last_page else 1 - elif view.value == 'cancel': - await resp_message.edit(content=None, embed=embed, view=None) - break - view.value = None - else: - await resp_message.edit(content=None, embed=embed, view=None) - break - - # await resp_message.edit(content=None, embed=embed, view=None) - embed = get_embed(page_num) - embed.description = f'Page {page_num} / {last_page}' - view = Pagination(responders=[ctx.author]) - await resp_message.edit(content=None, embed=embed, view=view) - - db.close() - - @commands.command(name='mycc', aliases=['showcc'], help='Show my commands') - @commands.has_any_role(SBA_PLAYERS_ROLE_NAME, 'Paper Dynasty Players') - async def my_custom_commands(self, ctx): - this_creator = Creator.get_or_none(Creator.discordid == ctx.author.id) - - if not this_creator: - await ctx.send('It doesn\'t look like you\'ve created any custom commands. Try it out by running the ' - '!help newcc for the command syntax!') - return - - all_commands = Command.select().join(Creator).where(Command.creator == this_creator) - - if all_commands.count() == 0: - await ctx.send('It doesn\'t look like you\'ve created any custom commands. Try it out by running the ' - '!help newcc for the command syntax!') - return - - comm_message = '' - for x in all_commands: - comm_message += f'{x.name}\n' - - embed = discord.Embed(title=f'{ctx.author.name}\'s Commands', color=0x2F939F) - embed.add_field(name=f'Command Names', value=comm_message, inline=False) - - await ctx.send(content=None, embed=embed) - db.close() @app_commands.command(name='woulditdong', description='Log a dinger to see would it dong across SBa') @@ -475,98 +550,6 @@ class Fun(commands.Cog): await send_to_channel(self.bot, 'news-ticker', content=None, embed=embed) await interaction.edit_original_response(content=None, embed=embed) - # @commands.command(name='showcc', help='Show one person\'s custom commands') - # @commands.has_any_role(SBA_PLAYERS_ROLE_NAME, 'Paper Dynasty Players') - # async def show_cc_command(self, ctx, ): - - # @commands.command(name='role', help='Toggle role') - # async def toggle_role_command(self, ctx, *, role_name): - # all_roles = [x.name for x in Roles.select().where(Roles.enabled)] - # - # async def toggle_role(full_role): - # if full_role in ctx.author.roles: - # await ctx.author.remove_roles(full_role) - # else: - # await ctx.author.add_roles(full_role) - # - # if len(role_name) < 4: - # await ctx.send('https://thumbs.gfycat.com/FrayedUnequaledGnat-size_restricted.gif') - # await ctx.send(f'What even is **{role_name}**...') - # db.close() - # return - # - # for name in all_roles: - # if role_name.lower() in name.lower(): - # try: - # this_role = discord.utils.get(ctx.guild.roles, name=name) - # await toggle_role(this_role) - # await ctx.send(random_conf_gif()) - # return - # except: - # await ctx.send(await get_emoji(ctx, 'fforrespect', False)) - # await ctx.send('I was not able to assign that role.') - # return - # - # await ctx.send(f'That doesn\'t sound familiar. **{role_name}**...did you make that shit up?') - - # @commands.command(name='showroles', help='Show toggleable roles') - # async def show_roles_command(self, ctx): - # all_roles = [x.name for x in Roles.select().where(Roles.enabled)] - # role_string = '\n- '.join(all_roles) - # - # embed = get_team_embed('Toggleable Roles', thumbnail=False) - # embed.description = 'Run !role to toggle the role on or off' - # embed.add_field(name='Role Names', value=f'- {role_string}') - # - # await ctx.send(content=None, embed=embed) - - # @commands.command(name='newrole', aliases=['removerole'], help='Make toggleable role') - # @commands.is_owner() - # async def make_toggleable_role_command(self, ctx, *, role_name): - # this_role = Roles.get_or_none(Roles.name == role_name) - # - # if not this_role: - # # Create the role if it doesn't exist - # - # this_role = Roles(name=role_name) - # this_role.save() - # if not discord.utils.get(ctx.guild.roles, name=this_role.name): - # await ctx.guild.create_role(name=f'{role_name}', mentionable=True) - # else: - # # Disable the role - # - # if this_role.enabled: - # this_role.enabled = False - # else: - # this_role.enabled = True - # this_role.save() - # this_role = discord.utils.get(ctx.guild.roles, name=this_role.name) - # - # if this_role: - # await this_role.edit(mentionable=False) - # else: - # await ctx.send('That role doesn\'t exist in the server.') - # - # await ctx.send(random_conf_gif()) - - # @commands.command(name='bulkrole', hidden=True) - # @commands.is_owner() - # async def bulkrole_command(self, ctx, *roles): - # all_roles = [] - # - # for x in roles: - # all_roles.append(discord.utils.get(ctx.guild.roles, name=x)) - # - # await ctx.send('On it. This could take a bit.') - # time_start = datetime.now() - # - # async for member in ctx.guild.fetch_members(): - # logger.warning(f'member: {member}') - # await member.add_roles(*all_roles) - # - # time_end = datetime.now() - # await ctx.send(f'All done! That took {time_end - time_start}') - async def setup(bot): await bot.add_cog(Fun(bot)) diff --git a/db_calls.py b/db_calls.py index 72789bb..7f45f32 100644 --- a/db_calls.py +++ b/db_calls.py @@ -236,6 +236,74 @@ async def get_player_headshot(player_name): return await get_player_photo(player_name) +### +# CUSTOM COMMANDS API FUNCTIONS +### + +async def get_custom_command_by_name(name: str): + """Get a custom command by name.""" + return await db_get(f'custom_commands/by_name/{name}', none_okay=True) + + +async def execute_custom_command(name: str): + """Execute a custom command and update usage statistics.""" + return await db_patch(f'custom_commands/by_name/{name}/execute', object_id=None, params=[]) + + +async def create_custom_command(command_data: dict): + """Create a new custom command.""" + return await db_post('custom_commands', payload=command_data) + + +async def update_custom_command(command_id: int, update_data: dict): + """Update an existing custom command.""" + return await db_put('custom_commands', object_id=command_id, payload=update_data) + + +async def delete_custom_command(command_id: int): + """Delete a custom command.""" + return await db_delete('custom_commands', object_id=command_id) + + +async def get_commands_by_creator(discord_id: int, page: int = 1, page_size: int = 25): + """Get all commands created by a specific Discord user.""" + params = [ + ('creator_discord_id', discord_id), + ('is_active', True), + ('page', page), + ('page_size', page_size) + ] + return await db_get('custom_commands', params=params, none_okay=True) + + +async def get_all_custom_commands(page: int = 1, page_size: int = 40, sort: str = 'name'): + """Get all custom commands with pagination.""" + params = [ + ('is_active', True), + ('sort', sort), + ('page', page), + ('page_size', page_size) + ] + return await db_get('custom_commands', params=params, none_okay=True) + + +async def get_or_create_creator(discord_id: int, username: str, display_name: Optional[str] = None): + """Get existing creator or create a new one.""" + # Try to get existing creator + existing = await db_get('custom_commands/creators', params=[('discord_id', discord_id)], none_okay=True) + + if existing and existing.get('creators') and len(existing['creators']) > 0: + return existing['creators'][0] + + # Create new creator + creator_data = { + 'discord_id': discord_id, + 'username': username, + 'display_name': display_name + } + return await db_post('custom_commands/creators', payload=creator_data) + + ### # TO BE DEPRECATED FUNCTIONS ### diff --git a/sba_is_fun.db b/sba_is_fun.db new file mode 100644 index 0000000..895b8d4 Binary files /dev/null and b/sba_is_fun.db differ