diff --git a/AGENTS.md b/AGENTS.md index ea34e56..4c4bb7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,4 +8,64 @@ - You are using python 3.11. - All pip dependencies are installed in the environment. (Except packages related to locally running AI, due to their large size.) - You have the ability to directly push to the git repo over https (url already set). And you should when you finish your task and have tested it. -- If you encounter errors when pushing just leave it to the user to fix them. +- If you encounter any hard to fix errors when pushing just leave it to the user to fix them. Try fixing merge conflicts/not having work from the remote by yourself. +- You have a python script "tavilytool.py" in the project root, that you can use to search the web. + +# Tavily API Script Usage Instructions + +## Basic Usage +Search for information using simple queries: +```bash +python tavilytool.py "your search query" +``` + +## Examples +```bash +python tavilytool.py "latest AI developments 2024" +python tavilytool.py "how to make chocolate chip cookies" +python tavilytool.py "current weather in New York" +python tavilytool.py "best programming practices Python" +``` + +## Advanced Options + +### Search Depth +- **Basic search**: `python tavilytool.py "query"` (default) +- **Advanced search**: `python tavilytool.py "query" --depth advanced` + +### Control Results +- **Limit results**: `python tavilytool.py "query" --max-results 3` +- **Include images**: `python tavilytool.py "query" --include-images` +- **Skip AI answer**: `python tavilytool.py "query" --no-answer` + +### Domain Filtering +- **Include specific domains**: `python tavilytool.py "query" --include-domains reddit.com stackoverflow.com` +- **Exclude domains**: `python tavilytool.py "query" --exclude-domains wikipedia.org` + +### Output Format +- **Formatted output**: `python tavilytool.py "query"` (default - human readable) +- **Raw JSON**: `python tavilytool.py "query" --raw` (for programmatic processing) + +## Output Structure +The default formatted output includes: +- ๐Ÿค– **AI Answer**: Direct answer to your query +- ๐Ÿ” **Search Results**: Titles, URLs, and content snippets +- ๐Ÿ–ผ๏ธ **Images**: Relevant images (when `--include-images` is used) + +## Command Combinations +```bash +# Advanced search with images, limited results +python tavilytool.py "machine learning tutorials" --depth advanced --include-images --max-results 3 + +# Search specific sites only, raw output +python tavilytool.py "Python best practices" --include-domains github.com stackoverflow.com --raw + +# Quick search without AI answer +python tavilytool.py "today's news" --no-answer --max-results 5 +``` + +## Tips +- Always quote your search queries to handle spaces and special characters +- Use `--max-results` to control response length and API usage +- Use `--raw` when you need to parse results programmatically +- Combine options as needed for specific use cases \ No newline at end of file diff --git a/cogs/logging_cog.py b/cogs/logging_cog.py index 66c9a6c..57833e9 100644 --- a/cogs/logging_cog.py +++ b/cogs/logging_cog.py @@ -1,5 +1,6 @@ import discord from discord.ext import commands, tasks +from discord import ui, AllowedMentions import datetime import asyncio import aiohttp # Added for webhook sending @@ -54,6 +55,83 @@ class LoggingCog(commands.Cog): else: asyncio.create_task(self.start_audit_log_poller_when_ready()) # Keep this for initial start + class LogView(ui.LayoutView): + """Simple view for log messages with helper methods.""" + + def __init__( + self, + bot: commands.Bot, + title: str, + description: str, + color: discord.Color, + author: Optional[discord.abc.User], + footer: Optional[str], + ): + super().__init__(timeout=None) + self.container = ui.Container(accent_colour=color) + self.add_item(self.container) + + self.header = ui.Section( + accessory=( + ui.Thumbnail(media=author.display_avatar.url) + if author + else ui.Button(label="\u200b", disabled=True) + ) + ) + self.header.add_item(ui.TextDisplay(f"**{title}**")) + if description: + self.header.add_item(ui.TextDisplay(description)) + self.container.add_item(self.header) + + # Placeholder for future field sections. They are inserted before + # the separator when the first field is added. + self._field_sections: list[ui.Section] = [] + + self.separator = ui.Separator(spacing=discord.SeparatorSpacing.small) + + footer_text = footer or f"Bot ID: {bot.user.id}" + ( + f" | User ID: {author.id}" if author else "" + ) + self.footer_display = ui.TextDisplay(footer_text) + + self.container.add_item(self.separator) + self.container.add_item(self.footer_display) + + # --- Compatibility helpers --- + def add_field(self, name: str, value: str, inline: bool = False): + """Mimic Embed.add_field by appending a bolded name/value line.""" + if not self._field_sections or len(self._field_sections[-1].children) >= 3: + section = ui.Section(accessory=ui.Button(label="\u200b", disabled=True)) + self._insert_field_section(section) + self._field_sections.append(section) + self._field_sections[-1].add_item(ui.TextDisplay(f"**{name}:** {value}")) + + def _insert_field_section(self, section: ui.Section) -> None: + """Insert a field section before the footer separator.""" + self.container.remove_item(self.separator) + self.container.remove_item(self.footer_display) + self.container.add_item(section) + self.container.add_item(self.separator) + self.container.add_item(self.footer_display) + + def set_footer(self, text: str): + """Mimic Embed.set_footer by replacing the footer text display.""" + self.footer_display.content = text + + def set_author(self, name: str, icon_url: Optional[str] = None): + """Mimic Embed.set_author by adjusting the header section.""" + self.header.clear_items() + if icon_url: + self.header.accessory = ui.Thumbnail(media=icon_url) + else: + self.header.accessory = ui.Button(label="\u200b", disabled=True) + self.header.add_item(ui.TextDisplay(name)) + def _user_display(self, user: Union[discord.Member, discord.User]) -> str: + """Return display name, username and ID string for a user.""" + display = user.display_name if isinstance(user, discord.Member) else user.name + username = f"{user.name}#{user.discriminator}" + return f"{display} ({username}) [ID: {user.id}]" + async def initialize_cog(self): """Asynchronous initialization tasks.""" log.info("Initializing LoggingCog...") @@ -103,8 +181,8 @@ class LoggingCog(commands.Cog): await self.session.close() log.info("aiohttp ClientSession closed for LoggingCog.") - async def _send_log_embed(self, guild: discord.Guild, embed: discord.Embed): - """Sends the log embed via the configured webhook for the guild.""" + async def _send_log_embed(self, guild: discord.Guild, embed: ui.LayoutView) -> None: + """Sends the log view via the configured webhook for the guild.""" if not self.session or self.session.closed: log.error(f"aiohttp session not available or closed in LoggingCog for guild {guild.id}. Cannot send log.") return @@ -116,15 +194,20 @@ class LoggingCog(commands.Cog): return try: - webhook = discord.Webhook.from_url(webhook_url, session=self.session) + webhook = discord.Webhook.from_url( + webhook_url, + session=self.session, + client=self.bot, + ) await webhook.send( - embed=embed, - username=f"{self.bot.user.name} Logs", # Optional: Customize webhook appearance - avatar_url=self.bot.user.display_avatar.url # Optional: Use bot's avatar + view=embed, + username=f"{self.bot.user.name} Logs", + avatar_url=self.bot.user.display_avatar.url, + allowed_mentions=AllowedMentions.none(), ) # log.debug(f"Sent log embed via webhook for guild {guild.id}") # Can be noisy - except ValueError: - log.error(f"Invalid logging webhook URL configured for guild {guild.id}.") + except ValueError as e: + log.exception(f"Invalid logging webhook URL configured for guild {guild.id}. Error: {e}") # Consider notifying an admin or disabling logging for this guild temporarily # await settings_manager.set_logging_webhook(guild.id, None) # Example: Auto-disable on invalid URL except (discord.Forbidden, discord.NotFound): @@ -139,26 +222,38 @@ class LoggingCog(commands.Cog): log.exception(f"Unexpected error sending log via webhook for guild {guild.id}: {e}") - def _create_log_embed(self, title: str, description: str = "", color: discord.Color = discord.Color.blue(), author: Optional[Union[discord.User, discord.Member]] = None, footer: Optional[str] = None) -> discord.Embed: - """Creates a standardized log embed.""" - embed = discord.Embed(title=title, description=description, color=color, timestamp=datetime.datetime.now(datetime.timezone.utc)) - if author: - embed.set_author(name=str(author), icon_url=author.display_avatar.url) - if footer: - embed.set_footer(text=footer) - else: - # Add User ID to footer if author is present and footer isn't custom - user_id_str = f" | User ID: {author.id}" if author else "" - embed.set_footer(text=f"Bot ID: {self.bot.user.id}{user_id_str}") - return embed + def _create_log_embed( + self, + title: str, + description: str = "", + color: discord.Color = discord.Color.blue(), + author: Optional[Union[discord.User, discord.Member]] = None, + footer: Optional[str] = None, + ) -> ui.LayoutView: + """Creates a standardized log view.""" + return self.LogView(self.bot, title, description, color, author, footer) - def _add_id_footer(self, embed: discord.Embed, obj: Union[discord.Member, discord.User, discord.Role, discord.abc.GuildChannel, discord.Message, discord.Invite, None] = None, obj_id: Optional[int] = None, id_name: str = "ID"): - """Adds an ID to the embed footer if possible.""" + def _add_id_footer( + self, + embed: ui.LayoutView, + obj: Union[ + discord.Member, + discord.User, + discord.Role, + discord.abc.GuildChannel, + discord.Message, + discord.Invite, + None, + ] = None, + obj_id: Optional[int] = None, + id_name: str = "ID", + ) -> None: + """Adds an ID to the footer text if possible.""" target_id = obj_id or (obj.id if obj else None) - if target_id: - existing_footer = embed.footer.text or "" + if target_id and hasattr(embed, "footer_display"): + existing_footer = embed.footer_display.content or "" separator = " | " if existing_footer else "" - embed.set_footer(text=f"{existing_footer}{separator}{id_name}: {target_id}") + embed.footer_display.content = f"{existing_footer}{separator}{id_name}: {target_id}" async def _check_log_enabled(self, guild_id: int, event_key: str) -> bool: """Checks if logging is enabled for a specific event key in a guild.""" @@ -191,10 +286,16 @@ class LoggingCog(commands.Cog): # 1. Check bot permissions if not channel.permissions_for(me).manage_webhooks: - await ctx.send(f"โŒ I don't have the 'Manage Webhooks' permission in {channel.mention}. Please grant it and try again.") + await ctx.send( + f"โŒ I don't have the 'Manage Webhooks' permission in {channel.mention}. Please grant it and try again.", + allowed_mentions=AllowedMentions.none(), + ) return if not channel.permissions_for(me).send_messages: - await ctx.send(f"โŒ I don't have the 'Send Messages' permission in {channel.mention}. Please grant it and try again (needed for webhook creation confirmation).") + await ctx.send( + f"โŒ I don't have the 'Send Messages' permission in {channel.mention}. Please grant it and try again (needed for webhook creation confirmation).", + allowed_mentions=AllowedMentions.none(), + ) return # 2. Check existing webhook setting @@ -205,15 +306,27 @@ class LoggingCog(commands.Cog): if not self.session or self.session.closed: self.session = aiohttp.ClientSession() # Ensure session exists existing_webhook = await discord.Webhook.from_url(existing_url, session=self.session).fetch() if existing_webhook.channel_id == channel.id: - await ctx.send(f"โœ… Logging is already configured for {channel.mention} using webhook `{existing_webhook.name}`.") + await ctx.send( + f"โœ… Logging is already configured for {channel.mention} using webhook `{existing_webhook.name}`.", + allowed_mentions=AllowedMentions.none(), + ) return else: - await ctx.send(f"โš ๏ธ Logging webhook is currently set for a different channel (<#{existing_webhook.channel_id}>). I will create a new one for {channel.mention}.") + await ctx.send( + f"โš ๏ธ Logging webhook is currently set for a different channel (<#{existing_webhook.channel_id}>). I will create a new one for {channel.mention}.", + allowed_mentions=AllowedMentions.none(), + ) except (discord.NotFound, discord.Forbidden, ValueError, aiohttp.ClientError): - await ctx.send(f"โš ๏ธ Could not verify the existing webhook URL. It might be invalid or deleted. I will create a new one for {channel.mention}.") + await ctx.send( + f"โš ๏ธ Could not verify the existing webhook URL. It might be invalid or deleted. I will create a new one for {channel.mention}.", + allowed_mentions=AllowedMentions.none(), + ) except Exception as e: log.exception(f"Error fetching existing webhook during setup for guild {guild.id}") - await ctx.send(f"โš ๏ธ An error occurred while checking the existing webhook. Proceeding to create a new one for {channel.mention}.") + await ctx.send( + f"โš ๏ธ An error occurred while checking the existing webhook. Proceeding to create a new one for {channel.mention}.", + allowed_mentions=AllowedMentions.none(), + ) # 3. Create new webhook @@ -230,27 +343,51 @@ class LoggingCog(commands.Cog): log.info(f"Created logging webhook '{webhook_name}' in channel {channel.id} for guild {guild.id}") except discord.HTTPException as e: log.error(f"Failed to create webhook in {channel.mention} for guild {guild.id}: {e}") - await ctx.send(f"โŒ Failed to create webhook. Error: {e}. This could be due to hitting the channel webhook limit (15).") + await ctx.send( + f"โŒ Failed to create webhook. Error: {e}. This could be due to hitting the channel webhook limit (15).", + allowed_mentions=AllowedMentions.none(), + ) return except Exception as e: log.exception(f"Unexpected error creating webhook in {channel.mention} for guild {guild.id}") - await ctx.send("โŒ An unexpected error occurred while creating the webhook.") + await ctx.send( + "โŒ An unexpected error occurred while creating the webhook.", + allowed_mentions=AllowedMentions.none(), + ) return # 4. Save webhook URL success = await settings_manager.set_logging_webhook(guild.id, new_webhook.url) if success: - await ctx.send(f"โœ… Successfully configured logging to send messages to {channel.mention} via the new webhook `{new_webhook.name}`.") + await ctx.send( + f"โœ… Successfully configured logging to send messages to {channel.mention} via the new webhook `{new_webhook.name}`.", + allowed_mentions=AllowedMentions.none(), + ) # Test send (optional) try: - test_embed = self._create_log_embed("โœ… Logging Setup Complete", f"Logs will now be sent to this channel via the webhook `{new_webhook.name}`.", color=discord.Color.green()) - await new_webhook.send(embed=test_embed, username=webhook_name, avatar_url=self.bot.user.display_avatar.url) + test_view = self._create_log_embed( + "โœ… Logging Setup Complete", + f"Logs will now be sent to this channel via the webhook `{new_webhook.name}`.", + color=discord.Color.green(), + ) + await new_webhook.send( + view=test_view, + username=webhook_name, + avatar_url=self.bot.user.display_avatar.url, + allowed_mentions=AllowedMentions.none(), + ) except Exception as e: log.error(f"Failed to send test message via new webhook for guild {guild.id}: {e}") - await ctx.send("โš ๏ธ Could not send a test message via the new webhook, but the URL has been saved.") + await ctx.send( + "โš ๏ธ Could not send a test message via the new webhook, but the URL has been saved.", + allowed_mentions=AllowedMentions.none(), + ) else: log.error(f"Failed to save webhook URL {new_webhook.url} to database for guild {guild.id}") - await ctx.send("โŒ Successfully created the webhook, but failed to save its URL to my settings. Please try again or contact support.") + await ctx.send( + "โŒ Successfully created the webhook, but failed to save its URL to my settings. Please try again or contact support.", + allowed_mentions=AllowedMentions.none(), + ) # Attempt to delete the created webhook to avoid orphans try: await new_webhook.delete(reason="Failed to save URL to settings") @@ -272,7 +409,10 @@ class LoggingCog(commands.Cog): event_key = event_key.lower() # Ensure case-insensitivity if event_key not in ALL_EVENT_KEYS: - await ctx.send(f"โŒ Invalid event key: `{event_key}`. Use `{ctx.prefix}log list_keys` to see valid keys.") + await ctx.send( + f"โŒ Invalid event key: `{event_key}`. Use `{ctx.prefix}log list_keys` to see valid keys.", + allowed_mentions=AllowedMentions.none(), + ) return # Determine the new status @@ -288,9 +428,15 @@ class LoggingCog(commands.Cog): if success: status_str = "ENABLED" if new_status else "DISABLED" - await ctx.send(f"โœ… Logging for event `{event_key}` is now **{status_str}**.") + await ctx.send( + f"โœ… Logging for event `{event_key}` is now **{status_str}**.", + allowed_mentions=AllowedMentions.none(), + ) else: - await ctx.send(f"โŒ Failed to update setting for event `{event_key}`. Please check logs or try again.") + await ctx.send( + f"โŒ Failed to update setting for event `{event_key}`. Please check logs or try again.", + allowed_mentions=AllowedMentions.none(), + ) @log_group.command(name="status") @commands.has_permissions(administrator=True) @@ -312,7 +458,7 @@ class LoggingCog(commands.Cog): for line in lines: if len(description) + len(line) + 1 > 4000: # Embed description limit (approx) embed.description = description - await ctx.send(embed=embed) + await ctx.send(embed=embed, allowed_mentions=AllowedMentions.none()) description = line + "\n" # Start new description embed = discord.Embed(color=discord.Color.blue()) # New embed for continuation else: @@ -320,7 +466,7 @@ class LoggingCog(commands.Cog): if description: # Send the last embed page embed.description = description.strip() - await ctx.send(embed=embed) + await ctx.send(embed=embed, allowed_mentions=AllowedMentions.none()) @log_group.command(name="list_keys") @@ -344,12 +490,15 @@ class LoggingCog(commands.Cog): parts.append(current_part) embed.description = parts[0] - await ctx.send(embed=embed) + await ctx.send(embed=embed, allowed_mentions=AllowedMentions.none()) for part in parts[1:]: - await ctx.send(embed=discord.Embed(description=part, color=discord.Color.purple())) + await ctx.send( + embed=discord.Embed(description=part, color=discord.Color.purple()), + allowed_mentions=AllowedMentions.none(), + ) else: embed.description = keys_text - await ctx.send(embed=embed) + await ctx.send(embed=embed, allowed_mentions=AllowedMentions.none()) # --- Thread Events --- @@ -418,7 +567,7 @@ class LoggingCog(commands.Cog): user = await self.bot.fetch_user(member.id) # Get user object embed = self._create_log_embed( title="โž• Member Joined Thread", - description=f"{user.mention} joined thread {thread.mention}.", + description=f"{self._user_display(user)} joined thread {thread.mention}.", color=discord.Color.dark_green(), author=user, footer=f"Thread ID: {thread.id} | User ID: {user.id}" @@ -435,7 +584,7 @@ class LoggingCog(commands.Cog): user = await self.bot.fetch_user(member.id) # Get user object embed = self._create_log_embed( title="โž– Member Left Thread", - description=f"{user.mention} left thread {thread.mention}.", + description=f"{self._user_display(user)} left thread {thread.mention}.", color=discord.Color.dark_orange(), author=user, footer=f"Thread ID: {thread.id} | User ID: {user.id}" @@ -509,7 +658,7 @@ class LoggingCog(commands.Cog): embed = self._create_log_embed( title="๐Ÿ“ฅ Member Joined", - description=f"{member.mention} ({member.id}) joined the server.", + description=f"{self._user_display(member)} joined the server.", color=discord.Color.green(), author=member # Footer already includes User ID via _create_log_embed @@ -527,7 +676,7 @@ class LoggingCog(commands.Cog): # We log it as a generic "left" event here. embed = self._create_log_embed( title="๐Ÿ“ค Member Left", - description=f"{member.mention} left the server.", + description=f"{self._user_display(member)} left the server.", color=discord.Color.orange(), author=member ) @@ -542,7 +691,7 @@ class LoggingCog(commands.Cog): # Note: Ban reason isn't available directly in this event. Audit log might have it. embed = self._create_log_embed( title="๐Ÿ”จ Member Banned (Event)", # Clarify this is the event, audit log has more details - description=f"{user.mention} was banned.\n*Audit log may contain moderator and reason.*", + description=f"{self._user_display(user)} was banned.\n*Audit log may contain moderator and reason.*", color=discord.Color.red(), author=user # User who was banned ) @@ -556,7 +705,7 @@ class LoggingCog(commands.Cog): embed = self._create_log_embed( title="๐Ÿ”“ Member Unbanned", - description=f"{user.mention} was unbanned.", + description=f"{self._user_display(user)} was unbanned.", color=discord.Color.blurple(), author=user # User who was unbanned ) @@ -841,7 +990,7 @@ class LoggingCog(commands.Cog): embed = self._create_log_embed( title="๐Ÿ‘ Reaction Added", - description=f"{user.mention} added {reaction.emoji} to a message by {reaction.message.author.mention} in {reaction.message.channel.mention} [Jump to Message]({reaction.message.jump_url})", + description=f"{self._user_display(user)} added {reaction.emoji} to a message by {self._user_display(reaction.message.author)} in {reaction.message.channel.mention} [Jump to Message]({reaction.message.jump_url})", color=discord.Color.gold(), author=user ) @@ -860,7 +1009,7 @@ class LoggingCog(commands.Cog): embed = self._create_log_embed( title="๐Ÿ‘Ž Reaction Removed", - description=f"{user.mention} removed {reaction.emoji} from a message by {reaction.message.author.mention} in {reaction.message.channel.mention} [Jump to Message]({reaction.message.jump_url})", + description=f"{self._user_display(user)} removed {reaction.emoji} from a message by {self._user_display(reaction.message.author)} in {reaction.message.channel.mention} [Jump to Message]({reaction.message.jump_url})", color=discord.Color.dark_gold(), author=user ) @@ -959,7 +1108,7 @@ class LoggingCog(commands.Cog): embed = self._create_log_embed( title=action, - description=f"{member.mention}\n{details}", + description=f"{self._user_display(member)}\n{details}", color=color, author=member ) @@ -1264,21 +1413,21 @@ class LoggingCog(commands.Cog): audit_event_key = "audit_ban" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Member Banned" - action_desc = f"{user.mention} banned {target.mention}" + action_desc = f"{self._user_display(user)} banned {self._user_display(target)}" color = discord.Color.red() # self._add_id_footer(embed, target, id_name="Target ID") # Footer set later elif entry.action == discord.AuditLogAction.unban: audit_event_key = "audit_unban" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Member Unbanned" - action_desc = f"{user.mention} unbanned {target.mention}" + action_desc = f"{self._user_display(user)} unbanned {self._user_display(target)}" color = discord.Color.blurple() # self._add_id_footer(embed, target, id_name="Target ID") # Footer set later elif entry.action == discord.AuditLogAction.kick: audit_event_key = "audit_kick" if not await self._check_log_enabled(guild.id, audit_event_key): return title = "๐Ÿ›ก๏ธ Audit Log: Member Kicked" - action_desc = f"{user.mention} kicked {target.mention}" + action_desc = f"{self._user_display(user)} kicked {self._user_display(target)}" color = discord.Color.brand_red() # self._add_id_footer(embed, target, id_name="Target ID") # Footer set later elif entry.action == discord.AuditLogAction.member_prune: @@ -1287,7 +1436,7 @@ class LoggingCog(commands.Cog): title = "๐Ÿ›ก๏ธ Audit Log: Member Prune" days = entry.extra.get('delete_member_days') count = entry.extra.get('members_removed') - action_desc = f"{user.mention} pruned {count} members inactive for {days} days." + action_desc = f"{self._user_display(user)} pruned {count} members inactive for {days} days." color = discord.Color.dark_red() # No specific target ID here @@ -1301,7 +1450,7 @@ class LoggingCog(commands.Cog): added = [r.mention for r in after_roles if r not in before_roles] removed = [r.mention for r in before_roles if r not in after_roles] if added or removed: # Only log if roles actually changed - action_desc = f"{user.mention} updated roles for {target.mention} ({target.id}):" + action_desc = f"{self._user_display(user)} updated roles for {self._user_display(target)} ({target.id}):" if added: action_desc += f"\n**Added:** {', '.join(added)}" if removed: action_desc += f"\n**Removed:** {', '.join(removed)}" color = discord.Color.blue() @@ -1317,10 +1466,10 @@ class LoggingCog(commands.Cog): title = "๐Ÿ›ก๏ธ Audit Log: Member Timeout Update" if after_timed_out: timeout_duration = discord.utils.format_dt(after_timed_out, style='R') - action_desc = f"{user.mention} timed out {target.mention} ({target.id}) until {timeout_duration}" + action_desc = f"{self._user_display(user)} timed out {self._user_display(target)} ({target.id}) until {timeout_duration}" color = discord.Color.orange() else: - action_desc = f"{user.mention} removed timeout from {target.mention} ({target.id})" + action_desc = f"{self._user_display(user)} removed timeout from {self._user_display(target)} ({target.id})" color = discord.Color.green() # self._add_id_footer(embed, target, id_name="Target ID") # Footer set later else: diff --git a/cogs/mod_log_cog.py b/cogs/mod_log_cog.py index 95d35e9..4f8c359 100644 --- a/cogs/mod_log_cog.py +++ b/cogs/mod_log_cog.py @@ -1,6 +1,6 @@ import discord from discord.ext import commands -from discord import app_commands, Interaction, Embed, Color, User, Member, Object +from discord import app_commands, Interaction, Embed, Color, User, Member, Object, ui import asyncpg import logging from typing import Optional, Union, Dict, Any @@ -32,6 +32,49 @@ class ModLogCog(commands.Cog): # Add command group to the bot's tree self.bot.tree.add_command(self.modlog_group) + class LogView(ui.LayoutView): + """View used for moderation log messages.""" + + def __init__(self, bot: commands.Bot, title: str, color: discord.Color, lines: list[str], footer: str): + super().__init__(timeout=None) + container = ui.Container(accent_colour=color) + self.add_item(container) + container.add_item(ui.TextDisplay(f"**{title}**")) + container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) + for line in lines: + container.add_item(ui.TextDisplay(line)) + container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) + self.footer_display = ui.TextDisplay(footer) + container.add_item(self.footer_display) + + def _format_user(self, user: Union[Member, User, Object], guild: Optional[discord.Guild] = None) -> str: + """Return a string with display name, username and ID for a user-like object.""" + if isinstance(user, Object): + return f"Unknown User (ID: {user.id})" + if isinstance(user, Member): + display = user.display_name + elif guild and isinstance(user, User): + member = guild.get_member(user.id) + display = member.display_name if member else user.name + else: + display = user.name + username = f"{user.name}#{user.discriminator}" if isinstance(user, (Member, User)) else "Unknown" + return f"{display} ({username}) [ID: {user.id}]" + + async def _fetch_user_display(self, user_id: int, guild: discord.Guild) -> str: + """Fetch and format a user by ID for display.""" + member = guild.get_member(user_id) + if member: + return self._format_user(member, guild) + user = self.bot.get_user(user_id) + if user: + return self._format_user(user, guild) + try: + user = await self.bot.fetch_user(user_id) + return self._format_user(user, guild) + except discord.HTTPException: + return f"Unknown User (ID: {user_id})" + def register_commands(self): """Register all commands for this cog""" @@ -192,8 +235,8 @@ class ModLogCog(commands.Cog): # Optionally update DB to remove channel ID? Or just leave it. return - # 3. Format and send embed - embed = self._format_log_embed( + # 3. Format and send view + view = self._format_log_embed( case_id=case_id, moderator=moderator, # Pass the object for display formatting target=target, @@ -205,7 +248,7 @@ class ModLogCog(commands.Cog): ai_details=ai_details, moderator_id_override=moderator_id_override # Pass override for formatting ) - log_message = await log_channel.send(embed=embed) + log_message = await log_channel.send(view=view) # 4. Update DB with message details await mod_log_db.update_mod_log_message_details(self.pool, case_id, log_message.id, log_channel.id) @@ -213,6 +256,7 @@ class ModLogCog(commands.Cog): except Exception as e: log.exception(f"Error during Discord mod log message sending/updating for case {case_id} in guild {guild_id}: {e}") + def _format_log_embed( self, case_id: int, @@ -224,9 +268,9 @@ class ModLogCog(commands.Cog): guild: discord.Guild, source: str = "BOT", ai_details: Optional[Dict[str, Any]] = None, - moderator_id_override: Optional[int] = None - ) -> Embed: - """Helper function to create the standard log embed.""" + moderator_id_override: Optional[int] = None, + ) -> ui.LayoutView: + """Helper function to create the standard log view.""" color_map = { "BAN": Color.red(), "UNBAN": Color.green(), @@ -237,98 +281,56 @@ class ModLogCog(commands.Cog): "AI_ALERT": Color.purple(), "AI_DELETE_REQUESTED": Color.dark_grey(), } - # Use a distinct color for AI actions - if source == "AI_API": - embed_color = Color.blurple() - else: - embed_color = color_map.get(action_type.upper(), Color.greyple()) + embed_color = Color.blurple() if source == "AI_API" else color_map.get(action_type.upper(), Color.greyple()) action_title_prefix = "๐Ÿค– AI Moderation Action" if source == "AI_API" else action_type.replace("_", " ").title() action_title = f"{action_title_prefix} | Case #{case_id}" - - embed = Embed( - title=action_title, - color=embed_color, - timestamp=discord.utils.utcnow() + target_display = self._format_user(target, guild) + moderator_display = ( + f"AI System (ID: {moderator_id_override or 'Unknown'})" if source == "AI_API" else self._format_user(moderator, guild) ) - - # Handle target display - check if it's a Discord Object or User/Member - if isinstance(target, discord.Object): - # For Object, we only have the ID - target_display = f"<@{target.id}> ({target.id})" - else: - # For User/Member, we can use mention - target_display = f"{target.mention} ({target.id})" - - # Determine moderator display based on source - if source == "AI_API": - moderator_display = f"AI System (ID: {moderator_id_override or 'Unknown'})" - elif isinstance(moderator, discord.Object): - # For Object, we only have the ID - moderator_display = f"<@{moderator.id}> ({moderator.id})" - else: - # For User/Member, we can use mention - moderator_display = f"{moderator.mention} ({moderator.id})" - - - embed.add_field(name="User", value=target_display, inline=True) - embed.add_field(name="Moderator", value=moderator_display, inline=True) - - # Add AI-specific details if available + lines = [f"**User:** {target_display}", f"**Moderator:** {moderator_display}"] if ai_details: - if 'rule_violated' in ai_details: - embed.add_field(name="Rule Violated", value=ai_details['rule_violated'], inline=True) - if 'reasoning' in ai_details: - # Use AI reasoning as the main reason field if bot reason is empty - reason_to_display = reason or ai_details['reasoning'] - embed.add_field(name="Reason / AI Reasoning", value=reason_to_display or "No reason provided.", inline=False) - # Optionally add bot reason separately if both exist and differ - if reason and reason != ai_details['reasoning']: - embed.add_field(name="Original Bot Reason", value=reason, inline=False) + if "rule_violated" in ai_details: + lines.append(f"**Rule Violated:** {ai_details['rule_violated']}") + if "reasoning" in ai_details: + reason_to_display = reason or ai_details["reasoning"] + lines.append(f"**Reason / AI Reasoning:** {reason_to_display or 'No reason provided.'}") + if reason and reason != ai_details["reasoning"]: + lines.append(f"**Original Bot Reason:** {reason}") else: - embed.add_field(name="Reason", value=reason or "No reason provided.", inline=False) - - # Add full message content if available - if 'message_content' in ai_details: - # Truncate if too long (Discord has a 1024 character limit for embed fields) - message_content = ai_details['message_content'] + lines.append(f"**Reason:** {reason or 'No reason provided.'}") + if "message_content" in ai_details: + message_content = ai_details["message_content"] if len(message_content) > 1000: message_content = message_content[:997] + "..." - embed.add_field(name="Message Content", value=message_content, inline=False) + lines.append(f"**Message Content:** {message_content}") else: - embed.add_field(name="Reason", value=reason or "No reason provided.", inline=False) - + lines.append(f"**Reason:** {reason or 'No reason provided.'}") if duration: - # Format duration nicely (e.g., "1 day", "2 hours 30 minutes") - # This is a simple version, could be made more robust total_seconds = int(duration.total_seconds()) days, remainder = divmod(total_seconds, 86400) hours, remainder = divmod(remainder, 3600) minutes, seconds = divmod(remainder, 60) duration_str = "" - if days > 0: duration_str += f"{days}d " - if hours > 0: duration_str += f"{hours}h " - if minutes > 0: duration_str += f"{minutes}m " - if seconds > 0 or not duration_str: duration_str += f"{seconds}s" + if days > 0: + duration_str += f"{days}d " + if hours > 0: + duration_str += f"{hours}h " + if minutes > 0: + duration_str += f"{minutes}m " + if seconds > 0 or not duration_str: + duration_str += f"{seconds}s" duration_str = duration_str.strip() - - embed.add_field(name="Duration", value=duration_str, inline=True) - # Add expiration timestamp if applicable (e.g., for timeouts) + lines.append(f"**Duration:** {duration_str}") if action_type.upper() == "TIMEOUT": - expires_at = discord.utils.utcnow() + duration - embed.add_field(name="Expires", value=f"", inline=True) - - - if source == "AI_API": - ai_model = ai_details.get("ai_model") if ai_details else None - embed.set_footer( - text=f"AI Moderation Action โ€ข {guild.name} ({guild.id})" + (f" โ€ข Model: {ai_model}" if ai_model else ""), - icon_url="https://cdn-icons-png.flaticon.com/512/4712/4712035.png" - ) - else: - embed.set_footer(text=f"Guild: {guild.name} ({guild.id})") - - return embed - + expires_at = discord.utils.utcnow() + duration + lines.append(f"**Expires:** ") + footer = ( + f"AI Moderation Action โ€ข {guild.name} ({guild.id})" + (f" โ€ข Model: {ai_details.get('ai_model')}" if ai_details and ai_details.get('ai_model') else "") + if source == "AI_API" + else f"Guild: {guild.name} ({guild.id})" + ) + return self.LogView(self.bot, action_title, embed_color, lines, footer) # --- View Command Callback --- @app_commands.checks.has_permissions(moderate_members=True) # Adjust permissions as needed async def modlog_view_callback(self, interaction: Interaction, user: Optional[discord.User] = None): @@ -359,9 +361,14 @@ class ModLogCog(commands.Cog): timestamp_str = record['timestamp'].strftime('%Y-%m-%d %H:%M:%S') reason_str = record['reason'] or "N/A" duration_str = f" ({record['duration_seconds']}s)" if record['duration_seconds'] else "" + target_disp = await self._fetch_user_display(record['target_user_id'], interaction.guild) + if record['moderator_id'] == 0: + mod_disp = "AI System" + else: + mod_disp = await self._fetch_user_display(record['moderator_id'], interaction.guild) response_lines.append( f"`Case #{record['case_id']}` [{timestamp_str}] **{record['action_type']}** " - f"Target: <@{record['target_user_id']}> Mod: <@{record['moderator_id']}> " + f"Target: {target_disp} Mod: {mod_disp} " f"Reason: {reason_str}{duration_str}" ) @@ -408,7 +415,7 @@ class ModLogCog(commands.Cog): duration = datetime.timedelta(seconds=record['duration_seconds']) if record['duration_seconds'] else None - embed = self._format_log_embed( + view = self._format_log_embed( case_id, moderator or Object(id=record['moderator_id']), # Fallback to Object if user not found target or Object(id=record['target_user_id']), # Fallback to Object if user not found @@ -421,9 +428,10 @@ class ModLogCog(commands.Cog): # Add log message link if available if record['log_message_id'] and record['log_channel_id']: link = f"https://discord.com/channels/{record['guild_id']}/{record['log_channel_id']}/{record['log_message_id']}" - embed.add_field(name="Log Message", value=f"[Jump to Log]({link})", inline=False) + # Append jump link as extra line + view.footer_display.content += f" | [Jump to Log]({link})" - await interaction.followup.send(embed=embed, ephemeral=True) + await interaction.followup.send(view=view, ephemeral=True) @app_commands.checks.has_permissions(manage_guild=True) # Higher permission for editing reasons @@ -455,7 +463,7 @@ class ModLogCog(commands.Cog): log_channel = interaction.guild.get_channel(original_record['log_channel_id']) if log_channel and isinstance(log_channel, discord.TextChannel): log_message = await log_channel.fetch_message(original_record['log_message_id']) - if log_message and log_message.author == self.bot.user and log_message.embeds: + if log_message and log_message.author == self.bot.user: # Re-fetch users/duration to reconstruct embed accurately # Special handling for AI moderator (ID 0) to avoid Discord API 404 error if original_record['moderator_id'] == 0: @@ -476,7 +484,7 @@ class ModLogCog(commands.Cog): duration = datetime.timedelta(seconds=original_record['duration_seconds']) if original_record['duration_seconds'] else None - new_embed = self._format_log_embed( + new_view = self._format_log_embed( case_id, moderator or Object(id=original_record['moderator_id']), target or Object(id=original_record['target_user_id']), @@ -485,13 +493,11 @@ class ModLogCog(commands.Cog): duration, interaction.guild ) - # Add log message link again link = f"https://discord.com/channels/{original_record['guild_id']}/{original_record['log_channel_id']}/{original_record['log_message_id']}" - new_embed.add_field(name="Log Message", value=f"[Jump to Log]({link})", inline=False) - new_embed.add_field(name="Updated Reason By", value=f"{interaction.user.mention}", inline=False) # Indicate update + new_view.footer_display.content += f" | [Jump to Log]({link}) | Updated By: {interaction.user.mention}" - await log_message.edit(embed=new_embed) - log.info(f"Successfully updated log message embed for case {case_id}") + await log_message.edit(view=new_view) + log.info(f"Successfully updated log message view for case {case_id}") except discord.NotFound: log.warning(f"Original log message or channel not found for case {case_id} when updating reason.") except discord.Forbidden: diff --git a/cogs/real_moderation_cog.py b/cogs/real_moderation_cog.py index 249c9a4..e6a05b9 100644 --- a/cogs/real_moderation_cog.py +++ b/cogs/real_moderation_cog.py @@ -30,6 +30,12 @@ class ModerationCog(commands.Cog): # Add command group to the bot's tree self.bot.tree.add_command(self.moderate_group) + def _user_display(self, user: Union[discord.Member, discord.User]) -> str: + """Return display name, username and ID string for a user.""" + display = user.display_name if isinstance(user, discord.Member) else user.name + username = f"{user.name}#{user.discriminator}" + return f"{display} ({username}) [ID: {user.id}]" + def register_commands(self): """Register all commands for this cog""" @@ -285,11 +291,12 @@ class ModerationCog(commands.Cog): # ------------------------- # Send confirmation message with DM status + target_text = self._user_display(member) if send_dm: dm_status = "โœ… DM notification sent" if dm_sent else "โŒ Could not send DM notification (user may have DMs disabled)" - await interaction.response.send_message(f"๐Ÿ”จ **Banned {member.mention}**! Reason: {reason or 'No reason provided'}\n{dm_status}") + await interaction.response.send_message(f"๐Ÿ”จ **Banned {target_text}**! Reason: {reason or 'No reason provided'}\n{dm_status}") else: - await interaction.response.send_message(f"๐Ÿ”จ **Banned {member.mention}**! Reason: {reason or 'No reason provided'}\nโš ๏ธ DM notification was disabled") + await interaction.response.send_message(f"๐Ÿ”จ **Banned {target_text}**! Reason: {reason or 'No reason provided'}\nโš ๏ธ DM notification was disabled") except discord.Forbidden: await interaction.response.send_message("โŒ I don't have permission to ban this member.", ephemeral=True) except discord.HTTPException as e: @@ -349,7 +356,7 @@ class ModerationCog(commands.Cog): # ------------------------- # Send confirmation message - await interaction.response.send_message(f"๐Ÿ”“ **Unbanned {banned_user}**! Reason: {reason or 'No reason provided'}") + await interaction.response.send_message(f"๐Ÿ”“ **Unbanned {self._user_display(banned_user)}**! Reason: {reason or 'No reason provided'}") except discord.Forbidden: await interaction.response.send_message("โŒ I don't have permission to unban this user.", ephemeral=True) except discord.HTTPException as e: @@ -429,7 +436,7 @@ class ModerationCog(commands.Cog): # Send confirmation message with DM status dm_status = "โœ… DM notification sent" if dm_sent else "โŒ Could not send DM notification (user may have DMs disabled)" - await interaction.response.send_message(f"๐Ÿ‘ข **Kicked {member.mention}**! Reason: {reason or 'No reason provided'}\n{dm_status}") + await interaction.response.send_message(f"๐Ÿ‘ข **Kicked {self._user_display(member)}**! Reason: {reason or 'No reason provided'}\n{dm_status}") except discord.Forbidden: await interaction.response.send_message("โŒ I don't have permission to kick this member.", ephemeral=True) except discord.HTTPException as e: @@ -541,7 +548,7 @@ class ModerationCog(commands.Cog): # Send confirmation message with DM status dm_status = "โœ… DM notification sent" if dm_sent else "โŒ Could not send DM notification (user may have DMs disabled)" - await safe_followup(f"โฐ **Timed out {member.mention}** for {duration}! Reason: {reason or 'No reason provided'}\n{dm_status}") + await safe_followup(f"โฐ **Timed out {self._user_display(member)}** for {duration}! Reason: {reason or 'No reason provided'}\n{dm_status}") except discord.Forbidden: await safe_followup("โŒ I don't have permission to timeout this member.", ephemeral=True) except discord.HTTPException as e: @@ -606,7 +613,7 @@ class ModerationCog(commands.Cog): # Send confirmation message with DM status dm_status = "โœ… DM notification sent" if dm_sent else "โŒ Could not send DM notification (user may have DMs disabled)" - await interaction.response.send_message(f"โฐ **Removed timeout from {member.mention}**! Reason: {reason or 'No reason provided'}\n{dm_status}") + await interaction.response.send_message(f"โฐ **Removed timeout from {self._user_display(member)}**! Reason: {reason or 'No reason provided'}\n{dm_status}") except discord.Forbidden: await interaction.response.send_message("โŒ I don't have permission to remove the timeout from this member.", ephemeral=True) except discord.HTTPException as e: @@ -645,7 +652,7 @@ class ModerationCog(commands.Cog): logger.info(f"{len(deleted)} messages from user {user} (ID: {user.id}) were purged from channel {interaction.channel.name} (ID: {interaction.channel.id}) in {interaction.guild.name} (ID: {interaction.guild.id}) by {interaction.user} (ID: {interaction.user.id}).") # Send confirmation message - await interaction.followup.send(f"๐Ÿงน **Purged {len(deleted)} messages** from {user.mention}!", ephemeral=True) + await interaction.followup.send(f"๐Ÿงน **Purged {len(deleted)} messages** from {self._user_display(user)}!", ephemeral=True) else: # Delete messages from anyone deleted = await interaction.channel.purge(limit=amount) @@ -699,7 +706,7 @@ class ModerationCog(commands.Cog): # ------------------------- # Send warning message in the channel - await interaction.response.send_message(f"โš ๏ธ **{member.mention} has been warned**! Reason: {reason}") + await interaction.response.send_message(f"โš ๏ธ **{self._user_display(member)} has been warned**! Reason: {reason}") # Try to DM the user about the warning try: @@ -788,7 +795,7 @@ class ModerationCog(commands.Cog): infractions = await mod_log_db.get_user_mod_logs(self.bot.pg_pool, interaction.guild.id, member.id) if not infractions: - await interaction.response.send_message(f"No infractions found for {member.mention}.", ephemeral=True) + await interaction.response.send_message(f"No infractions found for {self._user_display(member)}.", ephemeral=True) return embed = discord.Embed( @@ -901,11 +908,11 @@ class ModerationCog(commands.Cog): reason=f"Cleared {deleted_count} infractions. Reason: {reason or 'Not specified'}", duration=None ) - await interaction_confirm.response.edit_message(content=f"โœ… Successfully cleared {deleted_count} infractions for {member.mention}. Reason: {reason or 'Not specified'}", view=None) + await interaction_confirm.response.edit_message(content=f"โœ… Successfully cleared {deleted_count} infractions for {self._user_display(member)}. Reason: {reason or 'Not specified'}", view=None) elif deleted_count == 0: - await interaction_confirm.response.edit_message(content=f"โ„น๏ธ No infractions found for {member.mention} to clear.", view=None) + await interaction_confirm.response.edit_message(content=f"โ„น๏ธ No infractions found for {self._user_display(member)} to clear.", view=None) else: # Should not happen if 0 is returned for no logs - await interaction_confirm.response.edit_message(content=f"โŒ Failed to clear infractions for {member.mention}. An error occurred.", view=None) + await interaction_confirm.response.edit_message(content=f"โŒ Failed to clear infractions for {self._user_display(member)}. An error occurred.", view=None) async def cancel_callback(interaction_cancel: discord.Interaction): if interaction_cancel.user.id != interaction.user.id: @@ -919,7 +926,7 @@ class ModerationCog(commands.Cog): view.add_item(cancel_button) await interaction.response.send_message( - f"โš ๏ธ Are you sure you want to clear **ALL** infractions for {member.mention}?\n" + f"โš ๏ธ Are you sure you want to clear **ALL** infractions for {self._user_display(member)}?\n" f"This action is irreversible. Reason: {reason or 'Not specified'}", view=view, ephemeral=True @@ -1044,7 +1051,7 @@ class ModerationCog(commands.Cog): # Send confirmation message with DM status dm_status = "โœ… DM notification sent" if dm_sent else "โŒ Could not send DM notification (user may have DMs disabled)" - await ctx.reply(f"โฐ **Timed out {member.mention}** for {duration}! Reason: {reason or 'No reason provided'}\n{dm_status}") + await ctx.reply(f"โฐ **Timed out {self._user_display(member)}** for {duration}! Reason: {reason or 'No reason provided'}\n{dm_status}") except discord.Forbidden: await ctx.reply("โŒ I don't have permission to timeout this member.") except discord.HTTPException as e: @@ -1124,7 +1131,7 @@ class ModerationCog(commands.Cog): # Send confirmation message with DM status dm_status = "โœ… DM notification sent" if dm_sent else "โŒ Could not send DM notification (user may have DMs disabled)" - await ctx.reply(f"โฐ **Removed timeout from {member.mention}**! Reason: {reason or 'No reason provided'}\n{dm_status}") + await ctx.reply(f"โฐ **Removed timeout from {self._user_display(member)}**! Reason: {reason or 'No reason provided'}\n{dm_status}") except discord.Forbidden: await ctx.reply("โŒ I don't have permission to remove the timeout from this member.") except discord.HTTPException as e: diff --git a/cogs/role_management_cog.py b/cogs/role_management_cog.py index be63fef..f678ef3 100644 --- a/cogs/role_management_cog.py +++ b/cogs/role_management_cog.py @@ -439,9 +439,10 @@ class RoleManagementCog(commands.Cog): # Attempt to DM the user try: + role_info = f"{role.name} (ID: {role.id})" dm_embed = discord.Embed( title="Role Added", - description=f"The role {role.mention} was added to you in **{interaction.guild.name}**.", + description=f"The role {role_info} was added to you in **{interaction.guild.name}**.", color=role.color ) dm_embed.add_field(name="Added by", value=interaction.user.mention, inline=True) @@ -506,9 +507,10 @@ class RoleManagementCog(commands.Cog): # Attempt to DM the user try: + role_info = f"{role.name} (ID: {role.id})" dm_embed = discord.Embed( title="Role Removed", - description=f"The role {role.mention} was removed from you in **{interaction.guild.name}**.", + description=f"The role {role_info} was removed from you in **{interaction.guild.name}**.", color=role.color ) dm_embed.add_field(name="Removed by", value=interaction.user.mention, inline=True) diff --git a/tavily.py b/tavily.py new file mode 100644 index 0000000..3c89167 --- /dev/null +++ b/tavily.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +""" +Tavily API Script for AI Agents +Execute with: python tavily.py "your search query" +""" + +import os +import sys +import json +import requests +import argparse +from typing import Dict, List, Optional + +class TavilyAPI: + def __init__(self, api_key: str): + self.api_key = api_key + self.base_url = "https://api.tavily.com" + + def search(self, + query: str, + search_depth: str = "basic", + include_answer: bool = True, + include_images: bool = False, + include_raw_content: bool = False, + max_results: int = 5, + include_domains: Optional[List[str]] = None, + exclude_domains: Optional[List[str]] = None) -> Dict: + """ + Perform a search using Tavily API + + Args: + query: Search query string + search_depth: "basic" or "advanced" + include_answer: Include AI-generated answer + include_images: Include images in results + include_raw_content: Include raw HTML content + max_results: Maximum number of results (1-20) + include_domains: List of domains to include + exclude_domains: List of domains to exclude + + Returns: + Dictionary containing search results + """ + url = f"{self.base_url}/search" + + payload = { + "api_key": self.api_key, + "query": query, + "search_depth": search_depth, + "include_answer": include_answer, + "include_images": include_images, + "include_raw_content": include_raw_content, + "max_results": max_results + } + + if include_domains: + payload["include_domains"] = include_domains + if exclude_domains: + payload["exclude_domains"] = exclude_domains + + try: + response = requests.post(url, json=payload, timeout=30) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + return {"error": f"API request failed: {str(e)}"} + except json.JSONDecodeError: + return {"error": "Invalid JSON response from API"} + +def format_results(results: Dict) -> str: + """Format search results for display""" + if "error" in results: + return f"โŒ Error: {results['error']}" + + output = [] + + # Add answer if available + if results.get("answer"): + output.append("๐Ÿค– AI Answer:") + output.append(f" {results['answer']}") + output.append("") + + # Add search results + if results.get("results"): + output.append("๐Ÿ” Search Results:") + for i, result in enumerate(results["results"], 1): + output.append(f" {i}. {result.get('title', 'No title')}") + output.append(f" URL: {result.get('url', 'No URL')}") + if result.get("content"): + # Truncate content to first 200 chars + content = result["content"][:200] + "..." if len(result["content"]) > 200 else result["content"] + output.append(f" Content: {content}") + output.append("") + + # Add images if available + if results.get("images"): + output.append("๐Ÿ–ผ๏ธ Images:") + for img in results["images"][:3]: # Show first 3 images + output.append(f" {img}") + output.append("") + + return "\n".join(output) + +def main(): + parser = argparse.ArgumentParser(description="Search using Tavily API") + parser.add_argument("query", help="Search query") + parser.add_argument("--depth", choices=["basic", "advanced"], default="basic", + help="Search depth (default: basic)") + parser.add_argument("--max-results", type=int, default=5, + help="Maximum number of results (default: 5)") + parser.add_argument("--include-images", action="store_true", + help="Include images in results") + parser.add_argument("--no-answer", action="store_true", + help="Don't include AI-generated answer") + parser.add_argument("--include-domains", nargs="+", + help="Include only these domains") + parser.add_argument("--exclude-domains", nargs="+", + help="Exclude these domains") + parser.add_argument("--raw", action="store_true", + help="Output raw JSON response") + + args = parser.parse_args() + + # Get API key from environment + api_key = os.getenv("TAVILY_API_KEY") + if not api_key: + print("โŒ Error: TAVILY_API_KEY environment variable not set") + print("Set it with: export TAVILY_API_KEY='your-api-key-here'") + sys.exit(1) + + # Initialize Tavily API + tavily = TavilyAPI(api_key) + + # Perform search + results = tavily.search( + query=args.query, + search_depth=args.depth, + include_answer=not args.no_answer, + include_images=args.include_images, + max_results=args.max_results, + include_domains=args.include_domains, + exclude_domains=args.exclude_domains + ) + + # Output results + if args.raw: + print(json.dumps(results, indent=2)) + else: + print(format_results(results)) + +if __name__ == "__main__": + main() \ No newline at end of file