Compare commits

...

187 Commits

Author SHA1 Message Date
380feddeeb
Merge branch 'master' of https://github.com/Slipstreamm/disagreement 2025-06-15 20:53:38 -06:00
3beaed8a1b
Testing commit signing 2025-06-15 20:50:52 -06:00
e5ad932321
Add recursive command enumeration (#115) 2025-06-15 20:46:20 -06:00
8e88aaec2f
Implement channel and member aggregation (#117) 2025-06-15 20:42:21 -06:00
d710487fc2
Add voice playback control (#111)
Some checks are pending
Deploy MkDocs / deploy (push) Waiting to run
2025-06-15 20:39:30 -06:00
506adeca20
Add channel and emoji converters (#112) 2025-06-15 20:39:28 -06:00
e2061adc55
Add logout method (#114) 2025-06-15 20:39:26 -06:00
132521fa39
Add Object class and partial docs (#113)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-15 20:39:23 -06:00
cec747a575
Improve help command (#116)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-15 20:39:20 -06:00
17751d3b09
Add HashableById mixin and tests (#118) 2025-06-15 20:39:16 -06:00
4b3b6aeb45
Add Asset model and avatar helpers (#119) 2025-06-15 20:39:14 -06:00
aa55aa1d4c
feat: persist views (#120)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-15 20:39:12 -06:00
80f64c1f73
Add guild_permissions property and tests (#97) 2025-06-15 18:55:52 -06:00
f5f8f6908c
Add get and find helpers (#103) 2025-06-15 18:55:17 -06:00
3437050f0e
Add snowflake_time utility (#105)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-15 18:52:38 -06:00
87d67eb63b
Add invite fetching support (#95) 2025-06-15 18:50:06 -06:00
9c10ab0f70
Add timestamp datetime properties (#96) 2025-06-15 18:50:03 -06:00
2008dd33d1
Add webhook retrieval (#98) 2025-06-15 18:49:59 -06:00
de40aa2c29
Add UserConverter and tests (#99) 2025-06-15 18:49:57 -06:00
2056a3ddcf
Add VoiceState dataclass and member voice property (#100) 2025-06-15 18:49:55 -06:00
ccf55adba2
Add channel invite creation (#101) 2025-06-15 18:49:53 -06:00
a335ed972c
Add guild channel creation methods (#102) 2025-06-15 18:49:51 -06:00
2586d3cd0d
Add message crosspost support (#104) 2025-06-15 18:49:48 -06:00
7f9647a442
feat(tasks): allow runtime interval change (#106) 2025-06-15 18:49:45 -06:00
a222dec661
Add Permissions.all convenience (#107)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-15 18:49:43 -06:00
3f7c286322
Add guild prune support (#108) 2025-06-15 18:49:41 -06:00
cc17d11509
Add markdown and mention escaping helpers (#109) 2025-06-15 18:49:39 -06:00
9fabf1fbac
Add Webhook.from_token and fetch support (#110) 2025-06-15 18:49:37 -06:00
223c86cb78
Add cog retrieval helpers (#89)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-15 18:15:45 -06:00
98afb89629
Add Guild.me property (#90)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-15 18:13:06 -06:00
095e7e7192
Add get_context parsing for commands (#91) 2025-06-15 18:12:47 -06:00
c1c5cfb41a
Add Paginator utility and tests (#92)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-15 18:12:10 -06:00
8be234c1f0
Support async teardown in extension loader (#93) 2025-06-15 18:11:53 -06:00
1464937f6f
Add async start and sync run (#94)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-15 18:11:44 -06:00
5d66eb79cc chore(client): Remove merge conflict marker 2025-06-15 15:28:55 -06:00
5d72643390
Add is_owner decorator and owner checks (#81) 2025-06-15 15:23:52 -06:00
c7eb8563de
Add channel lists to Guild (#82) 2025-06-15 15:22:49 -06:00
a68bbe7826
Add guilds property to client (#83) 2025-06-15 15:21:01 -06:00
6eff962682
feat: dispatch connect events (#80)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-15 15:20:13 -06:00
f24c1befac
feat(client): track connection time (#84) 2025-06-15 15:20:06 -06:00
c811e2b578
Extend File to handle streams (#85) 2025-06-15 15:20:04 -06:00
9f2fc0857b
feat(client): sync commands on ready (#86) 2025-06-15 15:20:00 -06:00
775dce0c80
Store shard id on guild and expose shard property (#87) 2025-06-15 15:19:58 -06:00
a93ad432b7
Add thread and invite event parsing (#88) 2025-06-15 15:19:56 -06:00
3a264f4530 feat(ext-loader): Support async setup functions
Allow extension `setup` functions to be asynchronous. The loader now checks if `module.setup` returns an awaitable and runs it using asyncio, handling cases where an event loop is already running or not.

This enables extensions to perform asynchronous initialization tasks.
2025-06-15 15:17:42 -06:00
Slipstreamm
a41a301927 fix(core): Improve client ready state and user parsing
The `_ready_event` is now set in `GatewayClient` immediately after
receiving the `READY` payload, before dispatching `on_ready` to user code.
This ensures `Client.wait_until_ready()` and `Client.is_ready()`
accurately reflect the client's state before dependent user logic executes.

This change allows simplifying `Client.sync_commands` by removing
redundant `wait_until_ready()` calls and `application_id` checks,
as the application ID is guaranteed to be available upon READY.

Additionally, `User` model initialization is improved to correctly handle
nested user data found in certain API payloads (e.g., within `member`
objects in events like `PresenceUpdate`).

Add `SOUNDBOARD` and `VIDEO_QUALITY_720_60FPS` to `GuildFeature` enum.
2025-06-14 23:49:33 -06:00
Slipstreamm
bd16b1c026 chore(release): Bump version to 0.8.0 2025-06-14 21:53:41 -06:00
460583ef30
Fix pylint errors due to conditional imports (#78) 2025-06-14 21:52:59 -06:00
f1ca18a62a
Fix pylint errors due to conditional imports (#79) 2025-06-14 21:52:29 -06:00
Slipstreamm
2c8e426353 chore(release): Bump version to 0.7.0 2025-06-14 21:41:30 -06:00
c9aec0dc7e
Improve command sync and DM support (#77) 2025-06-14 21:40:52 -06:00
Slipstreamm
bd92806c4c bump 2 0.6.0 2025-06-14 19:06:57 -06:00
Slipstreamm
e965a675c1 refactor(api): Re-export common symbols from top-level package
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
Makes commonly used classes, functions, and decorators from `disagreement.ext` and `disagreement.ui` submodules directly accessible under the `disagreement` namespace.

This change simplifies import statements for users, leading to cleaner and more concise code. Documentation and examples have been updated to reflect these new, simplified import paths.
2025-06-14 18:57:12 -06:00
Slipstreamm
9237d12a24 docs(imports): Update import paths in documentation examples
Adjust examples to reflect the new top-level exposure of classes and enums, such as `Client`, `Permissions`, `Embed`, and `Button`, making imports simpler.
2025-06-14 18:44:04 -06:00
Slipstreamm
420c57df30 chore: Bump version to 0.5.0 in __init__.py and pyproject.toml 2025-06-14 18:22:20 -06:00
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
f58ffe8321
Apply global allowed_mentions setting (#76)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-13 22:10:19 -06:00
Slipstreamm
ffdb922142 ci(mirror): Make mirror remote addition idempotent
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
Wrap the `git remote add mirror` command in a conditional check.

This ensures the remote is only added if it doesn't already exist,
preventing potential errors if the command is executed multiple times
or if the remote is somehow already configured in the runner environment.
2025-06-13 00:30:31 -06:00
Slipstreamm
2b8f29bde2 chore(docs): Add CNAME file for custom domain configuration 2025-06-13 00:26:44 -06:00
Slipstreamm
f7a47619ac ci(workflows): Migrate to self-hosted runners
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
Switch GitHub Actions workflows (`docs`, `mirror`, `pypi`) from `ubuntu-latest` to `self-hosted` runners.

This change also updates the Python environment setup in `docs.yml` and `pypi.yml` to manually create and activate a virtual environment (`venv`). This provides more control over the Python environment on self-hosted machines and ensures dependencies are isolated.
2025-06-13 00:23:55 -06:00
Slipstreamm
675aab39ce chore(ci): Activate virtual environment before running Pyright and tests 2025-06-13 00:21:23 -06:00
Slipstreamm
a2bdc66ced chore(deps): Add aiohttp and python-dotenv to test dependencies 2025-06-13 00:16:23 -06:00
Slipstreamm
6fb371455b Update exclusion patterns in pyrightconfig.json to include virtual environments 2025-06-13 00:12:37 -06:00
Slipstreamm
8a228a9e1b Refactor Pyright execution step to simplify command 2025-06-13 00:11:50 -06:00
Slipstreamm
1505bdfd0a Add output of pyrightconfig.json before running Pyright 2025-06-13 00:10:33 -06:00
Slipstreamm
7354ff2244 Simplify dependency installation in CI workflow 2025-06-13 00:10:24 -06:00
Slipstreamm
66eb50833b Add output of pyrightconfig.json before running Pyright 2025-06-13 00:08:19 -06:00
Slipstreamm
398c2c34c0 Update CI workflow to include current directory output before running Pyright 2025-06-13 00:06:52 -06:00
2e72103b6a
Update ci.yml 2025-06-13 00:03:28 -06:00
91821e1c1d
Update ci.yml 2025-06-12 23:57:06 -06:00
12b14b9187
Update ci.yml 2025-06-12 23:55:41 -06:00
fae9cddb88
Update ci.yml 2025-06-12 23:52:54 -06:00
fd9ce4bbb8
Update ci.yml 2025-06-12 23:48:26 -06:00
3adce99f22
Update ci.yml 2025-06-12 23:40:59 -06:00
075811982d
Update ci.yml 2025-06-12 23:40:16 -06:00
aa01d74c01
Update ci.yml 2025-06-12 23:38:29 -06:00
ae45cc898d
Update ci.yml 2025-06-12 23:25:00 -06:00
890742b177
Update ci.yml 2025-06-12 23:20:05 -06:00
d0e55d3706
Update ci.yml 2025-06-12 23:10:37 -06:00
b5ee8dc408
Update ci.yml 2025-06-12 22:53:57 -06:00
dbdab08c7a
Update pyproject.toml 2025-06-12 21:28:42 -06:00
a3568f1287
Update __init__.py 2025-06-12 21:28:29 -06:00
c146b01cec
fix: declare public exports (#75) 2025-06-12 21:27:37 -06:00
c87bcefd41
Add moderation bot example (#74) 2025-06-12 13:39:13 -06:00
8e48da3bee
Add async context manager to VoiceClient (#67)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-11 18:24:22 -06:00
def2ff0183
Add Message.jump_url helper (#68)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-11 18:24:20 -06:00
7c7bebc95a
Add listener helpers (#69)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-11 18:24:18 -06:00
e693f00abe
Add VoiceStateUpdate event (#70)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-11 18:24:16 -06:00
c099466024
Add guild listing methods (#71)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-11 18:24:14 -06:00
235ea8fc69
Add get_or_fetch cache helper and tests (#72) 2025-06-11 18:24:11 -06:00
0a3f680e7f
Add AllowedMentions helpers and update docs (#73)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-11 18:24:09 -06:00
96cd3f1714
Add HybridContext and RateLimiter docs to mkdocs nav (#66)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-11 17:35:54 -06:00
d437a315ef
bump version to 0.4.1 in __init__.py and pyproject.toml 2025-06-11 17:35:15 -06:00
286862ebaf
docs: add Documentation URL to project metadata 2025-06-11 17:33:36 -06:00
e39f701f33
bump version to 0.4.0 in __init__.py and pyproject.toml 2025-06-11 17:30:34 -06:00
c4c27bc0d3
Update and fix docs.yml workflow 2025-06-11 17:23:41 -06:00
a13cf1e4f8
docs: sync with codebase (#65)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
2025-06-11 17:09:23 -06:00
ce670245c4
chore: remove unnecessary comments (#64) 2025-06-11 16:51:15 -06:00
e199d5494b
Update typing command response for clarity in typing indicator example 2025-06-11 16:23:56 -06:00
b97d52a365
Rename typing command from 'typing_test' to 'typing' for clarity 2025-06-11 16:16:28 -06:00
9dd9cc7e2b
Add typing indicator example bot using Disagreement library 2025-06-11 16:15:59 -06:00
5db4d40076
ci: run Python workflow only when relevant files change
Limit GitHub Actions workflow triggers to changes in .py files,
requirements.txt, setup.py, or pyproject.toml to avoid unnecessary builds.
2025-06-11 16:08:25 -06:00
79642a48da
Adds automated MkDocs deployment workflow
Enables automatic documentation deployment to GitHub Pages when documentation files are modified on the master branch.

Triggers on changes to docs directory or mkdocs.yml configuration file to ensure documentation stays current with code changes.
2025-06-11 16:07:12 -06:00
6bcde9c5b0
Improves reactions documentation with examples and events
Expands the reactions guide to include practical examples for adding reactions via message objects, demonstrates low-level client methods, and adds comprehensive event handling examples.

Shows how to listen for reaction add/remove events with proper type hints and user filtering to ignore bot reactions.
2025-06-11 16:05:35 -06:00
64576203ae
Adds comprehensive documentation with MkDocs setup
Establishes initial documentation structure including introduction guide,
API reference navigation, and MkDocs configuration with Material theme.

Provides complete setup instructions, usage examples, and feature overview
to help users get started with the Discord bot library.
2025-06-11 15:58:25 -06:00
071e01cd87
Simplifies latency property getter logic
Refactors the conditional check to use getattr with a default value,
reducing code complexity while maintaining the same functionality
2025-06-11 15:33:58 -06:00
592653cccd
Improves latency calculation precision and safety
Adds null check to prevent errors when latency is unavailable and rounds the returned value to 2 decimal places for consistent precision across the application.
2025-06-11 15:21:05 -06:00
9d8fd83497
Fix: Remove unnecessary type ignore comment for latency in ping command 2025-06-11 15:14:14 -06:00
3c638d17ce
Adds latency display and improves bot configuration
Adds millisecond latency property to gateway and client for more precise monitoring.

Enhances ping command to display gateway latency for better debugging.

Configures message content intent and mention replies for improved bot functionality.

Adds dotenv support for cleaner environment variable management.

Formats error classes for better code consistency and readability.
2025-06-11 15:13:37 -06:00
a702c66603
Merge branch 'master' of https://github.com/Slipstreamm/disagreement 2025-06-11 14:52:49 -06:00
aec0de3e58
Fix: Ensure newline at end of file in MemberCacheFlags and add __init__.py 2025-06-11 14:50:01 -06:00
66288ba920
Adds comprehensive Discord API error code mapping
Implements specific exception classes for all Discord API error codes to provide more granular error handling and better debugging experience.

Creates individual exception classes for each Discord error code (10001-520006) with descriptive names and documentation strings that include the official error messages and codes.

Updates HTTP client to automatically map Discord error codes to their corresponding exception classes, allowing developers to catch specific error types rather than generic HTTP exceptions.

Includes comprehensive test coverage for the new error code mapping functionality to ensure correct exception raising based on API responses.
2025-06-11 14:47:23 -06:00
15d95bc786
Use asyncio.get_running_loop fallback (#50) 2025-06-11 14:27:11 -06:00
3158d76e90
Document dotenv requirement and add optional handling (#51) 2025-06-11 14:27:09 -06:00
dd6cf9ad9e
Fix README code block and format code (#52) 2025-06-11 14:27:06 -06:00
6fd1f93bab
Add default allowed_mentions option (#53) 2025-06-11 14:27:03 -06:00
d4bf99eac6
Add embed helper methods and docs (#54) 2025-06-11 14:27:01 -06:00
cfb8bedeaf
Add volume and options support to audio (#55) 2025-06-11 14:26:58 -06:00
35eb459c36
Add clean_content property and tests (#56) 2025-06-11 14:26:55 -06:00
64dec9b3f5
Add Messageable mixin for channel messaging (#57) 2025-06-11 14:26:53 -06:00
6d55a2ca98
Add Greedy converter and support in parser (#58) 2025-06-11 14:26:51 -06:00
afeb86a395
Add guild widget support (#59) 2025-06-11 14:26:49 -06:00
39162b6543
feat: add Activity presence models (#60) 2025-06-11 14:26:47 -06:00
c47a7e49f8
Add LRU support to Cache and message cache size option (#61) 2025-06-11 14:26:44 -06:00
07daf78ef4
Add AutoArchiveDuration enum and update thread APIs (#62) 2025-06-11 14:26:41 -06:00
73416858ae
chore: Update version to 0.3.0b1 in __init__.py and pyproject.toml 2025-06-11 02:28:36 -06:00
1c45989988
chore: Bump version to 0.3.0rc1 in __init__.py and pyproject.toml 2025-06-11 02:27:15 -06:00
45a5ef1fb5
Improves asyncio loop handling and test initialization
Replaces deprecated get_event_loop() with proper running loop detection and fallback to new loop creation for better asyncio compatibility.

Fixes test suite by replacing manual Client instantiation with proper constructor calls, ensuring all internal caches and attributes are correctly initialized.

Updates cache access patterns to use new cache API methods consistently across the codebase.
2025-06-11 02:25:24 -06:00
ed83a9da85
Implements caching system with TTL and member filtering
Introduces a flexible caching infrastructure with time-to-live support and configurable member caching based on status, voice state, and join events.

Adds AudioSink abstract base class to support audio output handling in voice connections.

Replaces direct dictionary access with cache objects throughout the client, enabling automatic expiration and intelligent member filtering based on user-defined flags.

Updates guild parsing to incorporate presence and voice state data for more accurate member caching decisions.
2025-06-11 02:11:33 -06:00
0151526d07
chore: Apply code formatting across the codebase
This commit applies consistent code formatting to multiple files. No functional changes are included.
2025-06-11 02:10:33 -06:00
17b7ea35a9
feat: Implement guild.fetch_members 2025-06-11 02:06:19 -06:00
28702fa8a1
feat(voice): Implement voice receiving and audio sinks 2025-06-11 02:06:18 -06:00
97505948ee
feat: Add advanced message, channel, and thread management 2025-06-11 02:06:16 -06:00
152c0f12be
feat(ui): Implement persistent views 2025-06-11 02:06:15 -06:00
eb38ecf671
feat(commands): Add has_role and has_any_role check decorators 2025-06-11 02:06:13 -06:00
2bd45c87ca
feat: Enhance command framework with groups, checks, and events 2025-06-11 02:06:11 -06:00
be85444aa0
Bump version to 0.2.0rc1 in __init__.py and pyproject.toml 2025-06-10 21:13:59 -06:00
9509213f7a
Add guild templates support (#41) 2025-06-10 21:10:29 -06:00
c250c22737
Add guild scheduled event support (#42) 2025-06-10 21:09:47 -06:00
2d6c2cb0be
Add stage channel and instance support (#43) 2025-06-10 21:08:32 -06:00
3059041ba8
Add audit log support (#48) 2025-06-10 21:07:39 -06:00
43ca2dc561
Add voice region support (#44)
Squash and merge PR #44
2025-06-10 20:50:27 -06:00
7595e33fd1
Emit shard lifecycle events (#45)
Squash and merge PR #45
2025-06-10 20:50:25 -06:00
36b06c6c7a
Add HTTP session options (#46)
Squash and merge PR #46
2025-06-10 20:50:23 -06:00
a4aa4335a5
Merge branch 'master' of https://github.com/Slipstreamm/disagreement 2025-06-10 20:49:58 -06:00
463ad26217
Merge pull request #47 from Slipstreamm/codex/add-json-persistence-for-command-ids
Merge PR #47
2025-06-10 20:49:48 -06:00
029a0fe2bd
Merge pull request #49 from Slipstreamm/codex/implement-max_concurrency-decorator
Merge PR #49
2025-06-10 20:49:45 -06:00
b0f9381fa6 Add max concurrency support 2025-06-10 20:48:19 -06:00
5d3778b083 Add command ID persistence 2025-06-10 20:48:18 -06:00
b9d93a90fa
Adds invite management functionality
Introduces comprehensive invite handling capabilities including creation, deletion, and retrieval operations.

Implements invite data model with proper parsing and representation methods to handle Discord invite objects and their metadata.

Provides HTTP client methods for all invite-related API endpoints with appropriate error handling and response processing.

Includes documentation with practical examples for common invite operations.
2025-06-10 20:37:21 -06:00
669f00e745
Add extensive color helper methods (#40) 2025-06-10 18:42:52 -06:00
7db71e8124
fix: update user agent URL in HTTPClient 2025-06-10 18:36:24 -06:00
97273ce655
Format with black 2025-06-10 18:26:23 -06:00
d631ab8e7c
bump: update version to 0.1.0rc3 in __init__.py and pyproject.toml 2025-06-10 18:25:43 -06:00
d074839a29
Remove duplicate asyncio import and sort (#35) 2025-06-10 18:24:43 -06:00
09c2b3e0cf
Fix application_id handling (#36) 2025-06-10 18:24:37 -06:00
f91c6917b8
fix: ensure newline termination (#37) 2025-06-10 18:24:29 -06:00
fd31a3162b
Replace debug prints with logging (#38) 2025-06-10 18:24:00 -06:00
90ee3fcf7f
Include py.typed in package (#39) 2025-06-10 18:23:42 -06:00
f92425bbd1
Bump version to 0.1.0rc2 in __init__.py and pyproject.toml 2025-06-10 18:08:41 -06:00
134506e6ba
Removes duplicate code and unnecessary imports
Cleans up codebase by removing redundant type casting, unused imports, and duplicate method definitions.

Eliminates duplicate embeds list comprehension that was accidentally duplicated.

Removes unnecessary typing imports and cast operation that were no longer needed.

Deletes duplicate history method implementation that was redundant.
2025-06-10 18:06:50 -06:00
92b0bc5804
Add webhook send capability (#33) 2025-06-10 18:01:28 -06:00
71097c6fbe
Add async message pager and channel history (#32) 2025-06-10 18:01:21 -06:00
d423f5c03a
Add client extension management and examples (#30) 2025-06-10 17:59:24 -06:00
900cd27cc3
Merge pull request #28 from Slipstreamm/codex/add-display_name-property-on-member
Merge PR #28
2025-06-10 17:57:40 -06:00
b1861d510f
Merge pull request #29 from Slipstreamm/codex/add-reaction-methods-to-message
Merge PR #29
2025-06-10 17:57:38 -06:00
fdfb2034d5
Merge remote-tracking branch 'origin/master' into codex/add-reaction-methods-to-message 2025-06-10 17:57:26 -06:00
b375dc7d05
Merge remote-tracking branch 'origin/master' into codex/add-display_name-property-on-member 2025-06-10 17:57:06 -06:00
b9bfa24511
Merge pull request #26 from Slipstreamm/codex/implement-ratelimiter-in-httpclient
Merge PR #26
2025-06-10 17:56:28 -06:00
477419bd96
Merge pull request #27 from Slipstreamm/codex/implement-gatewayintent.none-returning-0
Merge PR #27
2025-06-10 17:56:26 -06:00
c27a25955a
Merge remote-tracking branch 'origin/master' into codex/implement-gatewayintent.none-returning-0 2025-06-10 17:56:02 -06:00
0630c8b916
Merge remote-tracking branch 'origin/master' into codex/implement-ratelimiter-in-httpclient 2025-06-10 17:55:09 -06:00
eb8d7a9656
Merge pull request #31 from Slipstreamm/codex/add-before_loop-and-after_loop-callbacks
Merge PR #31
2025-06-10 17:54:08 -06:00
d67097a619
Merge pull request #34 from Slipstreamm/codex/create-async-iterator-for-message-history
Merge PR #34
2025-06-10 17:54:04 -06:00
39b05bc958 Add message history iterator 2025-06-10 17:53:14 -06:00
df77a3fcec Add before/after loop callbacks 2025-06-10 17:52:05 -06:00
105640e54b Add reaction helpers and update tests 2025-06-10 17:52:02 -06:00
dfbda351e4 Add Member.display_name property and update docs/tests 2025-06-10 17:52:01 -06:00
8b0e6fcce2 Add GatewayIntent.none method with docs and tests 2025-06-10 17:52:00 -06:00
e55e963a59 Add rate limiter integration with HTTPClient 2025-06-10 17:51:58 -06:00
c0066525db
Add test for Color.parse method to validate tuple input for RGB values 2025-06-10 17:51:22 -06:00
e7773373f4
Enhance Color.parse method to accept tuple input for RGB values 2025-06-10 17:49:36 -06:00
9df06868a4
Allow color parameters to accept multiple formats (#25) 2025-06-10 17:45:52 -06:00
534b5b3980
Refactors interaction response handling for flexibility
Simplifies modal response creation by accepting both structured data objects and raw dictionaries, reducing unnecessary type casting and import dependencies.

Improves text input decorator by auto-generating custom IDs from function names when not explicitly provided.

Enhances example code by properly utilizing parsed Guild objects instead of raw data and fixes modal component registration.
2025-06-10 17:33:54 -06:00
b20f1fd292
Load environment variables from .env file in sharded bot example 2025-06-10 16:49:50 -06:00
6d5b92ad69
Fixes component type checking and import issues
Changes component validation to use the more general ComponentModel instead of ActionRowModel, allowing broader component types to be processed correctly.

Makes reply content parameter optional to improve API flexibility.

Adds missing import for Cog class to prevent runtime errors during cog validation.
2025-06-10 16:44:02 -06:00
60a183742a
Updates Python version requirements and refactors hybrid commands
Lowers minimum Python requirement from 3.11 to 3.10 to increase compatibility while updating CI to use Python 3.13 for testing.

Extracts hybrid command functionality into a separate module to improve code organization and reduce complexity in the main commands module.

Updates test timeouts and dependency versions to ensure reliable test execution and modern package compatibility.
2025-06-10 16:39:12 -06:00
153 changed files with 15895 additions and 6136 deletions

View File

@ -4,31 +4,43 @@ on:
push:
branches:
- master
paths:
- '**.py'
- 'requirements.txt'
- 'pyproject.toml'
- 'setup.py'
pull_request:
branches:
- master
paths:
- '**.py'
- 'requirements.txt'
- 'pyproject.toml'
- 'setup.py'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install deps
run: |
python -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
pip install -e .
- name: Run Pyright
run: |
source venv/bin/activate
pyright
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -e .
npm install -g pyright
- name: Run Pyright
run: pyright
- name: Run tests
run: |
pytest tests/
- name: Run Tests
run: |
source venv/bin/activate
pytest tests/

53
.github/workflows/docs.yml vendored Normal file
View File

@ -0,0 +1,53 @@
name: Deploy MkDocs
on:
push:
paths:
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/docs.yml'
branches:
- master
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
default: 'production'
debug:
description: 'Enable debug mode'
required: false
default: 'false'
jobs:
deploy:
runs-on: self-hosted
permissions:
contents: write
steps:
- name: Checkout source
uses: actions/checkout@v4
- name: Set up Python
run: |
python -m venv venv
source venv/bin/activate
pip install --upgrade pip
- name: Install dependencies
run: |
source venv/bin/activate
pip install mkdocs mkdocs-material
- name: Configure Git author from GitHub Actions metadata
run: |
git config --global user.name "${{ github.actor }}"
git config --global user.email "${{ github.actor }}@users.noreply.github.com"
- name: Deploy docs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
source venv/bin/activate
mkdocs gh-deploy --force --clean

View File

@ -3,11 +3,12 @@ name: Mirror to Gitea
on:
push:
branches:
- master # or change to your default branch
- master
jobs:
mirror:
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- name: Checkout repo
uses: actions/checkout@v3
@ -23,5 +24,7 @@ jobs:
env:
MIRROR_PAT: ${{ secrets.MIRROR_PAT }}
run: |
git remote add mirror https://slipstream:${MIRROR_PAT}@git.slipstreamm.dev/slipstream/disagreement.git
if ! git remote | grep -q "^mirror$"; then
git remote add mirror https://slipstream:${MIRROR_PAT}@git.slipstreamm.dev/slipstream/disagreement.git
fi
git push --mirror mirror

View File

@ -3,14 +3,14 @@ name: Publish to PyPI
on:
push:
tags:
- 'v*' # only trigger on version tags like v1.0.0
- 'v*'
jobs:
build-and-publish:
runs-on: ubuntu-latest
runs-on: self-hosted
permissions:
id-token: write # required for trusted publishing, if used
id-token: write
contents: read
steps:
@ -27,17 +27,19 @@ jobs:
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
run: |
python -m venv venv
source venv/bin/activate
pip install --upgrade pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
source venv/bin/activate
pip install build twine
- name: Build package
run: |
source venv/bin/activate
python -m build
- name: Publish to PyPI
@ -45,4 +47,5 @@ jobs:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: |
source venv/bin/activate
python -m twine upload dist/*

View File

@ -1,3 +1,4 @@
graft docs
graft examples
include LICENSE
include LICENSE
include disagreement/py.typed

View File

@ -4,11 +4,17 @@ A Python library for interacting with the Discord API, with a focus on bot devel
## Features
- Internationalization helpers
- Hybrid context for commands
- Built-in rate limiting
- Asynchronous design using `aiohttp`
- Gateway and HTTP API clients
- Slash command framework
- Message component helpers
- `Message.jump_url` property for quick links to messages
- Built-in caching layer
- `Guild.me` property to access the bot's member object
- Easy CDN asset handling via the `Asset` model
- Experimental voice support
- Helpful error handling utilities
@ -21,7 +27,14 @@ pip install disagreement
pip install -e .
```
Requires Python 3.11 or newer.
Requires Python 3.10 or newer.
To run the example scripts, you'll need the `python-dotenv` package to load
environment variables. Install the development extras with:
```bash
pip install "disagreement[dev]"
```
## Basic Usage
@ -31,6 +44,8 @@ import os
import disagreement
from disagreement.ext import commands
from dotenv import load_dotenv
load_dotenv()
class Basics(commands.Cog):
@ -39,23 +54,18 @@ class Basics(commands.Cog):
@commands.command()
async def ping(self, ctx: commands.CommandContext) -> None:
await ctx.reply("Pong!")
await ctx.reply(f"Pong! Gateway Latency: {self.client.latency_ms} ms.")
token = os.getenv("DISCORD_BOT_TOKEN")
if not token:
raise RuntimeError("DISCORD_BOT_TOKEN environment variable not set")
client = disagreement.Client(token=token, command_prefix="!")
intents = disagreement.GatewayIntent.default() | disagreement.GatewayIntent.MESSAGE_CONTENT
client = disagreement.Client(token=token, command_prefix="!", intents=intents, mention_replies=True)
client.add_cog(Basics(client))
async def main() -> None:
await client.run()
if __name__ == "__main__":
asyncio.run(main())
client.run()
```
### Global Error Handling
@ -86,6 +96,52 @@ setup_logging(logging.INFO)
setup_logging(logging.DEBUG, file="bot.log")
```
### HTTP Session Options
Pass additional keyword arguments to ``aiohttp.ClientSession`` using the
``http_options`` parameter when constructing :class:`disagreement.Client`:
```python
client = disagreement.Client(
token=token,
http_options={"proxy": "http://localhost:8080"},
)
```
These options are forwarded to ``HTTPClient`` when it creates the underlying
``aiohttp.ClientSession``. You can specify a custom ``connector`` or any other
session parameter supported by ``aiohttp``.
### Logging Out
Call ``Client.logout`` to disconnect from the Gateway and clear the current bot token while keeping the HTTP session alive. Assign a new token and call ``connect`` or ``run`` to log back in.
### Default Allowed Mentions
Specify default mention behaviour for all outgoing messages when constructing the client:
```python
from disagreement.models import AllowedMentions
client = disagreement.Client(
token=token,
allowed_mentions=AllowedMentions.none().to_dict(),
)
```
This dictionary is used whenever ``send_message`` or helpers like ``Message.reply``
are called without an explicit ``allowed_mentions`` argument.
### Working With Assets
Properties like ``User.avatar`` and ``Guild.icon`` return :class:`disagreement.Asset` objects.
Use ``read`` to get the bytes or ``save`` to write them to disk.
```python
user = await client.fetch_user(123)
data = await user.avatar.read()
await user.avatar.save("avatar.png")
```
### Defining Subcommands with `AppCommandGroup`
```python
@ -104,6 +160,7 @@ async def show(ctx: AppCommandContext, key: str):
@slash_command(name="set", description="Update a setting.", parent=admin_group)
async def set_setting(ctx: AppCommandContext, key: str, value: str):
...
```
## Fetching Guilds
Use `Client.fetch_guild` to retrieve a guild from the Discord API if it
@ -115,6 +172,14 @@ guild = await client.fetch_guild("123456789012345678")
roles = await client.fetch_roles(guild.id)
```
Call `Client.fetch_guilds` to list all guilds the current user has access to.
```python
guilds = await client.fetch_guilds()
for g in guilds:
print(g.name)
```
## Sharding
To run your bot across multiple gateway shards, pass ``shard_count`` when creating

View File

@ -1,5 +1,3 @@
# disagreement/__init__.py
"""
Disagreement
~~~~~~~~~~~~
@ -14,10 +12,35 @@ __title__ = "disagreement"
__author__ = "Slipstream"
__license__ = "BSD 3-Clause License"
__copyright__ = "Copyright 2025 Slipstream"
__version__ = "0.1.0rc1"
__version__ = "0.8.1"
from .client import Client, AutoShardedClient
from .models import Message, User, Reaction
from .asset import Asset
from .models import (
Message,
User,
Reaction,
AuditLogEntry,
Member,
Role,
Attachment,
Channel,
ActionRow,
Button,
SelectOption,
SelectMenu,
Embed,
PartialEmoji,
Section,
TextDisplay,
Thumbnail,
UnfurledMediaItem,
MediaGallery,
MediaGalleryItem,
Container,
Guild,
)
from .object import Object
from .voice_client import VoiceClient
from .audio import AudioSource, FFmpegAudioSource
from .typing import Typing
@ -30,12 +53,184 @@ from .errors import (
NotFound,
)
from .color import Color
from .utils import utcnow
from .enums import GatewayIntent, GatewayOpcode # Export enums
from .utils import (
utcnow,
message_pager,
get,
find,
escape_markdown,
escape_mentions,
snowflake_time,
)
from .enums import (
GatewayIntent,
GatewayOpcode,
ButtonStyle,
ChannelType,
MessageFlags,
InteractionType,
InteractionCallbackType,
ComponentType,
)
from .error_handler import setup_global_error_handler
from .hybrid_context import HybridContext
from .ext import tasks
from .interactions import Interaction
from .logging_config import setup_logging
from . import ui, ext
from .ext.app_commands import (
AppCommand,
AppCommandContext,
AppCommandGroup,
MessageCommand,
OptionMetadata,
SlashCommand,
UserCommand,
group,
hybrid_command,
message_command,
slash_command,
subcommand,
subcommand_group,
)
from .ext.commands import (
BadArgument,
CheckAnyFailure,
CheckFailure,
Cog,
Command,
CommandContext,
CommandError,
CommandInvokeError,
CommandNotFound,
CommandOnCooldown,
MaxConcurrencyReached,
MissingRequiredArgument,
ArgumentParsingError,
check,
check_any,
command,
cooldown,
has_any_role,
is_owner,
has_role,
listener,
max_concurrency,
requires_permissions,
)
from .ext.tasks import Task, loop
from .ui import Item, Modal, Select, TextInput, View, button, select, text_input
# Set up logging if desired
# import logging
# logging.getLogger(__name__).addHandler(logging.NullHandler())
import logging
__all__ = [
"Client",
"AutoShardedClient",
"Asset",
"Message",
"User",
"Reaction",
"AuditLogEntry",
"Member",
"Role",
"Attachment",
"Channel",
"ActionRow",
"Button",
"SelectOption",
"SelectMenu",
"Embed",
"PartialEmoji",
"Section",
"TextDisplay",
"Thumbnail",
"UnfurledMediaItem",
"MediaGallery",
"MediaGalleryItem",
"Container",
"Object",
"VoiceClient",
"AudioSource",
"FFmpegAudioSource",
"Typing",
"DisagreementException",
"HTTPException",
"GatewayException",
"AuthenticationError",
"Forbidden",
"NotFound",
"Color",
"utcnow",
"escape_markdown",
"escape_mentions",
"message_pager",
"get",
"find",
"snowflake_time",
"GatewayIntent",
"GatewayOpcode",
"ButtonStyle",
"ChannelType",
"MessageFlags",
"InteractionType",
"InteractionCallbackType",
"ComponentType",
"setup_global_error_handler",
"HybridContext",
"Interaction",
"setup_logging",
"ui",
"ext",
"AppCommand",
"AppCommandContext",
"AppCommandGroup",
"MessageCommand",
"OptionMetadata",
"SlashCommand",
"UserCommand",
"group",
"hybrid_command",
"message_command",
"slash_command",
"subcommand",
"subcommand_group",
"BadArgument",
"CheckAnyFailure",
"CheckFailure",
"Cog",
"Command",
"CommandContext",
"CommandError",
"CommandInvokeError",
"CommandNotFound",
"CommandOnCooldown",
"MaxConcurrencyReached",
"MissingRequiredArgument",
"ArgumentParsingError",
"check",
"check_any",
"command",
"cooldown",
"has_any_role",
"is_owner",
"has_role",
"listener",
"max_concurrency",
"requires_permissions",
"Task",
"loop",
"Item",
"Modal",
"Select",
"TextInput",
"View",
"button",
"select",
"text_input",
]
# Configure a default logger if none has been configured yet
if not logging.getLogger().hasHandlers():
setup_logging(logging.INFO)

51
disagreement/asset.py Normal file
View File

@ -0,0 +1,51 @@
"""Utility class for Discord CDN assets."""
from __future__ import annotations
import os
from typing import IO, Optional, Union, TYPE_CHECKING
import aiohttp # pylint: disable=import-error
if TYPE_CHECKING:
from .client import Client
class Asset:
"""Represents a CDN asset such as an avatar or icon."""
def __init__(self, url: str, client_instance: Optional["Client"] = None) -> None:
self.url = url
self._client = client_instance
async def read(self) -> bytes:
"""Read the asset's bytes."""
session: Optional[aiohttp.ClientSession] = None
if self._client is not None:
await self._client._http._ensure_session() # type: ignore[attr-defined]
session = self._client._http._session # type: ignore[attr-defined]
if session is None:
session = aiohttp.ClientSession()
close = True
else:
close = False
async with session.get(self.url) as resp:
data = await resp.read()
if close:
await session.close()
return data
async def save(self, fp: Union[str, os.PathLike[str], IO[bytes]]) -> None:
"""Save the asset to the given file path or file-like object."""
data = await self.read()
if isinstance(fp, (str, os.PathLike)):
path = os.fspath(fp)
with open(path, "wb") as file:
file.write(data)
else:
fp.write(data)
def __repr__(self) -> str:
return f"<Asset url='{self.url}'>"

View File

@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import contextlib
import io
import shlex
from typing import Optional, Union
@ -35,15 +36,27 @@ class FFmpegAudioSource(AudioSource):
A filename, URL, or file-like object to read from.
"""
def __init__(self, source: Union[str, io.BufferedIOBase]):
def __init__(
self,
source: Union[str, io.BufferedIOBase],
*,
before_options: Optional[str] = None,
options: Optional[str] = None,
volume: float = 1.0,
):
self.source = source
self.before_options = before_options
self.options = options
self.volume = volume
self.process: Optional[asyncio.subprocess.Process] = None
self._feeder: Optional[asyncio.Task] = None
async def _spawn(self) -> None:
if isinstance(self.source, str):
args = [
"ffmpeg",
args = ["ffmpeg"]
if self.before_options:
args += shlex.split(self.before_options)
args += [
"-i",
self.source,
"-f",
@ -54,14 +67,18 @@ class FFmpegAudioSource(AudioSource):
"2",
"pipe:1",
]
if self.options:
args += shlex.split(self.options)
self.process = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
else:
args = [
"ffmpeg",
args = ["ffmpeg"]
if self.before_options:
args += shlex.split(self.before_options)
args += [
"-i",
"pipe:0",
"-f",
@ -72,6 +89,8 @@ class FFmpegAudioSource(AudioSource):
"2",
"pipe:1",
]
if self.options:
args += shlex.split(self.options)
self.process = await asyncio.create_subprocess_exec(
*args,
stdin=asyncio.subprocess.PIPE,
@ -114,3 +133,21 @@ class FFmpegAudioSource(AudioSource):
if isinstance(self.source, io.IOBase):
with contextlib.suppress(Exception):
self.source.close()
class AudioSink:
"""Abstract base class for audio sinks."""
def write(self, user, data):
"""Write a chunk of PCM audio.
Subclasses must implement this. The data is raw PCM at 48kHz
stereo.
"""
raise NotImplementedError
def close(self) -> None:
"""Cleanup the sink when the voice client disconnects."""
return None

View File

@ -1,24 +1,33 @@
from __future__ import annotations
import time
from typing import TYPE_CHECKING, Dict, Generic, Optional, TypeVar
from typing import TYPE_CHECKING, Callable, Dict, Generic, Optional, TypeVar
from collections import OrderedDict
if TYPE_CHECKING:
from .models import Channel, Guild
from .models import Channel, Guild, Member
from .caching import MemberCacheFlags
T = TypeVar("T")
class Cache(Generic[T]):
"""Simple in-memory cache with optional TTL support."""
"""Simple in-memory cache with optional TTL and max size support."""
def __init__(self, ttl: Optional[float] = None) -> None:
def __init__(
self, ttl: Optional[float] = None, maxlen: Optional[int] = None
) -> None:
self.ttl = ttl
self._data: Dict[str, tuple[T, Optional[float]]] = {}
self.maxlen = maxlen
self._data: "OrderedDict[str, tuple[T, Optional[float]]]" = OrderedDict()
def set(self, key: str, value: T) -> None:
expiry = time.monotonic() + self.ttl if self.ttl is not None else None
if key in self._data:
self._data.move_to_end(key)
self._data[key] = (value, expiry)
if self.maxlen is not None and len(self._data) > self.maxlen:
self._data.popitem(last=False)
def get(self, key: str) -> Optional[T]:
item = self._data.get(key)
@ -28,6 +37,15 @@ class Cache(Generic[T]):
if expiry is not None and expiry < time.monotonic():
self.invalidate(key)
return None
self._data.move_to_end(key)
return value
def get_or_fetch(self, key: str, fetch_fn: Callable[[], T]) -> T:
"""Return a cached item or fetch and store it if missing."""
value = self.get(key)
if value is None:
value = fetch_fn()
self.set(key, value)
return value
def invalidate(self, key: str) -> None:
@ -53,3 +71,32 @@ class GuildCache(Cache["Guild"]):
class ChannelCache(Cache["Channel"]):
"""Cache specifically for :class:`Channel` objects."""
class MemberCache(Cache["Member"]):
"""
A cache for :class:`Member` objects that respects :class:`MemberCacheFlags`.
"""
def __init__(self, flags: MemberCacheFlags, ttl: Optional[float] = None) -> None:
super().__init__(ttl)
self.flags = flags
def _should_cache(self, member: Member) -> bool:
"""Determines if a member should be cached based on the flags."""
if self.flags.all_enabled:
return True
if self.flags.no_flags:
return False
if self.flags.online and member.status != "offline":
return True
if self.flags.voice and member.voice_state is not None:
return True
if self.flags.joined and getattr(member, "_just_joined", False):
return True
return False
def set(self, key: str, value: Member) -> None:
if self._should_cache(value):
super().set(key, value)

129
disagreement/caching.py Normal file
View File

@ -0,0 +1,129 @@
from __future__ import annotations
import operator
from typing import Any, Callable, ClassVar, Dict, Iterator, Tuple
class _MemberCacheFlagValue:
flag: int
def __init__(self, func: Callable[[Any], bool]):
self.flag = getattr(func, "flag", 0)
self.__doc__ = func.__doc__
def __get__(self, instance: "MemberCacheFlags", owner: type) -> Any:
if instance is None:
return self
return instance.value & self.flag != 0
def __set__(self, instance: Any, value: bool) -> None:
if value:
instance.value |= self.flag
else:
instance.value &= ~self.flag
def __repr__(self) -> str:
return f"<{self.__class__.__name__} flag={self.flag}>"
def flag_value(flag: int) -> Callable[[Callable[[Any], bool]], _MemberCacheFlagValue]:
def decorator(func: Callable[[Any], bool]) -> _MemberCacheFlagValue:
setattr(func, "flag", flag)
return _MemberCacheFlagValue(func)
return decorator
class MemberCacheFlags:
__slots__ = ("value",)
VALID_FLAGS: ClassVar[Dict[str, int]] = {
"joined": 1 << 0,
"voice": 1 << 1,
"online": 1 << 2,
}
DEFAULT_FLAGS: ClassVar[int] = 1 | 2 | 4
ALL_FLAGS: ClassVar[int] = sum(VALID_FLAGS.values())
def __init__(self, **kwargs: bool):
self.value = self.DEFAULT_FLAGS
for key, value in kwargs.items():
if key not in self.VALID_FLAGS:
raise TypeError(f"{key!r} is not a valid member cache flag.")
setattr(self, key, value)
@classmethod
def _from_value(cls, value: int) -> MemberCacheFlags:
self = cls.__new__(cls)
self.value = value
return self
def __eq__(self, other: object) -> bool:
return isinstance(other, MemberCacheFlags) and self.value == other.value
def __ne__(self, other: object) -> bool:
return not self.__eq__(other)
def __hash__(self) -> int:
return hash(self.value)
def __repr__(self) -> str:
return f"<MemberCacheFlags value={self.value}>"
def __iter__(self) -> Iterator[Tuple[str, bool]]:
for name in self.VALID_FLAGS:
yield name, getattr(self, name)
@property
def all_enabled(self) -> bool:
return self.value == self.ALL_FLAGS
@property
def no_flags(self) -> bool:
return self.value == 0
def __int__(self) -> int:
return self.value
def __index__(self) -> int:
return self.value
@classmethod
def all(cls) -> MemberCacheFlags:
"""A factory method that creates a :class:`MemberCacheFlags` with all flags enabled."""
return cls._from_value(cls.ALL_FLAGS)
@classmethod
def none(cls) -> MemberCacheFlags:
"""A factory method that creates a :class:`MemberCacheFlags` with all flags disabled."""
return cls._from_value(0)
@classmethod
def only_joined(cls) -> MemberCacheFlags:
"""A factory method that creates a :class:`MemberCacheFlags` with only the `joined` flag enabled."""
return cls._from_value(cls.VALID_FLAGS["joined"])
@classmethod
def only_voice(cls) -> MemberCacheFlags:
"""A factory method that creates a :class:`MemberCacheFlags` with only the `voice` flag enabled."""
return cls._from_value(cls.VALID_FLAGS["voice"])
@classmethod
def only_online(cls) -> MemberCacheFlags:
"""A factory method that creates a :class:`MemberCacheFlags` with only the `online` flag enabled."""
return cls._from_value(cls.VALID_FLAGS["online"])
@flag_value(1 << 0)
def joined(self) -> bool:
"""Whether to cache members that have just joined the guild."""
return False
@flag_value(1 << 1)
def voice(self) -> bool:
"""Whether to cache members that are in a voice channel."""
return False
@flag_value(1 << 2)
def online(self) -> bool:
"""Whether to cache members that are online."""
return False

File diff suppressed because it is too large Load Diff

View File

@ -46,5 +46,132 @@ class Color:
def blue(cls) -> "Color":
return cls(0x0000FF)
# Discord brand colors
@classmethod
def blurple(cls) -> "Color":
"""Discord brand blurple (#5865F2)."""
return cls(0x5865F2)
@classmethod
def light_blurple(cls) -> "Color":
"""Light blurple used by Discord (#E0E3FF)."""
return cls(0xE0E3FF)
@classmethod
def legacy_blurple(cls) -> "Color":
"""Legacy Discord blurple (#7289DA)."""
return cls(0x7289DA)
# Additional assorted colors
@classmethod
def teal(cls) -> "Color":
return cls(0x1ABC9C)
@classmethod
def dark_teal(cls) -> "Color":
return cls(0x11806A)
@classmethod
def brand_green(cls) -> "Color":
return cls(0x57F287)
@classmethod
def dark_green(cls) -> "Color":
return cls(0x206694)
@classmethod
def orange(cls) -> "Color":
return cls(0xE67E22)
@classmethod
def dark_orange(cls) -> "Color":
return cls(0xA84300)
@classmethod
def brand_red(cls) -> "Color":
return cls(0xED4245)
@classmethod
def dark_red(cls) -> "Color":
return cls(0x992D22)
@classmethod
def magenta(cls) -> "Color":
return cls(0xE91E63)
@classmethod
def dark_magenta(cls) -> "Color":
return cls(0xAD1457)
@classmethod
def purple(cls) -> "Color":
return cls(0x9B59B6)
@classmethod
def dark_purple(cls) -> "Color":
return cls(0x71368A)
@classmethod
def yellow(cls) -> "Color":
return cls(0xF1C40F)
@classmethod
def dark_gold(cls) -> "Color":
return cls(0xC27C0E)
@classmethod
def light_gray(cls) -> "Color":
return cls(0x99AAB5)
@classmethod
def dark_gray(cls) -> "Color":
return cls(0x2C2F33)
@classmethod
def lighter_gray(cls) -> "Color":
return cls(0xBFBFBF)
@classmethod
def darker_gray(cls) -> "Color":
return cls(0x23272A)
@classmethod
def black(cls) -> "Color":
return cls(0x000000)
@classmethod
def white(cls) -> "Color":
return cls(0xFFFFFF)
def to_rgb(self) -> tuple[int, int, int]:
return ((self.value >> 16) & 0xFF, (self.value >> 8) & 0xFF, self.value & 0xFF)
@classmethod
def parse(
cls, value: "Color | int | str | tuple[int, int, int] | None"
) -> "Color | None":
"""Convert ``value`` to a :class:`Color` instance.
Parameters
----------
value:
The value to convert. May be ``None``, an existing ``Color``, an
integer in the ``0xRRGGBB`` format, or a hex string like ``"#RRGGBB"``.
Returns
-------
Optional[Color]
A ``Color`` object if ``value`` is not ``None``.
"""
if value is None:
return None
if isinstance(value, cls):
return value
if isinstance(value, int):
return cls(value)
if isinstance(value, str):
return cls.from_hex(value)
if isinstance(value, tuple) and len(value) == 3:
return cls.from_rgb(*value)
raise TypeError("Color value must be Color, int, str, tuple, or None")

View File

@ -1,10 +1,8 @@
# disagreement/enums.py
"""
Enums for Discord constants.
"""
from enum import IntEnum, Enum # Import Enum
from enum import IntEnum, Enum
class GatewayOpcode(IntEnum):
@ -49,6 +47,11 @@ class GatewayIntent(IntEnum):
AUTO_MODERATION_CONFIGURATION = 1 << 20
AUTO_MODERATION_EXECUTION = 1 << 21
@classmethod
def none(cls) -> int:
"""Return a bitmask representing no intents."""
return 0
@classmethod
def default(cls) -> int:
"""Returns default intents (excluding privileged ones like members, presences, message content)."""
@ -265,12 +268,80 @@ class GuildFeature(str, Enum): # Changed from IntEnum to Enum
VERIFIED = "VERIFIED"
VIP_REGIONS = "VIP_REGIONS"
WELCOME_SCREEN_ENABLED = "WELCOME_SCREEN_ENABLED"
SOUNDBOARD = "SOUNDBOARD"
VIDEO_QUALITY_720_60FPS = "VIDEO_QUALITY_720_60FPS"
# Add more as they become known or needed
# This allows GuildFeature("UNKNOWN_FEATURE_STRING") to work
@classmethod
def _missing_(cls, value): # type: ignore
return str(value)
member = object.__new__(cls)
member._name_ = str(value)
member._value_ = str(value)
cls._value2member_map_[member._value_] = member # pylint: disable=no-member
cls._member_map_[member._name_] = member # pylint: disable=no-member
return member
# --- Guild Scheduled Event Enums ---
class GuildScheduledEventPrivacyLevel(IntEnum):
"""Privacy level for a scheduled event."""
GUILD_ONLY = 2
class GuildScheduledEventStatus(IntEnum):
"""Status of a scheduled event."""
SCHEDULED = 1
ACTIVE = 2
COMPLETED = 3
CANCELED = 4
class GuildScheduledEventEntityType(IntEnum):
"""Entity type for a scheduled event."""
STAGE_INSTANCE = 1
VOICE = 2
EXTERNAL = 3
class VoiceRegion(str, Enum):
"""Voice region identifier."""
AMSTERDAM = "amsterdam"
BRAZIL = "brazil"
DUBAI = "dubai"
EU_CENTRAL = "eu-central"
EU_WEST = "eu-west"
EUROPE = "europe"
FRANKFURT = "frankfurt"
HONGKONG = "hongkong"
INDIA = "india"
JAPAN = "japan"
RUSSIA = "russia"
SINGAPORE = "singapore"
SOUTHAFRICA = "southafrica"
SOUTH_KOREA = "south-korea"
SYDNEY = "sydney"
US_CENTRAL = "us-central"
US_EAST = "us-east"
US_SOUTH = "us-south"
US_WEST = "us-west"
VIP_US_EAST = "vip-us-east"
VIP_US_WEST = "vip-us-west"
@classmethod
def _missing_(cls, value): # type: ignore
member = object.__new__(cls)
member._name_ = str(value)
member._value_ = str(value)
cls._value2member_map_[member._value_] = member # pylint: disable=no-member
cls._member_map_[member._name_] = member # pylint: disable=no-member
return member
# --- Channel Enums ---
@ -300,6 +371,13 @@ class ChannelType(IntEnum):
GUILD_MEDIA = 16 # (Still in development) a channel that can only contain media
class StageInstancePrivacyLevel(IntEnum):
"""Privacy level of a stage instance."""
PUBLIC = 1
GUILD_ONLY = 2
class OverwriteType(IntEnum):
"""Type of target for a permission overwrite."""
@ -307,6 +385,15 @@ class OverwriteType(IntEnum):
MEMBER = 1
class AutoArchiveDuration(IntEnum):
"""Thread auto-archive duration in minutes."""
HOUR = 60
DAY = 1440
THREE_DAYS = 4320
WEEK = 10080
# --- Component Enums ---

View File

@ -14,7 +14,11 @@ def setup_global_error_handler(
The handler logs unhandled exceptions so they don't crash the bot.
"""
if loop is None:
loop = asyncio.get_event_loop()
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
if not logging.getLogger().hasHandlers():
setup_logging(logging.ERROR)

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,3 @@
# disagreement/event_dispatcher.py
"""
Event dispatcher for handling Discord Gateway events.
"""
@ -62,6 +60,12 @@ class EventDispatcher:
"GUILD_BAN_REMOVE": self._parse_guild_ban_remove,
"GUILD_ROLE_UPDATE": self._parse_guild_role_update,
"TYPING_START": self._parse_typing_start,
"VOICE_STATE_UPDATE": self._parse_voice_state_update,
"THREAD_CREATE": self._parse_thread_create,
"THREAD_UPDATE": self._parse_thread_update,
"THREAD_DELETE": self._parse_thread_delete,
"INVITE_CREATE": self._parse_invite_create,
"INVITE_DELETE": self._parse_invite_delete,
}
def _parse_message_create(self, data: Dict[str, Any]) -> Message:
@ -76,7 +80,7 @@ class EventDispatcher:
"""Parses MESSAGE_DELETE and updates message cache."""
message_id = data.get("id")
if message_id:
self._client._messages.pop(message_id, None)
self._client._messages.invalidate(message_id)
return data
def _parse_message_reaction_raw(self, data: Dict[str, Any]) -> Dict[str, Any]:
@ -113,6 +117,13 @@ class EventDispatcher:
return TypingStart(data, client_instance=self._client)
def _parse_voice_state_update(self, data: Dict[str, Any]):
"""Parses raw VOICE_STATE_UPDATE data into a VoiceStateUpdate object."""
from .models import VoiceStateUpdate
return VoiceStateUpdate(data, client_instance=self._client)
def _parse_message_reaction(self, data: Dict[str, Any]):
"""Parses raw reaction data into a Reaction object."""
@ -124,7 +135,7 @@ class EventDispatcher:
"""Parses GUILD_MEMBER_ADD into a Member object."""
guild_id = str(data.get("guild_id"))
return self._client.parse_member(data, guild_id)
return self._client.parse_member(data, guild_id, just_joined=True)
def _parse_guild_member_remove(self, data: Dict[str, Any]):
"""Parses GUILD_MEMBER_REMOVE into a GuildMemberRemove model."""
@ -159,6 +170,43 @@ class EventDispatcher:
return GuildRoleUpdate(data, client_instance=self._client)
def _parse_thread_create(self, data: Dict[str, Any]):
"""Parses THREAD_CREATE into a Thread object and updates caches."""
return self._client.parse_channel(data)
def _parse_thread_update(self, data: Dict[str, Any]):
"""Parses THREAD_UPDATE into a Thread object."""
return self._client.parse_channel(data)
def _parse_thread_delete(self, data: Dict[str, Any]):
"""Parses THREAD_DELETE, removing the thread from caches."""
thread = self._client.parse_channel(data)
thread_id = data.get("id")
if thread_id:
self._client._channels.invalidate(thread_id)
guild_id = data.get("guild_id")
if guild_id:
guild = self._client._guilds.get(guild_id)
if guild:
guild._channels.invalidate(thread_id)
guild._threads.pop(thread_id, None)
return thread
def _parse_invite_create(self, data: Dict[str, Any]):
"""Parses INVITE_CREATE into an Invite object."""
return self._client.parse_invite(data)
def _parse_invite_delete(self, data: Dict[str, Any]):
"""Parses INVITE_DELETE into an InviteDelete model."""
from .models import InviteDelete
return InviteDelete(data)
# Potentially add _parse_user for events that directly provide a full user object
# def _parse_user_update(self, data: Dict[str, Any]) -> User:
# return User(data=data)
@ -198,7 +246,7 @@ class EventDispatcher:
try:
self._listeners[event_name_upper].remove(coro)
except ValueError:
pass # Listener not in list
pass
def add_waiter(
self,

View File

@ -0,0 +1,3 @@
from . import app_commands, commands, tasks
__all__ = ["app_commands", "commands", "tasks"]

View File

@ -1,5 +1,3 @@
# disagreement/ext/app_commands/__init__.py
"""
Application Commands Extension for Disagreement.
@ -44,3 +42,5 @@ __all__ = [
"OptionMetadata",
"AppCommandContext", # To be defined
]
from .hybrid import *

View File

@ -1,58 +1,23 @@
# disagreement/ext/app_commands/commands.py
import inspect
from typing import Callable, Optional, List, Dict, Any, Union, TYPE_CHECKING
from typing import Any, Callable, Dict, List, Optional, Union, TYPE_CHECKING
from disagreement.enums import (
ApplicationCommandType,
ApplicationCommandOptionType,
IntegrationType,
InteractionContextType,
)
from disagreement.interactions import ApplicationCommandOption, Snowflake
if TYPE_CHECKING:
from disagreement.ext.commands.core import (
Command as PrefixCommand,
) # Alias to avoid name clash
from disagreement.interactions import ApplicationCommandOption, Snowflake
from disagreement.enums import (
ApplicationCommandType,
IntegrationType,
InteractionContextType,
ApplicationCommandOptionType, # Added
)
from disagreement.ext.commands.cog import Cog # Corrected import path
from disagreement.ext.commands.core import Command as PrefixCommand
from disagreement.ext.commands.cog import Cog
# Placeholder for Cog if not using the existing one or if it needs adaptation
if not TYPE_CHECKING:
# This dynamic Cog = Any might not be ideal if Cog is used in runtime type checks.
# However, for type hinting purposes when TYPE_CHECKING is false, it avoids import.
# If Cog is needed at runtime by this module (it is, for AppCommand.cog type hint),
# it should be imported directly.
# For now, the TYPE_CHECKING block handles the proper import for static analysis.
# Let's ensure Cog is available at runtime if AppCommand.cog is accessed.
# A simple way is to import it outside TYPE_CHECKING too, if it doesn't cause circularity.
# Given its usage, a forward reference string 'Cog' might be better in AppCommand.cog type hint.
# Let's try importing it directly for runtime, assuming no circularity with this specific module.
try:
from disagreement.ext.commands.cog import Cog
except ImportError:
Cog = Any # Fallback if direct import fails (e.g. during partial builds/tests)
# Import PrefixCommand at runtime for HybridCommand
try:
from disagreement.ext.commands.core import Command as PrefixCommand
except Exception: # pragma: no cover - safeguard against unusual import issues
PrefixCommand = Any # type: ignore
# Import enums used at runtime
try:
from disagreement.enums import (
ApplicationCommandType,
IntegrationType,
InteractionContextType,
ApplicationCommandOptionType,
)
from disagreement.interactions import ApplicationCommandOption, Snowflake
except Exception: # pragma: no cover
ApplicationCommandType = ApplicationCommandOptionType = IntegrationType = (
InteractionContextType
) = Any # type: ignore
ApplicationCommandOption = Snowflake = Any # type: ignore
else: # When TYPE_CHECKING is true, Cog and PrefixCommand are already imported above.
pass
except ImportError:
PrefixCommand = Any
class AppCommand:
@ -235,61 +200,6 @@ class MessageCommand(AppCommand):
super().__init__(callback, type=ApplicationCommandType.MESSAGE, **kwargs)
class HybridCommand(SlashCommand, PrefixCommand): # Inherit from both
"""
Represents a command that can be invoked as both a slash command
and a traditional prefix-based command.
"""
def __init__(self, callback: Callable[..., Any], **kwargs: Any):
# Initialize SlashCommand part (which calls AppCommand.__init__)
# We need to ensure 'type' is correctly passed for AppCommand
# kwargs for SlashCommand: name, description, guild_ids, default_member_permissions, nsfw, parent, cog, etc.
# kwargs for PrefixCommand: name, aliases, brief, description, cog
# Pop prefix-specific args before passing to SlashCommand constructor
prefix_aliases = kwargs.pop("aliases", [])
prefix_brief = kwargs.pop("brief", None)
# Description is used by both, AppCommand's constructor will handle it.
# Name is used by both. Cog is used by both.
# Call SlashCommand's __init__
# This will set up name, description, callback, type=CHAT_INPUT, options, etc.
super().__init__(callback, **kwargs) # This is SlashCommand.__init__
# Now, explicitly initialize the PrefixCommand parts that SlashCommand didn't cover
# or that need specific values for the prefix version.
# PrefixCommand.__init__(self, callback, name=self.name, aliases=prefix_aliases, brief=prefix_brief, description=self.description, cog=self.cog)
# However, PrefixCommand.__init__ also sets self.params, which AppCommand already did.
# We need to be careful not to re-initialize things unnecessarily or incorrectly.
# Let's manually set the distinct attributes for the PrefixCommand aspect.
# Attributes from PrefixCommand:
# self.callback is already set by AppCommand
# self.name is already set by AppCommand
self.aliases: List[str] = (
prefix_aliases # This was specific to HybridCommand before, now aligns with PrefixCommand
)
self.brief: Optional[str] = prefix_brief
# self.description is already set by AppCommand (SlashCommand ensures it exists)
# self.cog is already set by AppCommand
# self.params is already set by AppCommand
# Ensure the MRO is handled correctly. Python's MRO (C3 linearization)
# should call SlashCommand's __init__ then AppCommand's __init__.
# PrefixCommand.__init__ won't be called automatically unless we explicitly call it.
# By setting attributes directly, we avoid potential issues with multiple __init__ calls
# if their logic overlaps too much (e.g., both trying to set self.params).
# We might need to override invoke if the context or argument passing differs significantly
# between app command invocation and prefix command invocation.
# For now, SlashCommand.invoke and PrefixCommand.invoke are separate.
# The correct one will be called depending on how the command is dispatched.
# The AppCommandHandler will use AppCommand.invoke (via SlashCommand).
# The prefix CommandHandler will use PrefixCommand.invoke.
# This seems acceptable.
class AppCommandGroup:
"""
Represents a group of application commands (subcommands or subcommand groups).

View File

@ -1,5 +1,3 @@
# disagreement/ext/app_commands/context.py
from __future__ import annotations
from typing import TYPE_CHECKING, Optional, List, Union, Any, Dict
@ -255,6 +253,8 @@ class AppCommandContext:
Optional[Message]: The sent message object if a new message was created and not ephemeral.
None if the response was ephemeral or an edit to a deferred message.
"""
if allowed_mentions is None:
allowed_mentions = getattr(self.bot, "allowed_mentions", None)
if not self._responded and self._deferred: # Editing a deferred response
# Use edit_original_interaction_response
payload: Dict[str, Any] = {}
@ -395,6 +395,9 @@ class AppCommandContext:
"Must acknowledge or defer the interaction before sending a followup."
)
if allowed_mentions is None:
allowed_mentions = getattr(self.bot, "allowed_mentions", None)
payload: Dict[str, Any] = {}
if content is not None:
payload["content"] = content
@ -475,6 +478,9 @@ class AppCommandContext:
"Cannot edit response if interaction hasn't been responded to or deferred."
)
if allowed_mentions is None:
allowed_mentions = getattr(self.bot, "allowed_mentions", None)
payload: Dict[str, Any] = {}
if content is not None:
payload["content"] = content # Use None to clear

View File

@ -1,5 +1,3 @@
# disagreement/ext/app_commands/converters.py
"""
Converters for transforming application command option values.
"""
@ -466,8 +464,8 @@ async def run_converters(
# If no specific converter, and it's not a basic type match, raise error or return raw
# For now, let's raise if no converter found for a specific option type
if option_type in DEFAULT_CONVERTERS: # Should have been handled
pass # This path implies a logic error above or missing converter in DEFAULT_CONVERTERS
if option_type in DEFAULT_CONVERTERS:
pass
# If it's a model type but no converter yet, this will need to be handled
# e.g. if param_type is User and option_type is ApplicationCommandOptionType.USER

View File

@ -1,5 +1,3 @@
# disagreement/ext/app_commands/decorators.py
import inspect
import asyncio
from dataclasses import dataclass
@ -26,8 +24,8 @@ from .commands import (
MessageCommand,
AppCommand,
AppCommandGroup,
HybridCommand,
)
from .hybrid import HybridCommand
from disagreement.interactions import (
ApplicationCommandOption,
ApplicationCommandOptionChoice,
@ -134,7 +132,7 @@ def _extract_options_from_signature(
first_param = next(param_iter, None) # Consume 'self', get next
if first_param and first_param.name == "ctx": # Consume 'ctx'
pass # ctx is handled, now iterate over actual command options
pass
elif (
first_param
): # If first_param was not 'self' and not 'ctx', it's a command option
@ -147,7 +145,7 @@ def _extract_options_from_signature(
if param.kind == param.VAR_POSITIONAL or param.kind == param.VAR_KEYWORD:
# *args and **kwargs are not directly supported by slash command options structure.
# Could raise an error or ignore. For now, ignore.
# print(f"Warning: *args/**kwargs ({param.name}) are not supported for slash command options.")
continue
option_name = param.name
@ -190,7 +188,7 @@ def _extract_options_from_signature(
# More complex Unions are not directly supported by a single option type.
# Could default to STRING or raise.
# For now, let's assume simple Optional[T] or direct types.
# print(f"Warning: Complex Union type for '{option_name}' not fully supported, defaulting to STRING.")
actual_type_for_mapping = str
elif origin is list and len(args) == 1:
@ -198,7 +196,7 @@ def _extract_options_from_signature(
# via repeated options or specific component interactions, not directly in slash command options.
# This might indicate a need for a different interaction pattern or custom parsing.
# For now, treat List[str] as a string, others might error or default.
# print(f"Warning: List type for '{option_name}' not directly supported as a single option. Consider type {args[0]}.")
actual_type_for_mapping = args[
0
] # Use the inner type for mapping, but this is a simplification.
@ -247,7 +245,7 @@ def _extract_options_from_signature(
if not option_type:
# Fallback or error if type couldn't be mapped
# print(f"Warning: Could not map type '{actual_type_for_mapping}' for option '{option_name}'. Defaulting to STRING.")
option_type = ApplicationCommandOptionType.STRING # Default fallback
required = (param.default == inspect.Parameter.empty) and not is_optional

View File

@ -1,6 +1,7 @@
# disagreement/ext/app_commands/handler.py
import inspect
import json
import logging
import os
from typing import (
TYPE_CHECKING,
Dict,
@ -17,51 +18,28 @@ from typing import (
if TYPE_CHECKING:
from disagreement.client import Client
from disagreement.interactions import Interaction, ResolvedData, Snowflake
from disagreement.enums import (
ApplicationCommandType,
ApplicationCommandOptionType,
InteractionType,
)
from .commands import (
AppCommand,
SlashCommand,
UserCommand,
MessageCommand,
AppCommandGroup,
)
from .context import AppCommandContext
from disagreement.models import (
User,
Member,
Role,
Attachment,
Message,
) # For resolved data
# Channel models would also go here
from disagreement.enums import (
ApplicationCommandType,
ApplicationCommandOptionType,
InteractionType,
)
from .commands import (
AppCommand,
SlashCommand,
UserCommand,
MessageCommand,
AppCommandGroup,
)
from .context import AppCommandContext
from disagreement.models import User, Member, Role, Attachment, Message
# Placeholder for models not yet fully defined or imported
if not TYPE_CHECKING:
from disagreement.enums import (
ApplicationCommandType,
ApplicationCommandOptionType,
InteractionType,
)
from .commands import (
AppCommand,
SlashCommand,
UserCommand,
MessageCommand,
AppCommandGroup,
)
from .context import AppCommandContext
Channel = Any
User = Any
Member = Any
Role = Any
Attachment = Any
Channel = Any
Message = Any
logger = logging.getLogger(__name__)
COMMANDS_CACHE_FILE = ".disagreement_commands.json"
class AppCommandHandler:
@ -80,6 +58,33 @@ class AppCommandHandler:
self._app_command_groups: Dict[str, AppCommandGroup] = {}
self._converter_registry: Dict[type, type] = {}
def _load_cached_ids(self) -> Dict[str, Dict[str, str]]:
try:
with open(COMMANDS_CACHE_FILE, "r", encoding="utf-8") as fp:
return json.load(fp)
except FileNotFoundError:
return {}
except json.JSONDecodeError:
logger.warning("Invalid command cache file. Ignoring.")
return {}
def _save_cached_ids(self, data: Dict[str, Dict[str, str]]) -> None:
try:
with open(COMMANDS_CACHE_FILE, "w", encoding="utf-8") as fp:
json.dump(data, fp, indent=2)
except Exception as e: # pragma: no cover - logging only
logger.error("Failed to write command cache: %s", e)
def clear_stored_registrations(self) -> None:
"""Remove persisted command registration data."""
if os.path.exists(COMMANDS_CACHE_FILE):
os.remove(COMMANDS_CACHE_FILE)
def migrate_stored_registrations(self, new_path: str) -> None:
"""Move stored registrations to ``new_path``."""
if os.path.exists(COMMANDS_CACHE_FILE):
os.replace(COMMANDS_CACHE_FILE, new_path)
def add_command(self, command: Union["AppCommand", "AppCommandGroup"]) -> None:
"""Adds an application command or a command group to the handler."""
if isinstance(command, AppCommandGroup):
@ -306,7 +311,7 @@ class AppCommandHandler:
return value.lower() == "true"
return bool(value)
except (ValueError, TypeError):
pass # Conversion failed
pass
return value # Return as is if no specific resolution or conversion applied
async def _resolve_value(
@ -544,7 +549,7 @@ class AppCommandHandler:
await command.invoke(ctx, *parsed_args, **parsed_kwargs)
except Exception as e:
print(f"Error invoking app command '{command.name}': {e}")
logger.error("Error invoking app command '%s': %s", command.name, e)
await self.dispatch_app_command_error(ctx, e)
# else:
# # Default error reply if no handler on client
@ -554,17 +559,26 @@ class AppCommandHandler:
# print(f"Failed to send error message for app command: {send_e}")
async def sync_commands(
self, application_id: "Snowflake", guild_id: Optional["Snowflake"] = None
self,
application_id: Optional["Snowflake"] = None,
guild_id: Optional["Snowflake"] = None,
) -> None:
"""
Synchronizes (registers/updates) all application commands with Discord.
If guild_id is provided, syncs commands for that guild. Otherwise, syncs global commands.
"""
commands_to_sync: List[Dict[str, Any]] = []
if application_id is None:
application_id = self.client.application_id
if application_id is None:
raise ValueError("application_id must be provided to sync commands")
cache = self._load_cached_ids()
scope_key = str(guild_id) if guild_id else "global"
stored = cache.get(scope_key, {})
current_payloads: Dict[str, Dict[str, Any]] = {}
# Collect commands based on scope (global or specific guild)
# This needs to be more sophisticated to handle guild_ids on commands/groups
source_commands = (
list(self._slash_commands.values())
+ list(self._user_commands.values())
@ -573,55 +587,102 @@ class AppCommandHandler:
)
for cmd_or_group in source_commands:
# Determine if this command/group should be synced for the current scope
is_guild_specific_command = (
cmd_or_group.guild_ids is not None and len(cmd_or_group.guild_ids) > 0
)
if guild_id: # Syncing for a specific guild
# Skip if not a guild-specific command OR if it's for a different guild
if guild_id:
if not is_guild_specific_command or (
cmd_or_group.guild_ids is not None
and guild_id not in cmd_or_group.guild_ids
):
continue
else: # Syncing global commands
else:
if is_guild_specific_command:
continue # Skip guild-specific commands when syncing global
continue
# Use the to_dict() method from AppCommand or AppCommandGroup
try:
payload = cmd_or_group.to_dict()
commands_to_sync.append(payload)
current_payloads[cmd_or_group.name] = cmd_or_group.to_dict()
except AttributeError:
print(
f"Warning: Command or group '{cmd_or_group.name}' does not have a to_dict() method. Skipping."
logger.warning(
"Command or group '%s' does not have a to_dict() method. Skipping.",
cmd_or_group.name,
)
except Exception as e:
print(
f"Error converting command/group '{cmd_or_group.name}' to dict: {e}. Skipping."
logger.error(
"Error converting command/group '%s' to dict: %s. Skipping.",
cmd_or_group.name,
e,
)
if not commands_to_sync:
print(
f"No commands to sync for {'guild ' + str(guild_id) if guild_id else 'global'} scope."
if not current_payloads:
logger.info(
"No commands to sync for %s scope.",
f"guild {guild_id}" if guild_id else "global",
)
return
names_current = set(current_payloads)
names_stored = set(stored)
to_delete = names_stored - names_current
to_create = names_current - names_stored
to_update = names_current & names_stored
if not to_delete and not to_create and not to_update:
logger.info(
"Application commands already up to date for %s scope.", scope_key
)
return
try:
if guild_id:
print(
f"Syncing {len(commands_to_sync)} commands for guild {guild_id}..."
)
await self.client._http.bulk_overwrite_guild_application_commands(
application_id, guild_id, commands_to_sync
)
else:
print(f"Syncing {len(commands_to_sync)} global commands...")
await self.client._http.bulk_overwrite_global_application_commands(
application_id, commands_to_sync
)
print("Command sync successful.")
for name in to_delete:
cmd_id = stored[name]
if guild_id:
await self.client._http.delete_guild_application_command(
application_id, guild_id, cmd_id
)
else:
await self.client._http.delete_global_application_command(
application_id, cmd_id
)
new_ids: Dict[str, str] = {}
for name in to_create:
payload = current_payloads[name]
if guild_id:
result = await self.client._http.create_guild_application_command(
application_id, guild_id, payload
)
else:
result = await self.client._http.create_global_application_command(
application_id, payload
)
if result.id:
new_ids[name] = str(result.id)
for name in to_update:
payload = current_payloads[name]
cmd_id = stored[name]
if guild_id:
await self.client._http.edit_guild_application_command(
application_id, guild_id, cmd_id, payload
)
else:
await self.client._http.edit_global_application_command(
application_id, cmd_id, payload
)
new_ids[name] = cmd_id
final_ids: Dict[str, str] = {}
for name in names_current:
if name in new_ids:
final_ids[name] = new_ids[name]
else:
final_ids[name] = stored[name]
cache[scope_key] = final_ids
self._save_cached_ids(cache)
logger.info("Command sync successful.")
except Exception as e:
print(f"Error syncing application commands: {e}")
# Consider re-raising or specific error handling
logger.error("Error syncing application commands: %s", e)

View File

@ -0,0 +1,59 @@
from typing import Any, Callable, List, Optional
from .commands import SlashCommand
from disagreement.ext.commands.core import PrefixCommand
class HybridCommand(SlashCommand, PrefixCommand): # Inherit from both
"""
Represents a command that can be invoked as both a slash command
and a traditional prefix-based command.
"""
def __init__(self, callback: Callable[..., Any], **kwargs: Any):
# Initialize SlashCommand part (which calls AppCommand.__init__)
# We need to ensure 'type' is correctly passed for AppCommand
# kwargs for SlashCommand: name, description, guild_ids, default_member_permissions, nsfw, parent, cog, etc.
# kwargs for PrefixCommand: name, aliases, brief, description, cog
# Pop prefix-specific args before passing to SlashCommand constructor
prefix_aliases = kwargs.pop("aliases", [])
prefix_brief = kwargs.pop("brief", None)
# Description is used by both, AppCommand's constructor will handle it.
# Name is used by both. Cog is used by both.
# Call SlashCommand's __init__
# This will set up name, description, callback, type=CHAT_INPUT, options, etc.
super().__init__(callback, **kwargs) # This is SlashCommand.__init__
# Now, explicitly initialize the PrefixCommand parts that SlashCommand didn't cover
# or that need specific values for the prefix version.
# PrefixCommand.__init__(self, callback, name=self.name, aliases=prefix_aliases, brief=prefix_brief, description=self.description, cog=self.cog)
# However, PrefixCommand.__init__ also sets self.params, which AppCommand already did.
# We need to be careful not to re-initialize things unnecessarily or incorrectly.
# Let's manually set the distinct attributes for the PrefixCommand aspect.
# Attributes from PrefixCommand:
# self.callback is already set by AppCommand
# self.name is already set by AppCommand
self.aliases: List[str] = (
prefix_aliases # This was specific to HybridCommand before, now aligns with PrefixCommand
)
self.brief: Optional[str] = prefix_brief
# self.description is already set by AppCommand (SlashCommand ensures it exists)
# self.cog is already set by AppCommand
# self.params is already set by AppCommand
# Ensure the MRO is handled correctly. Python's MRO (C3 linearization)
# should call SlashCommand's __init__ then AppCommand's __init__.
# PrefixCommand.__init__ won't be called automatically unless we explicitly call it.
# By setting attributes directly, we avoid potential issues with multiple __init__ calls
# if their logic overlaps too much (e.g., both trying to set self.params).
# We might need to override invoke if the context or argument passing differs significantly
# between app command invocation and prefix command invocation.
# For now, SlashCommand.invoke and PrefixCommand.invoke are separate.
# The correct one will be called depending on how the command is dispatched.
# The AppCommandHandler will use AppCommand.invoke (via SlashCommand).
# The prefix CommandHandler will use PrefixCommand.invoke.
# This seems acceptable.

View File

@ -1,57 +1,65 @@
# disagreement/ext/commands/__init__.py
"""
disagreement.ext.commands - A command framework extension for the Disagreement library.
"""
from .cog import Cog
from .core import (
Command,
CommandContext,
CommandHandler,
) # CommandHandler might be internal
from .decorators import (
command,
listener,
check,
check_any,
cooldown,
"""
disagreement.ext.commands - A command framework extension for the Disagreement library.
"""
from .cog import Cog
from .core import (
Command,
CommandContext,
CommandHandler,
) # CommandHandler might be internal
from .decorators import (
command,
listener,
check,
check_any,
cooldown,
max_concurrency,
requires_permissions,
has_role,
has_any_role,
is_owner,
)
from .errors import (
CommandError,
CommandNotFound,
BadArgument,
MissingRequiredArgument,
ArgumentParsingError,
CheckFailure,
CheckAnyFailure,
CommandOnCooldown,
CommandInvokeError,
)
__all__ = [
# Cog
"Cog",
# Core
"Command",
"CommandContext",
# "CommandHandler", # Usually not part of public API for direct use by bot devs
# Decorators
"command",
"listener",
"check",
"check_any",
"cooldown",
"requires_permissions",
from .errors import (
CommandError,
CommandNotFound,
BadArgument,
MissingRequiredArgument,
ArgumentParsingError,
CheckFailure,
CheckAnyFailure,
CommandOnCooldown,
CommandInvokeError,
MaxConcurrencyReached,
)
__all__ = [
# Cog
"Cog",
# Core
"Command",
"CommandContext",
# "CommandHandler", # Usually not part of public API for direct use by bot devs
# Decorators
"command",
"listener",
"check",
"check_any",
"cooldown",
"max_concurrency",
"requires_permissions",
"has_role",
"has_any_role",
"is_owner",
# Errors
"CommandError",
"CommandNotFound",
"BadArgument",
"MissingRequiredArgument",
"ArgumentParsingError",
"CheckFailure",
"CheckAnyFailure",
"CommandOnCooldown",
"CommandInvokeError",
]
"CommandError",
"CommandNotFound",
"BadArgument",
"MissingRequiredArgument",
"ArgumentParsingError",
"CheckFailure",
"CheckAnyFailure",
"CommandOnCooldown",
"CommandInvokeError",
"MaxConcurrencyReached",
]

View File

@ -1,6 +1,5 @@
# disagreement/ext/commands/cog.py
import inspect
import logging
from typing import TYPE_CHECKING, List, Tuple, Callable, Awaitable, Any, Dict, Union
if TYPE_CHECKING:
@ -16,6 +15,8 @@ else: # pragma: no cover - runtime imports for isinstance checks
# EventDispatcher might be needed if cogs register listeners directly
# from disagreement.event_dispatcher import EventDispatcher
logger = logging.getLogger(__name__)
class Cog:
"""
@ -59,8 +60,10 @@ class Cog:
cmd.cog = self # Assign the cog instance to the command
if cmd.name in self._commands:
# This should ideally be caught earlier or handled by CommandHandler
print(
f"Warning: Duplicate command name '{cmd.name}' in cog '{self.cog_name}'. Overwriting."
logger.warning(
"Duplicate command name '%s' in cog '%s'. Overwriting.",
cmd.name,
self.cog_name,
)
self._commands[cmd.name.lower()] = cmd
# Also register aliases
@ -79,8 +82,10 @@ class Cog:
# For AppCommandGroup, its commands will have cog set individually if they are AppCommands
self._app_commands_and_groups.append(app_cmd_obj)
else:
print(
f"Warning: Member '{member_name}' in cog '{self.cog_name}' has '__app_command_object__' but it's not an AppCommand or AppCommandGroup."
logger.warning(
"Member '%s' in cog '%s' has '__app_command_object__' but it's not an AppCommand or AppCommandGroup.",
member_name,
self.cog_name,
)
elif isinstance(member, (AppCommand, AppCommandGroup)):
@ -92,8 +97,10 @@ class Cog:
# This is a method decorated with @commands.Cog.listener or @commands.listener
if not inspect.iscoroutinefunction(member):
# Decorator should have caught this, but double check
print(
f"Warning: Listener '{member_name}' in cog '{self.cog_name}' is not a coroutine. Skipping."
logger.warning(
"Listener '%s' in cog '%s' is not a coroutine. Skipping.",
member_name,
self.cog_name,
)
continue

View File

@ -1,11 +1,21 @@
# disagreement/ext/commands/converters.py
# pyright: reportIncompatibleMethodOverride=false
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar, Generic
from abc import ABC, abstractmethod
import re
import inspect
from .errors import BadArgument
from disagreement.models import Member, Guild, Role
from disagreement.models import (
Member,
Guild,
Role,
User,
TextChannel,
VoiceChannel,
Emoji,
PartialEmoji,
)
if TYPE_CHECKING:
from .core import CommandContext
@ -36,6 +46,20 @@ class Converter(ABC, Generic[T]):
raise NotImplementedError("Converter subclass must implement convert method.")
class Greedy(list):
"""Type hint helper to greedily consume arguments."""
converter: Any = None
def __class_getitem__(cls, param: Any) -> type: # pyright: ignore[override]
if isinstance(param, tuple):
if len(param) != 1:
raise TypeError("Greedy[...] expects a single parameter")
param = param[0]
name = f"Greedy[{getattr(param, '__name__', str(param))}]"
return type(name, (Greedy,), {"converter": param})
# --- Built-in Type Converters ---
@ -128,6 +152,97 @@ class GuildConverter(Converter["Guild"]):
raise BadArgument(f"Guild '{argument}' not found.")
class UserConverter(Converter["User"]):
async def convert(self, ctx: "CommandContext", argument: str) -> "User":
match = re.match(r"<@!?(\d+)>$", argument)
user_id = match.group(1) if match else argument
user = ctx.bot._users.get(user_id)
if user:
return user
user = await ctx.bot.fetch_user(user_id)
if user:
return user
raise BadArgument(f"User '{argument}' not found.")
class TextChannelConverter(Converter["TextChannel"]):
async def convert(self, ctx: "CommandContext", argument: str) -> "TextChannel":
if not ctx.message.guild_id:
raise BadArgument("TextChannel converter requires guild context.")
match = re.match(r"<#(?P<id>\d+)>$", argument)
channel_id = match.group("id") if match else argument
guild = ctx.bot.get_guild(ctx.message.guild_id)
if guild:
channel = guild.get_channel(channel_id)
if isinstance(channel, TextChannel):
return channel
channel = (
ctx.bot.get_channel(channel_id) if hasattr(ctx.bot, "get_channel") else None
)
if isinstance(channel, TextChannel):
return channel
if hasattr(ctx.bot, "fetch_channel"):
channel = await ctx.bot.fetch_channel(channel_id)
if isinstance(channel, TextChannel):
return channel
raise BadArgument(f"Text channel '{argument}' not found.")
class VoiceChannelConverter(Converter["VoiceChannel"]):
async def convert(self, ctx: "CommandContext", argument: str) -> "VoiceChannel":
if not ctx.message.guild_id:
raise BadArgument("VoiceChannel converter requires guild context.")
match = re.match(r"<#(?P<id>\d+)>$", argument)
channel_id = match.group("id") if match else argument
guild = ctx.bot.get_guild(ctx.message.guild_id)
if guild:
channel = guild.get_channel(channel_id)
if isinstance(channel, VoiceChannel):
return channel
channel = (
ctx.bot.get_channel(channel_id) if hasattr(ctx.bot, "get_channel") else None
)
if isinstance(channel, VoiceChannel):
return channel
if hasattr(ctx.bot, "fetch_channel"):
channel = await ctx.bot.fetch_channel(channel_id)
if isinstance(channel, VoiceChannel):
return channel
raise BadArgument(f"Voice channel '{argument}' not found.")
class EmojiConverter(Converter["PartialEmoji"]):
_CUSTOM_RE = re.compile(r"<(?P<animated>a)?:(?P<name>[^:]+):(?P<id>\d+)>$")
async def convert(self, ctx: "CommandContext", argument: str) -> "PartialEmoji":
match = self._CUSTOM_RE.match(argument)
if match:
return PartialEmoji(
{
"id": match.group("id"),
"name": match.group("name"),
"animated": bool(match.group("animated")),
}
)
if argument:
return PartialEmoji({"id": None, "name": argument})
raise BadArgument(f"Emoji '{argument}' not found.")
# Default converters mapping
DEFAULT_CONVERTERS: dict[type, Converter[Any]] = {
int: IntConverter(),
@ -137,7 +252,11 @@ DEFAULT_CONVERTERS: dict[type, Converter[Any]] = {
Member: MemberConverter(),
Guild: GuildConverter(),
Role: RoleConverter(),
# User: UserConverter(), # Add when User model and converter are ready
User: UserConverter(),
TextChannel: TextChannelConverter(),
VoiceChannel: VoiceChannelConverter(),
PartialEmoji: EmojiConverter(),
Emoji: EmojiConverter(),
}
@ -169,7 +288,3 @@ async def run_converters(ctx: "CommandContext", annotation: Any, argument: str)
raise BadArgument(f"No converter found for type annotation '{annotation}'.")
return argument # Default to string if no annotation or annotation is str
# Need to import inspect for the run_converters function
import inspect

File diff suppressed because it is too large Load Diff

View File

@ -1,192 +1,310 @@
# disagreement/ext/commands/decorators.py
from __future__ import annotations
import asyncio
import inspect
import time
from typing import Callable, Any, Optional, List, TYPE_CHECKING, Awaitable
if TYPE_CHECKING:
from .core import Command, CommandContext
from disagreement.permissions import Permissions
from disagreement.models import Member, Guild, Channel
def command(
name: Optional[str] = None, aliases: Optional[List[str]] = None, **attrs: Any
) -> Callable:
"""
A decorator that transforms a function into a Command.
Args:
name (Optional[str]): The name of the command. Defaults to the function name.
aliases (Optional[List[str]]): Alternative names for the command.
**attrs: Additional attributes to pass to the Command constructor
(e.g., brief, description, hidden).
Returns:
Callable: A decorator that registers the command.
"""
def decorator(
func: Callable[..., Awaitable[None]],
) -> Callable[..., Awaitable[None]]:
if not asyncio.iscoroutinefunction(func):
raise TypeError("Command callback must be a coroutine function.")
from .core import Command
cmd_name = name or func.__name__
if hasattr(func, "__command_attrs__"):
raise TypeError("Function is already a command or has command attributes.")
cmd = Command(callback=func, name=cmd_name, aliases=aliases or [], **attrs)
func.__command_object__ = cmd # type: ignore
return func
return decorator
def listener(
name: Optional[str] = None,
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
"""
A decorator that marks a function as an event listener within a Cog.
"""
def decorator(
func: Callable[..., Awaitable[None]],
) -> Callable[..., Awaitable[None]]:
if not asyncio.iscoroutinefunction(func):
raise TypeError("Listener callback must be a coroutine function.")
actual_event_name = name or func.__name__
setattr(func, "__listener_name__", actual_event_name)
return func
return decorator
def check(
predicate: Callable[["CommandContext"], Awaitable[bool] | bool],
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
"""Decorator to add a check to a command."""
def decorator(
func: Callable[..., Awaitable[None]],
) -> Callable[..., Awaitable[None]]:
checks = getattr(func, "__command_checks__", [])
checks.append(predicate)
setattr(func, "__command_checks__", checks)
return func
return decorator
def check_any(
*predicates: Callable[["CommandContext"], Awaitable[bool] | bool]
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
"""Decorator that passes if any predicate returns ``True``."""
async def predicate(ctx: "CommandContext") -> bool:
from .errors import CheckAnyFailure, CheckFailure
errors = []
for p in predicates:
try:
result = p(ctx)
if inspect.isawaitable(result):
result = await result
if result:
return True
except CheckFailure as e:
errors.append(e)
raise CheckAnyFailure(errors)
return check(predicate)
def cooldown(
rate: int, per: float
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
"""Simple per-user cooldown decorator."""
buckets: dict[str, dict[str, float]] = {}
async def predicate(ctx: "CommandContext") -> bool:
from .errors import CommandOnCooldown
now = time.monotonic()
user_buckets = buckets.setdefault(ctx.command.name, {})
reset = user_buckets.get(ctx.author.id, 0)
if now < reset:
raise CommandOnCooldown(reset - now)
user_buckets[ctx.author.id] = now + per
return True
return check(predicate)
def _compute_permissions(
member: "Member", channel: "Channel", guild: "Guild"
) -> "Permissions":
"""Compute the effective permissions for a member in a channel."""
return channel.permissions_for(member)
def requires_permissions(
*perms: "Permissions",
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
"""Check that the invoking member has the given permissions in the channel."""
async def predicate(ctx: "CommandContext") -> bool:
from .errors import CheckFailure
from disagreement.permissions import (
has_permissions,
missing_permissions,
)
from disagreement.models import Member
channel = getattr(ctx, "channel", None)
if channel is None and hasattr(ctx.bot, "get_channel"):
channel = ctx.bot.get_channel(ctx.message.channel_id)
if channel is None and hasattr(ctx.bot, "fetch_channel"):
channel = await ctx.bot.fetch_channel(ctx.message.channel_id)
if channel is None:
raise CheckFailure("Channel for permission check not found.")
guild = getattr(channel, "guild", None)
if not guild and hasattr(channel, "guild_id") and channel.guild_id:
if hasattr(ctx.bot, "get_guild"):
guild = ctx.bot.get_guild(channel.guild_id)
if not guild and hasattr(ctx.bot, "fetch_guild"):
guild = await ctx.bot.fetch_guild(channel.guild_id)
if not guild:
is_dm = not hasattr(channel, "guild_id") or not channel.guild_id
if is_dm:
if perms:
raise CheckFailure("Permission checks are not supported in DMs.")
return True
raise CheckFailure("Guild for permission check not found.")
member = ctx.author
if not isinstance(member, Member):
member = guild.get_member(ctx.author.id)
if not member and hasattr(ctx.bot, "fetch_member"):
member = await ctx.bot.fetch_member(guild.id, ctx.author.id)
if not member:
raise CheckFailure("Could not resolve author to a guild member.")
perms_value = _compute_permissions(member, channel, guild)
if not has_permissions(perms_value, *perms):
missing = missing_permissions(perms_value, *perms)
missing_names = ", ".join(p.name for p in missing if p.name)
raise CheckFailure(f"Missing permissions: {missing_names}")
return True
return check(predicate)
from __future__ import annotations
import asyncio
import inspect
import time
from typing import Callable, Any, Optional, List, TYPE_CHECKING, Awaitable
if TYPE_CHECKING:
from .core import Command, CommandContext
from disagreement.permissions import Permissions
from disagreement.models import Member, Guild, Channel
def command(
name: Optional[str] = None, aliases: Optional[List[str]] = None, **attrs: Any
) -> Callable:
"""
A decorator that transforms a function into a Command.
Args:
name (Optional[str]): The name of the command. Defaults to the function name.
aliases (Optional[List[str]]): Alternative names for the command.
**attrs: Additional attributes to pass to the Command constructor
(e.g., brief, description, hidden).
Returns:
Callable: A decorator that registers the command.
"""
def decorator(
func: Callable[..., Awaitable[None]],
) -> Callable[..., Awaitable[None]]:
if not asyncio.iscoroutinefunction(func):
raise TypeError("Command callback must be a coroutine function.")
from .core import Command
cmd_name = name or func.__name__
if hasattr(func, "__command_attrs__"):
raise TypeError("Function is already a command or has command attributes.")
cmd = Command(callback=func, name=cmd_name, aliases=aliases or [], **attrs)
func.__command_object__ = cmd # type: ignore
return func
return decorator
def listener(
name: Optional[str] = None,
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
"""
A decorator that marks a function as an event listener within a Cog.
"""
def decorator(
func: Callable[..., Awaitable[None]],
) -> Callable[..., Awaitable[None]]:
if not asyncio.iscoroutinefunction(func):
raise TypeError("Listener callback must be a coroutine function.")
actual_event_name = name or func.__name__
setattr(func, "__listener_name__", actual_event_name)
return func
return decorator
def check(
predicate: Callable[["CommandContext"], Awaitable[bool] | bool],
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
"""Decorator to add a check to a command."""
def decorator(
func: Callable[..., Awaitable[None]],
) -> Callable[..., Awaitable[None]]:
checks = getattr(func, "__command_checks__", [])
checks.append(predicate)
setattr(func, "__command_checks__", checks)
return func
return decorator
def check_any(
*predicates: Callable[["CommandContext"], Awaitable[bool] | bool]
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
"""Decorator that passes if any predicate returns ``True``."""
async def predicate(ctx: "CommandContext") -> bool:
from .errors import CheckAnyFailure, CheckFailure
errors = []
for p in predicates:
try:
result = p(ctx)
if inspect.isawaitable(result):
result = await result
if result:
return True
except CheckFailure as e:
errors.append(e)
raise CheckAnyFailure(errors)
return check(predicate)
def max_concurrency(
number: int, per: str = "user"
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
"""Limit how many concurrent invocations of a command are allowed.
Parameters
----------
number:
The maximum number of concurrent invocations.
per:
The scope of the limiter. Can be ``"user"``, ``"guild"`` or ``"global"``.
"""
if number < 1:
raise ValueError("Concurrency number must be at least 1.")
if per not in {"user", "guild", "global"}:
raise ValueError("per must be 'user', 'guild', or 'global'.")
def decorator(
func: Callable[..., Awaitable[None]],
) -> Callable[..., Awaitable[None]]:
setattr(func, "__max_concurrency__", (number, per))
return func
return decorator
def cooldown(
rate: int, per: float
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
"""Simple per-user cooldown decorator."""
buckets: dict[str, dict[str, float]] = {}
async def predicate(ctx: "CommandContext") -> bool:
from .errors import CommandOnCooldown
now = time.monotonic()
user_buckets = buckets.setdefault(ctx.command.name, {})
reset = user_buckets.get(ctx.author.id, 0)
if now < reset:
raise CommandOnCooldown(reset - now)
user_buckets[ctx.author.id] = now + per
return True
return check(predicate)
def _compute_permissions(
member: "Member", channel: "Channel", guild: "Guild"
) -> "Permissions":
"""Compute the effective permissions for a member in a channel."""
return channel.permissions_for(member)
def requires_permissions(
*perms: "Permissions",
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
"""Check that the invoking member has the given permissions in the channel."""
async def predicate(ctx: "CommandContext") -> bool:
from .errors import CheckFailure
from disagreement.permissions import (
has_permissions,
missing_permissions,
)
from disagreement.models import Member
channel = getattr(ctx, "channel", None)
if channel is None and hasattr(ctx.bot, "get_channel"):
channel = ctx.bot.get_channel(ctx.message.channel_id)
if channel is None and hasattr(ctx.bot, "fetch_channel"):
channel = await ctx.bot.fetch_channel(ctx.message.channel_id)
if channel is None:
raise CheckFailure("Channel for permission check not found.")
guild = getattr(channel, "guild", None)
if not guild and hasattr(channel, "guild_id") and channel.guild_id:
if hasattr(ctx.bot, "get_guild"):
guild = ctx.bot.get_guild(channel.guild_id)
if not guild and hasattr(ctx.bot, "fetch_guild"):
guild = await ctx.bot.fetch_guild(channel.guild_id)
if not guild:
is_dm = not hasattr(channel, "guild_id") or not channel.guild_id
if is_dm:
if perms:
raise CheckFailure("Permission checks are not supported in DMs.")
return True
raise CheckFailure("Guild for permission check not found.")
member = ctx.author
if not isinstance(member, Member):
member = guild.get_member(ctx.author.id)
if not member and hasattr(ctx.bot, "fetch_member"):
member = await ctx.bot.fetch_member(guild.id, ctx.author.id)
if not member:
raise CheckFailure("Could not resolve author to a guild member.")
perms_value = _compute_permissions(member, channel, guild)
if not has_permissions(perms_value, *perms):
missing = missing_permissions(perms_value, *perms)
missing_names = ", ".join(p.name for p in missing if p.name)
raise CheckFailure(f"Missing permissions: {missing_names}")
return True
return check(predicate)
def has_role(
name_or_id: str | int,
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
"""Check that the invoking member has a role with the given name or ID."""
async def predicate(ctx: "CommandContext") -> bool:
from .errors import CheckFailure
from disagreement.models import Member
if not ctx.guild:
raise CheckFailure("This command cannot be used in DMs.")
author = ctx.author
if not isinstance(author, Member):
try:
author = await ctx.bot.fetch_member(ctx.guild.id, author.id)
except Exception:
raise CheckFailure("Could not resolve author to a guild member.")
if not author:
raise CheckFailure("Could not resolve author to a guild member.")
# Create a list of the member's role objects by looking them up in the guild's roles list
member_roles = [role for role in ctx.guild.roles if role.id in author.roles]
if any(
role.id == str(name_or_id) or role.name == name_or_id
for role in member_roles
):
return True
raise CheckFailure(f"You need the '{name_or_id}' role to use this command.")
return check(predicate)
def has_any_role(
*names_or_ids: str | int,
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
"""Check that the invoking member has any of the roles with the given names or IDs."""
async def predicate(ctx: "CommandContext") -> bool:
from .errors import CheckFailure
from disagreement.models import Member
if not ctx.guild:
raise CheckFailure("This command cannot be used in DMs.")
author = ctx.author
if not isinstance(author, Member):
try:
author = await ctx.bot.fetch_member(ctx.guild.id, author.id)
except Exception:
raise CheckFailure("Could not resolve author to a guild member.")
if not author:
raise CheckFailure("Could not resolve author to a guild member.")
member_roles = [role for role in ctx.guild.roles if role.id in author.roles]
# Convert names_or_ids to a set for efficient lookup
names_or_ids_set = set(map(str, names_or_ids))
if any(
role.id in names_or_ids_set or role.name in names_or_ids_set
for role in member_roles
):
return True
role_list = ", ".join(f"'{r}'" for r in names_or_ids)
raise CheckFailure(
f"You need one of the following roles to use this command: {role_list}"
)
return check(predicate)
def is_owner() -> (
Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]
):
"""Check that the invoking user is listed as a bot owner."""
async def predicate(ctx: "CommandContext") -> bool:
from .errors import CheckFailure
owner_ids = getattr(ctx.bot, "owner_ids", [])
if str(ctx.author.id) not in {str(o) for o in owner_ids}:
raise CheckFailure("This command can only be used by the bot owner.")
return True
return check(predicate)

View File

@ -1,5 +1,3 @@
# disagreement/ext/commands/errors.py
"""
Custom exceptions for the command extension.
"""
@ -72,5 +70,13 @@ class CommandInvokeError(CommandError):
super().__init__(f"Error during command invocation: {original}")
class MaxConcurrencyReached(CommandError):
"""Raised when a command exceeds its concurrency limit."""
def __init__(self, limit: int):
self.limit = limit
super().__init__(f"Max concurrency of {limit} reached")
# Add more specific errors as needed, e.g., UserNotFound, ChannelNotFound, etc.
# These might inherit from BadArgument.

View File

@ -1,8 +1,8 @@
# disagreement/ext/commands/help.py
from collections import defaultdict
from typing import List, Optional
from .core import Command, CommandContext, CommandHandler
from ...utils import Paginator
from .core import Command, CommandContext, CommandHandler, Group
class HelpCommand(Command):
@ -17,17 +17,22 @@ class HelpCommand(Command):
if not cmd or cmd.name.lower() != command.lower():
await ctx.send(f"Command '{command}' not found.")
return
description = cmd.description or cmd.brief or "No description provided."
await ctx.send(f"**{ctx.prefix}{cmd.name}**\n{description}")
else:
lines: List[str] = []
for registered in dict.fromkeys(handler.commands.values()):
brief = registered.brief or registered.description or ""
lines.append(f"{ctx.prefix}{registered.name} - {brief}".strip())
if lines:
await ctx.send("\n".join(lines))
if isinstance(cmd, Group):
await self.send_group_help(ctx, cmd)
elif cmd:
description = cmd.description or cmd.brief or "No description provided."
await ctx.send(f"**{ctx.prefix}{cmd.name}**\n{description}")
else:
await ctx.send("No commands available.")
lines: List[str] = []
for registered in handler.walk_commands():
brief = registered.brief or registered.description or ""
lines.append(f"{ctx.prefix}{registered.name} - {brief}".strip())
if lines:
await ctx.send("\n".join(lines))
else:
await self.send_command_help(ctx, cmd)
else:
await self.send_bot_help(ctx)
super().__init__(
callback,
@ -35,3 +40,42 @@ class HelpCommand(Command):
brief="Show command help.",
description="Displays help for commands.",
)
async def send_bot_help(self, ctx: CommandContext) -> None:
groups = defaultdict(list)
for cmd in dict.fromkeys(self.handler.commands.values()):
key = cmd.cog.cog_name if cmd.cog else "No Category"
groups[key].append(cmd)
paginator = Paginator()
for cog_name, cmds in groups.items():
paginator.add_line(f"**{cog_name}**")
for cmd in cmds:
brief = cmd.brief or cmd.description or ""
paginator.add_line(f"{ctx.prefix}{cmd.name} - {brief}".strip())
paginator.add_line("")
pages = paginator.pages
if not pages:
await ctx.send("No commands available.")
return
for page in pages:
await ctx.send(page)
async def send_command_help(self, ctx: CommandContext, command: Command) -> None:
description = command.description or command.brief or "No description provided."
await ctx.send(f"**{ctx.prefix}{command.name}**\n{description}")
async def send_group_help(self, ctx: CommandContext, group: Group) -> None:
paginator = Paginator()
description = group.description or group.brief or "No description provided."
paginator.add_line(f"**{ctx.prefix}{group.name}**\n{description}")
if group.commands:
for sub in dict.fromkeys(group.commands.values()):
brief = sub.brief or sub.description or ""
paginator.add_line(
f"{ctx.prefix}{group.name} {sub.name} - {brief}".strip()
)
for page in paginator.pages:
await ctx.send(page)

View File

@ -1,5 +1,3 @@
# disagreement/ext/commands/view.py
import re
@ -47,7 +45,7 @@ class StringView:
word = match.group(0)
self.index += len(word)
return word
return "" # Should not happen if not eof and skip_whitespace was called
return ""
def get_quoted_string(self) -> str:
"""

View File

@ -1,9 +1,11 @@
from __future__ import annotations
import asyncio
from importlib import import_module
import inspect
import sys
from types import ModuleType
from typing import Dict
from typing import Any, Coroutine, Dict, cast
__all__ = ["load_extension", "unload_extension", "reload_extension"]
@ -25,7 +27,20 @@ def load_extension(name: str) -> ModuleType:
if not hasattr(module, "setup"):
raise ImportError(f"Extension '{name}' does not define a setup function")
module.setup()
result = module.setup()
if inspect.isawaitable(result):
coro = cast(Coroutine[Any, Any, Any], result)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
asyncio.run(coro)
else:
if loop.is_running():
future = asyncio.run_coroutine_threadsafe(coro, loop)
future.result()
else:
loop.run_until_complete(coro)
_loaded_extensions[name] = module
return module
@ -38,7 +53,19 @@ def unload_extension(name: str) -> None:
raise ValueError(f"Extension '{name}' is not loaded")
if hasattr(module, "teardown"):
module.teardown()
result = module.teardown()
if inspect.isawaitable(result):
coro = cast(Coroutine[Any, Any, Any], result)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
asyncio.run(coro)
else:
if loop.is_running():
future = asyncio.run_coroutine_threadsafe(coro, loop)
future.result()
else:
loop.run_until_complete(coro)
sys.modules.pop(name, None)

View File

@ -18,9 +18,12 @@ class Task:
delta: Optional[datetime.timedelta] = None,
time_of_day: Optional[datetime.time] = None,
on_error: Optional[Callable[[Exception], Awaitable[None]]] = None,
before_loop: Optional[Callable[[], Awaitable[None] | None]] = None,
after_loop: Optional[Callable[[], Awaitable[None] | None]] = None,
) -> None:
self._coro = coro
self._task: Optional[asyncio.Task[None]] = None
self._current_loop = 0
if time_of_day is not None and (
seconds or minutes or hours or delta is not None
):
@ -36,6 +39,8 @@ class Task:
self._seconds = float(interval_seconds)
self._time_of_day = time_of_day
self._on_error = on_error
self._before_loop = before_loop
self._after_loop = after_loop
def _seconds_until_time(self) -> float:
assert self._time_of_day is not None
@ -47,6 +52,9 @@ class Task:
async def _run(self, *args: Any, **kwargs: Any) -> None:
try:
if self._before_loop is not None:
await _maybe_call_no_args(self._before_loop)
first = True
while True:
if self._time_of_day is not None:
@ -61,13 +69,18 @@ class Task:
await _maybe_call(self._on_error, exc)
else:
raise
self._current_loop += 1
first = False
except asyncio.CancelledError:
pass
finally:
if self._after_loop is not None:
await _maybe_call_no_args(self._after_loop)
def start(self, *args: Any, **kwargs: Any) -> asyncio.Task[None]:
if self._task is None or self._task.done():
self._current_loop = 0
self._task = asyncio.create_task(self._run(*args, **kwargs))
return self._task
@ -80,6 +93,34 @@ class Task:
def running(self) -> bool:
return self._task is not None and not self._task.done()
@property
def current_loop(self) -> int:
return self._current_loop
def change_interval(
self,
*,
seconds: float = 0.0,
minutes: float = 0.0,
hours: float = 0.0,
delta: Optional[datetime.timedelta] = None,
time_of_day: Optional[datetime.time] = None,
) -> None:
if time_of_day is not None and (
seconds or minutes or hours or delta is not None
):
raise ValueError("time_of_day cannot be used with an interval")
if delta is not None:
if not isinstance(delta, datetime.timedelta):
raise TypeError("delta must be a datetime.timedelta")
interval_seconds = delta.total_seconds()
else:
interval_seconds = seconds + minutes * 60.0 + hours * 3600.0
self._seconds = float(interval_seconds)
self._time_of_day = time_of_day
async def _maybe_call(
func: Callable[[Exception], Awaitable[None] | None], exc: Exception
@ -89,6 +130,12 @@ async def _maybe_call(
await result
async def _maybe_call_no_args(func: Callable[[], Awaitable[None] | None]) -> None:
result = func()
if asyncio.iscoroutine(result):
await result
class _Loop:
def __init__(
self,
@ -110,6 +157,8 @@ class _Loop:
self.on_error = on_error
self._task: Optional[Task] = None
self._owner: Any = None
self._before_loop: Optional[Callable[..., Awaitable[Any]]] = None
self._after_loop: Optional[Callable[..., Awaitable[Any]]] = None
def __get__(self, obj: Any, objtype: Any) -> "_BoundLoop":
return _BoundLoop(self, obj)
@ -119,7 +168,33 @@ class _Loop:
return self.func(*args, **kwargs)
return self.func(self._owner, *args, **kwargs)
def before_loop(
self, func: Callable[..., Awaitable[Any]]
) -> Callable[..., Awaitable[Any]]:
self._before_loop = func
return func
def after_loop(
self, func: Callable[..., Awaitable[Any]]
) -> Callable[..., Awaitable[Any]]:
self._after_loop = func
return func
def start(self, *args: Any, **kwargs: Any) -> asyncio.Task[None]:
def call_before() -> Awaitable[None] | None:
if self._before_loop is None:
return None
if self._owner is not None:
return self._before_loop(self._owner)
return self._before_loop()
def call_after() -> Awaitable[None] | None:
if self._after_loop is None:
return None
if self._owner is not None:
return self._after_loop(self._owner)
return self._after_loop()
self._task = Task(
self._coro,
seconds=self.seconds,
@ -128,6 +203,8 @@ class _Loop:
delta=self.delta,
time_of_day=self.time_of_day,
on_error=self.on_error,
before_loop=call_before,
after_loop=call_after,
)
return self._task.start(*args, **kwargs)
@ -135,10 +212,37 @@ class _Loop:
if self._task is not None:
self._task.stop()
def change_interval(
self,
*,
seconds: float = 0.0,
minutes: float = 0.0,
hours: float = 0.0,
delta: Optional[datetime.timedelta] = None,
time_of_day: Optional[datetime.time] = None,
) -> None:
self.seconds = seconds
self.minutes = minutes
self.hours = hours
self.delta = delta
self.time_of_day = time_of_day
if self._task is not None:
self._task.change_interval(
seconds=seconds,
minutes=minutes,
hours=hours,
delta=delta,
time_of_day=time_of_day,
)
@property
def running(self) -> bool:
return self._task.running if self._task else False
@property
def current_loop(self) -> int:
return self._task.current_loop if self._task else 0
class _BoundLoop:
def __init__(self, parent: _Loop, owner: Any) -> None:
@ -156,6 +260,27 @@ class _BoundLoop:
def running(self) -> bool:
return self._parent.running
def change_interval(
self,
*,
seconds: float = 0.0,
minutes: float = 0.0,
hours: float = 0.0,
delta: Optional[datetime.timedelta] = None,
time_of_day: Optional[datetime.time] = None,
) -> None:
self._parent.change_interval(
seconds=seconds,
minutes=minutes,
hours=hours,
delta=delta,
time_of_day=time_of_day,
)
@property
def current_loop(self) -> int:
return self._parent.current_loop
def loop(
*,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,3 @@
# disagreement/interactions.py
"""
Data models for Discord Interaction objects.
"""
@ -395,17 +393,14 @@ class Interaction:
async def respond_modal(self, modal: "Modal") -> None:
"""|coro| Send a modal in response to this interaction."""
from typing import Any, cast
payload = {
"type": InteractionCallbackType.MODAL.value,
"data": modal.to_dict(),
}
payload = InteractionResponsePayload(
type=InteractionCallbackType.MODAL,
data=modal.to_dict(),
)
await self._client._http.create_interaction_response(
interaction_id=self.id,
interaction_token=self.token,
payload=cast(Any, payload),
payload=payload,
)
async def edit(
@ -489,7 +484,7 @@ class InteractionResponse:
"""Sends a modal response."""
payload = InteractionResponsePayload(
type=InteractionCallbackType.MODAL,
data=InteractionCallbackData(modal.to_dict()),
data=modal.to_dict(),
)
await self._interaction._client._http.create_interaction_response(
self._interaction.id,
@ -510,7 +505,7 @@ class InteractionCallbackData:
)
self.allowed_mentions: Optional[AllowedMentions] = (
AllowedMentions(data["allowed_mentions"])
if data.get("allowed_mentions")
if "allowed_mentions" in data
else None
)
self.flags: Optional[int] = data.get("flags") # MessageFlags enum could be used
@ -557,16 +552,22 @@ class InteractionResponsePayload:
def __init__(
self,
type: InteractionCallbackType,
data: Optional[InteractionCallbackData] = None,
data: Optional[Union[InteractionCallbackData, Dict[str, Any]]] = None,
):
self.type: InteractionCallbackType = type
self.data: Optional[InteractionCallbackData] = data
self.type = type
self.data = data
def to_dict(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {"type": self.type.value}
if self.data:
payload["data"] = self.data.to_dict()
if isinstance(self.data, dict):
payload["data"] = self.data
else:
payload["data"] = self.data.to_dict()
return payload
def __repr__(self) -> str:
return f"<InteractionResponsePayload type={self.type!r}>"
def __getitem__(self, key: str) -> Any:
return self.to_dict()[key]

File diff suppressed because it is too large Load Diff

19
disagreement/object.py Normal file
View File

@ -0,0 +1,19 @@
class Object:
"""A minimal wrapper around a Discord snowflake ID."""
__slots__ = ("id",)
def __init__(self, object_id: int) -> None:
self.id = int(object_id)
def __int__(self) -> int:
return self.id
def __hash__(self) -> int:
return hash(self.id)
def __eq__(self, other: object) -> bool:
return isinstance(other, Object) and self.id == other.id
def __repr__(self) -> str:
return f"<Object id={self.id}>"

View File

@ -57,6 +57,15 @@ class Permissions(IntFlag):
USE_EXTERNAL_SOUNDS = 1 << 45
SEND_VOICE_MESSAGES = 1 << 46
@classmethod
def all(cls) -> "Permissions":
"""Return a ``Permissions`` object with every permission bit enabled."""
value = 0
for perm in cls:
value |= perm.value
return cls(value)
def permissions_value(*perms: Permissions | int | Iterable[Permissions | int]) -> int:
"""Return a combined integer value for multiple permissions."""

0
disagreement/py.typed Normal file
View File

View File

@ -1,5 +1,3 @@
# disagreement/shard_manager.py
"""Sharding utilities for managing multiple gateway connections."""
from __future__ import annotations

View File

@ -74,7 +74,7 @@ def text_input(
item = TextInput(
label=label,
custom_id=custom_id,
custom_id=custom_id or func.__name__,
style=style,
placeholder=placeholder,
required=required,

View File

@ -1,165 +1,167 @@
from __future__ import annotations
import asyncio
import uuid
from typing import Any, Callable, Coroutine, Dict, List, Optional, TYPE_CHECKING
from ..models import ActionRow
from .item import Item
if TYPE_CHECKING:
from ..client import Client
from ..interactions import Interaction
class View:
"""Represents a container for UI components that can be sent with a message.
Args:
timeout (Optional[float]): The number of seconds to wait for an interaction before the view times out.
Defaults to 180.
"""
def __init__(self, *, timeout: Optional[float] = 180.0):
self.timeout = timeout
self.id = str(uuid.uuid4())
self.__children: List[Item] = []
self.__stopped = asyncio.Event()
self._client: Optional[Client] = None
self._message_id: Optional[str] = None
for item in self.__class__.__dict__.values():
if isinstance(item, Item):
self.add_item(item)
@property
def children(self) -> List[Item]:
return self.__children
def add_item(self, item: Item):
"""Adds an item to the view."""
if not isinstance(item, Item):
raise TypeError("Only instances of 'Item' can be added to a View.")
if len(self.__children) >= 25:
raise ValueError("A view can only have a maximum of 25 components.")
item._view = self
self.__children.append(item)
@property
def message_id(self) -> Optional[str]:
return self._message_id
@message_id.setter
def message_id(self, value: str):
self._message_id = value
def to_components(self) -> List[ActionRow]:
"""Converts the view's children into a list of ActionRow components.
This retains the original, simple layout behaviour where each item is
placed in its own :class:`ActionRow` to ensure backward compatibility.
"""
rows: List[ActionRow] = []
for item in self.children:
if item.custom_id is None:
item.custom_id = (
f"{self.id}:{item.__class__.__name__}:{len(self.__children)}"
)
rows.append(ActionRow(components=[item]))
return rows
def layout_components_advanced(self) -> List[ActionRow]:
"""Group compatible components into rows following Discord rules."""
rows: List[ActionRow] = []
for item in self.children:
if item.custom_id is None:
item.custom_id = (
f"{self.id}:{item.__class__.__name__}:{len(self.__children)}"
)
target_row = item.row
if target_row is not None:
if not 0 <= target_row <= 4:
raise ValueError("Row index must be between 0 and 4.")
while len(rows) <= target_row:
if len(rows) >= 5:
raise ValueError("A view can have at most 5 action rows.")
rows.append(ActionRow())
rows[target_row].add_component(item)
continue
placed = False
for row in rows:
try:
row.add_component(item)
placed = True
break
except ValueError:
continue
if not placed:
if len(rows) >= 5:
raise ValueError("A view can have at most 5 action rows.")
new_row = ActionRow([item])
rows.append(new_row)
return rows
def to_components_payload(self) -> List[Dict[str, Any]]:
"""Converts the view's children into a list of component dictionaries
that can be sent to the Discord API."""
return [row.to_dict() for row in self.to_components()]
async def _dispatch(self, interaction: Interaction):
"""Called by the client to dispatch an interaction to the correct item."""
if self.timeout is not None:
self.__stopped.set() # Reset the timeout on each interaction
self.__stopped.clear()
if interaction.data:
custom_id = interaction.data.custom_id
for child in self.children:
if child.custom_id == custom_id:
if child.callback:
await child.callback(self, interaction)
break
async def wait(self) -> bool:
"""Waits until the view has stopped interacting."""
return await self.__stopped.wait()
def stop(self):
"""Stops the view from listening to interactions."""
if not self.__stopped.is_set():
self.__stopped.set()
async def on_timeout(self):
"""Called when the view times out."""
pass # User can override this
async def _start(self, client: Client):
"""Starts the view's internal listener."""
self._client = client
if self.timeout is not None:
asyncio.create_task(self._timeout_task())
async def _timeout_task(self):
"""The task that waits for the timeout and then stops the view."""
try:
await asyncio.wait_for(self.wait(), timeout=self.timeout)
except asyncio.TimeoutError:
self.stop()
await self.on_timeout()
if self._client and self._message_id:
# Remove the view from the client's listeners
self._client._views.pop(self._message_id, None)
from __future__ import annotations
import asyncio
import uuid
from typing import Any, Callable, Coroutine, Dict, List, Optional, TYPE_CHECKING
from ..models import ActionRow
from .item import Item
if TYPE_CHECKING:
from ..client import Client
from ..interactions import Interaction
class View:
"""Represents a container for UI components that can be sent with a message.
Args:
timeout (Optional[float]): The number of seconds to wait for an interaction before the view times out.
Defaults to 180.
"""
def __init__(self, *, timeout: Optional[float] = 180.0):
self.timeout = timeout
self.id = str(uuid.uuid4())
self.__children: List[Item] = []
self.__stopped = asyncio.Event()
self._client: Optional[Client] = None
self._message_id: Optional[str] = None
# The below is a bit of a hack to support items defined as class members
# e.g. button = Button(...)
for item in self.__class__.__dict__.values():
if isinstance(item, Item):
self.add_item(item)
@property
def children(self) -> List[Item]:
return self.__children
def add_item(self, item: Item):
"""Adds an item to the view."""
if not isinstance(item, Item):
raise TypeError("Only instances of 'Item' can be added to a View.")
if len(self.__children) >= 25:
raise ValueError("A view can only have a maximum of 25 components.")
if self.timeout is None and item.custom_id is None:
raise ValueError(
"All components in a persistent view must have a 'custom_id'."
)
item._view = self
self.__children.append(item)
@property
def message_id(self) -> Optional[str]:
return self._message_id
@message_id.setter
def message_id(self, value: str):
self._message_id = value
def to_components(self) -> List[ActionRow]:
"""Converts the view's children into a list of ActionRow components.
This retains the original, simple layout behaviour where each item is
placed in its own :class:`ActionRow` to ensure backward compatibility.
"""
rows: List[ActionRow] = []
for item in self.children:
rows.append(ActionRow(components=[item]))
return rows
def layout_components_advanced(self) -> List[ActionRow]:
"""Group compatible components into rows following Discord rules."""
rows: List[ActionRow] = []
for item in self.children:
if item.custom_id is None:
item.custom_id = (
f"{self.id}:{item.__class__.__name__}:{len(self.__children)}"
)
target_row = item.row
if target_row is not None:
if not 0 <= target_row <= 4:
raise ValueError("Row index must be between 0 and 4.")
while len(rows) <= target_row:
if len(rows) >= 5:
raise ValueError("A view can have at most 5 action rows.")
rows.append(ActionRow())
rows[target_row].add_component(item)
continue
placed = False
for row in rows:
try:
row.add_component(item)
placed = True
break
except ValueError:
continue
if not placed:
if len(rows) >= 5:
raise ValueError("A view can have at most 5 action rows.")
new_row = ActionRow([item])
rows.append(new_row)
return rows
def to_components_payload(self) -> List[Dict[str, Any]]:
"""Converts the view's children into a list of component dictionaries
that can be sent to the Discord API."""
return [row.to_dict() for row in self.to_components()]
async def _dispatch(self, interaction: Interaction):
"""Called by the client to dispatch an interaction to the correct item."""
if self.timeout is not None:
self.__stopped.set() # Reset the timeout on each interaction
self.__stopped.clear()
if interaction.data:
custom_id = interaction.data.custom_id
for child in self.children:
if child.custom_id == custom_id:
if child.callback:
await child.callback(self, interaction)
break
async def wait(self) -> bool:
"""Waits until the view has stopped interacting."""
return await self.__stopped.wait()
def stop(self):
"""Stops the view from listening to interactions."""
if not self.__stopped.is_set():
self.__stopped.set()
async def on_timeout(self):
"""Called when the view times out."""
pass
async def _start(self, client: Client):
"""Starts the view's internal listener."""
self._client = client
if self.timeout is not None:
asyncio.create_task(self._timeout_task())
async def _timeout_task(self):
"""The task that waits for the timeout and then stops the view."""
try:
await asyncio.wait_for(self.wait(), timeout=self.timeout)
except asyncio.TimeoutError:
self.stop()
await self.on_timeout()
if self._client and self._message_id:
# Remove the view from the client's listeners
self._client._views.pop(self._message_id, None)

View File

@ -3,8 +3,124 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, AsyncIterator, Dict, Iterable, Optional, TYPE_CHECKING, Callable
import re
# Discord epoch in milliseconds (2015-01-01T00:00:00Z)
DISCORD_EPOCH = 1420070400000
if TYPE_CHECKING: # pragma: no cover - for type hinting only
from .models import Message, TextChannel
def utcnow() -> datetime:
"""Return the current timezone-aware UTC time."""
return datetime.now(timezone.utc)
def find(predicate: Callable[[Any], bool], iterable: Iterable[Any]) -> Optional[Any]:
"""Return the first element in ``iterable`` matching the ``predicate``."""
for element in iterable:
if predicate(element):
return element
return None
def get(iterable: Iterable[Any], **attrs: Any) -> Optional[Any]:
"""Return the first element with matching attributes."""
def predicate(elem: Any) -> bool:
return all(getattr(elem, attr, None) == value for attr, value in attrs.items())
return find(predicate, iterable)
def snowflake_time(snowflake: int) -> datetime:
"""Return the creation time of a Discord snowflake."""
timestamp_ms = (snowflake >> 22) + DISCORD_EPOCH
return datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc)
async def message_pager(
channel: "TextChannel",
*,
limit: Optional[int] = None,
before: Optional[str] = None,
after: Optional[str] = None,
) -> AsyncIterator["Message"]:
"""Asynchronously paginate a channel's messages."""
remaining = limit
last_id = before
while remaining is None or remaining > 0:
fetch_limit = min(100, remaining) if remaining is not None else 100
params: Dict[str, Any] = {"limit": fetch_limit}
if last_id is not None:
params["before"] = last_id
if after is not None:
params["after"] = after
data = await channel._client._http.request( # type: ignore[attr-defined]
"GET",
f"/channels/{channel.id}/messages",
params=params,
)
if not data:
break
for raw in data:
msg = channel._client.parse_message(raw) # type: ignore[attr-defined]
yield msg
last_id = msg.id
if remaining is not None:
remaining -= 1
if remaining == 0:
return
class Paginator:
"""Helper to split text into pages under a character limit."""
def __init__(self, limit: int = 2000) -> None:
self.limit = limit
self._pages: list[str] = []
self._current = ""
def add_line(self, line: str) -> None:
"""Add a line of text to the paginator."""
if len(line) > self.limit:
if self._current:
self._pages.append(self._current)
self._current = ""
for i in range(0, len(line), self.limit):
chunk = line[i : i + self.limit]
if len(chunk) == self.limit:
self._pages.append(chunk)
else:
self._current = chunk
return
if not self._current:
self._current = line
elif len(self._current) + 1 + len(line) <= self.limit:
self._current += "\n" + line
else:
self._pages.append(self._current)
self._current = line
@property
def pages(self) -> list[str]:
"""Return the accumulated pages."""
pages = list(self._pages)
if self._current:
pages.append(self._current)
return pages
def escape_markdown(text: str) -> str:
"""Escape Discord markdown formatting in ``text``."""
return re.sub(r"([\\*_~`>|])", r"\\\1", text)
def escape_mentions(text: str) -> str:
"""Escape Discord mentions in ``text``."""
return text.replace("@", "@\u200b")

View File

@ -1,162 +1,308 @@
# disagreement/voice_client.py
"""Voice gateway and UDP audio client."""
from __future__ import annotations
import asyncio
import contextlib
import socket
from typing import Optional, Sequence
import aiohttp
from .audio import AudioSource, FFmpegAudioSource
class VoiceClient:
"""Handles the Discord voice WebSocket connection and UDP streaming."""
def __init__(
self,
endpoint: str,
session_id: str,
token: str,
guild_id: int,
user_id: int,
*,
ws=None,
udp: Optional[socket.socket] = None,
loop: Optional[asyncio.AbstractEventLoop] = None,
verbose: bool = False,
) -> None:
self.endpoint = endpoint
self.session_id = session_id
self.token = token
self.guild_id = str(guild_id)
self.user_id = str(user_id)
self._ws: Optional[aiohttp.ClientWebSocketResponse] = ws
self._udp = udp
self._session: Optional[aiohttp.ClientSession] = None
self._heartbeat_task: Optional[asyncio.Task] = None
self._heartbeat_interval: Optional[float] = None
self._loop = loop or asyncio.get_event_loop()
self.verbose = verbose
self.ssrc: Optional[int] = None
self.secret_key: Optional[Sequence[int]] = None
self._server_ip: Optional[str] = None
self._server_port: Optional[int] = None
"""Voice gateway and UDP audio client."""
from __future__ import annotations
import asyncio
import contextlib
import socket
import threading
from array import array
def _apply_volume(data: bytes, volume: float) -> bytes:
samples = array("h")
samples.frombytes(data)
for i, sample in enumerate(samples):
scaled = int(sample * volume)
if scaled > 32767:
scaled = 32767
elif scaled < -32768:
scaled = -32768
samples[i] = scaled
return samples.tobytes()
from typing import TYPE_CHECKING, Optional, Sequence
import aiohttp
# The following import is correct, but may be flagged by Pylance if the virtual
# environment is not configured correctly.
from nacl.secret import SecretBox
from .audio import AudioSink, AudioSource, FFmpegAudioSource
from .models import User
if TYPE_CHECKING:
from .client import Client
class VoiceClient:
"""Handles the Discord voice WebSocket connection and UDP streaming."""
def __init__(
self,
client: Client,
endpoint: str,
session_id: str,
token: str,
guild_id: int,
user_id: int,
*,
ws=None,
udp: Optional[socket.socket] = None,
loop: Optional[asyncio.AbstractEventLoop] = None,
verbose: bool = False,
) -> None:
self.client = client
self.endpoint = endpoint
self.session_id = session_id
self.token = token
self.guild_id = str(guild_id)
self.user_id = str(user_id)
self._ws: Optional[aiohttp.ClientWebSocketResponse] = ws
self._udp = udp
self._session: Optional[aiohttp.ClientSession] = None
self._heartbeat_task: Optional[asyncio.Task] = None
self._receive_task: Optional[asyncio.Task] = None
self._udp_receive_thread: Optional[threading.Thread] = None
self._heartbeat_interval: Optional[float] = None
try:
self._loop = loop or asyncio.get_running_loop()
except RuntimeError:
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
self.verbose = verbose
self.ssrc: Optional[int] = None
self.secret_key: Optional[Sequence[int]] = None
self._server_ip: Optional[str] = None
self._server_port: Optional[int] = None
self._current_source: Optional[AudioSource] = None
self._play_task: Optional[asyncio.Task] = None
async def connect(self) -> None:
if self._ws is None:
self._session = aiohttp.ClientSession()
self._ws = await self._session.ws_connect(self.endpoint)
hello = await self._ws.receive_json()
self._heartbeat_interval = hello["d"]["heartbeat_interval"] / 1000
self._heartbeat_task = self._loop.create_task(self._heartbeat())
await self._ws.send_json(
{
"op": 0,
"d": {
"server_id": self.guild_id,
"user_id": self.user_id,
"session_id": self.session_id,
"token": self.token,
},
}
)
ready = await self._ws.receive_json()
data = ready["d"]
self.ssrc = data["ssrc"]
self._server_ip = data["ip"]
self._server_port = data["port"]
if self._udp is None:
self._udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._udp.connect((self._server_ip, self._server_port))
await self._ws.send_json(
{
"op": 1,
"d": {
"protocol": "udp",
"data": {
"address": self._udp.getsockname()[0],
"port": self._udp.getsockname()[1],
"mode": "xsalsa20_poly1305",
},
},
}
)
session_desc = await self._ws.receive_json()
self.secret_key = session_desc["d"].get("secret_key")
async def _heartbeat(self) -> None:
assert self._ws is not None
assert self._heartbeat_interval is not None
try:
while True:
await self._ws.send_json({"op": 3, "d": int(self._loop.time() * 1000)})
await asyncio.sleep(self._heartbeat_interval)
except asyncio.CancelledError:
pass
async def send_audio_frame(self, frame: bytes) -> None:
if not self._udp:
raise RuntimeError("UDP socket not initialised")
self._udp.send(frame)
self._pause_event = asyncio.Event()
self._pause_event.set()
self._is_playing = False
self._sink: Optional[AudioSink] = None
self._ssrc_map: dict[int, int] = {}
self._ssrc_lock = threading.Lock()
async def connect(self) -> None:
if self._ws is None:
self._session = aiohttp.ClientSession()
self._ws = await self._session.ws_connect(self.endpoint)
hello = await self._ws.receive_json()
self._heartbeat_interval = hello["d"]["heartbeat_interval"] / 1000
self._heartbeat_task = self._loop.create_task(self._heartbeat())
await self._ws.send_json(
{
"op": 0,
"d": {
"server_id": self.guild_id,
"user_id": self.user_id,
"session_id": self.session_id,
"token": self.token,
},
}
)
ready = await self._ws.receive_json()
data = ready["d"]
self.ssrc = data["ssrc"]
self._server_ip = data["ip"]
self._server_port = data["port"]
if self._udp is None:
self._udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._udp.connect((self._server_ip, self._server_port))
await self._ws.send_json(
{
"op": 1,
"d": {
"protocol": "udp",
"data": {
"address": self._udp.getsockname()[0],
"port": self._udp.getsockname()[1],
"mode": "xsalsa20_poly1305",
},
},
}
)
session_desc = await self._ws.receive_json()
self.secret_key = session_desc["d"].get("secret_key")
async def _heartbeat(self) -> None:
assert self._ws is not None
assert self._heartbeat_interval is not None
try:
while True:
await self._ws.send_json({"op": 3, "d": int(self._loop.time() * 1000)})
await asyncio.sleep(self._heartbeat_interval)
except asyncio.CancelledError:
pass
async def _receive_loop(self) -> None:
assert self._ws is not None
while True:
try:
msg = await self._ws.receive_json()
op = msg.get("op")
data = msg.get("d")
if op == 5: # Speaking
user_id = int(data["user_id"])
ssrc = data["ssrc"]
with self._ssrc_lock:
self._ssrc_map[ssrc] = user_id
except (asyncio.CancelledError, aiohttp.ClientError):
break
def _udp_receive_loop(self) -> None:
assert self._udp is not None
assert self.secret_key is not None
box = SecretBox(bytes(self.secret_key))
while True:
try:
packet = self._udp.recv(4096)
if len(packet) < 12:
continue
ssrc = int.from_bytes(packet[8:12], "big")
with self._ssrc_lock:
if ssrc not in self._ssrc_map:
continue
user_id = self._ssrc_map[ssrc]
user = self.client._users.get(str(user_id))
if not user:
continue
decrypted = box.decrypt(packet[12:])
if self._sink:
self._sink.write(user, decrypted)
except (socket.error, asyncio.CancelledError):
break
except Exception as e:
if self.verbose:
print(f"Error in UDP receive loop: {e}")
async def send_audio_frame(self, frame: bytes) -> None:
if not self._udp:
raise RuntimeError("UDP socket not initialised")
self._udp.send(frame)
async def _play_loop(self) -> None:
assert self._current_source is not None
self._is_playing = True
try:
while True:
await self._pause_event.wait()
data = await self._current_source.read()
if not data:
break
volume = getattr(self._current_source, "volume", 1.0)
if volume != 1.0:
data = _apply_volume(data, volume)
await self.send_audio_frame(data)
finally:
await self._current_source.close()
self._current_source = None
self._play_task = None
self._is_playing = False
self._pause_event.set()
async def stop(self) -> None:
if self._play_task:
self._play_task.cancel()
self._pause_event.set()
with contextlib.suppress(asyncio.CancelledError):
await self._play_task
self._play_task = None
self._is_playing = False
if self._current_source:
await self._current_source.close()
self._current_source = None
async def play(self, source: AudioSource, *, wait: bool = True) -> None:
"""|coro| Play an :class:`AudioSource` on the voice connection."""
await self.stop()
self._current_source = source
self._play_task = self._loop.create_task(self._play_loop())
if wait:
await self._play_task
async def play(self, source: AudioSource, *, wait: bool = True) -> None:
"""|coro| Play an :class:`AudioSource` on the voice connection."""
await self.stop()
self._current_source = source
self._play_task = self._loop.create_task(self._play_loop())
if wait:
await self._play_task
async def play_file(self, filename: str, *, wait: bool = True) -> None:
"""|coro| Stream an audio file or URL using FFmpeg."""
await self.play(FFmpegAudioSource(filename), wait=wait)
async def close(self) -> None:
await self.stop()
if self._heartbeat_task:
self._heartbeat_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._heartbeat_task
if self._ws:
await self._ws.close()
if self._session:
await self._session.close()
if self._udp:
self._udp.close()
def pause(self) -> None:
"""Pause the current audio source."""
if self._play_task and not self._play_task.done():
self._pause_event.clear()
def resume(self) -> None:
"""Resume playback of a paused source."""
if self._play_task and not self._play_task.done():
self._pause_event.set()
def is_paused(self) -> bool:
"""Return ``True`` if playback is currently paused."""
return bool(self._play_task and not self._pause_event.is_set())
def is_playing(self) -> bool:
"""Return ``True`` if audio is actively being played."""
return self._is_playing and self._pause_event.is_set()
def listen(self, sink: AudioSink) -> None:
"""Start listening to voice and routing to a sink."""
if not isinstance(sink, AudioSink):
raise TypeError("sink must be an AudioSink instance")
self._sink = sink
if not self._udp_receive_thread:
self._udp_receive_thread = threading.Thread(
target=self._udp_receive_loop, daemon=True
)
self._udp_receive_thread.start()
async def close(self) -> None:
await self.stop()
if self._heartbeat_task:
self._heartbeat_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._heartbeat_task
if self._receive_task:
self._receive_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._receive_task
if self._ws:
await self._ws.close()
if self._session:
await self._session.close()
if self._udp:
self._udp.close()
if self._udp_receive_thread:
self._udp_receive_thread.join(timeout=1)
if self._sink:
self._sink.close()
async def __aenter__(self) -> "VoiceClient":
"""Enter the context manager by connecting to the voice gateway."""
await self.connect()
return self
async def __aexit__(
self,
exc_type: Optional[type],
exc: Optional[BaseException],
tb: Optional[BaseException],
) -> bool:
"""Exit the context manager and close the connection."""
await self.close()
return False

1
docs/CNAME Normal file
View File

@ -0,0 +1 @@
disagreement.xyz

15
docs/audit_logs.md Normal file
View File

@ -0,0 +1,15 @@
# Audit Logs
`Client.fetch_audit_logs` provides an async iterator over a guild's audit log entries.
```python
async for entry in client.fetch_audit_logs(guild_id, limit=100):
print(entry.action_type, entry.user_id)
```
Discord imposes stricter rate limits on this endpoint compared to other REST calls. Avoid polling too frequently or you may hit a `429` response.
## Next Steps
- [Caching](caching.md)
- [Message History](message_history.md)

View File

@ -10,7 +10,16 @@ Once you have a `Guild` object you can look up its cached members. `Guild.get_me
guild = client.get_guild(123456789012345678)
member = guild.get_member_named("Slipstream")
if member:
print(member.id)
print(member.display_name)
```
To access the bot's own member object, use the ``Guild.me`` property. It returns
``None`` if the bot is not in the guild or its user data hasn't been loaded:
```python
bot_member = guild.me
if bot_member:
print(bot_member.joined_at)
```
The cache can be cleared manually if needed:
@ -19,9 +28,17 @@ The cache can be cleared manually if needed:
client.cache.clear()
```
## Partial Objects
Some events only include minimal data for related resources. When only an ``id``
is available, Disagreement represents the resource using :class:`~disagreement.Object`.
These objects can be compared and used in sets or dictionaries and can be passed
to API methods to fetch the full data when needed.
## Next Steps
- [Components](using_components.md)
- [Slash Commands](slash_commands.md)
- [Voice Features](voice_features.md)
- [HTTP Client Options](http_client.md)

View File

@ -11,7 +11,11 @@ The command handler registers a `help` command automatically. Use it to list all
!help ping # shows help for the "ping" command
```
The help command will show each command's brief description if provided.
Commands are grouped by their Cog name and paginated so that long help
lists are split into multiple messages using the `Paginator` utility.
If you need custom formatting you can subclass
`HelpCommand` and override `send_command_help` or `send_group_help`.
## Checks
@ -20,7 +24,7 @@ returns ``True``. Checks may be regular or async callables that accept a
`CommandContext`.
```python
from disagreement.ext.commands import command, check, CheckFailure
from disagreement import command, check, CheckFailure
def is_owner(ctx):
return ctx.author.id == "1"
@ -40,7 +44,7 @@ Commands can be rate limited using the ``cooldown`` decorator. The example
below restricts usage to once every three seconds per user:
```python
from disagreement.ext.commands import command, cooldown
from disagreement import command, cooldown
@command()
@cooldown(1, 3.0)
@ -56,8 +60,8 @@ Use `commands.requires_permissions` to ensure the invoking member has the
required permissions in the channel.
```python
from disagreement.ext.commands import command, requires_permissions
from disagreement.permissions import Permissions
from disagreement import command, requires_permissions
from disagreement import Permissions
@command()
@requires_permissions(Permissions.MANAGE_MESSAGES)

View File

@ -1,12 +1,11 @@
# Context Menu Commands
`disagreement` supports Discord's user and message context menu commands. Use the
`user_command` and `message_command` decorators from `ext.app_commands` to
define them.
`user_command` and `message_command` decorators to define them.
```python
from disagreement.ext.app_commands import user_command, message_command, AppCommandContext
from disagreement.models import User, Message
from disagreement import User, Message
from disagreement import User, Message, user_command, message_command, AppCommandContext
@user_command(name="User Info")
async def user_info(ctx: AppCommandContext, user: User) -> None:

View File

@ -13,8 +13,8 @@
```python
from disagreement.ext.commands import command
from disagreement import Member
from disagreement.ext.commands.core import CommandContext
from disagreement.models import Member
@command()
async def kick(ctx: CommandContext, target: Member):
@ -22,4 +22,7 @@ async def kick(ctx: CommandContext, target: Member):
await ctx.send(f"Kicked {target.display_name}")
```
`Member.display_name` returns the member's nickname if one is set, otherwise it
falls back to the username.
The framework will automatically convert the first argument to a `Member` using the mention or ID provided by the user.

22
docs/embeds.md Normal file
View File

@ -0,0 +1,22 @@
# Embeds
`Embed` objects can be constructed piece by piece much like in `discord.py`.
These helper methods return the embed instance so you can chain calls.
```python
from disagreement import Embed
embed = (
Embed()
.set_author(name="Disagreement", url="https://example.com", icon_url="https://cdn.example.com/bot.png")
.add_field(name="Info", value="Some details")
.set_footer(text="Made with Disagreement")
.set_image(url="https://cdn.example.com/image.png")
)
```
Call `to_dict()` to convert the embed back to a payload dictionary before sending:
```python
payload = embed.to_dict()
```

View File

@ -1,7 +1,7 @@
# Events
Disagreement dispatches Gateway events to asynchronous callbacks. Handlers can be registered with `@client.event` or `client.on_event`.
Listeners may be removed later using `EventDispatcher.unregister(event_name, coro)`.
Disagreement dispatches Gateway events to asynchronous callbacks. Handlers can be registered with `@client.event`, `client.on_event`, or `client.add_listener(event_name, coro)`.
Listeners may be removed later using `client.remove_listener(event_name, coro)` or `EventDispatcher.unregister(event_name, coro)`.
## Raw Events
@ -20,7 +20,7 @@ Triggered when a user's presence changes. The callback receives a `PresenceUpdat
```python
@client.event
async def on_presence_update(presence: disagreement.PresenceUpdate):
async def on_presence_update(presence: PresenceUpdate):
...
```
@ -30,7 +30,7 @@ Dispatched when a user begins typing in a channel. The callback receives a `Typi
```python
@client.event
async def on_typing_start(typing: disagreement.TypingStart):
async def on_typing_start(typing: TypingStart):
...
```
@ -40,7 +40,7 @@ Fired when a new member joins a guild. The callback receives a `Member` model.
```python
@client.event
async def on_guild_member_add(member: disagreement.Member):
async def on_guild_member_add(member: Member):
...
```
@ -51,7 +51,7 @@ receives a `GuildMemberRemove` model.
```python
@client.event
async def on_guild_member_remove(event: disagreement.GuildMemberRemove):
async def on_guild_member_remove(event: GuildMemberRemove):
...
```
@ -62,7 +62,7 @@ Dispatched when a user is banned from a guild. The callback receives a
```python
@client.event
async def on_guild_ban_add(event: disagreement.GuildBanAdd):
async def on_guild_ban_add(event: GuildBanAdd):
...
```
@ -73,7 +73,7 @@ Dispatched when a user's ban is lifted. The callback receives a
```python
@client.event
async def on_guild_ban_remove(event: disagreement.GuildBanRemove):
async def on_guild_ban_remove(event: GuildBanRemove):
...
```
@ -84,7 +84,7 @@ Sent when a channel's settings change. The callback receives an updated
```python
@client.event
async def on_channel_update(channel: disagreement.Channel):
async def on_channel_update(channel: Channel):
...
```
@ -95,6 +95,71 @@ Emitted when a guild role is updated. The callback receives a
```python
@client.event
async def on_guild_role_update(event: disagreement.GuildRoleUpdate):
async def on_guild_role_update(event: GuildRoleUpdate):
...
```
## SHARD_CONNECT
Fired when a shard establishes its gateway connection. The callback receives a
dictionary with the shard ID.
```python
@client.event
async def on_shard_connect(info: dict):
print("shard connected", info["shard_id"])
```
## SHARD_DISCONNECT
Emitted when a shard's gateway connection is closed. The callback receives a
dictionary with the shard ID.
```python
@client.event
async def on_shard_disconnect(info: dict):
...
```
## SHARD_RESUME
Sent when a shard successfully resumes after a reconnect. The callback receives
a dictionary with the shard ID.
```python
@client.event
async def on_shard_resume(info: dict):
...
```
## CONNECT
Dispatched when the WebSocket connection opens. The callback receives a
dictionary with the shard ID.
```python
@client.event
async def on_connect(info: dict):
print("connected", info.get("shard_id"))
```
## DISCONNECT
Fired when the WebSocket connection closes. The callback receives a dictionary
with the shard ID.
```python
@client.event
async def on_disconnect(info: dict):
...
```
## VOICE_STATE_UPDATE
Triggered when a user's voice connection state changes, such as joining or leaving a voice channel. The callback receives a `VoiceStateUpdate` model.
```python
@client.event
async def on_voice_state_update(state: VoiceStateUpdate):
...
```

View File

@ -8,6 +8,8 @@ You can control the maximum number of retries and the backoff cap when construct
These options are forwarded to `GatewayClient` as `max_retries` and `max_backoff`:
```python
from disagreement import Client
bot = Client(
token="your-token",
gateway_max_retries=10,
@ -16,3 +18,9 @@ bot = Client(
```
These values are passed to `GatewayClient` and applied whenever the connection needs to be re-established.
## Gateway Intents
`GatewayIntent` values control which events your bot receives from the Gateway. Use
`GatewayIntent.none()` to opt out of all events entirely. It returns `0`, which
represents a bitmask with no intents enabled.

31
docs/http_client.md Normal file
View File

@ -0,0 +1,31 @@
# HTTP Client Options
Disagreement uses `aiohttp` for all HTTP requests. Additional options for the
underlying `aiohttp.ClientSession` can be provided when constructing a
`Client` or an `HTTPClient` directly.
```python
import aiohttp
from disagreement import Client
connector = aiohttp.TCPConnector(limit=50)
client = Client(
token="YOUR_TOKEN",
http_options={"proxy": "http://localhost:8080", "connector": connector},
)
```
These options are passed through to `aiohttp.ClientSession` when the session is
created. You can set a proxy URL, provide a custom connector, or supply any
other supported session argument.
## Get Current User Guilds
The HTTP client can list the guilds the bot user is in:
```python
from disagreement import HTTPClient
http = HTTPClient(token="TOKEN")
guilds = await http.get_current_user_guilds()
```

14
docs/hybrid_context.md Normal file
View File

@ -0,0 +1,14 @@
# HybridContext
`HybridContext` wraps either a prefix `CommandContext` or a slash `AppCommandContext`. It exposes a single `send` method that proxies to the appropriate reply method for the underlying context.
```python
from disagreement import HybridContext
@commands.command()
async def ping(ctx: commands.CommandContext) -> None:
hybrid = HybridContext(ctx)
await hybrid.send("Pong!")
```
It also forwards attribute access to the wrapped context and provides an `edit` helper when supported.

5
docs/index.md Normal file
View File

@ -0,0 +1,5 @@
# Welcome to Disagreement
This is the official documentation for the Disagreement library.
To get started, check out the [User Guide](introduction.md). See [HybridContext](hybrid_context.md) and [Rate Limiter](rate_limiter.md) for additional features or browse the [API Reference](http_client.md).

194
docs/introduction.md Normal file
View File

@ -0,0 +1,194 @@
# Disagreement
A Python library for interacting with the Discord API, with a focus on bot development.
## Features
- Asynchronous design using `aiohttp`
- Gateway and HTTP API clients
- Slash command framework
- Message component helpers
- Internationalization helpers
- Hybrid context for commands
- Built-in rate limiting
- Built-in caching layer
- Experimental voice support
- Helpful error handling utilities
- Paginator utility for splitting long messages
## Installation
```bash
python -m pip install -U pip
pip install disagreement
# or install from source for development
pip install -e .
```
Requires Python 3.10 or newer.
To run the example scripts, you'll need the `python-dotenv` package to load
environment variables. Install the development extras with:
```bash
pip install "disagreement[dev]"
```
## Basic Usage
```python
import asyncio
import os
from disagreement import Client, GatewayIntent
from disagreement.ext import commands
from dotenv import load_dotenv
load_dotenv()
class Basics(commands.Cog):
def __init__(self, client: Client) -> None:
super().__init__(client)
@commands.command()
async def ping(self, ctx: commands.CommandContext) -> None:
await ctx.reply(f"Pong! Gateway Latency: {self.client.latency_ms} ms.")
token = os.getenv("DISCORD_BOT_TOKEN")
if not token:
raise RuntimeError("DISCORD_BOT_TOKEN environment variable not set")
intents = GatewayIntent.default() | GatewayIntent.MESSAGE_CONTENT
client = Client(token=token, command_prefix="!", intents=intents, mention_replies=True)
client.add_cog(Basics(client))
client.run()
```
### Global Error Handling
To ensure unexpected errors don't crash your bot, you can enable the library's
global error handler:
```python
import disagreement
disagreement.setup_global_error_handler()
```
Call this early in your program to log unhandled exceptions instead of letting
them terminate the process.
### Configuring Logging
Use :func:`disagreement.logging_config.setup_logging` to configure logging for
your bot. The helper accepts a logging level and an optional file path.
```python
import logging
from disagreement.logging_config import setup_logging
setup_logging(logging.INFO)
# Or log to a file
setup_logging(logging.DEBUG, file="bot.log")
```
### HTTP Session Options
Pass additional keyword arguments to ``aiohttp.ClientSession`` using the
``http_options`` parameter when constructing :class:`Client`:
```python
client = Client(
token=token,
http_options={"proxy": "http://localhost:8080"},
)
```
These options are forwarded to ``HTTPClient`` when it creates the underlying
``aiohttp.ClientSession``. You can specify a custom ``connector`` or any other
session parameter supported by ``aiohttp``.
### Default Allowed Mentions
Specify default mention behaviour for all outgoing messages when constructing the client:
```python
from disagreement.models import AllowedMentions
client = Client(
token=token,
allowed_mentions=AllowedMentions.none().to_dict(),
)
```
This dictionary is used whenever ``send_message`` or helpers like ``Message.reply``
are called without an explicit ``allowed_mentions`` argument.
The :class:`AllowedMentions` class offers ``none()`` and ``all()`` helpers for
quickly generating these configurations.
### Defining Subcommands with `AppCommandGroup`
```python
from disagreement.ext.app_commands import AppCommandGroup, slash_command
from disagreement.ext.app_commands.context import AppCommandContext
settings_group = AppCommandGroup("settings", "Manage settings")
admin_group = AppCommandGroup("admin", "Admin settings", parent=settings_group)
@slash_command(name="show", description="Display a setting.", parent=settings_group)
async def show(ctx: AppCommandContext, key: str):
...
@slash_command(name="set", description="Update a setting.", parent=admin_group)
async def set_setting(ctx: AppCommandContext, key: str, value: str):
...
```
## Fetching Guilds
Use `Client.fetch_guild` to retrieve a guild from the Discord API if it
isn't already cached. This is useful when working with guild IDs from
outside the gateway events.
```python
guild = await client.fetch_guild("123456789012345678")
roles = await client.fetch_roles(guild.id)
```
To retrieve all guilds available to the bot, use `Client.fetch_guilds`.
```python
guilds = await client.fetch_guilds()
```
## Sharding
To run your bot across multiple gateway shards, pass ``shard_count`` when creating
the client:
```python
client = Client(token=BOT_TOKEN, shard_count=2)
```
If you want the library to determine the recommended shard count automatically,
use ``AutoShardedClient``:
```python
from disagreement import AutoShardedClient
client = AutoShardedClient(token=BOT_TOKEN)
```
See `examples/sharded_bot.py` for a full example.
## Contributing
Contributions are welcome! Please open an issue or submit a pull request.
See the [documentation home](index.md) for detailed guides on components, slash commands, caching, and voice features.
## License
This project is licensed under the BSD 3-Clause license. See the [LICENSE](https://github.com/your-username/disagreement/blob/main/LICENSE) file for details.

24
docs/invites.md Normal file
View File

@ -0,0 +1,24 @@
# Working with Invites
The library exposes helper methods for creating and deleting invites.
## Create an Invite
```python
invite = await client.create_invite("1234567890", {"max_age": 3600})
print(invite.code)
```
## Delete an Invite
```python
await client.delete_invite(invite.code)
```
## List Invites
```python
invites = await client.fetch_invites("1234567890")
for inv in invites:
print(inv.code, inv.uses)
```

29
docs/mentions.md Normal file
View File

@ -0,0 +1,29 @@
# Controlling Mentions
The client exposes settings to control how mentions behave in outgoing messages.
## Default Allowed Mentions
Use the ``allowed_mentions`` parameter of :class:`disagreement.Client` to set a
default for all messages:
```python
from disagreement import AllowedMentions, Client
client = Client(
token="YOUR_TOKEN",
allowed_mentions=AllowedMentions.none().to_dict(),
)
```
When ``Client.send_message`` or convenience methods like ``Message.reply`` and
``CommandContext.reply`` are called without an explicit ``allowed_mentions``
argument this value will be used.
``AllowedMentions`` also provides the convenience methods
``AllowedMentions.none()`` and ``AllowedMentions.all()`` to quickly create
common configurations.
## Next Steps
- [Commands](commands.md)
- [HTTP Client Options](http_client.md)

21
docs/message_history.md Normal file
View File

@ -0,0 +1,21 @@
# Message History
`TextChannel.history` provides an async iterator over a channel's past messages. The iterator is powered by `utils.message_pager` which handles pagination for you.
```python
channel = await client.fetch_channel(123456789012345678)
async for message in channel.history(limit=200):
print(message.content)
```
Each returned `Message` has a ``jump_url`` property that links directly to the
message in the Discord client.
Pass `before` or `after` to control the range of messages returned. The paginator fetches messages in batches of up to 100 until the limit is reached or Discord returns no more messages.
## Next Steps
- [Caching](caching.md)
- [Typing Indicator](typing_indicator.md)
- [Audit Logs](audit_logs.md)
- [HTTP Client Options](http_client.md)

View File

@ -10,11 +10,17 @@ Each attribute of ``Permissions`` represents a single permission bit. The value
is a power of two so multiple permissions can be combined using bitwise OR.
```python
from disagreement.permissions import Permissions
from disagreement import Permissions
value = Permissions.SEND_MESSAGES | Permissions.MANAGE_MESSAGES
```
You can also get a bitmask containing **every** permission:
```python
all_perms = Permissions.all()
```
## Helper Functions
### ``permissions_value``
@ -47,10 +53,10 @@ Return a list of permissions that ``current`` does not contain.
```python
from disagreement.permissions import (
Permissions,
has_permissions,
missing_permissions,
)
from disagreement import Permissions
current = Permissions.SEND_MESSAGES | Permissions.MANAGE_MESSAGES

View File

@ -1,6 +1,7 @@
# Updating Presence
The `Client.change_presence` method allows you to update the bot's status and displayed activity.
Pass an :class:`~disagreement.models.Activity` (such as :class:`~disagreement.models.Game` or :class:`~disagreement.models.Streaming`) to describe what your bot is doing.
## Status Strings
@ -22,8 +23,18 @@ An activity dictionary must include a `name` and a `type` field. The type value
| `4` | Custom |
| `5` | Competing |
Example:
Example using the provided activity classes:
```python
await client.change_presence(status="idle", activity={"name": "with Discord", "type": 0})
from disagreement import Game
await client.change_presence(status="idle", activity=Game("with Discord"))
```
You can also specify a streaming URL:
```python
from disagreement import Streaming
await client.change_presence(status="online", activity=Streaming("My Stream", "https://twitch.tv/someone"))
```

14
docs/rate_limiter.md Normal file
View File

@ -0,0 +1,14 @@
# Rate Limiter
The HTTP client uses an asynchronous `RateLimiter` to respect Discord's per-route and global rate limits. Each request acquires a bucket associated with the route. The limiter delays requests when the bucket is exhausted and handles global rate limits automatically.
```python
from disagreement.rate_limiter import RateLimiter
rl = RateLimiter()
bucket = await rl.acquire("GET:/channels/1")
# perform request
rl.release("GET:/channels/1", response_headers)
```
`handle_rate_limit(route, retry_after, is_global)` can be used when the API returns a rate limit response.

View File

@ -1,32 +1,62 @@
# Handling Reactions
`disagreement` provides simple helpers for adding and removing message reactions.
`disagreement` provides several ways to add, remove, and listen for message reactions.
## HTTP Methods
## Adding & Removing Reactions
Use the `HTTPClient` methods directly if you need lower level control:
The easiest way to add a reaction is to use the helper method on a `Message` object. This is often done within a command context.
```python
await client._http.create_reaction(channel_id, message_id, "👍")
await client._http.delete_reaction(channel_id, message_id, "👍")
users = await client._http.get_reactions(channel_id, message_id, "👍")
# Inside a command function:
# ctx is a commands.CommandContext object
await ctx.message.add_reaction("👍")
```
You can also use the higher level helpers on :class:`Client`:
You can also remove your own reactions.
```python
await ctx.message.remove_reaction("👍", client.user)
```
## Low-Level Control
For more direct control, you can use methods on the `Client` or `HTTPClient` if you have the channel and message IDs.
```python
# Using the client helper
await client.create_reaction(channel_id, message_id, "👍")
await client.delete_reaction(channel_id, message_id, "👍")
# Using the raw HTTP method
await client._http.create_reaction(channel_id, message_id, "👍")
```
Similarly, you can delete reactions and get a list of users who reacted.
```python
# Delete a specific user's reaction
await client.delete_reaction(channel_id, message_id, "👍", user_id)
# Get users who reacted with an emoji
users = await client.get_reactions(channel_id, message_id, "👍")
```
## Reaction Events
Register listeners for `MESSAGE_REACTION_ADD` and `MESSAGE_REACTION_REMOVE`.
Each listener receives a `Reaction` model instance.
Your bot can listen for reaction events by using the `@client.on_event` decorator. The two main events are `MESSAGE_REACTION_ADD` and `MESSAGE_REACTION_REMOVE`.
The event handlers for these events receive both a `Reaction` object and the `User` or `Member` who triggered the event.
```python
import disagreement
from disagreement import Reaction, User, Member
@client.on_event("MESSAGE_REACTION_ADD")
async def on_reaction(reaction: disagreement.Reaction):
print(f"{reaction.user_id} reacted with {reaction.emoji}")
```
async def on_reaction_add(reaction: Reaction, user: User | Member):
# Ignore reactions from the bot itself
if client.user and user.id == client.user.id:
return
print(f"{user.username} reacted to message {reaction.message_id} with {reaction.emoji}")
@client.on_event("MESSAGE_REACTION_REMOVE")
async def on_reaction_remove(reaction: Reaction, user: User | Member):
print(f"{user.username} removed their {reaction.emoji} reaction from message {reaction.message_id}")

26
docs/scheduled_events.md Normal file
View File

@ -0,0 +1,26 @@
# Guild Scheduled Events
The `Client` provides helpers to manage guild scheduled events.
```python
from disagreement import Client
client = Client(token="TOKEN")
payload = {
"name": "Movie Night",
"scheduled_start_time": "2024-05-01T20:00:00Z",
"privacy_level": 2,
"entity_type": 3,
"entity_metadata": {"location": "https://discord.gg/example"},
}
event = await client.create_scheduled_event(123456789012345678, payload)
print(event.id, event.name)
```
## Next Steps
- [Commands](commands.md)
- [Caching](caching.md)
- [Voice Features](voice_features.md)

View File

@ -8,13 +8,8 @@ manually.
and configures the `ShardManager` automatically.
```python
import asyncio
import disagreement
bot = disagreement.AutoShardedClient(token="YOUR_TOKEN")
async def main():
await bot.run()
asyncio.run(main())
bot.run()
```

View File

@ -1,9 +1,9 @@
# Using Slash Commands
The library provides a slash command framework via the `ext.app_commands` package. Define commands with decorators and register them with Discord.
The library provides a slash command framework to define commands with decorators and register them with Discord.
```python
from disagreement.ext.app_commands import AppCommandGroup
from disagreement import AppCommandGroup
bot_commands = AppCommandGroup("bot", "Bot commands")
@ -19,4 +19,16 @@ Use `AppCommandGroup` to group related commands. See the [components guide](usin
- [Components](using_components.md)
- [Caching](caching.md)
- [Voice Features](voice_features.md)
- [HTTP Client Options](http_client.md)
## Command Persistence
`AppCommandHandler.sync_commands` can persist registered command IDs in
`.disagreement_commands.json`. When enabled, subsequent syncs compare the
stored IDs to the commands defined in code and only create, edit or delete
commands when changes are detected.
Call `AppCommandHandler.clear_stored_registrations()` if you need to wipe the
stored IDs or migrate them elsewhere with
`AppCommandHandler.migrate_stored_registrations()`.

View File

@ -1,11 +1,11 @@
# Task Loops
The tasks extension allows you to run functions periodically. Decorate an async function with `@tasks.loop` and start it using `.start()`.
The tasks extension allows you to run functions periodically. Decorate an async function with `@loop` and start it using `.start()`.
```python
from disagreement.ext import tasks
from disagreement import loop
@tasks.loop(minutes=1.0)
@loop(minutes=1.0)
async def announce():
print("Hello from a loop")
@ -19,7 +19,7 @@ You can provide the interval in seconds, minutes, hours or as a `datetime.timede
```python
import datetime
@tasks.loop(delta=datetime.timedelta(seconds=30))
@loop(delta=datetime.timedelta(seconds=30))
async def ping():
...
```
@ -30,11 +30,27 @@ Handle exceptions raised by the looped coroutine using `on_error`:
async def log_error(exc: Exception) -> None:
print("Loop failed:", exc)
@tasks.loop(seconds=5.0, on_error=log_error)
@loop(seconds=5.0, on_error=log_error)
async def worker():
...
```
Run setup and teardown code using `before_loop` and `after_loop`:
```python
@loop(seconds=5.0)
async def worker():
...
@worker.before_loop
async def before_worker():
print("starting")
@worker.after_loop
async def after_worker():
print("stopped")
```
You can also schedule a task at a specific time of day:
```python
@ -42,7 +58,7 @@ from datetime import datetime, timedelta
time_to_run = (datetime.now() + timedelta(seconds=5)).time()
@tasks.loop(time_of_day=time_to_run)
@loop(time_of_day=time_to_run)
async def daily_task():
...
```

18
docs/threads.md Normal file
View File

@ -0,0 +1,18 @@
# Threads
`Message.create_thread` and `TextChannel.create_thread` let you start new threads.
Use :class:`AutoArchiveDuration` to control when a thread is automatically archived.
```python
from disagreement import AutoArchiveDuration
await message.create_thread(
"discussion",
auto_archive_duration=AutoArchiveDuration.DAY,
)
```
## Next Steps
- [Message History](message_history.md)
- [Caching](caching.md)

View File

@ -4,9 +4,9 @@ The library exposes an async context manager to send the typing indicator for a
```python
import asyncio
import disagreement
from disagreement import Client
client = disagreement.Client(token="YOUR_TOKEN")
client = Client(token="YOUR_TOKEN")
async def indicate(channel_id: str):
async with client.typing(channel_id):

View File

@ -19,8 +19,7 @@ The library exposes three broad categories of components:
`ActionRow` is a layout container. It may hold up to five buttons or a single select menu.
```python
from disagreement.models import ActionRow, Button
from disagreement.enums import ButtonStyle
from disagreement import ActionRow, Button, ButtonStyle
row = ActionRow(components=[
Button(style=ButtonStyle.PRIMARY, label="Click", custom_id="btn")
@ -32,8 +31,7 @@ row = ActionRow(components=[
Buttons provide a clickable UI element.
```python
from disagreement.models import Button
from disagreement.enums import ButtonStyle
from disagreement import Button, ButtonStyle
button = Button(
style=ButtonStyle.SUCCESS,
@ -47,8 +45,7 @@ button = Button(
`SelectMenu` lets the user choose one or more options. The `type` parameter controls the menu variety (`STRING_SELECT`, `USER_SELECT`, `ROLE_SELECT`, `MENTIONABLE_SELECT`, `CHANNEL_SELECT`).
```python
from disagreement.models import SelectMenu, SelectOption
from disagreement.enums import ComponentType, ChannelType
from disagreement import SelectMenu, SelectOption, ComponentType, ChannelType
menu = SelectMenu(
custom_id="example",
@ -70,7 +67,7 @@ For channel selects you may pass `channel_types` with a list of allowed `Channel
`Section` groups one or more `TextDisplay` components and can include an accessory `Button` or `Thumbnail`.
```python
from disagreement.models import Section, TextDisplay, Thumbnail, UnfurledMediaItem
from disagreement import Section, TextDisplay, Thumbnail, UnfurledMediaItem
section = Section(
components=[
@ -86,7 +83,7 @@ section = Section(
`TextDisplay` simply renders markdown text.
```python
from disagreement.models import TextDisplay
from disagreement import TextDisplay
text_display = TextDisplay(content="**Bold text**")
```
@ -96,7 +93,7 @@ text_display = TextDisplay(content="**Bold text**")
`Thumbnail` shows a small image. Set `spoiler=True` to hide the image until clicked.
```python
from disagreement.models import Thumbnail, UnfurledMediaItem
from disagreement import Thumbnail, UnfurledMediaItem
thumb = Thumbnail(
media=UnfurledMediaItem(url="https://example.com/image.png"),
@ -110,7 +107,7 @@ thumb = Thumbnail(
`MediaGallery` holds multiple `MediaGalleryItem` objects.
```python
from disagreement.models import MediaGallery, MediaGalleryItem, UnfurledMediaItem
from disagreement import MediaGallery, MediaGalleryItem, UnfurledMediaItem
gallery = MediaGallery(
items=[
@ -125,7 +122,7 @@ gallery = MediaGallery(
`File` displays an uploaded file. Use `spoiler=True` to mark it as a spoiler.
```python
from disagreement.models import File, UnfurledMediaItem
from disagreement import File, UnfurledMediaItem
file_component = File(
file=UnfurledMediaItem(url="attachment://file.zip"),
@ -138,7 +135,7 @@ file_component = File(
`Separator` adds vertical spacing or an optional divider line between components.
```python
from disagreement.models import Separator
from disagreement import Separator
separator = Separator(divider=True, spacing=2)
```
@ -148,11 +145,11 @@ separator = Separator(divider=True, spacing=2)
`Container` visually groups a set of components and can apply an accent colour or spoiler.
```python
from disagreement.models import Container, TextDisplay
from disagreement import Container, TextDisplay
container = Container(
components=[TextDisplay(content="Inside a container")],
accent_color=0xFF0000,
accent_color="#FF0000", # int or Color() also work
spoiler=False,
)
```
@ -160,9 +157,26 @@ container = Container(
A container can itself contain layout and content components, letting you build complex messages.
## Persistent Views
Views with ``timeout=None`` are persistent. Their ``custom_id`` components are saved to ``persistent_views.json`` so they survive bot restarts.
```python
class MyView(View):
@button(label="Press", custom_id="press")
async def handle(self, view, inter):
await inter.respond("Pressed!")
client.add_persistent_view(MyView())
```
When the client starts, it loads this file and registers each view again. Remove
the file to clear stored views.
## Next Steps
- [Slash Commands](slash_commands.md)
- [Caching](caching.md)
- [Voice Features](voice_features.md)
- [HTTP Client Options](http_client.md)

20
docs/utils.md Normal file
View File

@ -0,0 +1,20 @@
# Utility Helpers
Disagreement provides a few small utility functions for working with Discord data.
## `utcnow`
Returns the current timezone-aware UTC `datetime`.
## `snowflake_time`
Converts a Discord snowflake ID into the UTC timestamp when it was generated.
```python
from disagreement.utils import snowflake_time
created_at = snowflake_time(175928847299117063)
print(created_at.isoformat())
```
The function extracts the timestamp from the snowflake and returns a `datetime` in UTC.

View File

@ -9,15 +9,17 @@ import asyncio
import os
import disagreement
vc = disagreement.VoiceClient(
os.environ["DISCORD_VOICE_ENDPOINT"],
os.environ["DISCORD_SESSION_ID"],
os.environ["DISCORD_VOICE_TOKEN"],
int(os.environ["DISCORD_GUILD_ID"]),
int(os.environ["DISCORD_USER_ID"]),
)
async def main():
async with disagreement.VoiceClient(
os.environ["DISCORD_VOICE_ENDPOINT"],
os.environ["DISCORD_SESSION_ID"],
os.environ["DISCORD_VOICE_TOKEN"],
int(os.environ["DISCORD_GUILD_ID"]),
int(os.environ["DISCORD_USER_ID"]),
) as vc:
await vc.send_audio_frame(b"...")
asyncio.run(vc.connect())
asyncio.run(main())
```
After connecting you can send raw Opus frames:
@ -41,4 +43,15 @@ You can switch sources while connected:
await vc.play(FFmpegAudioSource("other.mp3"))
```
Call `await vc.close()` when finished.
The connection will be closed automatically when leaving the `async with` block.
## Fetching Available Voice Regions
Use :meth:`Client.fetch_voice_regions` to list the voice regions that Discord
currently offers. The method returns a list of :class:`VoiceRegion` values.
```python
regions = await client.fetch_voice_regions()
for region in regions:
print(region.value)
```

View File

@ -6,6 +6,10 @@ Disagreement includes experimental support for connecting to voice channels. You
voice = await client.join_voice(guild_id, channel_id)
await voice.play_file("welcome.mp3")
await voice.play_file("another.mp3") # switch sources while connected
voice.pause()
voice.resume()
if voice.is_playing():
print("audio is playing")
await voice.close()
```
@ -16,4 +20,5 @@ Voice support is optional and may require additional system dependencies such as
- [Components](using_components.md)
- [Slash Commands](slash_commands.md)
- [Caching](caching.md)
- [HTTP Client Options](http_client.md)

View File

@ -5,7 +5,7 @@ The `HTTPClient` includes helper methods for creating, editing and deleting Disc
## Create a webhook
```python
from disagreement.http import HTTPClient
from disagreement import HTTPClient
http = HTTPClient(token="TOKEN")
payload = {"name": "My Webhook"}
@ -27,7 +27,7 @@ await http.delete_webhook("456")
The methods now return a `Webhook` object directly:
```python
from disagreement.models import Webhook
from disagreement import Webhook
print(webhook.id, webhook.name)
```
@ -37,8 +37,17 @@ print(webhook.id, webhook.name)
You can construct a `Webhook` object from an existing webhook URL without any API calls:
```python
from disagreement.models import Webhook
from disagreement import Webhook
webhook = Webhook.from_url("https://discord.com/api/webhooks/123/token")
print(webhook.id, webhook.token)
```
## Send a message through a Webhook
Once you have a `Webhook` instance bound to a :class:`Client`, you can send messages using it:
```python
webhook = await client.create_webhook("123", {"name": "Bot Webhook"})
await webhook.send(content="Hello from my webhook!", username="Bot")
```

View File

@ -27,8 +27,17 @@ if os.path.join(os.getcwd(), "examples") == os.path.dirname(os.path.abspath(__fi
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
try:
import disagreement
from disagreement.ext import commands # Import the new commands extension
from disagreement import (
Client,
GatewayIntent,
Message,
Guild,
AuthenticationError,
DisagreementException,
Cog,
command,
CommandContext,
)
except ImportError:
print(
"Failed to import disagreement. Make sure it's installed or PYTHONPATH is set correctly."
@ -38,9 +47,14 @@ except ImportError:
)
sys.exit(1)
from dotenv import load_dotenv
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")
load_dotenv()
if load_dotenv:
load_dotenv()
# Optional: Configure logging for more insight, especially for gateway events
# logging.basicConfig(level=logging.DEBUG) # For very verbose output
@ -53,15 +67,13 @@ BOT_TOKEN = os.environ.get("DISCORD_BOT_TOKEN")
# --- Intents Configuration ---
# Define the intents your bot needs. For basic message reading and responding:
intents = (
disagreement.GatewayIntent.GUILDS
| disagreement.GatewayIntent.GUILD_MESSAGES
| disagreement.GatewayIntent.MESSAGE_CONTENT
GatewayIntent.GUILDS | GatewayIntent.GUILD_MESSAGES | GatewayIntent.MESSAGE_CONTENT
) # MESSAGE_CONTENT is privileged!
# If you don't need message content and only react to commands/mentions,
# you might not need MESSAGE_CONTENT intent.
# intents = disagreement.GatewayIntent.default() # A good starting point without privileged intents
# intents |= disagreement.GatewayIntent.MESSAGE_CONTENT # Add if needed
# intents = GatewayIntent.default() # A good starting point without privileged intents
# intents |= GatewayIntent.MESSAGE_CONTENT # Add if needed
# --- Initialize the Client ---
if not BOT_TOKEN:
@ -70,30 +82,30 @@ if not BOT_TOKEN:
sys.exit(1)
# Initialize Client with a command prefix
client = disagreement.Client(token=BOT_TOKEN, intents=intents, command_prefix="!")
client = Client(token=BOT_TOKEN, intents=intents, command_prefix="!")
# --- Define a Cog for example commands ---
class ExampleCog(commands.Cog): # Ensuring this uses commands.Cog
class ExampleCog(Cog): # Ensuring this uses commands.Cog
def __init__(
self, bot_client
): # Renamed client to bot_client to avoid conflict with self.client
super().__init__(bot_client) # Pass the client instance to the base Cog
@commands.command(name="hello", aliases=["hi"])
async def hello_command(self, ctx: commands.CommandContext, *, who: str = "world"):
@command(name="hello", aliases=["hi"])
async def hello_command(self, ctx: CommandContext, *, who: str = "world"):
"""Greets someone."""
await ctx.reply(f"Hello {ctx.author.mention} and {who}!")
print(f"Executed 'hello' command for {ctx.author.username}, greeting {who}")
@commands.command()
async def ping(self, ctx: commands.CommandContext):
@command()
async def ping(self, ctx: CommandContext):
"""Responds with Pong!"""
await ctx.reply("Pong!")
print(f"Executed 'ping' command for {ctx.author.username}")
@commands.command()
async def me(self, ctx: commands.CommandContext):
@command()
async def me(self, ctx: CommandContext):
"""Shows information about the invoking user."""
reply_content = (
f"Hello {ctx.author.mention}!\n"
@ -104,8 +116,8 @@ class ExampleCog(commands.Cog): # Ensuring this uses commands.Cog
await ctx.reply(reply_content)
print(f"Executed 'me' command for {ctx.author.username}")
@commands.command(name="add")
async def add_numbers(self, ctx: commands.CommandContext, num1: int, num2: int):
@command(name="add")
async def add_numbers(self, ctx: CommandContext, num1: int, num2: int):
"""Adds two numbers."""
result = num1 + num2
await ctx.reply(f"The sum of {num1} and {num2} is {result}.")
@ -113,16 +125,16 @@ class ExampleCog(commands.Cog): # Ensuring this uses commands.Cog
f"Executed 'add' command for {ctx.author.username}: {num1} + {num2} = {result}"
)
@commands.command(name="say")
async def say_something(self, ctx: commands.CommandContext, *, text_to_say: str):
@command(name="say")
async def say_something(self, ctx: CommandContext, *, text_to_say: str):
"""Repeats the text you provide."""
await ctx.reply(f"You said: {text_to_say}")
print(
f"Executed 'say' command for {ctx.author.username}, saying: {text_to_say}"
)
@commands.command(name="whois")
async def whois(self, ctx: commands.CommandContext, *, name: str):
@command(name="whois")
async def whois(self, ctx: CommandContext, *, name: str):
"""Looks up a member by username or nickname using the guild cache."""
if not ctx.guild:
await ctx.reply("This command can only be used in a guild.")
@ -131,13 +143,13 @@ class ExampleCog(commands.Cog): # Ensuring this uses commands.Cog
member = ctx.guild.get_member_named(name)
if member:
await ctx.reply(
f"Found: {member.username}#{member.discriminator} (nick: {member.nick})"
f"Found: {member.username}#{member.discriminator} (display: {member.display_name})"
)
else:
await ctx.reply("Member not found in cache.")
@commands.command(name="quit")
async def quit_command(self, ctx: commands.CommandContext):
@command(name="quit")
async def quit_command(self, ctx: CommandContext):
"""Shuts down the bot (requires YOUR_USER_ID to be set)."""
# Replace YOUR_USER_ID with your actual Discord User ID for a safe shutdown command
your_user_id = "YOUR_USER_ID_REPLACE_ME" # IMPORTANT: Replace this
@ -171,7 +183,7 @@ async def on_ready():
@client.event
async def on_message(message: disagreement.Message):
async def on_message(message: Message):
"""Called when a message is created and received."""
# Command processing is now handled by the CommandHandler via client._process_message_for_commands
# This on_message can be used for other message-related logic if needed,
@ -190,25 +202,25 @@ async def on_message(message: disagreement.Message):
@client.on_event(
"GUILD_CREATE"
) # Example of listening to a specific event by its Discord name
async def on_guild_available(guild_data: dict): # Receives raw data for now
# In a real scenario, guild_data would be parsed into a Guild model
print(f"Guild available: {guild_data.get('name')} (ID: {guild_data.get('id')})")
async def on_guild_available(guild: Guild):
# The event now passes a Guild object directly
print(f"Guild available: {guild.name} (ID: {guild.id})")
# --- Main Execution ---
async def main():
def main():
print("Starting Disagreement Bot...")
try:
# Add the Cog to the client
client.add_cog(ExampleCog(client)) # Pass client instance to Cog constructor
# client.add_cog is synchronous, but it schedules cog.cog_load() if it's async.
await client.run()
except disagreement.AuthenticationError:
client.run()
except AuthenticationError:
print(
"Authentication failed. Please check your bot token and ensure it's correct."
)
except disagreement.DisagreementException as e:
except DisagreementException as e:
print(f"A Disagreement library error occurred: {e}")
except KeyboardInterrupt:
print("Bot shutting down due to KeyboardInterrupt...")
@ -218,7 +230,7 @@ async def main():
finally:
if not client.is_closed():
print("Ensuring client is closed...")
await client.close()
asyncio.run(client.close())
print("Bot has been shut down.")
@ -230,4 +242,4 @@ if __name__ == "__main__":
# if os.name == 'nt':
# asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
asyncio.run(main())
main()

View File

@ -1,8 +1,9 @@
import os
import asyncio
from typing import Union, Optional
from disagreement import Client, ui, HybridContext
from disagreement.models import (
from typing import Union
from disagreement import (
Client,
HybridContext,
Message,
SelectOption,
User,
@ -19,27 +20,28 @@ from disagreement.models import (
MediaGallery,
MediaGalleryItem,
Container,
)
from disagreement.enums import (
ButtonStyle,
GatewayIntent,
ChannelType,
MessageFlags,
InteractionCallbackType,
MessageFlags,
)
from disagreement.ext.commands.cog import Cog
from disagreement.ext.commands.core import CommandContext
from disagreement.ext.app_commands.decorators import hybrid_command, slash_command
from disagreement.ext.app_commands.context import AppCommandContext
from disagreement.interactions import (
Interaction,
InteractionResponsePayload,
InteractionCallbackData,
Cog,
CommandContext,
AppCommandContext,
hybrid_command,
View,
button,
select,
)
from dotenv import load_dotenv
load_dotenv()
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()
# Get the bot token and application ID from the environment variables
token = os.getenv("DISCORD_BOT_TOKEN")
@ -70,12 +72,12 @@ STOCKS = [
# Define a View class that contains our components
class MyView(ui.View):
class MyView(View):
def __init__(self):
super().__init__(timeout=180) # 180-second timeout
self.click_count = 0
@ui.button(label="Click Me!", style=ButtonStyle.SUCCESS, emoji="🖱️")
@button(label="Click Me!", style=ButtonStyle.SUCCESS, emoji="🖱️")
async def click_me(self, interaction: Interaction):
self.click_count += 1
await interaction.respond(
@ -83,7 +85,7 @@ class MyView(ui.View):
ephemeral=True,
)
@ui.select(
@select(
custom_id="string_select",
placeholder="Choose an option",
options=[
@ -109,12 +111,12 @@ class MyView(ui.View):
# View for cycling through available stocks
class StockView(ui.View):
class StockView(View):
def __init__(self):
super().__init__(timeout=180)
self.index = 0
@ui.button(label="Next Stock", style=ButtonStyle.PRIMARY)
@button(label="Next Stock", style=ButtonStyle.PRIMARY)
async def next_stock(self, interaction: Interaction):
self.index = (self.index + 1) % len(STOCKS)
stock = STOCKS[self.index]
@ -261,7 +263,7 @@ class ComponentCommandsCog(Cog):
)
async def main():
def main():
@client.event
async def on_ready():
if client.user:
@ -281,8 +283,8 @@ async def main():
)
client.add_cog(ComponentCommandsCog(client))
await client.run()
client.run()
if __name__ == "__main__":
asyncio.run(main())
main()

View File

@ -7,17 +7,21 @@ import sys
if os.path.join(os.getcwd(), "examples") == os.path.dirname(os.path.abspath(__file__)):
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from disagreement.client import Client
from disagreement import Client, User, Message
from disagreement.ext.app_commands import (
user_command,
message_command,
AppCommandContext,
)
from disagreement.models import User, Message
from dotenv import load_dotenv
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")
load_dotenv()
if load_dotenv:
load_dotenv()
BOT_TOKEN = os.environ.get("DISCORD_BOT_TOKEN", "")
APP_ID = os.environ.get("DISCORD_APPLICATION_ID", "")
@ -61,11 +65,9 @@ client.app_command_handler.add_command(user_info)
client.app_command_handler.add_command(quote)
async def main() -> None:
await client.run()
def main() -> None:
client.run()
if __name__ == "__main__":
import asyncio
asyncio.run(main())
main()

View File

@ -0,0 +1,36 @@
# Example from the README file of the disagreement library
# This example demonstrates a simple bot that responds to the "!ping" command with "Pong!".
import asyncio
import os
from disagreement import Client, GatewayIntent, Cog, command, CommandContext
from dotenv import load_dotenv
load_dotenv()
class Basics(Cog):
def __init__(self, client: Client) -> None:
super().__init__(client)
@command()
async def ping(self, ctx: CommandContext) -> None:
await ctx.reply(f"Pong! Gateway Latency: {self.client.latency_ms} ms.") # type: ignore (latency is None during static analysis)
token = os.getenv("DISCORD_BOT_TOKEN")
if not token:
raise RuntimeError("DISCORD_BOT_TOKEN environment variable not set")
intents = GatewayIntent.default() | GatewayIntent.MESSAGE_CONTENT
client = Client(token=token, command_prefix="!", intents=intents, mention_replies=True)
def main() -> None:
client.add_cog(Basics(client))
client.run()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,50 @@
"""Demonstrates dynamic extension loading using Client.load_extension."""
import asyncio
import os
import sys
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")
# Allow running from the examples folder without installing
if os.path.join(os.getcwd(), "examples") == os.path.dirname(os.path.abspath(__file__)):
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from disagreement import Client
if load_dotenv:
load_dotenv()
TOKEN = os.environ.get("DISCORD_BOT_TOKEN")
async def main() -> None:
if not TOKEN:
print("DISCORD_BOT_TOKEN environment variable not set")
return
client = Client(token=TOKEN)
# Load the extension which starts a simple ticker task
client.load_extension("examples.sample_extension")
await client.connect()
await asyncio.sleep(6)
# Reload the extension to restart the ticker
client.reload_extension("examples.sample_extension")
await asyncio.sleep(6)
# Unload the extension and stop the ticker
client.unload_extension("examples.sample_extension")
await asyncio.sleep(1)
await client.close()
if __name__ == "__main__":
asyncio.run(main())

View File

@ -3,32 +3,27 @@ import os
import logging
from typing import Any, Optional, Literal, Union
from disagreement import HybridContext
from disagreement.client import Client
from disagreement.ext.commands.cog import Cog
from disagreement.ext.commands.core import CommandContext
from disagreement.ext.app_commands.decorators import (
slash_command,
user_command,
message_command,
hybrid_command,
)
from disagreement.ext.app_commands.commands import (
AppCommandGroup,
) # For defining groups
from disagreement.ext.app_commands.context import AppCommandContext # Added
from disagreement.models import (
from disagreement import (
HybridContext,
Client,
User,
Member,
Role,
Attachment,
Message,
Channel,
) # For type hints
from disagreement.enums import (
ChannelType,
) # For channel option type hints, assuming it exists
)
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
@ -36,9 +31,14 @@ from disagreement.enums import (
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
from dotenv import load_dotenv
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")
load_dotenv()
if load_dotenv:
load_dotenv()
# --- Define a Test Cog ---
@ -230,7 +230,7 @@ class TestCog(Cog):
# --- Main Bot Script ---
async def main():
def main():
bot_token = os.getenv("DISCORD_BOT_TOKEN")
application_id = os.getenv("DISCORD_APPLICATION_ID")
@ -291,7 +291,7 @@ async def main():
client.add_cog(TestCog(client))
try:
await client.run()
client.run()
except KeyboardInterrupt:
logger.info("Bot shutting down...")
except Exception as e:
@ -300,7 +300,7 @@ async def main():
)
finally:
if not client.is_closed():
await client.close()
asyncio.run(client.close())
logger.info("Bot has been closed.")
@ -310,6 +310,6 @@ if __name__ == "__main__":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
try:
asyncio.run(main())
main()
except KeyboardInterrupt:
logger.info("Main loop interrupted. Exiting.")

View File

@ -0,0 +1,37 @@
"""Example showing how to read a channel's message history."""
import asyncio
import os
import sys
# Allow running example from repository root
if os.path.join(os.getcwd(), "examples") == os.path.dirname(os.path.abspath(__file__)):
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from disagreement import Client, Channel
from disagreement.models import TextChannel
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()
BOT_TOKEN = os.environ.get("DISCORD_BOT_TOKEN", "")
CHANNEL_ID = os.environ.get("DISCORD_CHANNEL_ID", "")
client = Client(token=BOT_TOKEN)
async def main() -> None:
channel = await client.fetch_channel(CHANNEL_ID)
if isinstance(channel, TextChannel):
async for message in channel.history(limit=10):
print(message.content)
if __name__ == "__main__":
asyncio.run(main())

View File

@ -2,14 +2,19 @@
import os
import asyncio
from dotenv import load_dotenv
from disagreement import Client, ui
from disagreement.enums import GatewayIntent, TextInputStyle
from disagreement.ext.app_commands.decorators import slash_command
from disagreement.ext.app_commands.context import AppCommandContext
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")
load_dotenv()
from disagreement import Client, ui, GatewayIntent
from disagreement.enums import TextInputStyle
from disagreement.ext.app_commands import slash_command, AppCommandContext
if load_dotenv:
load_dotenv()
token = os.getenv("DISCORD_BOT_TOKEN", "")
application_id = os.getenv("DISCORD_APPLICATION_ID", "")
@ -57,9 +62,9 @@ async def on_ready():
print("------")
async def main():
await client.run()
def main():
client.run()
if __name__ == "__main__":
asyncio.run(main())
main()

View File

@ -3,15 +3,19 @@
import os
import sys
from dotenv import load_dotenv
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")
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from disagreement import Client, GatewayIntent, ui # type: ignore
from disagreement.ext.app_commands.decorators import slash_command
from disagreement.ext.app_commands.context import AppCommandContext
from disagreement import Client, GatewayIntent, ui
from disagreement.ext.app_commands import slash_command, AppCommandContext
load_dotenv()
if load_dotenv:
load_dotenv()
TOKEN = os.getenv("DISCORD_BOT_TOKEN", "")
APP_ID = os.getenv("DISCORD_APPLICATION_ID", "")
@ -26,6 +30,7 @@ class NameModal(ui.Modal):
def __init__(self):
super().__init__(title="Your Name", custom_id="name_modal")
self.name = ui.TextInput(label="Name", custom_id="name")
self.add_item(self.name)
@slash_command(name="namemodal", description="Shows a modal")
@ -58,6 +63,4 @@ async def on_ready():
if __name__ == "__main__":
import asyncio
asyncio.run(client.run())
client.run()

View File

@ -0,0 +1,93 @@
"""Example moderation bot using the Disagreement library."""
import asyncio
import os
import sys
from typing import Set
# Allow running example from repository root
if os.path.join(os.getcwd(), "examples") == os.path.dirname(os.path.abspath(__file__)):
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from disagreement import (
Client,
GatewayIntent,
Member,
Message,
Cog,
command,
CommandContext,
)
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()
BOT_TOKEN = os.environ.get("DISCORD_BOT_TOKEN")
if not BOT_TOKEN:
print("DISCORD_BOT_TOKEN environment variable not set")
sys.exit(1)
intents = (
GatewayIntent.GUILDS | GatewayIntent.GUILD_MESSAGES | GatewayIntent.MESSAGE_CONTENT
)
client = Client(token=BOT_TOKEN, command_prefix="!", intents=intents)
# Simple list of banned words
BANNED_WORDS: Set[str] = {"badword1", "badword2"}
class ModerationCog(Cog):
def __init__(self, bot: Client) -> None:
super().__init__(bot)
@command()
async def kick(
self, ctx: CommandContext, member: Member, *, reason: str = ""
) -> None:
"""Kick a member from the guild."""
await member.kick(reason=reason or None)
await ctx.reply(f"Kicked {member.display_name}")
@command()
async def ban(
self, ctx: CommandContext, member: Member, *, reason: str = ""
) -> None:
"""Ban a member from the guild."""
await member.ban(reason=reason or None)
await ctx.reply(f"Banned {member.display_name}")
@client.event
async def on_ready() -> None:
if client.user:
print(f"Logged in as {client.user.username}#{client.user.discriminator}")
print("Moderation bot ready.")
@client.event
async def on_message(message: Message) -> None:
await client._process_message_for_commands(message)
if message.author.bot:
return
content_lower = message.content.lower()
if any(word in content_lower for word in BANNED_WORDS):
await message.delete()
await client.send_message(
message.channel_id,
f"{message.author.mention}, your message was removed for inappropriate content.",
)
def main() -> None:
client.add_cog(ModerationCog(client))
client.run()
if __name__ == "__main__":
main()

157
examples/reactions.py Normal file
View File

@ -0,0 +1,157 @@
# examples/reactions.py
"""
An example bot demonstrating reaction handling with the Disagreement library.
This bot will:
1. React to a specific command with a thumbs-up emoji.
2. Log when any reaction is added to a message in a server it's in.
3. Log when any reaction is removed from a message.
To run this bot:
1. Follow the setup steps in 'basic_bot.py' to set your DISCORD_BOT_TOKEN.
2. Ensure you have the GUILD_MESSAGE_REACTIONS intent enabled for your bot in the Discord Developer Portal.
3. Run this script: python examples/reactions.py
"""
import asyncio
import os
import sys
import logging
import traceback
# Add project root to path for local development
if os.path.join(os.getcwd(), "examples") == os.path.dirname(os.path.abspath(__file__)):
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
try:
from disagreement import (
Client,
GatewayIntent,
Reaction,
User,
Member,
HTTPException,
AuthenticationError,
Cog,
command,
CommandContext,
)
except ImportError:
print(
"Failed to import disagreement. Make sure it's installed or PYTHONPATH is set correctly."
)
sys.exit(1)
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()
# --- Bot Configuration ---
BOT_TOKEN = os.environ.get("DISCORD_BOT_TOKEN")
# --- Intents Configuration ---
# We need GUILDS for server context, GUILD_MESSAGES to receive messages,
# and GUILD_MESSAGE_REACTIONS to listen for reaction events.
intents = (
GatewayIntent.GUILDS
| GatewayIntent.GUILD_MESSAGES
| GatewayIntent.GUILD_MESSAGE_REACTIONS
| GatewayIntent.MESSAGE_CONTENT # For commands
)
# --- Initialize the Client ---
if not BOT_TOKEN:
print("Error: The DISCORD_BOT_TOKEN environment variable is not set.")
sys.exit(1)
client = Client(token=BOT_TOKEN, intents=intents, command_prefix="!")
# --- Define a Cog for reaction-related commands ---
class ReactionCog(Cog):
def __init__(self, bot_client):
super().__init__(bot_client)
@command(name="react")
async def react_command(self, ctx: CommandContext):
"""Reacts to the command message with a thumbs up."""
try:
# The emoji can be a standard Unicode emoji or a custom one in the format '<:name:id>'
await ctx.message.add_reaction("👍")
print(f"Reacted to command from {ctx.author.username}")
except HTTPException as e:
print(f"Failed to add reaction: {e}")
await ctx.reply(
"I couldn't add the reaction. I might be missing permissions."
)
# --- Event Handlers ---
@client.event
async def on_ready():
"""Called when the bot is ready and connected to Discord."""
if client.user:
print(f"Bot is ready! Logged in as {client.user.username}")
else:
print("Bot is ready, but client.user is missing!")
print("------")
print("Reaction example bot is operational.")
@client.on_event("MESSAGE_REACTION_ADD")
async def on_reaction_add(reaction: Reaction, user: User | Member):
"""Called when a message reaction is added."""
# We can ignore reactions from the bot itself
if client.user and user.id == client.user.id:
return
print(
f"Reaction '{reaction.emoji}' added by {user.username} "
f"to message ID {reaction.message_id} in channel ID {reaction.channel_id}"
)
# You can fetch the message if you need its content, but it's an extra API call.
# try:
# channel = await client.fetch_channel(reaction.channel_id)
# if isinstance(channel, disagreement.TextChannel):
# message = await channel.fetch_message(reaction.message_id)
# print(f" Message content: '{message.content}'")
# except disagreement.errors.NotFound:
# print(" Could not fetch message (maybe it was deleted).")
@client.on_event("MESSAGE_REACTION_REMOVE")
async def on_reaction_remove(reaction: Reaction, user: User | Member):
"""Called when a message reaction is removed."""
print(
f"Reaction '{reaction.emoji}' removed by {user.username} "
f"from message ID {reaction.message_id} in channel ID {reaction.channel_id}"
)
# --- Main Execution ---
def main():
print("Starting Reaction Bot...")
try:
client.add_cog(ReactionCog(client))
client.run()
except AuthenticationError:
print("Authentication failed. Check your bot token.")
except Exception as e:
print(f"An unexpected error occurred: {e}")
traceback.print_exc()
finally:
if not client.is_closed():
asyncio.run(client.close())
print("Bot has been shut down.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,16 @@
from disagreement import loop
@loop(seconds=2.0)
async def ticker() -> None:
print("Extension tick")
def setup() -> None:
print("sample_extension setup")
ticker.start()
def teardown() -> None:
print("sample_extension teardown")
ticker.stop()

View File

@ -8,13 +8,22 @@ import sys
if os.path.join(os.getcwd(), "examples") == os.path.dirname(os.path.abspath(__file__)):
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
import disagreement
from disagreement import Client
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()
TOKEN = os.environ.get("DISCORD_BOT_TOKEN")
if not TOKEN:
raise RuntimeError("DISCORD_BOT_TOKEN environment variable not set")
client = disagreement.Client(token=TOKEN, shard_count=2)
client = Client(token=TOKEN, shard_count=2)
@client.event
@ -25,12 +34,12 @@ async def on_ready():
print("Shard bot ready")
async def main():
def main():
if not TOKEN:
print("DISCORD_BOT_TOKEN environment variable not set")
return
await client.run()
client.run()
if __name__ == "__main__":
asyncio.run(main())
main()

View File

@ -8,12 +8,12 @@ import sys
if os.path.join(os.getcwd(), "examples") == os.path.dirname(os.path.abspath(__file__)):
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from disagreement.ext import tasks
from disagreement import loop
counter = 0
@tasks.loop(seconds=1.0)
@loop(seconds=1.0)
async def ticker() -> None:
global counter
counter += 1

View File

@ -0,0 +1,124 @@
# examples/typing_indicator.py
"""
An example bot demonstrating how to use the typing indicator with the Disagreement library.
This bot will:
1. Respond to a command `!typing_test`.
2. Send a typing indicator to the channel where the command was invoked.
3. Simulate a long-running task while the typing indicator is active.
To run this bot:
1. Follow the setup steps in 'basic_bot.py' to set your DISCORD_BOT_TOKEN.
2. Ensure you have the necessary intents (GUILDS, GUILD_MESSAGES, MESSAGE_CONTENT).
3. Run this script: python examples/typing_indicator.py
"""
import asyncio
import os
import sys
import traceback
# Add project root to path for local development
if os.path.join(os.getcwd(), "examples") == os.path.dirname(os.path.abspath(__file__)):
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
try:
from disagreement import (
Client,
GatewayIntent,
HTTPException,
AuthenticationError,
Cog,
command,
CommandContext,
)
except ImportError:
print(
"Failed to import disagreement. Make sure it's installed or PYTHONPATH is set correctly."
)
sys.exit(1)
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()
# --- Bot Configuration ---
BOT_TOKEN = os.environ.get("DISCORD_BOT_TOKEN")
# --- Intents Configuration ---
intents = (
GatewayIntent.GUILDS | GatewayIntent.GUILD_MESSAGES | GatewayIntent.MESSAGE_CONTENT
)
# --- Initialize the Client ---
if not BOT_TOKEN:
print("Error: The DISCORD_BOT_TOKEN environment variable is not set.")
sys.exit(1)
client = Client(token=BOT_TOKEN, intents=intents, command_prefix="!")
# --- Define a Cog for the typing indicator command ---
class TypingCog(Cog):
def __init__(self, bot_client):
super().__init__(bot_client)
@command(name="typing")
async def typing_test_command(self, ctx: CommandContext):
"""Shows a typing indicator for 5 seconds."""
await ctx.reply("Showing typing indicator for 5 seconds...")
try:
async with client.typing(ctx.message.channel_id):
print(
f"Displaying typing indicator in channel {ctx.message.channel_id} for 5 seconds."
)
await asyncio.sleep(5)
print("Typing indicator stopped.")
await ctx.send("Done!")
except HTTPException as e:
print(f"Failed to send typing indicator: {e}")
await ctx.reply(
"I couldn't show the typing indicator. I might be missing permissions."
)
# --- Event Handlers ---
@client.event
async def on_ready():
"""Called when the bot is ready and connected to Discord."""
if client.user:
print(f"Bot is ready! Logged in as {client.user.username}")
else:
print("Bot is ready, but client.user is missing!")
print("------")
print("Typing indicator example bot is operational.")
print("Use the `!typing_test` command in a server channel.")
# --- Main Execution ---
def main():
print("Starting Typing Indicator Bot...")
try:
client.add_cog(TypingCog(client))
client.run()
except AuthenticationError:
print("Authentication failed. Check your bot token.")
except Exception as e:
print(f"An unexpected error occurred: {e}")
traceback.print_exc()
finally:
if not client.is_closed():
asyncio.run(client.close())
print("Bot has been shut down.")
if __name__ == "__main__":
main()

View File

@ -10,11 +10,16 @@ if os.path.join(os.getcwd(), "examples") == os.path.dirname(os.path.abspath(__fi
from typing import cast
from dotenv import load_dotenv
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")
import disagreement
from disagreement import Client
load_dotenv()
if load_dotenv:
load_dotenv()
_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
_GUILD_ID = os.getenv("DISCORD_GUILD_ID")
@ -34,7 +39,7 @@ CHANNEL_ID = cast(str, _CHANNEL_ID)
async def main() -> None:
client = disagreement.Client(TOKEN)
client = Client(TOKEN)
await client.connect()
voice = await client.join_voice(GUILD_ID, CHANNEL_ID)
try:

66
mkdocs.yml Normal file
View File

@ -0,0 +1,66 @@
site_name: Disagreement Docs
site_description: 'Documentation for the Disagreement library.'
repo_url: https://github.com/Slipstreamm/disagreement # Replace with your repo URL
edit_uri: "" # Optional: link to edit page in repo
theme:
name: material
features:
- navigation.tabs
- navigation.sections
- toc.integrate
- navigation.top
- search.suggest
- search.highlight
- content.tabs.link
- content.code.annotation
- content.code.copy
language: en
palette:
- scheme: default
toggle:
icon: material/weather-night
name: Switch to dark mode
- scheme: slate
toggle:
icon: material/weather-sunny
name: Switch to light mode
nav:
- 'Home': 'index.md'
- 'User Guide':
- 'Introduction': 'introduction.md'
- 'Getting Started':
- 'Slash Commands': 'slash_commands.md'
- 'Events': 'events.md'
- 'Components': 'using_components.md'
- 'Embeds': 'embeds.md'
- 'Context Menus': 'context_menus.md'
- 'Message History': 'message_history.md'
- 'Reactions': 'reactions.md'
- 'Threads': 'threads.md'
- 'Typing Indicator': 'typing_indicator.md'
- 'Webhooks': 'webhooks.md'
- 'Advanced Topics':
- 'Caching': 'caching.md'
- 'Sharding': 'sharding.md'
- 'Internationalization': 'i18n.md'
- 'Hybrid Context': 'hybrid_context.md'
- 'Voice': 'voice_features.md'
- 'Task Loop': 'task_loop.md'
- 'Extension Loader': 'extension_loader.md'
- 'Scheduled Events': 'scheduled_events.md'
- 'API Reference':
- 'HTTP Client': 'http_client.md'
- 'Rate Limiter': 'rate_limiter.md'
- 'Gateway': 'gateway.md'
- 'Permissions': 'permissions.md'
- 'Audit Logs': 'audit_logs.md'
- 'Commands': 'commands.md'
- 'Converters': 'converters.md'
- 'Invites': 'invites.md'
- 'Mentions': 'mentions.md'
- 'OAuth2': 'oauth2.md'
- 'Presence': 'presence.md'
- 'Voice Client': 'voice_client.md'
- 'Utility Helpers': 'utils.md'

View File

@ -1,55 +1,58 @@
[project]
name = "disagreement"
version = "0.1.0rc1"
description = "A Python library for the Discord API."
readme = "README.md"
requires-python = ">=3.11"
license = {text = "BSD 3-Clause"}
authors = [
{name = "Slipstream", email = "me@slipstreamm.dev"}
]
keywords = ["discord", "api", "bot", "async", "aiohttp"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Internet",
]
dependencies = [
"aiohttp>=3.9.0,<4.0.0",
]
[project.optional-dependencies]
test = [
"pytest>=8.0.0",
"pytest-asyncio>=1.0.0",
"hypothesis>=6.89.0",
]
dev = [
"dotenv>=0.0.5",
]
[project.urls]
Homepage = "https://github.com/Slipstreamm/disagreement"
Issues = "https://github.com/Slipstreamm/disagreement/issues"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
# Optional: for linting/formatting, e.g., Ruff
# [tool.ruff]
# line-length = 88
# select = ["E", "W", "F", "I", "UP", "C4", "B"] # Example rule set
# ignore = []
# [tool.ruff.format]
# quote-style = "double"
[project]
name = "disagreement"
version = "0.8.1"
description = "A Python library for the Discord API."
readme = "README.md"
requires-python = ">=3.10"
license = {text = "BSD 3-Clause"}
authors = [
{name = "Slipstream", email = "me@slipstreamm.dev"}
]
keywords = ["discord", "api", "bot", "async", "aiohttp"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Internet",
]
dependencies = [
"aiohttp>=3.9.0,<4.0.0",
"PyNaCl>=1.5.0,<2.0.0",
]
[project.optional-dependencies]
test = [
"pytest>=8.0.0",
"pytest-asyncio>=1.0.0",
"hypothesis>=6.132.0",
]
dev = [
"python-dotenv>=1.0.0",
]
[project.urls]
Homepage = "https://github.com/Slipstreamm/disagreement"
Issues = "https://github.com/Slipstreamm/disagreement/issues"
Documentation = "https://disagreement.xyz/"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
# Optional: for linting/formatting, e.g., Ruff
# [tool.ruff]
# line-length = 88
# select = ["E", "W", "F", "I", "UP", "C4", "B"] # Example rule set
# ignore = []
# [tool.ruff.format]
# quote-style = "double"

View File

@ -1,6 +1,6 @@
{
"include": ["."],
"exclude": ["**/node_modules", "**/__pycache__", "**/.venv", "**/.git", "**/dist", "**/build", "**/tests/**", "tavilytool.py"],
"exclude": ["**/node_modules", "**/__pycache__", "**/.venv", "**/venv", "**/.git", "**/dist", "**/build", "**/tests/**", "tavilytool.py"],
"ignore": [],
"reportMissingImports": true,
"reportMissingTypeStubs": false,

View File

@ -1 +1 @@
-e .[test,dev]
-e .[test,dev]

View File

@ -1,2 +1,2 @@
[options]
packages = find:
packages = find:

View File

@ -3,7 +3,16 @@ import pytest
from disagreement.ext.commands.converters import run_converters
from disagreement.ext.commands.core import CommandContext, Command
from disagreement.ext.commands.errors import BadArgument
from disagreement.models import Message, Member, Role, Guild
from disagreement.models import (
Message,
Member,
Role,
Guild,
User,
TextChannel,
VoiceChannel,
PartialEmoji,
)
from disagreement.enums import (
VerificationLevel,
MessageNotificationLevel,
@ -11,26 +20,43 @@ from disagreement.enums import (
MFALevel,
GuildNSFWLevel,
PremiumTier,
ChannelType,
)
class DummyBot:
def __init__(self, guild: Guild):
self._guilds = {guild.id: guild}
from disagreement.client import Client
from disagreement.cache import GuildCache, Cache, ChannelCache
def get_guild(self, gid):
return self._guilds.get(gid)
async def fetch_member(self, gid, mid):
guild = self._guilds.get(gid)
return guild.get_member(mid) if guild else None
class DummyBot(Client):
def __init__(self):
super().__init__(token="test")
self._guilds = GuildCache()
self._users = Cache()
self._channels = ChannelCache()
async def fetch_role(self, gid, rid):
guild = self._guilds.get(gid)
return guild.get_role(rid) if guild else None
def get_guild(self, guild_id):
return self._guilds.get(guild_id)
async def fetch_guild(self, gid):
return self._guilds.get(gid)
def get_channel(self, channel_id):
return self._channels.get(channel_id)
async def fetch_member(self, guild_id, member_id):
guild = self._guilds.get(guild_id)
return guild.get_member(member_id) if guild else None
async def fetch_role(self, guild_id, role_id):
guild = self._guilds.get(guild_id)
return guild.get_role(role_id) if guild else None
async def fetch_guild(self, guild_id):
return self._guilds.get(guild_id)
async def fetch_user(self, user_id):
return self._users.get(user_id)
async def fetch_channel(self, channel_id):
return self._channels.get(channel_id)
@pytest.fixture()
@ -51,7 +77,12 @@ def guild_objects():
"premium_tier": PremiumTier.NONE.value,
"nsfw_level": GuildNSFWLevel.DEFAULT.value,
}
guild = Guild(guild_data, client_instance=None)
bot = DummyBot()
guild = Guild(guild_data, client_instance=bot)
bot._guilds.set(guild.id, guild)
user = User({"id": "7", "username": "u", "discriminator": "0001"})
bot._users.set(user.id, user)
member = Member(
{
@ -76,16 +107,42 @@ def guild_objects():
}
)
guild._members[member.id] = member
guild._members.set(member.id, member)
guild.roles.append(role)
return guild, member, role
text_channel = TextChannel(
{
"id": "20",
"type": ChannelType.GUILD_TEXT.value,
"guild_id": guild.id,
"permission_overwrites": [],
},
client_instance=bot,
)
voice_channel = VoiceChannel(
{
"id": "21",
"type": ChannelType.GUILD_VOICE.value,
"guild_id": guild.id,
"permission_overwrites": [],
},
client_instance=bot,
)
guild._channels.set(text_channel.id, text_channel)
guild.text_channels.append(text_channel)
guild._channels.set(voice_channel.id, voice_channel)
guild.voice_channels.append(voice_channel)
bot._channels.set(text_channel.id, text_channel)
bot._channels.set(voice_channel.id, voice_channel)
return guild, member, role, user, text_channel, voice_channel
@pytest.fixture()
def command_context(guild_objects):
guild, member, role = guild_objects
bot = DummyBot(guild)
guild, member, role, _, _, _ = guild_objects
bot = guild._client
message_data = {
"id": "10",
"channel_id": "20",
@ -107,7 +164,7 @@ def command_context(guild_objects):
@pytest.mark.asyncio
async def test_member_converter(command_context, guild_objects):
_, member, _ = guild_objects
_, member, _, _, _, _ = guild_objects
mention = f"<@!{member.id}>"
result = await run_converters(command_context, Member, mention)
assert result is member
@ -117,7 +174,7 @@ async def test_member_converter(command_context, guild_objects):
@pytest.mark.asyncio
async def test_role_converter(command_context, guild_objects):
_, _, role = guild_objects
_, _, role, _, _, _ = guild_objects
mention = f"<@&{role.id}>"
result = await run_converters(command_context, Role, mention)
assert result is role
@ -125,13 +182,55 @@ async def test_role_converter(command_context, guild_objects):
assert result is role
@pytest.mark.asyncio
async def test_user_converter(command_context, guild_objects):
_, _, _, user, _, _ = guild_objects
mention = f"<@{user.id}>"
result = await run_converters(command_context, User, mention)
assert result is user
result = await run_converters(command_context, User, user.id)
assert result is user
@pytest.mark.asyncio
async def test_guild_converter(command_context, guild_objects):
guild, _, _ = guild_objects
guild, _, _, _, _, _ = guild_objects
result = await run_converters(command_context, Guild, guild.id)
assert result is guild
@pytest.mark.asyncio
async def test_text_channel_converter(command_context, guild_objects):
_, _, _, _, text_channel, _ = guild_objects
mention = f"<#{text_channel.id}>"
result = await run_converters(command_context, TextChannel, mention)
assert result is text_channel
result = await run_converters(command_context, TextChannel, text_channel.id)
assert result is text_channel
@pytest.mark.asyncio
async def test_voice_channel_converter(command_context, guild_objects):
_, _, _, _, _, voice_channel = guild_objects
mention = f"<#{voice_channel.id}>"
result = await run_converters(command_context, VoiceChannel, mention)
assert result is voice_channel
result = await run_converters(command_context, VoiceChannel, voice_channel.id)
assert result is voice_channel
@pytest.mark.asyncio
async def test_emoji_converter(command_context):
result = await run_converters(command_context, PartialEmoji, "<:smile:1>")
assert isinstance(result, PartialEmoji)
assert result.id == "1"
assert result.name == "smile"
result = await run_converters(command_context, PartialEmoji, "😄")
assert result.id is None
assert result.name == "😄"
@pytest.mark.asyncio
async def test_member_converter_no_guild():
guild_data = {
@ -150,8 +249,9 @@ async def test_member_converter_no_guild():
"premium_tier": PremiumTier.NONE.value,
"nsfw_level": GuildNSFWLevel.DEFAULT.value,
}
guild = Guild(guild_data, client_instance=None)
bot = DummyBot(guild)
bot = DummyBot()
guild = Guild(guild_data, client_instance=bot)
bot._guilds.set(guild.id, guild)
message_data = {
"id": "11",
"channel_id": "20",

Some files were not shown because too many files have changed in this diff Show More