Compare commits
187 Commits
Author | SHA1 | Date | |
---|---|---|---|
380feddeeb | |||
3beaed8a1b | |||
e5ad932321 | |||
8e88aaec2f | |||
d710487fc2 | |||
506adeca20 | |||
e2061adc55 | |||
132521fa39 | |||
cec747a575 | |||
17751d3b09 | |||
4b3b6aeb45 | |||
aa55aa1d4c | |||
80f64c1f73 | |||
f5f8f6908c | |||
3437050f0e | |||
87d67eb63b | |||
9c10ab0f70 | |||
2008dd33d1 | |||
de40aa2c29 | |||
2056a3ddcf | |||
ccf55adba2 | |||
a335ed972c | |||
2586d3cd0d | |||
7f9647a442 | |||
a222dec661 | |||
3f7c286322 | |||
cc17d11509 | |||
9fabf1fbac | |||
223c86cb78 | |||
98afb89629 | |||
095e7e7192 | |||
c1c5cfb41a | |||
8be234c1f0 | |||
1464937f6f | |||
5d66eb79cc | |||
5d72643390 | |||
c7eb8563de | |||
a68bbe7826 | |||
6eff962682 | |||
f24c1befac | |||
c811e2b578 | |||
9f2fc0857b | |||
775dce0c80 | |||
a93ad432b7 | |||
3a264f4530 | |||
|
a41a301927 | ||
|
bd16b1c026 | ||
460583ef30 | |||
f1ca18a62a | |||
|
2c8e426353 | ||
c9aec0dc7e | |||
|
bd92806c4c | ||
|
e965a675c1 | ||
|
9237d12a24 | ||
|
420c57df30 | ||
|
b039b2e948 | ||
f58ffe8321 | |||
|
ffdb922142 | ||
|
2b8f29bde2 | ||
|
f7a47619ac | ||
|
675aab39ce | ||
|
a2bdc66ced | ||
|
6fb371455b | ||
|
8a228a9e1b | ||
|
1505bdfd0a | ||
|
7354ff2244 | ||
|
66eb50833b | ||
|
398c2c34c0 | ||
2e72103b6a | |||
91821e1c1d | |||
12b14b9187 | |||
fae9cddb88 | |||
fd9ce4bbb8 | |||
3adce99f22 | |||
075811982d | |||
aa01d74c01 | |||
ae45cc898d | |||
890742b177 | |||
d0e55d3706 | |||
b5ee8dc408 | |||
dbdab08c7a | |||
a3568f1287 | |||
c146b01cec | |||
c87bcefd41 | |||
8e48da3bee | |||
def2ff0183 | |||
7c7bebc95a | |||
e693f00abe | |||
c099466024 | |||
235ea8fc69 | |||
0a3f680e7f | |||
96cd3f1714 | |||
d437a315ef | |||
286862ebaf | |||
e39f701f33 | |||
c4c27bc0d3 | |||
a13cf1e4f8 | |||
ce670245c4 | |||
e199d5494b | |||
b97d52a365 | |||
9dd9cc7e2b | |||
5db4d40076 | |||
79642a48da | |||
6bcde9c5b0 | |||
64576203ae | |||
071e01cd87 | |||
592653cccd | |||
9d8fd83497 | |||
3c638d17ce | |||
a702c66603 | |||
aec0de3e58 | |||
66288ba920 | |||
15d95bc786 | |||
3158d76e90 | |||
dd6cf9ad9e | |||
6fd1f93bab | |||
d4bf99eac6 | |||
cfb8bedeaf | |||
35eb459c36 | |||
64dec9b3f5 | |||
6d55a2ca98 | |||
afeb86a395 | |||
39162b6543 | |||
c47a7e49f8 | |||
07daf78ef4 | |||
73416858ae | |||
1c45989988 | |||
45a5ef1fb5 | |||
ed83a9da85 | |||
0151526d07 | |||
17b7ea35a9 | |||
28702fa8a1 | |||
97505948ee | |||
152c0f12be | |||
eb38ecf671 | |||
2bd45c87ca | |||
be85444aa0 | |||
9509213f7a | |||
c250c22737 | |||
2d6c2cb0be | |||
3059041ba8 | |||
43ca2dc561 | |||
7595e33fd1 | |||
36b06c6c7a | |||
a4aa4335a5 | |||
463ad26217 | |||
029a0fe2bd | |||
b0f9381fa6 | |||
5d3778b083 | |||
b9d93a90fa | |||
669f00e745 | |||
7db71e8124 | |||
97273ce655 | |||
d631ab8e7c | |||
d074839a29 | |||
09c2b3e0cf | |||
f91c6917b8 | |||
fd31a3162b | |||
90ee3fcf7f | |||
f92425bbd1 | |||
134506e6ba | |||
92b0bc5804 | |||
71097c6fbe | |||
d423f5c03a | |||
900cd27cc3 | |||
b1861d510f | |||
fdfb2034d5 | |||
b375dc7d05 | |||
b9bfa24511 | |||
477419bd96 | |||
c27a25955a | |||
0630c8b916 | |||
eb8d7a9656 | |||
d67097a619 | |||
39b05bc958 | |||
df77a3fcec | |||
105640e54b | |||
dfbda351e4 | |||
8b0e6fcce2 | |||
e55e963a59 | |||
c0066525db | |||
e7773373f4 | |||
9df06868a4 | |||
534b5b3980 | |||
b20f1fd292 | |||
6d5b92ad69 | |||
60a183742a |
50
.github/workflows/ci.yml
vendored
50
.github/workflows/ci.yml
vendored
@ -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
53
.github/workflows/docs.yml
vendored
Normal 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
|
9
.github/workflows/mirror.yml
vendored
9
.github/workflows/mirror.yml
vendored
@ -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
|
||||
|
17
.github/workflows/pypi.yml
vendored
17
.github/workflows/pypi.yml
vendored
@ -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/*
|
||||
|
@ -1,3 +1,4 @@
|
||||
graft docs
|
||||
graft examples
|
||||
include LICENSE
|
||||
include LICENSE
|
||||
include disagreement/py.typed
|
||||
|
87
README.md
87
README.md
@ -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
|
||||
|
@ -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
51
disagreement/asset.py
Normal 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}'>"
|
@ -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
|
||||
|
@ -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
129
disagreement/caching.py
Normal 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
@ -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")
|
||||
|
@ -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 ---
|
||||
|
||||
|
||||
|
@ -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
@ -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,
|
||||
|
@ -0,0 +1,3 @@
|
||||
from . import app_commands, commands, tasks
|
||||
|
||||
__all__ = ["app_commands", "commands", "tasks"]
|
@ -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 *
|
||||
|
@ -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).
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
59
disagreement/ext/app_commands/hybrid.py
Normal file
59
disagreement/ext/app_commands/hybrid.py
Normal 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.
|
@ -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",
|
||||
]
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
@ -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)
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
"""
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
2225
disagreement/http.py
2225
disagreement/http.py
File diff suppressed because it is too large
Load Diff
@ -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
19
disagreement/object.py
Normal 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}>"
|
@ -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
0
disagreement/py.typed
Normal file
@ -1,5 +1,3 @@
|
||||
# disagreement/shard_manager.py
|
||||
|
||||
"""Sharding utilities for managing multiple gateway connections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
|
@ -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
1
docs/CNAME
Normal file
@ -0,0 +1 @@
|
||||
disagreement.xyz
|
15
docs/audit_logs.md
Normal file
15
docs/audit_logs.md
Normal 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)
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -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
22
docs/embeds.md
Normal 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()
|
||||
```
|
@ -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):
|
||||
...
|
||||
```
|
||||
|
@ -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
31
docs/http_client.md
Normal 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
14
docs/hybrid_context.md
Normal 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
5
docs/index.md
Normal 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
194
docs/introduction.md
Normal 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
24
docs/invites.md
Normal 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
29
docs/mentions.md
Normal 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
21
docs/message_history.md
Normal 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)
|
@ -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
|
||||
|
||||
|
@ -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
14
docs/rate_limiter.md
Normal 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.
|
@ -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
26
docs/scheduled_events.md
Normal 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)
|
@ -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()
|
||||
```
|
||||
|
@ -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()`.
|
||||
|
||||
|
@ -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
18
docs/threads.md
Normal 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)
|
@ -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):
|
||||
|
@ -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
20
docs/utils.md
Normal 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.
|
@ -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)
|
||||
```
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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")
|
||||
```
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
36
examples/example_from_readme.py
Normal file
36
examples/example_from_readme.py
Normal 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()
|
50
examples/extension_management.py
Normal file
50
examples/extension_management.py
Normal 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())
|
@ -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.")
|
||||
|
37
examples/message_history.py
Normal file
37
examples/message_history.py
Normal 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())
|
@ -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()
|
||||
|
@ -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()
|
||||
|
93
examples/moderation_bot.py
Normal file
93
examples/moderation_bot.py
Normal 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
157
examples/reactions.py
Normal 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()
|
16
examples/sample_extension.py
Normal file
16
examples/sample_extension.py
Normal 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()
|
@ -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()
|
||||
|
@ -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
|
||||
|
124
examples/typing_indicator.py
Normal file
124
examples/typing_indicator.py
Normal 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()
|
@ -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
66
mkdocs.yml
Normal 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'
|
113
pyproject.toml
113
pyproject.toml
@ -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"
|
||||
|
@ -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,
|
||||
|
@ -1 +1 @@
|
||||
-e .[test,dev]
|
||||
-e .[test,dev]
|
||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user