disagreement/examples/hybrid_bot.py
Slipstreamm b039b2e948 refactor(init): Consolidate module imports and exports
This commit refactors the `disagreement/__init__.py` file to import and export new models, enums, and components.

The primary changes are:
- Add imports and exports for `Member`, `Role`, `Attachment`, `Channel`, `ActionRow`, `Button`, `SelectOption`, `SelectMenu`, `Embed`, `PartialEmoji`, `Section`, `TextDisplay`, `Thumbnail`, `UnfurledMediaItem`, `MediaGallery`, `MediaGalleryItem`, `Container`, and `Guild` from `disagreement.models`.
- Add imports and exports for `ButtonStyle`, `ChannelType`, `MessageFlags`, `InteractionType`, `InteractionCallbackType`, and `ComponentType` from `disagreement.enums`.
- Add `Interaction` from `disagreement.interactions`.
- Add `ui` and `ext` as top-level modules.
- Update `disagreement.ext/__init__.py` to expose `app_commands`, `commands`, and `tasks`.

These changes consolidate the library's public API, making new features more accessible.
The example files were also updated to use the direct imports from the `disagreement` package or its `ext` subpackage, improving readability and consistency.
2025-06-14 18:17:57 -06:00

316 lines
11 KiB
Python

import asyncio
import os
import logging
from typing import Any, Optional, Literal, Union
from disagreement import (
HybridContext,
Client,
User,
Member,
Role,
Attachment,
Message,
Channel,
ChannelType,
)
from disagreement.ext import commands
from disagreement.ext.commands import Cog, CommandContext
from disagreement.ext.app_commands import (
AppCommandContext,
AppCommandGroup,
slash_command,
user_command,
message_command,
hybrid_command,
)
# from disagreement.interactions import Interaction # Replaced by AppCommandContext
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
try:
from dotenv import load_dotenv
except ImportError: # pragma: no cover - example helper
load_dotenv = None
print("python-dotenv is not installed. Environment variables will not be loaded")
if load_dotenv:
load_dotenv()
# --- Define a Test Cog ---
class TestCog(Cog):
def __init__(self, client: Client):
super().__init__(client)
@slash_command(name="greet", description="Sends a greeting.")
async def greet_slash(self, ctx: AppCommandContext, name: str):
await ctx.send(f"Hello, {name}! (Slash)")
@user_command(name="Show User Info")
async def show_user_info_user(
self, ctx: AppCommandContext, user: User
): # Target user is in ctx.interaction.data.target_id and resolved
target_user = (
ctx.interaction.data.resolved.users.get(ctx.interaction.data.target_id)
if ctx.interaction.data
and ctx.interaction.data.resolved
and ctx.interaction.data.target_id
else user
)
if target_user:
await ctx.send(
f"User: {target_user.username}#{target_user.discriminator} (ID: {target_user.id}) (User Cmd)",
ephemeral=True,
)
else:
await ctx.send("Could not find user information.", ephemeral=True)
@message_command(name="Quote Message")
async def quote_message_msg(
self, ctx: AppCommandContext, message: Message
): # Target message is in ctx.interaction.data.target_id and resolved
target_message = (
ctx.interaction.data.resolved.messages.get(ctx.interaction.data.target_id)
if ctx.interaction.data
and ctx.interaction.data.resolved
and ctx.interaction.data.target_id
else message
)
if target_message:
await ctx.send(
f'Quoting {target_message.author.username}: "{target_message.content}" (Message Cmd)',
ephemeral=True,
)
else:
await ctx.send("Could not find message to quote.", ephemeral=True)
@hybrid_command(name="ping", description="Checks bot latency.", aliases=["pong"])
async def ping_hybrid(
self, ctx: Union[CommandContext, AppCommandContext], arg: Optional[str] = None
):
latency = self.client.latency
latency_ms = f"{latency * 1000:.0f}" if latency is not None else "N/A"
hybrid = HybridContext(ctx)
await hybrid.send(f"Pong! {latency_ms}ms. Arg: {arg} (Hybrid)")
@slash_command(name="options_test", description="Tests various option types.")
async def options_test_slash(
self,
ctx: AppCommandContext,
text: str,
integer: int,
boolean: bool,
number: float,
user_option: User,
role_option: Role,
attachment_option: Attachment,
choice_option_str: Literal["apple", "banana", "cherry"],
# Channel and member options as well as numeric Literal choices are
# not yet exercised in tests pending full library support.
):
response_parts = [
f"Text: {text}",
f"Integer: {integer}",
f"Boolean: {boolean}",
f"Number: {number}",
f"User: {user_option.username}#{user_option.discriminator}",
f"Role: {role_option.name}",
f"Attachment: {attachment_option.filename} (URL: {attachment_option.url})",
f"Choice Str: {choice_option_str}",
]
await ctx.send("\n".join(response_parts), ephemeral=True)
# --- Subcommand Group Test ---
# Define the group as a class attribute.
# The AppCommandHandler's discovery mechanism (via Cog) should pick up AppCommandGroup instances.
settings_group = AppCommandGroup(
name="settings",
description="Manage bot settings.",
# guild_ids can be added here if the group is guild-specific
)
@slash_command(
name="show", description="Shows current setting values.", parent=settings_group
)
async def settings_show(
self, ctx: AppCommandContext, setting_name: Optional[str] = None
):
if setting_name:
await ctx.send(
f"Showing value for setting: {setting_name} (Value: Placeholder)",
ephemeral=True,
)
else:
await ctx.send(
"Showing all settings: (Placeholder for all settings)", ephemeral=True
)
@slash_command(
name="update", description="Updates a setting.", parent=settings_group
)
async def settings_update(
self, ctx: AppCommandContext, setting_name: str, value: str
):
await ctx.send(
f"Updated setting: {setting_name} to value: {value}", ephemeral=True
)
# The Cog's metaclass or command registration logic should handle adding `settings_group`
# (and its subcommands) to the client's AppCommandHandler.
# The decorators now handle associating subcommands with their parent group.
@slash_command(
name="numeric_choices_test", description="Tests integer and float choices."
)
async def numeric_choices_test_slash(
self,
ctx: AppCommandContext,
int_choice: Literal[10, 20, 30, 42],
float_choice: float,
):
response = (
f"Integer Choice: {int_choice} (Type: {type(int_choice).__name__})\n"
f"Float Choice: {float_choice} (Type: {type(float_choice).__name__})"
)
await ctx.send(response, ephemeral=True)
@slash_command(
name="numeric_choices_extended",
description="Tests additional integer and float choice handling.",
)
async def numeric_choices_extended_slash(
self,
ctx: AppCommandContext,
int_choice: Literal[-5, 0, 5],
float_choice: float,
):
response = (
f"Int Choice: {int_choice} (Type: {type(int_choice).__name__})\n"
f"Float Choice: {float_choice} (Type: {type(float_choice).__name__})"
)
await ctx.send(response, ephemeral=True)
@slash_command(
name="channel_member_test",
description="Tests channel and member options.",
)
async def channel_member_test_slash(
self,
ctx: AppCommandContext,
channel: Channel,
member: Member,
):
response = (
f"Channel: {channel.name} (Type: {channel.type.name})\n"
f"Member: {member.username}#{member.discriminator}"
)
await ctx.send(response, ephemeral=True)
@slash_command(
name="channel_types_test",
description="Demonstrates multiple channel type options.",
)
async def channel_types_test_slash(
self,
ctx: AppCommandContext,
text_channel: Channel,
voice_channel: Channel,
category_channel: Channel,
):
response = (
f"Text: {text_channel.type.name}\n"
f"Voice: {voice_channel.type.name}\n"
f"Category: {category_channel.type.name}"
)
await ctx.send(response, ephemeral=True)
# --- Main Bot Script ---
async def main():
bot_token = os.getenv("DISCORD_BOT_TOKEN")
application_id = os.getenv("DISCORD_APPLICATION_ID")
if not bot_token:
logger.error("Error: DISCORD_BOT_TOKEN environment variable not set.")
return
if not application_id:
logger.error("Error: DISCORD_APPLICATION_ID environment variable not set.")
return
client = Client(token=bot_token, command_prefix="!", application_id=application_id)
@client.event
async def on_ready():
if client.user:
logger.info(
f"Bot logged in as {client.user.username}#{client.user.discriminator}"
)
else:
logger.error(
"Client ready, but client.user is not populated! This should not happen."
)
return # Avoid proceeding if basic client info isn't there
if client.application_id:
logger.info(f"Application ID is: {client.application_id}")
# Sync application commands (global in this case)
try:
logger.info("Attempting to sync application commands...")
# Ensure application_id is not None before passing
app_id_to_sync = client.application_id
if (
app_id_to_sync is not None
): # Redundant due to outer if, but good for clarity
await client.app_command_handler.sync_commands(
application_id=app_id_to_sync
)
logger.info("Application commands synced successfully.")
else: # Should not be reached if outer if client.application_id is true
logger.error(
"Application ID was None despite initial check. Skipping sync."
)
except Exception as e:
logger.error(f"Error syncing application commands: {e}", exc_info=True)
else:
# This case should be less likely now that Client gets it from READY.
# If DISCORD_APPLICATION_ID was critical as a fallback, that logic would be here.
# For now, we rely on the READY event.
logger.warning(
"Client's application ID is not set after READY. Skipping application command sync."
)
# Check if the environment variable was provided, as a diagnostic.
if not application_id:
logger.warning(
"DISCORD_APPLICATION_ID environment variable was also not provided."
)
client.add_cog(TestCog(client))
try:
await client.run()
except KeyboardInterrupt:
logger.info("Bot shutting down...")
except Exception as e:
logger.error(
f"An error occurred in the bot's main run loop: {e}", exc_info=True
)
finally:
if not client.is_closed():
await client.close()
logger.info("Bot has been closed.")
if __name__ == "__main__":
# For Windows, to allow graceful shutdown with Ctrl+C
if os.name == "nt":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("Main loop interrupted. Exiting.")