Compare commits
136 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 |
48
.github/workflows/ci.yml
vendored
48
.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.13'
|
||||
- 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: 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: |
|
||||
source venv/bin/activate
|
||||
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/*
|
||||
|
69
README.md
69
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
|
||||
|
||||
@ -23,6 +29,13 @@ 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
|
||||
@ -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
|
||||
@ -102,6 +112,36 @@ 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
|
||||
@ -120,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
|
||||
@ -131,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.2.0rc1"
|
||||
__version__ = "0.8.1"
|
||||
|
||||
from .client import Client, AutoShardedClient
|
||||
from .models import Message, User, Reaction, AuditLogEntry
|
||||
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,16 +53,184 @@ from .errors import (
|
||||
NotFound,
|
||||
)
|
||||
from .color import Color
|
||||
from .utils import utcnow, message_pager
|
||||
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
|
||||
|
||||
|
||||
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
|
@ -1,11 +1,12 @@
|
||||
# disagreement/client.py
|
||||
|
||||
"""
|
||||
The main Client class for interacting with the Discord API.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import signal
|
||||
import json
|
||||
import os
|
||||
import importlib
|
||||
from typing import (
|
||||
Optional,
|
||||
Callable,
|
||||
@ -16,9 +17,14 @@ from typing import (
|
||||
Union,
|
||||
List,
|
||||
Dict,
|
||||
cast,
|
||||
)
|
||||
from types import ModuleType
|
||||
|
||||
PERSISTENT_VIEWS_FILE = "persistent_views.json"
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from .http import HTTPClient
|
||||
from .gateway import GatewayClient
|
||||
from .shard_manager import ShardManager
|
||||
@ -26,7 +32,9 @@ from .event_dispatcher import EventDispatcher
|
||||
from .enums import GatewayIntent, InteractionType, GatewayOpcode, VoiceRegion
|
||||
from .errors import DisagreementException, AuthenticationError
|
||||
from .typing import Typing
|
||||
from .ext.commands.core import CommandHandler
|
||||
from .caching import MemberCacheFlags
|
||||
from .cache import Cache, GuildCache, ChannelCache, MemberCache
|
||||
from .ext.commands.core import Command, CommandHandler, Group
|
||||
from .ext.commands.cog import Cog
|
||||
from .ext.app_commands.handler import AppCommandHandler
|
||||
from .ext.app_commands.context import AppCommandContext
|
||||
@ -34,6 +42,8 @@ from .ext import loader as ext_loader
|
||||
from .interactions import Interaction, Snowflake
|
||||
from .error_handler import setup_global_error_handler
|
||||
from .voice_client import VoiceClient
|
||||
from .models import Activity
|
||||
from .utils import utcnow
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import (
|
||||
@ -63,6 +73,15 @@ if TYPE_CHECKING:
|
||||
from .ext.app_commands.commands import AppCommand, AppCommandGroup
|
||||
|
||||
|
||||
def _update_list(lst: List[Any], item: Any) -> None:
|
||||
"""Replace an item with the same ID in a list or append if missing."""
|
||||
for i, existing in enumerate(lst):
|
||||
if getattr(existing, "id", None) == getattr(item, "id", None):
|
||||
lst[i] = item
|
||||
return
|
||||
lst.append(item)
|
||||
|
||||
|
||||
class Client:
|
||||
"""
|
||||
Represents a client connection that connects to Discord.
|
||||
@ -73,13 +92,24 @@ class Client:
|
||||
intents (Optional[int]): The Gateway Intents to use. Defaults to `GatewayIntent.default()`.
|
||||
You might need to enable privileged intents in your bot's application page.
|
||||
loop (Optional[asyncio.AbstractEventLoop]): The event loop to use for asynchronous operations.
|
||||
Defaults to `asyncio.get_event_loop()`.
|
||||
Defaults to the running loop
|
||||
via `asyncio.get_running_loop()`,
|
||||
or a new loop from
|
||||
`asyncio.new_event_loop()` if
|
||||
none is running.
|
||||
command_prefix (Union[str, List[str], Callable[['Client', Message], Union[str, List[str]]]]):
|
||||
The prefix(es) for commands. Defaults to '!'.
|
||||
verbose (bool): If True, print raw HTTP and Gateway traffic for debugging.
|
||||
mention_replies (bool): Whether replies mention the author by default.
|
||||
allowed_mentions (Optional[Dict[str, Any]]): Default allowed mentions for messages.
|
||||
http_options (Optional[Dict[str, Any]]): Extra options passed to
|
||||
:class:`HTTPClient` for creating the internal
|
||||
:class:`aiohttp.ClientSession`.
|
||||
message_cache_maxlen (Optional[int]): Maximum number of messages to keep
|
||||
in the cache. When ``None``, the cache size is unlimited.
|
||||
sync_commands_on_ready (bool): If ``True``, automatically call
|
||||
:meth:`Client.sync_application_commands` after the ``READY`` event
|
||||
when :attr:`Client.application_id` is available.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@ -93,17 +123,34 @@ class Client:
|
||||
application_id: Optional[Union[str, int]] = None,
|
||||
verbose: bool = False,
|
||||
mention_replies: bool = False,
|
||||
allowed_mentions: Optional[Dict[str, Any]] = None,
|
||||
shard_count: Optional[int] = None,
|
||||
gateway_max_retries: int = 5,
|
||||
gateway_max_backoff: float = 60.0,
|
||||
member_cache_flags: Optional[MemberCacheFlags] = None,
|
||||
message_cache_maxlen: Optional[int] = None,
|
||||
http_options: Optional[Dict[str, Any]] = None,
|
||||
owner_ids: Optional[List[Union[str, int]]] = None,
|
||||
sync_commands_on_ready: bool = True,
|
||||
):
|
||||
|
||||
if not token:
|
||||
raise ValueError("A bot token must be provided.")
|
||||
|
||||
self.token: str = token
|
||||
self.member_cache_flags: MemberCacheFlags = (
|
||||
member_cache_flags if member_cache_flags is not None else MemberCacheFlags()
|
||||
)
|
||||
self.message_cache_maxlen: Optional[int] = message_cache_maxlen
|
||||
self.intents: int = intents if intents is not None else GatewayIntent.default()
|
||||
self.loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop()
|
||||
if loop:
|
||||
self.loop: asyncio.AbstractEventLoop = loop
|
||||
else:
|
||||
try:
|
||||
self.loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
self.loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self.loop)
|
||||
self.application_id: Optional[Snowflake] = (
|
||||
str(application_id) if application_id else None
|
||||
)
|
||||
@ -117,12 +164,13 @@ class Client:
|
||||
)
|
||||
self._event_dispatcher: EventDispatcher = EventDispatcher(client_instance=self)
|
||||
self._gateway: Optional[GatewayClient] = (
|
||||
None # Initialized in run() or connect()
|
||||
None # Initialized in start() or connect()
|
||||
)
|
||||
self.shard_count: Optional[int] = shard_count
|
||||
self.gateway_max_retries: int = gateway_max_retries
|
||||
self.gateway_max_backoff: float = gateway_max_backoff
|
||||
self._shard_manager: Optional[ShardManager] = None
|
||||
self.owner_ids: List[str] = [str(o) for o in owner_ids] if owner_ids else []
|
||||
|
||||
# Initialize CommandHandler
|
||||
self.command_handler: CommandHandler = CommandHandler(
|
||||
@ -140,21 +188,25 @@ class Client:
|
||||
None # The bot's own user object, populated on READY
|
||||
)
|
||||
|
||||
self.start_time: Optional[datetime] = None
|
||||
|
||||
# Internal Caches
|
||||
self._guilds: Dict[Snowflake, "Guild"] = {}
|
||||
self._channels: Dict[Snowflake, "Channel"] = (
|
||||
{}
|
||||
) # Stores all channel types by ID
|
||||
self._users: Dict[Snowflake, Any] = (
|
||||
{}
|
||||
) # Placeholder for User model cache if needed
|
||||
self._messages: Dict[Snowflake, "Message"] = {}
|
||||
self._guilds: GuildCache = GuildCache()
|
||||
self._channels: ChannelCache = ChannelCache()
|
||||
self._users: Cache["User"] = Cache()
|
||||
self._messages: Cache["Message"] = Cache(ttl=3600, maxlen=message_cache_maxlen)
|
||||
self._views: Dict[Snowflake, "View"] = {}
|
||||
self._persistent_views: Dict[str, "View"] = {}
|
||||
self._voice_clients: Dict[Snowflake, VoiceClient] = {}
|
||||
self._webhooks: Dict[Snowflake, "Webhook"] = {}
|
||||
|
||||
# Load persistent views stored on disk
|
||||
self._load_persistent_views()
|
||||
|
||||
# Default whether replies mention the user
|
||||
self.mention_replies: bool = mention_replies
|
||||
self.allowed_mentions: Optional[Dict[str, Any]] = allowed_mentions
|
||||
self.sync_commands_on_ready: bool = sync_commands_on_ready
|
||||
|
||||
# Basic signal handling for graceful shutdown
|
||||
# This might be better handled by the user's application code, but can be a nice default.
|
||||
@ -174,6 +226,39 @@ class Client:
|
||||
"Graceful shutdown via signals might not work as expected on this platform."
|
||||
)
|
||||
|
||||
def _load_persistent_views(self) -> None:
|
||||
"""Load registered persistent views from disk."""
|
||||
if not os.path.isfile(PERSISTENT_VIEWS_FILE):
|
||||
return
|
||||
try:
|
||||
with open(PERSISTENT_VIEWS_FILE, "r") as fp:
|
||||
mapping = json.load(fp)
|
||||
except Exception as e: # pragma: no cover - best effort load
|
||||
print(f"Failed to load persistent views: {e}")
|
||||
return
|
||||
|
||||
for custom_id, path in mapping.items():
|
||||
try:
|
||||
module_name, class_name = path.rsplit(".", 1)
|
||||
module = importlib.import_module(module_name)
|
||||
cls = getattr(module, class_name)
|
||||
view = cls()
|
||||
self._persistent_views[custom_id] = view
|
||||
except Exception as e: # pragma: no cover - best effort load
|
||||
print(f"Failed to initialize persistent view {path}: {e}")
|
||||
|
||||
def _save_persistent_views(self) -> None:
|
||||
"""Persist registered views to disk."""
|
||||
data = {}
|
||||
for custom_id, view in self._persistent_views.items():
|
||||
cls = view.__class__
|
||||
data[custom_id] = f"{cls.__module__}.{cls.__name__}"
|
||||
try:
|
||||
with open(PERSISTENT_VIEWS_FILE, "w") as fp:
|
||||
json.dump(data, fp)
|
||||
except Exception as e: # pragma: no cover - best effort save
|
||||
print(f"Failed to save persistent views: {e}")
|
||||
|
||||
async def _initialize_gateway(self):
|
||||
"""Initializes the GatewayClient if it doesn't exist."""
|
||||
if self._gateway is None:
|
||||
@ -217,6 +302,7 @@ class Client:
|
||||
f"Client connected using {self.shard_count} shards, waiting for READY signal..."
|
||||
)
|
||||
await self.wait_until_ready()
|
||||
self.start_time = utcnow()
|
||||
print("Client is READY!")
|
||||
return
|
||||
|
||||
@ -224,7 +310,7 @@ class Client:
|
||||
assert self._gateway is not None # Should be initialized by now
|
||||
|
||||
retry_delay = 5 # seconds
|
||||
max_retries = 5 # For initial connection attempts by Client.run, Gateway has its own internal retries for some cases.
|
||||
max_retries = 5 # For initial connection attempts by Client.start, Gateway has its own internal retries for some cases.
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
@ -233,6 +319,7 @@ class Client:
|
||||
# and its READY handler will set self._ready_event via dispatcher.
|
||||
print("Client connected to Gateway, waiting for READY signal...")
|
||||
await self.wait_until_ready() # Wait for the READY event from Gateway
|
||||
self.start_time = utcnow()
|
||||
print("Client is READY!")
|
||||
return # Successfully connected and ready
|
||||
except AuthenticationError: # Non-recoverable by retry here
|
||||
@ -251,15 +338,13 @@ class Client:
|
||||
print("Max connection retries reached. Giving up.")
|
||||
await self.close() # Ensure cleanup
|
||||
raise
|
||||
# Should not be reached if max_retries is > 0
|
||||
if max_retries == 0: # If max_retries was 0, means no retries attempted
|
||||
raise DisagreementException("Connection failed with 0 retries allowed.")
|
||||
|
||||
async def run(self) -> None:
|
||||
async def start(self) -> None:
|
||||
"""
|
||||
A blocking call that connects the client to Discord and runs until the client is closed.
|
||||
This method is a coroutine.
|
||||
It handles login, Gateway connection, and keeping the connection alive.
|
||||
Connect the client to Discord and run until the client is closed.
|
||||
This method is a coroutine containing the main run loop logic.
|
||||
"""
|
||||
if self._closed:
|
||||
raise DisagreementException("Client is already closed.")
|
||||
@ -327,6 +412,10 @@ class Client:
|
||||
if not self._closed:
|
||||
await self.close()
|
||||
|
||||
def run(self) -> None:
|
||||
"""Synchronously start the client using :func:`asyncio.run`."""
|
||||
asyncio.run(self.start())
|
||||
|
||||
async def close(self) -> None:
|
||||
"""
|
||||
Closes the connection to Discord. This method is a coroutine.
|
||||
@ -347,6 +436,7 @@ class Client:
|
||||
await self._http.close()
|
||||
|
||||
self._ready_event.set() # Ensure any waiters for ready are unblocked
|
||||
self.start_time = None
|
||||
print("Client closed.")
|
||||
|
||||
async def __aenter__(self) -> "Client":
|
||||
@ -374,6 +464,14 @@ class Client:
|
||||
self._gateway = None
|
||||
self._ready_event.clear() # No longer ready if gateway is closed
|
||||
|
||||
async def logout(self) -> None:
|
||||
"""Invalidate the bot token and disconnect from the Gateway."""
|
||||
await self.close_gateway()
|
||||
self.token = ""
|
||||
self._http.token = ""
|
||||
self.user = None
|
||||
self.start_time = None
|
||||
|
||||
def is_closed(self) -> bool:
|
||||
"""Indicates if the client has been closed."""
|
||||
return self._closed
|
||||
@ -389,6 +487,23 @@ class Client:
|
||||
return self._gateway.latency
|
||||
return None
|
||||
|
||||
@property
|
||||
def latency_ms(self) -> Optional[float]:
|
||||
"""Returns the gateway latency in milliseconds, or ``None`` if unavailable."""
|
||||
latency = getattr(self._gateway, "latency_ms", None)
|
||||
return round(latency, 2) if latency is not None else None
|
||||
|
||||
@property
|
||||
def guilds(self) -> List["Guild"]:
|
||||
"""Returns all guilds from the internal cache."""
|
||||
return self._guilds.values()
|
||||
|
||||
def uptime(self) -> Optional[timedelta]:
|
||||
"""Return the duration since the client connected, or ``None`` if not connected."""
|
||||
if self.start_time is None:
|
||||
return None
|
||||
return utcnow() - self.start_time
|
||||
|
||||
async def wait_until_ready(self) -> None:
|
||||
"""|coro|
|
||||
Waits until the client is fully connected to Discord and the initial state is processed.
|
||||
@ -425,8 +540,7 @@ class Client:
|
||||
async def change_presence(
|
||||
self,
|
||||
status: str,
|
||||
activity_name: Optional[str] = None,
|
||||
activity_type: int = 0,
|
||||
activity: Optional[Activity] = None,
|
||||
since: int = 0,
|
||||
afk: bool = False,
|
||||
):
|
||||
@ -435,8 +549,7 @@ class Client:
|
||||
|
||||
Args:
|
||||
status (str): The new status for the client (e.g., "online", "idle", "dnd", "invisible").
|
||||
activity_name (Optional[str]): The name of the activity.
|
||||
activity_type (int): The type of the activity.
|
||||
activity (Optional[Activity]): Activity instance describing what the bot is doing.
|
||||
since (int): The timestamp (in milliseconds) of when the client went idle.
|
||||
afk (bool): Whether the client is AFK.
|
||||
"""
|
||||
@ -446,8 +559,7 @@ class Client:
|
||||
if self._gateway:
|
||||
await self._gateway.update_presence(
|
||||
status=status,
|
||||
activity_name=activity_name,
|
||||
activity_type=activity_type,
|
||||
activity=activity,
|
||||
since=since,
|
||||
afk=afk,
|
||||
)
|
||||
@ -516,6 +628,20 @@ class Client:
|
||||
|
||||
return decorator
|
||||
|
||||
def add_listener(
|
||||
self, event_name: str, coro: Callable[..., Awaitable[None]]
|
||||
) -> None:
|
||||
"""Register ``coro`` to listen for ``event_name``."""
|
||||
|
||||
self._event_dispatcher.register(event_name, coro)
|
||||
|
||||
def remove_listener(
|
||||
self, event_name: str, coro: Callable[..., Awaitable[None]]
|
||||
) -> None:
|
||||
"""Remove ``coro`` from ``event_name`` listeners."""
|
||||
|
||||
self._event_dispatcher.unregister(event_name, coro)
|
||||
|
||||
async def _process_message_for_commands(self, message: "Message") -> None:
|
||||
"""Internal listener to process messages for commands."""
|
||||
# Make sure message object is valid and not from a bot (optional, common check)
|
||||
@ -525,6 +651,11 @@ class Client:
|
||||
return
|
||||
await self.command_handler.process_commands(message)
|
||||
|
||||
async def get_context(self, message: "Message") -> Optional["CommandContext"]:
|
||||
"""Return a :class:`CommandContext` for ``message`` without executing the command."""
|
||||
|
||||
return await self.command_handler.get_context(message)
|
||||
|
||||
# --- Command Framework Methods ---
|
||||
|
||||
def add_cog(self, cog: Cog) -> None:
|
||||
@ -579,6 +710,46 @@ class Client:
|
||||
# For now, assuming name is sufficient for removal from the handler's flat list.
|
||||
return removed_cog
|
||||
|
||||
def get_cog(self, name: str) -> Optional[Cog]:
|
||||
"""Return a loaded cog by name if present."""
|
||||
|
||||
return self.command_handler.get_cog(name)
|
||||
|
||||
def check(self, coro: Callable[["CommandContext"], Awaitable[bool]]):
|
||||
"""
|
||||
A decorator that adds a global check to the bot.
|
||||
This check will be called for every command before it's executed.
|
||||
|
||||
Example:
|
||||
@bot.check
|
||||
async def block_dms(ctx):
|
||||
return ctx.guild is not None
|
||||
"""
|
||||
self.command_handler.add_check(coro)
|
||||
return coro
|
||||
|
||||
def command(
|
||||
self, **attrs: Any
|
||||
) -> Callable[[Callable[..., Awaitable[None]]], Command]:
|
||||
"""A decorator that transforms a function into a Command."""
|
||||
|
||||
def decorator(func: Callable[..., Awaitable[None]]) -> Command:
|
||||
cmd = Command(func, **attrs)
|
||||
self.command_handler.add_command(cmd)
|
||||
return cmd
|
||||
|
||||
return decorator
|
||||
|
||||
def group(self, **attrs: Any) -> Callable[[Callable[..., Awaitable[None]]], Group]:
|
||||
"""A decorator that transforms a function into a Group command."""
|
||||
|
||||
def decorator(func: Callable[..., Awaitable[None]]) -> Group:
|
||||
cmd = Group(func, **attrs)
|
||||
self.command_handler.add_command(cmd)
|
||||
return cmd
|
||||
|
||||
return decorator
|
||||
|
||||
def add_app_command(self, command: Union["AppCommand", "AppCommandGroup"]) -> None:
|
||||
"""
|
||||
Adds a standalone application command or group to the bot.
|
||||
@ -649,6 +820,16 @@ class Client:
|
||||
# import traceback
|
||||
# traceback.print_exception(type(error.original), error.original, error.original.__traceback__)
|
||||
|
||||
async def on_command_completion(self, ctx: "CommandContext") -> None:
|
||||
"""
|
||||
Default command completion handler. Called when a command has successfully completed.
|
||||
Users can override this method in a subclass of Client.
|
||||
|
||||
Args:
|
||||
ctx (CommandContext): The context of the command that completed.
|
||||
"""
|
||||
pass
|
||||
|
||||
# --- Extension Management Methods ---
|
||||
|
||||
def load_extension(self, name: str) -> ModuleType:
|
||||
@ -672,21 +853,32 @@ class Client:
|
||||
"""Parses user data and returns a User object, updating cache."""
|
||||
from .models import User # Ensure User model is available
|
||||
|
||||
user = User(data)
|
||||
self._users[user.id] = user # Cache the user
|
||||
user = User(data, client_instance=self)
|
||||
self._users.set(user.id, user) # Cache the user
|
||||
return user
|
||||
|
||||
def parse_channel(self, data: Dict[str, Any]) -> "Channel":
|
||||
"""Parses channel data and returns a Channel object, updating caches."""
|
||||
|
||||
from .models import channel_factory
|
||||
from .models import (
|
||||
channel_factory,
|
||||
TextChannel,
|
||||
VoiceChannel,
|
||||
CategoryChannel,
|
||||
)
|
||||
|
||||
channel = channel_factory(data, self)
|
||||
self._channels[channel.id] = channel
|
||||
self._channels.set(channel.id, channel)
|
||||
if channel.guild_id:
|
||||
guild = self._guilds.get(channel.guild_id)
|
||||
if guild:
|
||||
guild._channels[channel.id] = channel
|
||||
guild._channels.set(channel.id, channel)
|
||||
if isinstance(channel, TextChannel):
|
||||
_update_list(guild.text_channels, channel)
|
||||
elif isinstance(channel, VoiceChannel):
|
||||
_update_list(guild.voice_channels, channel)
|
||||
elif isinstance(channel, CategoryChannel):
|
||||
_update_list(guild.category_channels, channel)
|
||||
return channel
|
||||
|
||||
def parse_message(self, data: Dict[str, Any]) -> "Message":
|
||||
@ -695,7 +887,7 @@ class Client:
|
||||
from .models import Message
|
||||
|
||||
message = Message(data, client_instance=self)
|
||||
self._messages[message.id] = message
|
||||
self._messages.set(message.id, message)
|
||||
return message
|
||||
|
||||
def parse_webhook(self, data: Union[Dict[str, Any], "Webhook"]) -> "Webhook":
|
||||
@ -752,7 +944,7 @@ class Client:
|
||||
|
||||
cached_user = self._users.get(user_id)
|
||||
if cached_user:
|
||||
return cached_user # Return cached if available, though fetch implies wanting fresh
|
||||
return cached_user
|
||||
|
||||
try:
|
||||
user_data = await self._http.get_user(user_id)
|
||||
@ -782,23 +974,26 @@ class Client:
|
||||
)
|
||||
return None
|
||||
|
||||
def parse_member(self, data: Dict[str, Any], guild_id: Snowflake) -> "Member":
|
||||
def parse_member(
|
||||
self, data: Dict[str, Any], guild_id: Snowflake, *, just_joined: bool = False
|
||||
) -> "Member":
|
||||
"""Parses member data and returns a Member object, updating relevant caches."""
|
||||
from .models import Member # Ensure Member model is available
|
||||
from .models import Member
|
||||
|
||||
# Member's __init__ should handle the nested 'user' data.
|
||||
member = Member(data, client_instance=self)
|
||||
member.guild_id = str(guild_id)
|
||||
|
||||
# Cache the member in the guild's member cache
|
||||
if just_joined:
|
||||
setattr(member, "_just_joined", True)
|
||||
|
||||
guild = self._guilds.get(guild_id)
|
||||
if guild:
|
||||
guild._members[member.id] = member # Assuming Guild has _members dict
|
||||
guild._members.set(member.id, member)
|
||||
|
||||
# Also cache the user part if not already cached or if this is newer
|
||||
# Since Member inherits from User, the member object itself is the user.
|
||||
self._users[member.id] = member
|
||||
# If 'user' was in data and Member.__init__ used it, it's already part of 'member'.
|
||||
if just_joined and hasattr(member, "_just_joined"):
|
||||
delattr(member, "_just_joined")
|
||||
|
||||
self._users.set(member.id, member)
|
||||
return member
|
||||
|
||||
async def fetch_member(
|
||||
@ -842,20 +1037,30 @@ class Client:
|
||||
|
||||
def parse_guild(self, data: Dict[str, Any]) -> "Guild":
|
||||
"""Parses guild data and returns a Guild object, updating cache."""
|
||||
|
||||
from .models import Guild
|
||||
|
||||
guild = Guild(data, client_instance=self)
|
||||
self._guilds[guild.id] = guild
|
||||
shard_id = data.get("shard_id")
|
||||
guild = Guild(data, client_instance=self, shard_id=shard_id)
|
||||
self._guilds.set(guild.id, guild)
|
||||
|
||||
# Populate channel and member caches if provided
|
||||
for ch in data.get("channels", []):
|
||||
channel_obj = self.parse_channel(ch)
|
||||
guild._channels[channel_obj.id] = channel_obj
|
||||
presences = {p["user"]["id"]: p for p in data.get("presences", [])}
|
||||
voice_states = {vs["user_id"]: vs for vs in data.get("voice_states", [])}
|
||||
|
||||
for member in data.get("members", []):
|
||||
member_obj = self.parse_member(member, guild.id)
|
||||
guild._members[member_obj.id] = member_obj
|
||||
for ch_data in data.get("channels", []):
|
||||
self.parse_channel(ch_data)
|
||||
|
||||
for member_data in data.get("members", []):
|
||||
user_id = member_data.get("user", {}).get("id")
|
||||
if user_id:
|
||||
presence = presences.get(user_id)
|
||||
if presence:
|
||||
member_data["status"] = presence.get("status", "offline")
|
||||
|
||||
voice_state = voice_states.get(user_id)
|
||||
if voice_state:
|
||||
member_data["voice_state"] = voice_state
|
||||
|
||||
self.parse_member(member_data, guild.id)
|
||||
|
||||
return guild
|
||||
|
||||
@ -943,7 +1148,7 @@ class Client:
|
||||
embeds (Optional[List[Embed]]): A list of embeds to send. Cannot be used with `embed`.
|
||||
Discord supports up to 10 embeds per message.
|
||||
components (Optional[List[ActionRow]]): A list of ActionRow components to include.
|
||||
allowed_mentions (Optional[Dict[str, Any]]): Allowed mentions for the message.
|
||||
allowed_mentions (Optional[Dict[str, Any]]): Allowed mentions for the message. Defaults to :attr:`Client.allowed_mentions`.
|
||||
message_reference (Optional[Dict[str, Any]]): Message reference for replying.
|
||||
attachments (Optional[List[Any]]): Attachments to include with the message.
|
||||
files (Optional[List[Any]]): Files to upload with the message.
|
||||
@ -990,6 +1195,9 @@ class Client:
|
||||
if isinstance(comp, ComponentModel)
|
||||
]
|
||||
|
||||
if allowed_mentions is None:
|
||||
allowed_mentions = self.allowed_mentions
|
||||
|
||||
message_data = await self._http.send_message(
|
||||
channel_id=channel_id,
|
||||
content=content,
|
||||
@ -1010,6 +1218,23 @@ class Client:
|
||||
|
||||
return self.parse_message(message_data)
|
||||
|
||||
async def create_dm(self, user_id: Snowflake) -> "DMChannel":
|
||||
"""|coro| Create or fetch a DM channel with a user."""
|
||||
from .models import DMChannel
|
||||
|
||||
dm_data = await self._http.create_dm(user_id)
|
||||
return cast(DMChannel, self.parse_channel(dm_data))
|
||||
|
||||
async def send_dm(
|
||||
self,
|
||||
user_id: Snowflake,
|
||||
content: Optional[str] = None,
|
||||
**kwargs: Any,
|
||||
) -> "Message":
|
||||
"""|coro| Convenience method to send a direct message to a user."""
|
||||
channel = await self.create_dm(user_id)
|
||||
return await self.send_message(channel.id, content=content, **kwargs)
|
||||
|
||||
def typing(self, channel_id: str) -> Typing:
|
||||
"""Return a context manager to show a typing indicator in a channel."""
|
||||
|
||||
@ -1067,6 +1292,7 @@ class Client:
|
||||
session_id = state["session_id"]
|
||||
|
||||
voice = VoiceClient(
|
||||
self,
|
||||
endpoint,
|
||||
session_id,
|
||||
token,
|
||||
@ -1227,6 +1453,26 @@ class Client:
|
||||
|
||||
return self._messages.get(message_id)
|
||||
|
||||
def get_all_channels(self) -> List["Channel"]:
|
||||
"""Return all channels cached in every guild."""
|
||||
|
||||
channels: List["Channel"] = []
|
||||
for guild in self._guilds.values():
|
||||
channels.extend(guild._channels.values())
|
||||
return channels
|
||||
|
||||
def get_all_members(self) -> List["Member"]:
|
||||
"""Return all cached members across all guilds.
|
||||
|
||||
When member caching is disabled via :class:`MemberCacheFlags.none`, this
|
||||
list will always be empty.
|
||||
"""
|
||||
|
||||
members: List["Member"] = []
|
||||
for guild in self._guilds.values():
|
||||
members.extend(guild._members.values())
|
||||
return members
|
||||
|
||||
async def fetch_guild(self, guild_id: Snowflake) -> Optional["Guild"]:
|
||||
"""Fetches a guild by ID from Discord and caches it."""
|
||||
|
||||
@ -1244,6 +1490,18 @@ class Client:
|
||||
print(f"Failed to fetch guild {guild_id}: {e}")
|
||||
return None
|
||||
|
||||
async def fetch_guilds(self) -> List["Guild"]:
|
||||
"""Fetch all guilds the current user is in."""
|
||||
|
||||
if self._closed:
|
||||
raise DisagreementException("Client is closed.")
|
||||
|
||||
data = await self._http.get_current_user_guilds()
|
||||
guilds: List["Guild"] = []
|
||||
for guild_data in data:
|
||||
guilds.append(self.parse_guild(guild_data))
|
||||
return guilds
|
||||
|
||||
async def fetch_channel(self, channel_id: Snowflake) -> Optional["Channel"]:
|
||||
"""Fetches a channel from Discord by its ID and updates the cache."""
|
||||
|
||||
@ -1259,7 +1517,7 @@ class Client:
|
||||
|
||||
channel = channel_factory(channel_data, self)
|
||||
|
||||
self._channels[channel.id] = channel
|
||||
self._channels.set(channel.id, channel)
|
||||
return channel
|
||||
|
||||
except DisagreementException as e: # Includes HTTPException
|
||||
@ -1321,6 +1579,23 @@ class Client:
|
||||
|
||||
await self._http.delete_webhook(webhook_id)
|
||||
|
||||
async def fetch_webhook(self, webhook_id: Snowflake) -> Optional["Webhook"]:
|
||||
"""|coro| Fetch a webhook by ID."""
|
||||
|
||||
if self._closed:
|
||||
raise DisagreementException("Client is closed.")
|
||||
|
||||
cached = self._webhooks.get(webhook_id)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
try:
|
||||
data = await self._http.get_webhook(webhook_id)
|
||||
return self.parse_webhook(data)
|
||||
except DisagreementException as e:
|
||||
print(f"Failed to fetch webhook {webhook_id}: {e}")
|
||||
return None
|
||||
|
||||
async def fetch_templates(self, guild_id: Snowflake) -> List["GuildTemplate"]:
|
||||
"""|coro| Fetch all templates for a guild."""
|
||||
|
||||
@ -1360,6 +1635,24 @@ class Client:
|
||||
|
||||
await self._http.delete_guild_template(guild_id, template_code)
|
||||
|
||||
async def fetch_widget(self, guild_id: Snowflake) -> Dict[str, Any]:
|
||||
"""|coro| Fetch a guild's widget settings."""
|
||||
|
||||
if self._closed:
|
||||
raise DisagreementException("Client is closed.")
|
||||
|
||||
return await self._http.get_guild_widget(guild_id)
|
||||
|
||||
async def edit_widget(
|
||||
self, guild_id: Snowflake, payload: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""|coro| Edit a guild's widget settings."""
|
||||
|
||||
if self._closed:
|
||||
raise DisagreementException("Client is closed.")
|
||||
|
||||
return await self._http.edit_guild_widget(guild_id, payload)
|
||||
|
||||
async def fetch_scheduled_events(
|
||||
self, guild_id: Snowflake
|
||||
) -> List["ScheduledEvent"]:
|
||||
@ -1436,6 +1729,19 @@ class Client:
|
||||
|
||||
await self._http.delete_invite(code)
|
||||
|
||||
async def fetch_invite(self, code: Snowflake) -> Optional["Invite"]:
|
||||
"""|coro| Fetch a single invite by code."""
|
||||
|
||||
if self._closed:
|
||||
raise DisagreementException("Client is closed.")
|
||||
|
||||
try:
|
||||
data = await self._http.get_invite(code)
|
||||
return self.parse_invite(data)
|
||||
except DisagreementException as e:
|
||||
print(f"Failed to fetch invite {code}: {e}")
|
||||
return None
|
||||
|
||||
async def fetch_invites(self, channel_id: Snowflake) -> List["Invite"]:
|
||||
"""|coro| Fetch all invites for a channel."""
|
||||
|
||||
@ -1445,6 +1751,39 @@ class Client:
|
||||
data = await self._http.get_channel_invites(channel_id)
|
||||
return [self.parse_invite(inv) for inv in data]
|
||||
|
||||
def add_persistent_view(self, view: "View") -> None:
|
||||
"""
|
||||
Registers a persistent view with the client.
|
||||
|
||||
Persistent views have a timeout of `None` and their components must have a `custom_id`.
|
||||
This allows the view to be re-instantiated across bot restarts.
|
||||
|
||||
Args:
|
||||
view (View): The view instance to register.
|
||||
|
||||
Raises:
|
||||
ValueError: If the view is not persistent (timeout is not None) or if a component's
|
||||
custom_id is already registered.
|
||||
"""
|
||||
if self.is_ready():
|
||||
print(
|
||||
"Warning: Adding a persistent view after the client is ready. "
|
||||
"This view will only be available for interactions on this session."
|
||||
)
|
||||
|
||||
if view.timeout is not None:
|
||||
raise ValueError("Persistent views must have a timeout of None.")
|
||||
|
||||
for item in view.children:
|
||||
if item.custom_id: # Ensure custom_id is not None
|
||||
if item.custom_id in self._persistent_views:
|
||||
raise ValueError(
|
||||
f"A component with custom_id '{item.custom_id}' is already registered."
|
||||
)
|
||||
self._persistent_views[item.custom_id] = view
|
||||
|
||||
self._save_persistent_views()
|
||||
|
||||
# --- Application Command Methods ---
|
||||
async def process_interaction(self, interaction: Interaction) -> None:
|
||||
"""Internal method to process an interaction from the gateway."""
|
||||
@ -1455,11 +1794,25 @@ class Client:
|
||||
if (
|
||||
interaction.type == InteractionType.MESSAGE_COMPONENT
|
||||
and interaction.message
|
||||
and interaction.data
|
||||
):
|
||||
view = self._views.get(interaction.message.id)
|
||||
if view:
|
||||
asyncio.create_task(view._dispatch(interaction))
|
||||
return
|
||||
else:
|
||||
# No active view found, check for persistent views
|
||||
custom_id = interaction.data.custom_id
|
||||
if custom_id:
|
||||
registered_view = self._persistent_views.get(custom_id)
|
||||
if registered_view:
|
||||
# Create a new instance of the persistent view
|
||||
new_view = registered_view.__class__()
|
||||
await new_view._start(self)
|
||||
new_view.message_id = interaction.message.id
|
||||
self._views[interaction.message.id] = new_view
|
||||
asyncio.create_task(new_view._dispatch(interaction))
|
||||
return
|
||||
|
||||
await self.app_command_handler.process_interaction(interaction)
|
||||
|
||||
@ -1474,16 +1827,6 @@ class Client:
|
||||
"Ensure the client is connected and READY."
|
||||
)
|
||||
return
|
||||
if not self.is_ready():
|
||||
print(
|
||||
"Warning: Client is not ready. Waiting for client to be ready before syncing commands."
|
||||
)
|
||||
await self.wait_until_ready()
|
||||
if not self.application_id:
|
||||
print(
|
||||
"Error: application_id still not set after client is ready. Cannot sync commands."
|
||||
)
|
||||
return
|
||||
|
||||
await self.app_command_handler.sync_commands(
|
||||
application_id=self.application_id, guild_id=guild_id
|
||||
@ -1504,6 +1847,16 @@ class Client:
|
||||
|
||||
pass
|
||||
|
||||
async def on_connect(self) -> None:
|
||||
"""|coro| Called when the WebSocket connection opens."""
|
||||
|
||||
pass
|
||||
|
||||
async def on_disconnect(self) -> None:
|
||||
"""|coro| Called when the WebSocket connection closes."""
|
||||
|
||||
pass
|
||||
|
||||
async def on_app_command_error(
|
||||
self, context: AppCommandContext, error: Exception
|
||||
) -> 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):
|
||||
@ -270,12 +268,19 @@ 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 ---
|
||||
@ -331,7 +336,12 @@ class VoiceRegion(str, Enum):
|
||||
|
||||
@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
|
||||
|
||||
|
||||
# --- Channel Enums ---
|
||||
@ -375,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.
|
||||
|
||||
|
@ -1,5 +1,3 @@
|
||||
# disagreement/ext/app_commands/commands.py
|
||||
|
||||
import inspect
|
||||
from typing import Any, Callable, Dict, List, Optional, Union, TYPE_CHECKING
|
||||
|
||||
|
@ -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
|
||||
@ -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,5 +1,3 @@
|
||||
# disagreement/ext/app_commands/handler.py
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
@ -20,51 +18,23 @@ 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
|
||||
|
||||
User = Any
|
||||
Member = Any
|
||||
Role = Any
|
||||
Attachment = Any
|
||||
Channel = Any
|
||||
Message = Any
|
||||
Channel = Any
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -341,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(
|
||||
@ -589,12 +559,19 @@ 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.
|
||||
"""
|
||||
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, {})
|
||||
|
@ -1,5 +1,3 @@
|
||||
# disagreement/ext/app_commands/hybrid.py
|
||||
|
||||
from typing import Any, Callable, List, Optional
|
||||
|
||||
from .commands import SlashCommand
|
||||
|
@ -1,5 +1,3 @@
|
||||
# disagreement/ext/commands/__init__.py
|
||||
|
||||
"""
|
||||
disagreement.ext.commands - A command framework extension for the Disagreement library.
|
||||
"""
|
||||
@ -18,6 +16,9 @@ from .decorators import (
|
||||
cooldown,
|
||||
max_concurrency,
|
||||
requires_permissions,
|
||||
has_role,
|
||||
has_any_role,
|
||||
is_owner,
|
||||
)
|
||||
from .errors import (
|
||||
CommandError,
|
||||
@ -47,6 +48,9 @@ __all__ = [
|
||||
"cooldown",
|
||||
"max_concurrency",
|
||||
"requires_permissions",
|
||||
"has_role",
|
||||
"has_any_role",
|
||||
"is_owner",
|
||||
# Errors
|
||||
"CommandError",
|
||||
"CommandNotFound",
|
||||
|
@ -1,5 +1,3 @@
|
||||
# disagreement/ext/commands/cog.py
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, List, Tuple, Callable, Awaitable, Any, Dict, Union
|
||||
|
@ -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
|
||||
|
@ -1,5 +1,3 @@
|
||||
# disagreement/ext/commands/core.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@ -29,7 +27,7 @@ from .errors import (
|
||||
CheckFailure,
|
||||
CommandInvokeError,
|
||||
)
|
||||
from .converters import run_converters, DEFAULT_CONVERTERS, Converter
|
||||
from .converters import Greedy, run_converters, DEFAULT_CONVERTERS, Converter
|
||||
from disagreement.typing import Typing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -40,7 +38,59 @@ if TYPE_CHECKING:
|
||||
from disagreement.models import Message, User
|
||||
|
||||
|
||||
class Command:
|
||||
class GroupMixin:
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__()
|
||||
self.commands: Dict[str, "Command"] = {}
|
||||
self.name: str = ""
|
||||
|
||||
def command(
|
||||
self, **attrs: Any
|
||||
) -> Callable[[Callable[..., Awaitable[None]]], "Command"]:
|
||||
def decorator(func: Callable[..., Awaitable[None]]) -> "Command":
|
||||
cmd = Command(func, **attrs)
|
||||
cmd.cog = getattr(self, "cog", None)
|
||||
self.add_command(cmd)
|
||||
return cmd
|
||||
|
||||
return decorator
|
||||
|
||||
def group(
|
||||
self, **attrs: Any
|
||||
) -> Callable[[Callable[..., Awaitable[None]]], "Group"]:
|
||||
def decorator(func: Callable[..., Awaitable[None]]) -> "Group":
|
||||
cmd = Group(func, **attrs)
|
||||
cmd.cog = getattr(self, "cog", None)
|
||||
self.add_command(cmd)
|
||||
return cmd
|
||||
|
||||
return decorator
|
||||
|
||||
def add_command(self, command: "Command") -> None:
|
||||
if command.name in self.commands:
|
||||
raise ValueError(
|
||||
f"Command '{command.name}' is already registered in group '{self.name}'."
|
||||
)
|
||||
self.commands[command.name.lower()] = command
|
||||
for alias in command.aliases:
|
||||
if alias in self.commands:
|
||||
logger.warning(
|
||||
f"Alias '{alias}' for command '{command.name}' in group '{self.name}' conflicts with an existing command or alias."
|
||||
)
|
||||
self.commands[alias.lower()] = command
|
||||
|
||||
def get_command(self, name: str) -> Optional["Command"]:
|
||||
return self.commands.get(name.lower())
|
||||
|
||||
def walk_commands(self):
|
||||
"""Yield all commands in this group recursively."""
|
||||
for cmd in dict.fromkeys(self.commands.values()):
|
||||
yield cmd
|
||||
if isinstance(cmd, Group):
|
||||
yield from cmd.walk_commands()
|
||||
|
||||
|
||||
class Command(GroupMixin):
|
||||
"""
|
||||
Represents a bot command.
|
||||
|
||||
@ -58,12 +108,14 @@ class Command:
|
||||
if not asyncio.iscoroutinefunction(callback):
|
||||
raise TypeError("Command callback must be a coroutine function.")
|
||||
|
||||
super().__init__(**attrs)
|
||||
self.callback: Callable[..., Awaitable[None]] = callback
|
||||
self.name: str = attrs.get("name", callback.__name__)
|
||||
self.aliases: List[str] = attrs.get("aliases", [])
|
||||
self.brief: Optional[str] = attrs.get("brief")
|
||||
self.description: Optional[str] = attrs.get("description") or callback.__doc__
|
||||
self.cog: Optional["Cog"] = attrs.get("cog")
|
||||
self.invoke_without_command: bool = attrs.get("invoke_without_command", False)
|
||||
|
||||
self.params = inspect.signature(callback).parameters
|
||||
self.checks: List[Callable[["CommandContext"], Awaitable[bool] | bool]] = []
|
||||
@ -79,20 +131,74 @@ class Command:
|
||||
) -> None:
|
||||
self.checks.append(predicate)
|
||||
|
||||
async def invoke(self, ctx: "CommandContext", *args: Any, **kwargs: Any) -> None:
|
||||
async def _run_checks(self, ctx: "CommandContext") -> None:
|
||||
"""Runs all cog, local and global checks for the command."""
|
||||
from .errors import CheckFailure
|
||||
|
||||
# Run cog-level check first
|
||||
if self.cog:
|
||||
cog_check = getattr(self.cog, "cog_check", None)
|
||||
if cog_check:
|
||||
try:
|
||||
result = cog_check(ctx)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
if not result:
|
||||
raise CheckFailure(
|
||||
f"The cog-level check for command '{self.name}' failed."
|
||||
)
|
||||
except CheckFailure:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise CommandInvokeError(e) from e
|
||||
|
||||
# Run local checks
|
||||
for predicate in self.checks:
|
||||
result = predicate(ctx)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
if not result:
|
||||
raise CheckFailure("Check predicate failed.")
|
||||
raise CheckFailure(f"A local check for command '{self.name}' failed.")
|
||||
|
||||
# Then run global checks from the handler
|
||||
if hasattr(ctx.bot, "command_handler"):
|
||||
for predicate in ctx.bot.command_handler._global_checks:
|
||||
result = predicate(ctx)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
if not result:
|
||||
raise CheckFailure(
|
||||
f"A global check failed for command '{self.name}'."
|
||||
)
|
||||
|
||||
async def invoke(self, ctx: "CommandContext", *args: Any, **kwargs: Any) -> None:
|
||||
await self._run_checks(ctx)
|
||||
|
||||
before_invoke = None
|
||||
after_invoke = None
|
||||
|
||||
if self.cog:
|
||||
await self.callback(self.cog, ctx, *args, **kwargs)
|
||||
else:
|
||||
await self.callback(ctx, *args, **kwargs)
|
||||
before_invoke = getattr(self.cog, "cog_before_invoke", None)
|
||||
after_invoke = getattr(self.cog, "cog_after_invoke", None)
|
||||
|
||||
if before_invoke:
|
||||
await before_invoke(ctx)
|
||||
|
||||
try:
|
||||
if self.cog:
|
||||
await self.callback(self.cog, ctx, *args, **kwargs)
|
||||
else:
|
||||
await self.callback(ctx, *args, **kwargs)
|
||||
finally:
|
||||
if after_invoke:
|
||||
await after_invoke(ctx)
|
||||
|
||||
|
||||
class Group(Command):
|
||||
"""A command that can have subcommands."""
|
||||
|
||||
def __init__(self, callback: Callable[..., Awaitable[None]], **attrs: Any):
|
||||
super().__init__(callback, **attrs)
|
||||
|
||||
|
||||
PrefixCommand = Command # Alias for clarity in hybrid commands
|
||||
@ -156,10 +262,10 @@ class CommandContext:
|
||||
mention_author = getattr(self.bot, "mention_replies", False)
|
||||
|
||||
if allowed_mentions is None:
|
||||
allowed_mentions = {"replied_user": mention_author}
|
||||
allowed_mentions = dict(getattr(self.bot, "allowed_mentions", {}) or {})
|
||||
else:
|
||||
allowed_mentions = dict(allowed_mentions)
|
||||
allowed_mentions.setdefault("replied_user", mention_author)
|
||||
allowed_mentions.setdefault("replied_user", mention_author)
|
||||
|
||||
return await self.bot.send_message(
|
||||
channel_id=self.message.channel_id,
|
||||
@ -220,11 +326,20 @@ class CommandHandler:
|
||||
self.commands: Dict[str, Command] = {}
|
||||
self.cogs: Dict[str, "Cog"] = {}
|
||||
self._concurrency: Dict[str, Dict[str, int]] = {}
|
||||
self._global_checks: List[
|
||||
Callable[["CommandContext"], Awaitable[bool] | bool]
|
||||
] = []
|
||||
|
||||
from .help import HelpCommand
|
||||
|
||||
self.add_command(HelpCommand(self))
|
||||
|
||||
def add_check(
|
||||
self, predicate: Callable[["CommandContext"], Awaitable[bool] | bool]
|
||||
) -> None:
|
||||
"""Adds a global check to the command handler."""
|
||||
self._global_checks.append(predicate)
|
||||
|
||||
def add_command(self, command: Command) -> None:
|
||||
if command.name in self.commands:
|
||||
raise ValueError(f"Command '{command.name}' is already registered.")
|
||||
@ -239,6 +354,15 @@ class CommandHandler:
|
||||
)
|
||||
self.commands[alias.lower()] = command
|
||||
|
||||
if isinstance(command, Group):
|
||||
for sub_cmd in command.commands.values():
|
||||
if sub_cmd.name in self.commands:
|
||||
logger.warning(
|
||||
"Subcommand '%s' of group '%s' conflicts with a top-level command.",
|
||||
sub_cmd.name,
|
||||
command.name,
|
||||
)
|
||||
|
||||
def remove_command(self, name: str) -> Optional[Command]:
|
||||
command = self.commands.pop(name.lower(), None)
|
||||
if command:
|
||||
@ -249,6 +373,18 @@ class CommandHandler:
|
||||
def get_command(self, name: str) -> Optional[Command]:
|
||||
return self.commands.get(name.lower())
|
||||
|
||||
def walk_commands(self):
|
||||
"""Yield every registered command, including subcommands."""
|
||||
for cmd in dict.fromkeys(self.commands.values()):
|
||||
yield cmd
|
||||
if isinstance(cmd, Group):
|
||||
yield from cmd.walk_commands()
|
||||
|
||||
def get_cog(self, name: str) -> Optional["Cog"]:
|
||||
"""Return a loaded cog by name if present."""
|
||||
|
||||
return self.cogs.get(name)
|
||||
|
||||
def add_cog(self, cog_to_add: "Cog") -> None:
|
||||
from .cog import Cog
|
||||
|
||||
@ -386,7 +522,34 @@ class CommandHandler:
|
||||
None # Holds the raw string for current param
|
||||
)
|
||||
|
||||
if view.eof: # No more input string
|
||||
annotation = param.annotation
|
||||
if inspect.isclass(annotation) and issubclass(annotation, Greedy):
|
||||
greedy_values = []
|
||||
converter_type = annotation.converter
|
||||
while not view.eof:
|
||||
view.skip_whitespace()
|
||||
if view.eof:
|
||||
break
|
||||
start = view.index
|
||||
if view.buffer[view.index] == '"':
|
||||
arg_str_value = view.get_quoted_string()
|
||||
if arg_str_value == "" and view.buffer[view.index] == '"':
|
||||
raise BadArgument(
|
||||
f"Unterminated quoted string for argument '{param.name}'."
|
||||
)
|
||||
else:
|
||||
arg_str_value = view.get_word()
|
||||
try:
|
||||
converted = await run_converters(
|
||||
ctx, converter_type, arg_str_value
|
||||
)
|
||||
except BadArgument:
|
||||
view.index = start
|
||||
break
|
||||
greedy_values.append(converted)
|
||||
final_value_for_param = greedy_values
|
||||
arg_str_value = None
|
||||
elif view.eof: # No more input string
|
||||
if param.default is not inspect.Parameter.empty:
|
||||
final_value_for_param = param.default
|
||||
elif param.kind != inspect.Parameter.VAR_KEYWORD:
|
||||
@ -421,9 +584,7 @@ class CommandHandler:
|
||||
|
||||
# If final_value_for_param was not set by greedy logic, try conversion
|
||||
if final_value_for_param is inspect.Parameter.empty:
|
||||
if (
|
||||
arg_str_value is None
|
||||
): # Should not happen if view.get_word/get_quoted_string is robust
|
||||
if arg_str_value is None:
|
||||
if param.default is not inspect.Parameter.empty:
|
||||
final_value_for_param = param.default
|
||||
else:
|
||||
@ -463,7 +624,7 @@ class CommandHandler:
|
||||
final_value_for_param = None
|
||||
elif last_err_union:
|
||||
raise last_err_union
|
||||
else: # Should not be reached if logic is correct
|
||||
else:
|
||||
raise BadArgument(
|
||||
f"Could not convert '{arg_str_value}' to any of {union_args} for param '{param.name}'."
|
||||
)
|
||||
@ -496,6 +657,78 @@ class CommandHandler:
|
||||
|
||||
return args_list, kwargs_dict
|
||||
|
||||
async def get_context(self, message: "Message") -> Optional[CommandContext]:
|
||||
"""Parse a message and return a :class:`CommandContext` without executing the command.
|
||||
|
||||
Returns ``None`` if the message does not invoke a command."""
|
||||
|
||||
if not message.content:
|
||||
return None
|
||||
|
||||
prefix_to_use = await self.get_prefix(message)
|
||||
if not prefix_to_use:
|
||||
return None
|
||||
|
||||
actual_prefix: Optional[str] = None
|
||||
if isinstance(prefix_to_use, list):
|
||||
for p in prefix_to_use:
|
||||
if message.content.startswith(p):
|
||||
actual_prefix = p
|
||||
break
|
||||
if not actual_prefix:
|
||||
return None
|
||||
elif isinstance(prefix_to_use, str):
|
||||
if message.content.startswith(prefix_to_use):
|
||||
actual_prefix = prefix_to_use
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
if actual_prefix is None:
|
||||
return None
|
||||
|
||||
view = StringView(message.content[len(actual_prefix) :])
|
||||
|
||||
command_name = view.get_word()
|
||||
if not command_name:
|
||||
return None
|
||||
|
||||
command = self.get_command(command_name)
|
||||
if not command:
|
||||
return None
|
||||
|
||||
invoked_with = command_name
|
||||
|
||||
if isinstance(command, Group):
|
||||
view.skip_whitespace()
|
||||
potential_subcommand = view.get_word()
|
||||
if potential_subcommand:
|
||||
subcommand = command.get_command(potential_subcommand)
|
||||
if subcommand:
|
||||
command = subcommand
|
||||
invoked_with += f" {potential_subcommand}"
|
||||
elif command.invoke_without_command:
|
||||
view.index -= len(potential_subcommand) + view.previous
|
||||
else:
|
||||
raise CommandNotFound(
|
||||
f"Subcommand '{potential_subcommand}' not found."
|
||||
)
|
||||
|
||||
ctx = CommandContext(
|
||||
message=message,
|
||||
bot=self.client,
|
||||
prefix=actual_prefix,
|
||||
command=command,
|
||||
invoked_with=invoked_with,
|
||||
cog=command.cog,
|
||||
)
|
||||
|
||||
parsed_args, parsed_kwargs = await self._parse_arguments(command, ctx, view)
|
||||
ctx.args = parsed_args
|
||||
ctx.kwargs = parsed_kwargs
|
||||
return ctx
|
||||
|
||||
async def process_commands(self, message: "Message") -> None:
|
||||
if not message.content:
|
||||
return
|
||||
@ -534,12 +767,30 @@ class CommandHandler:
|
||||
if not command:
|
||||
return
|
||||
|
||||
invoked_with = command_name
|
||||
original_command = command
|
||||
|
||||
if isinstance(command, Group):
|
||||
view.skip_whitespace()
|
||||
potential_subcommand = view.get_word()
|
||||
if potential_subcommand:
|
||||
subcommand = command.get_command(potential_subcommand)
|
||||
if subcommand:
|
||||
command = subcommand
|
||||
invoked_with += f" {potential_subcommand}"
|
||||
elif command.invoke_without_command:
|
||||
view.index -= len(potential_subcommand) + view.previous
|
||||
else:
|
||||
raise CommandNotFound(
|
||||
f"Subcommand '{potential_subcommand}' not found."
|
||||
)
|
||||
|
||||
ctx = CommandContext(
|
||||
message=message,
|
||||
bot=self.client,
|
||||
prefix=actual_prefix,
|
||||
command=command,
|
||||
invoked_with=command_name,
|
||||
invoked_with=invoked_with,
|
||||
cog=command.cog,
|
||||
)
|
||||
|
||||
@ -553,11 +804,16 @@ class CommandHandler:
|
||||
finally:
|
||||
self._release_concurrency(ctx)
|
||||
except CommandError as e:
|
||||
logger.error("Command error for '%s': %s", command.name, e)
|
||||
logger.error("Command error for '%s': %s", original_command.name, e)
|
||||
if hasattr(self.client, "on_command_error"):
|
||||
await self.client.on_command_error(ctx, e)
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error invoking command '%s': %s", command.name, e)
|
||||
logger.error(
|
||||
"Unexpected error invoking command '%s': %s", original_command.name, e
|
||||
)
|
||||
exc = CommandInvokeError(e)
|
||||
if hasattr(self.client, "on_command_error"):
|
||||
await self.client.on_command_error(ctx, exc)
|
||||
else:
|
||||
if hasattr(self.client, "on_command_completion"):
|
||||
await self.client.on_command_completion(ctx)
|
||||
|
@ -1,4 +1,3 @@
|
||||
# disagreement/ext/commands/decorators.py
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@ -217,3 +216,95 @@ def requires_permissions(
|
||||
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.
|
||||
"""
|
||||
|
@ -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)
|
||||
|
||||
|
@ -23,6 +23,7 @@ class Task:
|
||||
) -> 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
|
||||
):
|
||||
@ -68,6 +69,7 @@ class Task:
|
||||
await _maybe_call(self._on_error, exc)
|
||||
else:
|
||||
raise
|
||||
self._current_loop += 1
|
||||
|
||||
first = False
|
||||
except asyncio.CancelledError:
|
||||
@ -78,6 +80,7 @@ class Task:
|
||||
|
||||
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
|
||||
|
||||
@ -90,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
|
||||
@ -181,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:
|
||||
@ -202,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(
|
||||
*,
|
||||
|
@ -1,5 +1,3 @@
|
||||
# disagreement/gateway.py
|
||||
|
||||
"""
|
||||
Manages the WebSocket connection to the Discord Gateway.
|
||||
"""
|
||||
@ -14,6 +12,8 @@ import time
|
||||
import random
|
||||
from typing import Optional, TYPE_CHECKING, Any, Dict
|
||||
|
||||
from .models import Activity
|
||||
|
||||
from .enums import GatewayOpcode, GatewayIntent
|
||||
from .errors import GatewayException, DisagreementException, AuthenticationError
|
||||
from .interactions import Interaction
|
||||
@ -63,7 +63,11 @@ class GatewayClient:
|
||||
self._max_backoff: float = max_backoff
|
||||
|
||||
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
|
||||
self._loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
|
||||
try:
|
||||
self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
self._loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._loop)
|
||||
self._heartbeat_interval: Optional[float] = None
|
||||
self._last_sequence: Optional[int] = None
|
||||
self._session_id: Optional[str] = None
|
||||
@ -79,6 +83,8 @@ class GatewayClient:
|
||||
self._buffer = bytearray()
|
||||
self._inflator = zlib.decompressobj()
|
||||
|
||||
self._member_chunk_requests: Dict[str, asyncio.Future] = {}
|
||||
|
||||
async def _reconnect(self) -> None:
|
||||
"""Attempts to reconnect using exponential backoff with jitter."""
|
||||
delay = 1.0
|
||||
@ -144,12 +150,11 @@ class GatewayClient:
|
||||
self._last_heartbeat_sent = time.monotonic()
|
||||
payload = {"op": GatewayOpcode.HEARTBEAT, "d": self._last_sequence}
|
||||
await self._send_json(payload)
|
||||
# print("Sent heartbeat.")
|
||||
|
||||
async def _keep_alive(self):
|
||||
"""Manages the heartbeating loop."""
|
||||
if self._heartbeat_interval is None:
|
||||
# This should not happen if HELLO was processed correctly
|
||||
|
||||
logger.error("Heartbeat interval not set. Cannot start keep_alive.")
|
||||
return
|
||||
|
||||
@ -211,32 +216,49 @@ class GatewayClient:
|
||||
async def update_presence(
|
||||
self,
|
||||
status: str,
|
||||
activity_name: Optional[str] = None,
|
||||
activity_type: int = 0,
|
||||
activity: Optional[Activity] = None,
|
||||
*,
|
||||
since: int = 0,
|
||||
afk: bool = False,
|
||||
):
|
||||
) -> None:
|
||||
"""Sends the presence update payload to the Gateway."""
|
||||
payload = {
|
||||
"op": GatewayOpcode.PRESENCE_UPDATE,
|
||||
"d": {
|
||||
"since": since,
|
||||
"activities": (
|
||||
[
|
||||
{
|
||||
"name": activity_name,
|
||||
"type": activity_type,
|
||||
}
|
||||
]
|
||||
if activity_name
|
||||
else []
|
||||
),
|
||||
"activities": [activity.to_dict()] if activity else [],
|
||||
"status": status,
|
||||
"afk": afk,
|
||||
},
|
||||
}
|
||||
await self._send_json(payload)
|
||||
|
||||
async def request_guild_members(
|
||||
self,
|
||||
guild_id: str,
|
||||
query: str = "",
|
||||
limit: int = 0,
|
||||
presences: bool = False,
|
||||
user_ids: Optional[list[str]] = None,
|
||||
nonce: Optional[str] = None,
|
||||
):
|
||||
"""Sends the request guild members payload to the Gateway."""
|
||||
payload = {
|
||||
"op": GatewayOpcode.REQUEST_GUILD_MEMBERS,
|
||||
"d": {
|
||||
"guild_id": guild_id,
|
||||
"query": query,
|
||||
"limit": limit,
|
||||
"presences": presences,
|
||||
},
|
||||
}
|
||||
if user_ids:
|
||||
payload["d"]["user_ids"] = user_ids
|
||||
if nonce:
|
||||
payload["d"]["nonce"] = nonce
|
||||
|
||||
await self._send_json(payload)
|
||||
|
||||
async def _handle_dispatch(self, data: Dict[str, Any]):
|
||||
"""Handles DISPATCH events (actual Discord events)."""
|
||||
event_name = data.get("t")
|
||||
@ -312,9 +334,40 @@ class GatewayClient:
|
||||
self._resume_gateway_url,
|
||||
)
|
||||
|
||||
# The client is now ready for operations. Set the event before dispatching to user code.
|
||||
self._client_instance._ready_event.set()
|
||||
logger.info("Client is now marked as ready.")
|
||||
|
||||
if isinstance(raw_event_d_payload, dict) and self._shard_id is not None:
|
||||
raw_event_d_payload["shard_id"] = self._shard_id
|
||||
await self._dispatcher.dispatch(event_name, raw_event_d_payload)
|
||||
|
||||
if (
|
||||
getattr(self._client_instance, "sync_commands_on_ready", True)
|
||||
and self._client_instance.application_id
|
||||
):
|
||||
asyncio.create_task(self._client_instance.sync_application_commands())
|
||||
elif event_name == "GUILD_MEMBERS_CHUNK":
|
||||
if isinstance(raw_event_d_payload, dict):
|
||||
nonce = raw_event_d_payload.get("nonce")
|
||||
if nonce and nonce in self._member_chunk_requests:
|
||||
future = self._member_chunk_requests[nonce]
|
||||
if not future.done():
|
||||
# Append members to a temporary list stored on the future object
|
||||
if not hasattr(future, "_members"):
|
||||
future._members = [] # type: ignore
|
||||
future._members.extend(raw_event_d_payload.get("members", [])) # type: ignore
|
||||
|
||||
# If this is the last chunk, resolve the future
|
||||
if (
|
||||
raw_event_d_payload.get("chunk_index")
|
||||
== raw_event_d_payload.get("chunk_count", 1) - 1
|
||||
):
|
||||
future.set_result(future._members) # type: ignore
|
||||
del self._member_chunk_requests[nonce]
|
||||
|
||||
elif event_name == "INTERACTION_CREATE":
|
||||
# print(f"GATEWAY RECV INTERACTION_CREATE: {raw_event_d_payload}")
|
||||
|
||||
if isinstance(raw_event_d_payload, dict):
|
||||
interaction = Interaction(
|
||||
data=raw_event_d_payload, client_instance=self._client_instance
|
||||
@ -343,6 +396,8 @@ class GatewayClient:
|
||||
event_data_to_dispatch = (
|
||||
raw_event_d_payload if isinstance(raw_event_d_payload, dict) else {}
|
||||
)
|
||||
if isinstance(event_data_to_dispatch, dict) and self._shard_id is not None:
|
||||
event_data_to_dispatch["shard_id"] = self._shard_id
|
||||
await self._dispatcher.dispatch(event_name, event_data_to_dispatch)
|
||||
await self._dispatcher.dispatch(
|
||||
"SHARD_RESUME", {"shard_id": self._shard_id}
|
||||
@ -353,7 +408,9 @@ class GatewayClient:
|
||||
event_data_to_dispatch = (
|
||||
raw_event_d_payload if isinstance(raw_event_d_payload, dict) else {}
|
||||
)
|
||||
# print(f"GATEWAY RECV EVENT: {event_name} | DATA: {event_data_to_dispatch}")
|
||||
if isinstance(event_data_to_dispatch, dict) and self._shard_id is not None:
|
||||
event_data_to_dispatch["shard_id"] = self._shard_id
|
||||
|
||||
await self._dispatcher.dispatch(event_name, event_data_to_dispatch)
|
||||
else:
|
||||
logger.warning("Received dispatch with no event name: %s", data)
|
||||
@ -452,8 +509,6 @@ class GatewayClient:
|
||||
await self._identify()
|
||||
elif op == GatewayOpcode.HEARTBEAT_ACK:
|
||||
self._last_heartbeat_ack = time.monotonic()
|
||||
# print("Received heartbeat ACK.")
|
||||
pass # Good, connection is alive
|
||||
else:
|
||||
logger.warning(
|
||||
"Received unhandled Gateway Opcode: %s with data: %s", op, data
|
||||
@ -514,6 +569,7 @@ class GatewayClient:
|
||||
await self._dispatcher.dispatch(
|
||||
"SHARD_CONNECT", {"shard_id": self._shard_id}
|
||||
)
|
||||
await self._dispatcher.dispatch("CONNECT", {"shard_id": self._shard_id})
|
||||
|
||||
except aiohttp.ClientConnectorError as e:
|
||||
raise GatewayException(
|
||||
@ -540,7 +596,7 @@ class GatewayClient:
|
||||
try:
|
||||
await self._keep_alive_task
|
||||
except asyncio.CancelledError:
|
||||
pass # Expected
|
||||
pass
|
||||
|
||||
if self._receive_task and not self._receive_task.done():
|
||||
current = asyncio.current_task(loop=self._loop)
|
||||
@ -549,7 +605,7 @@ class GatewayClient:
|
||||
try:
|
||||
await self._receive_task
|
||||
except asyncio.CancelledError:
|
||||
pass # Expected
|
||||
pass
|
||||
|
||||
if self._ws and not self._ws.closed:
|
||||
await self._ws.close(code=code)
|
||||
@ -569,6 +625,7 @@ class GatewayClient:
|
||||
await self._dispatcher.dispatch(
|
||||
"SHARD_DISCONNECT", {"shard_id": self._shard_id}
|
||||
)
|
||||
await self._dispatcher.dispatch("DISCONNECT", {"shard_id": self._shard_id})
|
||||
|
||||
@property
|
||||
def latency(self) -> Optional[float]:
|
||||
@ -577,6 +634,13 @@ class GatewayClient:
|
||||
return None
|
||||
return self._last_heartbeat_ack - self._last_heartbeat_sent
|
||||
|
||||
@property
|
||||
def latency_ms(self) -> Optional[float]:
|
||||
"""Returns the latency between heartbeat and ACK in milliseconds."""
|
||||
if self._last_heartbeat_sent is None or self._last_heartbeat_ack is None:
|
||||
return None
|
||||
return (self._last_heartbeat_ack - self._last_heartbeat_sent) * 1000
|
||||
|
||||
@property
|
||||
def last_heartbeat_sent(self) -> Optional[float]:
|
||||
return self._last_heartbeat_sent
|
||||
|
@ -1,5 +1,3 @@
|
||||
# disagreement/http.py
|
||||
|
||||
"""
|
||||
HTTP client for interacting with the Discord REST API.
|
||||
"""
|
||||
@ -11,12 +9,7 @@ import json
|
||||
from urllib.parse import quote
|
||||
from typing import Optional, Dict, Any, Union, TYPE_CHECKING, List
|
||||
|
||||
from .errors import (
|
||||
HTTPException,
|
||||
RateLimitError,
|
||||
AuthenticationError,
|
||||
DisagreementException,
|
||||
)
|
||||
from .errors import * # Import all custom exceptions
|
||||
from . import __version__ # For User-Agent
|
||||
from .rate_limiter import RateLimiter
|
||||
from .interactions import InteractionResponsePayload
|
||||
@ -31,6 +24,232 @@ API_BASE_URL = "https://discord.com/api/v10" # Using API v10
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DISCORD_ERROR_CODE_TO_EXCEPTION = {
|
||||
0: GeneralError,
|
||||
10001: UnknownAccount,
|
||||
10002: UnknownApplication,
|
||||
10003: UnknownChannel,
|
||||
10004: UnknownGuild,
|
||||
10005: UnknownIntegration,
|
||||
10006: UnknownInvite,
|
||||
10007: UnknownMember,
|
||||
10008: UnknownMessage,
|
||||
10009: UnknownPermissionOverwrite,
|
||||
10010: UnknownProvider,
|
||||
10011: UnknownRole,
|
||||
10012: UnknownToken,
|
||||
10013: UnknownUser,
|
||||
10014: UnknownEmoji,
|
||||
10015: UnknownWebhook,
|
||||
10016: UnknownWebhookService,
|
||||
10020: UnknownSession,
|
||||
10021: UnknownAsset,
|
||||
10026: UnknownBan,
|
||||
10027: UnknownSKU,
|
||||
10028: UnknownStoreListing,
|
||||
10029: UnknownEntitlement,
|
||||
10030: UnknownBuild,
|
||||
10031: UnknownLobby,
|
||||
10032: UnknownBranch,
|
||||
10033: UnknownStoreDirectoryLayout,
|
||||
10036: UnknownRedistributable,
|
||||
10038: UnknownGiftCode,
|
||||
10049: UnknownStream,
|
||||
10050: UnknownPremiumServerSubscribeCooldown,
|
||||
10057: UnknownGuildTemplate,
|
||||
10059: UnknownDiscoverableServerCategory,
|
||||
10060: UnknownSticker,
|
||||
10061: UnknownStickerPack,
|
||||
10062: UnknownInteraction,
|
||||
10063: UnknownApplicationCommand,
|
||||
10065: UnknownVoiceState,
|
||||
10066: UnknownApplicationCommandPermissions,
|
||||
10067: UnknownStageInstance,
|
||||
10068: UnknownGuildMemberVerificationForm,
|
||||
10069: UnknownGuildWelcomeScreen,
|
||||
10070: UnknownGuildScheduledEvent,
|
||||
10071: UnknownGuildScheduledEventUser,
|
||||
10087: UnknownTag,
|
||||
10097: UnknownSound,
|
||||
20001: BotsCannotUseThisEndpoint,
|
||||
20002: OnlyBotsCanUseThisEndpoint,
|
||||
20009: ExplicitContentCannotBeSentToTheDesiredRecipients,
|
||||
20012: NotAuthorizedToPerformThisActionOnThisApplication,
|
||||
20016: ActionCannotBePerformedDueToSlowmodeRateLimit,
|
||||
20018: OnlyTheOwnerOfThisAccountCanPerformThisAction,
|
||||
20022: MessageCannotBeEditedDueToAnnouncementRateLimits,
|
||||
20024: UnderMinimumAge,
|
||||
20028: ChannelHitWriteRateLimit,
|
||||
20029: ServerHitWriteRateLimit,
|
||||
20031: DisallowedWordsInStageTopicOrNames,
|
||||
20035: GuildPremiumSubscriptionLevelTooLow,
|
||||
30001: MaximumNumberOfGuildsReached,
|
||||
30002: MaximumNumberOfFriendsReached,
|
||||
30003: MaximumNumberOfPinsReached,
|
||||
30004: MaximumNumberOfRecipientsReached,
|
||||
30005: MaximumNumberOfGuildRolesReached,
|
||||
30007: MaximumNumberOfWebhooksReached,
|
||||
30008: MaximumNumberOfEmojisReached,
|
||||
30010: MaximumNumberOfReactionsReached,
|
||||
30011: MaximumNumberOfGroupDMsReached,
|
||||
30013: MaximumNumberOfGuildChannelsReached,
|
||||
30015: MaximumNumberOfAttachmentsInAMessageReached,
|
||||
30016: MaximumNumberOfInvitesReached,
|
||||
30018: MaximumNumberOfAnimatedEmojisReached,
|
||||
30019: MaximumNumberOfServerMembersReached,
|
||||
30030: MaximumNumberOfServerCategoriesReached,
|
||||
30031: GuildAlreadyHasATemplate,
|
||||
30032: MaximumNumberOfApplicationCommandsReached,
|
||||
30033: MaximumNumberOfThreadParticipantsReached,
|
||||
30034: MaximumNumberOfDailyApplicationCommandCreatesReached,
|
||||
30035: MaximumNumberOfBansForNonGuildMembersExceeded,
|
||||
30037: MaximumNumberOfBansFetchesReached,
|
||||
30038: MaximumNumberOfUncompletedGuildScheduledEventsReached,
|
||||
30039: MaximumNumberOfStickersReached,
|
||||
30040: MaximumNumberOfPruneRequestsReached,
|
||||
30042: MaximumNumberOfGuildWidgetSettingsUpdatesReached,
|
||||
30045: MaximumNumberOfSoundboardSoundsReached,
|
||||
30046: MaximumNumberOfEditsToMessagesOlderThan1HourReached,
|
||||
30047: MaximumNumberOfPinnedThreadsInAForumChannelReached,
|
||||
30048: MaximumNumberOfTagsInAForumChannelReached,
|
||||
30052: BitrateIsTooHighForChannelOfThisType,
|
||||
30056: MaximumNumberOfPremiumEmojisReached,
|
||||
30058: MaximumNumberOfWebhooksPerGuildReached,
|
||||
30061: MaximumNumberOfChannelPermissionOverwritesReached,
|
||||
30062: TheChannelsForThisGuildAreTooLarge,
|
||||
40001: Unauthorized,
|
||||
40002: YouNeedToVerifyYourAccount,
|
||||
40003: YouAreOpeningDirectMessagesTooFast,
|
||||
40004: SendMessagesHasBeenTemporarilyDisabled,
|
||||
40005: RequestEntityTooLarge,
|
||||
40006: ThisFeatureHasBeenTemporarilyDisabledServerSide,
|
||||
40007: TheUserIsBannedFromThisGuild,
|
||||
40012: ConnectionHasBeenRevoked,
|
||||
40018: OnlyConsumableSKUsCanBeConsumed,
|
||||
40019: YouCanOnlyDeleteSandboxEntitlements,
|
||||
40032: TargetUserIsNotConnectedToVoice,
|
||||
40033: ThisMessageHasAlreadyBeenCrossposted,
|
||||
40041: AnApplicationCommandWithThatNameAlreadyExists,
|
||||
40043: ApplicationInteractionFailedToSend,
|
||||
40058: CannotSendAMessageInAForumChannel,
|
||||
40060: InteractionHasAlreadyBeenAcknowledged,
|
||||
40061: TagNamesMustBeUnique,
|
||||
40062: ServiceResourceIsBeingRateLimited,
|
||||
40066: ThereAreNoTagsAvailableThatCanBeSetByNonModerators,
|
||||
40067: ATagIsRequiredToCreateAForumPostInThisChannel,
|
||||
40074: AnEntitlementHasAlreadyBeenGrantedForThisResource,
|
||||
40094: ThisInteractionHasHitTheMaximumNumberOfFollowUpMessages,
|
||||
40333: CloudflareIsBlockingYourRequest,
|
||||
50001: MissingAccess,
|
||||
50002: InvalidAccountType,
|
||||
50003: CannotExecuteActionOnADMChannel,
|
||||
50004: GuildWidgetDisabled,
|
||||
50005: CannotEditAMessageAuthoredByAnotherUser,
|
||||
50006: CannotSendAnEmptyMessage,
|
||||
50007: CannotSendMessagesToThisUser,
|
||||
50008: CannotSendMessagesInANonTextChannel,
|
||||
50009: ChannelVerificationLevelIsTooHighForYouToGainAccess,
|
||||
50010: OAuth2ApplicationDoesNotHaveABot,
|
||||
50011: OAuth2ApplicationLimitReached,
|
||||
50012: InvalidOAuth2State,
|
||||
50013: YouLackPermissionsToPerformThatAction,
|
||||
50014: InvalidAuthenticationTokenProvided,
|
||||
50015: NoteWasTooLong,
|
||||
50016: ProvidedTooFewOrTooManyMessagesToDelete,
|
||||
50017: InvalidMFALevel,
|
||||
50019: AMessageCanOnlyBePinnedToTheChannelItWasSentIn,
|
||||
50020: InviteCodeWasEitherInvalidOrTaken,
|
||||
50021: CannotExecuteActionOnASystemMessage,
|
||||
50024: CannotExecuteActionOnThisChannelType,
|
||||
50025: InvalidOAuth2AccessTokenProvided,
|
||||
50026: MissingRequiredOAuth2Scope,
|
||||
50027: InvalidWebhookTokenProvided,
|
||||
50028: InvalidRole,
|
||||
50033: InvalidRecipients,
|
||||
50034: AMessageProvidedWasTooOldToBulkDelete,
|
||||
50035: InvalidFormBody,
|
||||
50036: AnInviteWasAcceptedToAGuildTheApplicationBotIsNotIn,
|
||||
50039: InvalidActivityAction,
|
||||
50041: InvalidAPIVersionProvided,
|
||||
50045: FileUploadedExceedsTheMaximumSize,
|
||||
50046: InvalidFileUploaded,
|
||||
50054: CannotSelfRedeemThisGift,
|
||||
50055: InvalidGuild,
|
||||
50057: InvalidSKU,
|
||||
50067: InvalidRequestOrigin,
|
||||
50068: InvalidMessageType,
|
||||
50070: PaymentSourceRequiredToRedeemGift,
|
||||
50073: CannotModifyASystemWebhook,
|
||||
50074: CannotDeleteAChannelRequiredForCommunityGuilds,
|
||||
50080: CannotEditStickersWithinAMessage,
|
||||
50081: InvalidStickerSent,
|
||||
50083: TriedToPerformAnOperationOnAnArchivedThread,
|
||||
50085: InvalidThreadNotificationSettings,
|
||||
50086: BeforeValueIsEarlierThanTheThreadCreationDate,
|
||||
50087: CommunityServerChannelsMustBeTextChannels,
|
||||
50091: TheEntityTypeOfTheEventIsDifferentFromTheEntityYouAreTryingToStartTheEventFor,
|
||||
50095: ThisServerIsNotAvailableInYourLocation,
|
||||
50097: ThisServerNeedsMonetizationEnabledInOrderToPerformThisAction,
|
||||
50101: ThisServerNeedsMoreBoostsToPerformThisAction,
|
||||
50109: TheRequestBodyContainsInvalidJSON,
|
||||
50110: TheProvidedFileIsInvalid,
|
||||
50123: TheProvidedFileTypeIsInvalid,
|
||||
50124: TheProvidedFileDurationExceedsMaximumOf52Seconds,
|
||||
50131: OwnerCannotBePendingMember,
|
||||
50132: OwnershipCannotBeTransferredToABotUser,
|
||||
50138: FailedToResizeAssetBelowTheMaximumSize,
|
||||
50144: CannotMixSubscriptionAndNonSubscriptionRolesForAnEmoji,
|
||||
50145: CannotConvertBetweenPremiumEmojiAndNormalEmoji,
|
||||
50146: UploadedFileNotFound,
|
||||
50151: TheSpecifiedEmojiIsInvalid,
|
||||
50159: VoiceMessagesDoNotSupportAdditionalContent,
|
||||
50160: VoiceMessagesMustHaveASingleAudioAttachment,
|
||||
50161: VoiceMessagesMustHaveSupportingMetadata,
|
||||
50162: VoiceMessagesCannotBeEdited,
|
||||
50163: CannotDeleteGuildSubscriptionIntegration,
|
||||
50173: YouCannotSendVoiceMessagesInThisChannel,
|
||||
50178: TheUserAccountMustFirstBeVerified,
|
||||
50192: TheProvidedFileDoesNotHaveAValidDuration,
|
||||
50600: YouDoNotHavePermissionToSendThisSticker,
|
||||
60003: TwoFactorIsRequiredForThisOperation,
|
||||
80004: NoUsersWithDiscordTagExist,
|
||||
90001: ReactionWasBlocked,
|
||||
90002: UserCannotUseBurstReactions,
|
||||
110001: ApplicationNotYetAvailable,
|
||||
130000: APIResourceIsCurrentlyOverloaded,
|
||||
150006: TheStageIsAlreadyOpen,
|
||||
160002: CannotReplyWithoutPermissionToReadMessageHistory,
|
||||
160004: AThreadHasAlreadyBeenCreatedForThisMessage,
|
||||
160005: ThreadIsLocked,
|
||||
160006: MaximumNumberOfActiveThreadsReached,
|
||||
160007: MaximumNumberOfActiveAnnouncementThreadsReached,
|
||||
170001: InvalidJSONForUploadedLottieFile,
|
||||
170002: UploadedLottiesCannotContainRasterizedImages,
|
||||
170003: StickerMaximumFramerateExceeded,
|
||||
170004: StickerFrameCountExceedsMaximumOf1000Frames,
|
||||
170005: LottieAnimationMaximumDimensionsExceeded,
|
||||
170006: StickerFrameRateIsEitherTooSmallOrTooLarge,
|
||||
170007: StickerAnimationDurationExceedsMaximumOf5Seconds,
|
||||
180000: CannotUpdateAFinishedEvent,
|
||||
180002: FailedToCreateStageNeededForStageEvent,
|
||||
200000: MessageWasBlockedByAutomaticModeration,
|
||||
200001: TitleWasBlockedByAutomaticModeration,
|
||||
220001: WebhooksPostedToForumChannelsMustHaveAThreadNameOrThreadId,
|
||||
220002: WebhooksPostedToForumChannelsCannotHaveBothAThreadNameAndThreadId,
|
||||
220003: WebhooksCanOnlyCreateThreadsInForumChannels,
|
||||
220004: WebhookServicesCannotBeUsedInForumChannels,
|
||||
240000: MessageBlockedByHarmfulLinksFilter,
|
||||
350000: CannotEnableOnboardingRequirementsAreNotMet,
|
||||
350001: CannotUpdateOnboardingWhileBelowRequirements,
|
||||
500000: FailedToBanUsers,
|
||||
520000: PollVotingBlocked,
|
||||
520001: PollExpired,
|
||||
520002: InvalidChannelTypeForPollCreation,
|
||||
520003: CannotEditAPollMessage,
|
||||
520004: CannotUseAnEmojiIncludedWithThePoll,
|
||||
520006: CannotExpireANonPollMessage,
|
||||
}
|
||||
|
||||
|
||||
class HTTPClient:
|
||||
"""Handles HTTP requests to the Discord API."""
|
||||
@ -208,6 +427,17 @@ class HTTPClient:
|
||||
discord_error_code = (
|
||||
data.get("code") if isinstance(data, dict) else None
|
||||
)
|
||||
|
||||
if discord_error_code in DISCORD_ERROR_CODE_TO_EXCEPTION:
|
||||
exc_class = DISCORD_ERROR_CODE_TO_EXCEPTION[discord_error_code]
|
||||
raise exc_class(
|
||||
response,
|
||||
f"API Error on {method} {endpoint}: {error_text}",
|
||||
status=response.status,
|
||||
text=error_text,
|
||||
error_code=discord_error_code,
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
response,
|
||||
f"API Error on {method} {endpoint}: {error_text}",
|
||||
@ -215,8 +445,6 @@ class HTTPClient:
|
||||
text=error_text,
|
||||
error_code=discord_error_code,
|
||||
)
|
||||
|
||||
# Should not be reached if retries are exhausted by RateLimitError
|
||||
raise DisagreementException(
|
||||
f"Failed request to {method} {endpoint} after multiple retries."
|
||||
)
|
||||
@ -368,6 +596,20 @@ class HTTPClient:
|
||||
f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}/@me",
|
||||
)
|
||||
|
||||
async def delete_user_reaction(
|
||||
self,
|
||||
channel_id: "Snowflake",
|
||||
message_id: "Snowflake",
|
||||
emoji: str,
|
||||
user_id: "Snowflake",
|
||||
) -> None:
|
||||
"""Removes another user's reaction from a message."""
|
||||
encoded = quote(emoji)
|
||||
await self.request(
|
||||
"DELETE",
|
||||
f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}/{user_id}",
|
||||
)
|
||||
|
||||
async def get_reactions(
|
||||
self, channel_id: "Snowflake", message_id: "Snowflake", emoji: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
@ -400,6 +642,36 @@ class HTTPClient:
|
||||
)
|
||||
return messages
|
||||
|
||||
async def get_pinned_messages(
|
||||
self, channel_id: "Snowflake"
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fetches all pinned messages in a channel."""
|
||||
|
||||
return await self.request("GET", f"/channels/{channel_id}/pins")
|
||||
|
||||
async def pin_message(
|
||||
self, channel_id: "Snowflake", message_id: "Snowflake"
|
||||
) -> None:
|
||||
"""Pins a message in a channel."""
|
||||
|
||||
await self.request("PUT", f"/channels/{channel_id}/pins/{message_id}")
|
||||
|
||||
async def unpin_message(
|
||||
self, channel_id: "Snowflake", message_id: "Snowflake"
|
||||
) -> None:
|
||||
"""Unpins a message from a channel."""
|
||||
|
||||
await self.request("DELETE", f"/channels/{channel_id}/pins/{message_id}")
|
||||
|
||||
async def crosspost_message(
|
||||
self, channel_id: "Snowflake", message_id: "Snowflake"
|
||||
) -> Dict[str, Any]:
|
||||
"""Crossposts a message to any following channels."""
|
||||
|
||||
return await self.request(
|
||||
"POST", f"/channels/{channel_id}/messages/{message_id}/crosspost"
|
||||
)
|
||||
|
||||
async def delete_channel(
|
||||
self, channel_id: str, reason: Optional[str] = None
|
||||
) -> None:
|
||||
@ -420,10 +692,41 @@ class HTTPClient:
|
||||
custom_headers=custom_headers if custom_headers else None,
|
||||
)
|
||||
|
||||
async def edit_channel(
|
||||
self,
|
||||
channel_id: "Snowflake",
|
||||
payload: Dict[str, Any],
|
||||
reason: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Edits a channel."""
|
||||
headers = {"X-Audit-Log-Reason": reason} if reason else None
|
||||
return await self.request(
|
||||
"PATCH",
|
||||
f"/channels/{channel_id}",
|
||||
payload=payload,
|
||||
custom_headers=headers,
|
||||
)
|
||||
|
||||
async def get_channel(self, channel_id: str) -> Dict[str, Any]:
|
||||
"""Fetches a channel by ID."""
|
||||
return await self.request("GET", f"/channels/{channel_id}")
|
||||
|
||||
async def create_guild_channel(
|
||||
self,
|
||||
guild_id: "Snowflake",
|
||||
payload: Dict[str, Any],
|
||||
reason: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Creates a new channel in the specified guild."""
|
||||
|
||||
headers = {"X-Audit-Log-Reason": reason} if reason else None
|
||||
return await self.request(
|
||||
"POST",
|
||||
f"/guilds/{guild_id}/channels",
|
||||
payload=payload,
|
||||
custom_headers=headers,
|
||||
)
|
||||
|
||||
async def get_channel_invites(
|
||||
self, channel_id: "Snowflake"
|
||||
) -> List[Dict[str, Any]]:
|
||||
@ -443,11 +746,36 @@ class HTTPClient:
|
||||
|
||||
return Invite.from_dict(data)
|
||||
|
||||
async def create_channel_invite(
|
||||
self,
|
||||
channel_id: "Snowflake",
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
reason: Optional[str] = None,
|
||||
) -> "Invite":
|
||||
"""Creates an invite for a channel with an optional audit log reason."""
|
||||
|
||||
headers = {"X-Audit-Log-Reason": reason} if reason else None
|
||||
data = await self.request(
|
||||
"POST",
|
||||
f"/channels/{channel_id}/invites",
|
||||
payload=payload,
|
||||
custom_headers=headers,
|
||||
)
|
||||
from .models import Invite
|
||||
|
||||
return Invite.from_dict(data)
|
||||
|
||||
async def delete_invite(self, code: str) -> None:
|
||||
"""Deletes an invite by code."""
|
||||
|
||||
await self.request("DELETE", f"/invites/{code}")
|
||||
|
||||
async def get_invite(self, code: "Snowflake") -> Dict[str, Any]:
|
||||
"""Fetches a single invite by its code."""
|
||||
|
||||
return await self.request("GET", f"/invites/{code}")
|
||||
|
||||
async def create_webhook(
|
||||
self, channel_id: "Snowflake", payload: Dict[str, Any]
|
||||
) -> "Webhook":
|
||||
@ -460,6 +788,11 @@ class HTTPClient:
|
||||
|
||||
return Webhook(data)
|
||||
|
||||
async def get_webhook(self, webhook_id: "Snowflake") -> Dict[str, Any]:
|
||||
"""Fetches a webhook by ID and returns the raw payload."""
|
||||
|
||||
return await self.request("GET", f"/webhooks/{webhook_id}")
|
||||
|
||||
async def edit_webhook(
|
||||
self, webhook_id: "Snowflake", payload: Dict[str, Any]
|
||||
) -> "Webhook":
|
||||
@ -475,6 +808,24 @@ class HTTPClient:
|
||||
|
||||
await self.request("DELETE", f"/webhooks/{webhook_id}")
|
||||
|
||||
async def get_webhook_with_token(
|
||||
self, webhook_id: "Snowflake", token: Optional[str] = None
|
||||
) -> "Webhook":
|
||||
"""Fetches a webhook by ID, optionally using its token."""
|
||||
|
||||
endpoint = f"/webhooks/{webhook_id}"
|
||||
use_auth = True
|
||||
if token is not None:
|
||||
endpoint += f"/{token}"
|
||||
use_auth = False
|
||||
if use_auth:
|
||||
data = await self.request("GET", endpoint)
|
||||
else:
|
||||
data = await self.request("GET", endpoint, use_auth_header=False)
|
||||
from .models import Webhook
|
||||
|
||||
return Webhook(data)
|
||||
|
||||
async def execute_webhook(
|
||||
self,
|
||||
webhook_id: "Snowflake",
|
||||
@ -565,6 +916,10 @@ class HTTPClient:
|
||||
"""Fetches a user object for a given user ID."""
|
||||
return await self.request("GET", f"/users/{user_id}")
|
||||
|
||||
async def get_current_user_guilds(self) -> List[Dict[str, Any]]:
|
||||
"""Returns the guilds the current user is in."""
|
||||
return await self.request("GET", "/users/@me/guilds")
|
||||
|
||||
async def get_guild_member(
|
||||
self, guild_id: "Snowflake", user_id: "Snowflake"
|
||||
) -> Dict[str, Any]:
|
||||
@ -620,6 +975,29 @@ class HTTPClient:
|
||||
custom_headers=headers,
|
||||
)
|
||||
|
||||
async def get_guild_prune_count(self, guild_id: "Snowflake", *, days: int) -> int:
|
||||
"""Returns the number of members that would be pruned."""
|
||||
|
||||
data = await self.request(
|
||||
"GET",
|
||||
f"/guilds/{guild_id}/prune",
|
||||
params={"days": days},
|
||||
)
|
||||
return int(data.get("pruned", 0))
|
||||
|
||||
async def begin_guild_prune(
|
||||
self, guild_id: "Snowflake", *, days: int, compute_count: bool = True
|
||||
) -> int:
|
||||
"""Begins a prune operation for the guild and returns the count."""
|
||||
|
||||
payload = {"days": days, "compute_prune_count": compute_count}
|
||||
data = await self.request(
|
||||
"POST",
|
||||
f"/guilds/{guild_id}/prune",
|
||||
payload=payload,
|
||||
)
|
||||
return int(data.get("pruned", 0))
|
||||
|
||||
async def get_guild_roles(self, guild_id: "Snowflake") -> List[Dict[str, Any]]:
|
||||
"""Returns a list of role objects for the guild."""
|
||||
return await self.request("GET", f"/guilds/{guild_id}/roles")
|
||||
@ -628,6 +1006,20 @@ class HTTPClient:
|
||||
"""Fetches a guild object for a given guild ID."""
|
||||
return await self.request("GET", f"/guilds/{guild_id}")
|
||||
|
||||
async def get_guild_widget(self, guild_id: "Snowflake") -> Dict[str, Any]:
|
||||
"""Fetches the guild widget settings."""
|
||||
|
||||
return await self.request("GET", f"/guilds/{guild_id}/widget")
|
||||
|
||||
async def edit_guild_widget(
|
||||
self, guild_id: "Snowflake", payload: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Edits the guild widget settings."""
|
||||
|
||||
return await self.request(
|
||||
"PATCH", f"/guilds/{guild_id}/widget", payload=payload
|
||||
)
|
||||
|
||||
async def get_guild_templates(self, guild_id: "Snowflake") -> List[Dict[str, Any]]:
|
||||
"""Fetches all templates for the given guild."""
|
||||
return await self.request("GET", f"/guilds/{guild_id}/templates")
|
||||
@ -1039,3 +1431,37 @@ class HTTPClient:
|
||||
async def get_voice_regions(self) -> List[Dict[str, Any]]:
|
||||
"""Returns available voice regions."""
|
||||
return await self.request("GET", "/voice/regions")
|
||||
|
||||
async def start_thread_from_message(
|
||||
self,
|
||||
channel_id: "Snowflake",
|
||||
message_id: "Snowflake",
|
||||
payload: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Starts a new thread from an existing message."""
|
||||
return await self.request(
|
||||
"POST",
|
||||
f"/channels/{channel_id}/messages/{message_id}/threads",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
async def start_thread_without_message(
|
||||
self, channel_id: "Snowflake", payload: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Starts a new thread that is not attached to a message."""
|
||||
return await self.request(
|
||||
"POST", f"/channels/{channel_id}/threads", payload=payload
|
||||
)
|
||||
|
||||
async def join_thread(self, channel_id: "Snowflake") -> None:
|
||||
"""Joins the current user to a thread."""
|
||||
await self.request("PUT", f"/channels/{channel_id}/thread-members/@me")
|
||||
|
||||
async def leave_thread(self, channel_id: "Snowflake") -> None:
|
||||
"""Removes the current user from a thread."""
|
||||
await self.request("DELETE", f"/channels/{channel_id}/thread-members/@me")
|
||||
|
||||
async def create_dm(self, recipient_id: "Snowflake") -> Dict[str, Any]:
|
||||
"""Creates (or opens) a DM channel with the given user."""
|
||||
payload = {"recipient_id": str(recipient_id)}
|
||||
return await self.request("POST", "/users/@me/channels", payload=payload)
|
||||
|
@ -1,5 +1,3 @@
|
||||
# disagreement/interactions.py
|
||||
|
||||
"""
|
||||
Data models for Discord Interaction objects.
|
||||
"""
|
||||
|
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."""
|
||||
|
@ -1,5 +1,3 @@
|
||||
# disagreement/shard_manager.py
|
||||
|
||||
"""Sharding utilities for managing multiple gateway connections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
@ -28,6 +28,8 @@ class View:
|
||||
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)
|
||||
@ -44,6 +46,11 @@ class 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)
|
||||
|
||||
@ -65,11 +72,6 @@ class View:
|
||||
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
|
||||
@ -145,7 +147,7 @@ class View:
|
||||
|
||||
async def on_timeout(self):
|
||||
"""Called when the view times out."""
|
||||
pass # User can override this
|
||||
pass
|
||||
|
||||
async def _start(self, client: Client):
|
||||
"""Starts the view's internal listener."""
|
||||
|
@ -3,7 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, AsyncIterator, Dict, Optional, TYPE_CHECKING
|
||||
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
|
||||
@ -14,6 +18,27 @@ def utcnow() -> datetime:
|
||||
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",
|
||||
*,
|
||||
@ -21,32 +46,11 @@ async def message_pager(
|
||||
before: Optional[str] = None,
|
||||
after: Optional[str] = None,
|
||||
) -> AsyncIterator["Message"]:
|
||||
"""Asynchronously paginate a channel's messages.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel:
|
||||
The :class:`TextChannel` to fetch messages from.
|
||||
limit:
|
||||
The maximum number of messages to yield. ``None`` fetches until no
|
||||
more messages are returned.
|
||||
before:
|
||||
Fetch messages with IDs less than this snowflake.
|
||||
after:
|
||||
Fetch messages with IDs greater than this snowflake.
|
||||
|
||||
Yields
|
||||
------
|
||||
Message
|
||||
Messages in the channel, oldest first.
|
||||
"""
|
||||
|
||||
"""Asynchronously paginate a channel's messages."""
|
||||
remaining = limit
|
||||
last_id = before
|
||||
while remaining is None or remaining > 0:
|
||||
fetch_limit = 100
|
||||
if remaining is not None:
|
||||
fetch_limit = min(fetch_limit, remaining)
|
||||
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:
|
||||
@ -71,3 +75,52 @@ async def message_pager(
|
||||
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,4 +1,3 @@
|
||||
# disagreement/voice_client.py
|
||||
"""Voice gateway and UDP audio client."""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -6,11 +5,36 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import socket
|
||||
from typing import Optional, Sequence
|
||||
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
|
||||
|
||||
from .audio import AudioSource, FFmpegAudioSource
|
||||
# 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:
|
||||
@ -18,6 +42,7 @@ class VoiceClient:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Client,
|
||||
endpoint: str,
|
||||
session_id: str,
|
||||
token: str,
|
||||
@ -29,6 +54,7 @@ class VoiceClient:
|
||||
loop: Optional[asyncio.AbstractEventLoop] = None,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
self.client = client
|
||||
self.endpoint = endpoint
|
||||
self.session_id = session_id
|
||||
self.token = token
|
||||
@ -38,8 +64,14 @@ class VoiceClient:
|
||||
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
|
||||
self._loop = loop or asyncio.get_event_loop()
|
||||
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
|
||||
@ -47,6 +79,12 @@ class VoiceClient:
|
||||
self._server_port: Optional[int] = None
|
||||
self._current_source: Optional[AudioSource] = None
|
||||
self._play_task: Optional[asyncio.Task] = None
|
||||
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:
|
||||
@ -106,6 +144,49 @@ class VoiceClient:
|
||||
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")
|
||||
@ -113,23 +194,32 @@ class VoiceClient:
|
||||
|
||||
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
|
||||
@ -148,15 +238,71 @@ class VoiceClient:
|
||||
|
||||
await self.play(FFmpegAudioSource(filename), wait=wait)
|
||||
|
||||
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
|
@ -13,12 +13,28 @@ if member:
|
||||
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:
|
||||
|
||||
```python
|
||||
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)
|
||||
|
@ -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
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,7 +95,7 @@ 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):
|
||||
...
|
||||
```
|
||||
|
||||
@ -131,3 +131,35 @@ a dictionary with the shard ID.
|
||||
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,
|
||||
|
@ -18,3 +18,14 @@ client = Client(
|
||||
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.
|
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)
|
@ -8,6 +8,9 @@ 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
|
||||
|
@ -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}")
|
||||
|
@ -3,7 +3,7 @@
|
||||
The `Client` provides helpers to manage guild scheduled events.
|
||||
|
||||
```python
|
||||
from disagreement.client import Client
|
||||
from disagreement import Client
|
||||
|
||||
client = Client(token="TOKEN")
|
||||
|
||||
|
@ -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")
|
||||
|
||||
|
@ -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,7 +30,7 @@ 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():
|
||||
...
|
||||
```
|
||||
@ -38,7 +38,7 @@ async def worker():
|
||||
Run setup and teardown code using `before_loop` and `after_loop`:
|
||||
|
||||
```python
|
||||
@tasks.loop(seconds=5.0)
|
||||
@loop(seconds=5.0)
|
||||
async def worker():
|
||||
...
|
||||
|
||||
@ -58,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,7 +145,7 @@ 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")],
|
||||
@ -160,6 +157,22 @@ 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)
|
||||
|
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,7 +43,7 @@ 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
|
||||
|
||||
|
@ -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()
|
||||
```
|
||||
|
||||
|
@ -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,7 +37,7 @@ 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)
|
||||
|
@ -27,9 +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.models import Guild
|
||||
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."
|
||||
@ -39,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
|
||||
@ -54,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:
|
||||
@ -71,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"
|
||||
@ -105,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}.")
|
||||
@ -114,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.")
|
||||
@ -137,8 +148,8 @@ class ExampleCog(commands.Cog): # Ensuring this uses commands.Cog
|
||||
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
|
||||
@ -172,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,
|
||||
@ -197,19 +208,19 @@ async def on_guild_available(guild: Guild):
|
||||
|
||||
|
||||
# --- 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...")
|
||||
@ -219,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.")
|
||||
|
||||
|
||||
@ -231,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()
|
@ -4,7 +4,11 @@ import asyncio
|
||||
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")
|
||||
|
||||
# Allow running from the examples folder without installing
|
||||
if os.path.join(os.getcwd(), "examples") == os.path.dirname(os.path.abspath(__file__)):
|
||||
@ -12,7 +16,8 @@ if os.path.join(os.getcwd(), "examples") == os.path.dirname(os.path.abspath(__fi
|
||||
|
||||
from disagreement import Client
|
||||
|
||||
load_dotenv()
|
||||
if load_dotenv:
|
||||
load_dotenv()
|
||||
|
||||
TOKEN = os.environ.get("DISCORD_BOT_TOKEN")
|
||||
|
||||
|
@ -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.")
|
||||
|
@ -8,11 +8,17 @@ 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, Channel
|
||||
from disagreement.models import TextChannel
|
||||
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()
|
||||
|
||||
BOT_TOKEN = os.environ.get("DISCORD_BOT_TOKEN", "")
|
||||
CHANNEL_ID = os.environ.get("DISCORD_CHANNEL_ID", "")
|
||||
|
@ -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", "")
|
||||
|
||||
@ -59,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()
|
@ -1,7 +1,7 @@
|
||||
from disagreement.ext import tasks
|
||||
from disagreement import loop
|
||||
|
||||
|
||||
@tasks.loop(seconds=2.0)
|
||||
@loop(seconds=2.0)
|
||||
async def ticker() -> None:
|
||||
print("Extension tick")
|
||||
|
||||
|
@ -8,16 +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 dotenv import load_dotenv
|
||||
from disagreement import Client
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
@ -28,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'
|
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "disagreement"
|
||||
version = "0.2.0rc1"
|
||||
version = "0.8.1"
|
||||
description = "A Python library for the Discord API."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@ -26,6 +26,7 @@ classifiers = [
|
||||
|
||||
dependencies = [
|
||||
"aiohttp>=3.9.0,<4.0.0",
|
||||
"PyNaCl>=1.5.0,<2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@ -41,6 +42,7 @@ dev = [
|
||||
[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"]
|
||||
|
@ -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,
|
||||
|
@ -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",
|
||||
|
14
tests/test_asset.py
Normal file
14
tests/test_asset.py
Normal file
@ -0,0 +1,14 @@
|
||||
from disagreement.models import User
|
||||
from disagreement.asset import Asset
|
||||
|
||||
|
||||
def test_user_avatar_returns_asset():
|
||||
user = User({"id": "1", "username": "u", "discriminator": "0001", "avatar": "abc"})
|
||||
avatar = user.avatar
|
||||
assert isinstance(avatar, Asset)
|
||||
assert avatar.url == "https://cdn.discordapp.com/avatars/1/abc.png"
|
||||
|
||||
|
||||
def test_user_avatar_none():
|
||||
user = User({"id": "1", "username": "u", "discriminator": "0001"})
|
||||
assert user.avatar is None
|
83
tests/test_auto_sync_commands.py
Normal file
83
tests/test_auto_sync_commands.py
Normal file
@ -0,0 +1,83 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from disagreement.client import Client
|
||||
from disagreement.gateway import GatewayClient
|
||||
from disagreement.event_dispatcher import EventDispatcher
|
||||
|
||||
|
||||
class DummyHTTP:
|
||||
pass
|
||||
|
||||
|
||||
class DummyUser:
|
||||
username = "u"
|
||||
discriminator = "0001"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_sync_on_ready(monkeypatch):
|
||||
client = Client(token="t", application_id="123")
|
||||
http = DummyHTTP()
|
||||
dispatcher = EventDispatcher(client)
|
||||
gw = GatewayClient(
|
||||
http_client=http,
|
||||
event_dispatcher=dispatcher,
|
||||
token="t",
|
||||
intents=0,
|
||||
client_instance=client,
|
||||
)
|
||||
monkeypatch.setattr(client, "parse_user", lambda d: DummyUser())
|
||||
monkeypatch.setattr(gw._dispatcher, "dispatch", AsyncMock())
|
||||
sync_mock = AsyncMock()
|
||||
monkeypatch.setattr(client, "sync_application_commands", sync_mock)
|
||||
|
||||
data = {
|
||||
"t": "READY",
|
||||
"s": 1,
|
||||
"d": {
|
||||
"session_id": "s1",
|
||||
"resume_gateway_url": "url",
|
||||
"application": {"id": "123"},
|
||||
"user": {"id": "1"},
|
||||
},
|
||||
}
|
||||
|
||||
await gw._handle_dispatch(data)
|
||||
await asyncio.sleep(0)
|
||||
sync_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_sync_disabled(monkeypatch):
|
||||
client = Client(token="t", application_id="123", sync_commands_on_ready=False)
|
||||
http = DummyHTTP()
|
||||
dispatcher = EventDispatcher(client)
|
||||
gw = GatewayClient(
|
||||
http_client=http,
|
||||
event_dispatcher=dispatcher,
|
||||
token="t",
|
||||
intents=0,
|
||||
client_instance=client,
|
||||
)
|
||||
monkeypatch.setattr(client, "parse_user", lambda d: DummyUser())
|
||||
monkeypatch.setattr(gw._dispatcher, "dispatch", AsyncMock())
|
||||
sync_mock = AsyncMock()
|
||||
monkeypatch.setattr(client, "sync_application_commands", sync_mock)
|
||||
|
||||
data = {
|
||||
"t": "READY",
|
||||
"s": 1,
|
||||
"d": {
|
||||
"session_id": "s1",
|
||||
"resume_gateway_url": "url",
|
||||
"application": {"id": "123"},
|
||||
"user": {"id": "1"},
|
||||
},
|
||||
}
|
||||
|
||||
await gw._handle_dispatch(data)
|
||||
await asyncio.sleep(0)
|
||||
sync_mock.assert_not_called()
|
@ -1,6 +1,60 @@
|
||||
import time
|
||||
|
||||
from disagreement.cache import Cache
|
||||
from disagreement.client import Client
|
||||
from disagreement.caching import MemberCacheFlags
|
||||
from disagreement.enums import (
|
||||
ChannelType,
|
||||
ExplicitContentFilterLevel,
|
||||
GuildNSFWLevel,
|
||||
MFALevel,
|
||||
MessageNotificationLevel,
|
||||
PremiumTier,
|
||||
VerificationLevel,
|
||||
)
|
||||
|
||||
|
||||
def _guild_payload(gid: str, channel_count: int, member_count: int) -> dict:
|
||||
base = {
|
||||
"id": gid,
|
||||
"name": f"g{gid}",
|
||||
"owner_id": "1",
|
||||
"afk_timeout": 60,
|
||||
"verification_level": VerificationLevel.NONE.value,
|
||||
"default_message_notifications": MessageNotificationLevel.ALL_MESSAGES.value,
|
||||
"explicit_content_filter": ExplicitContentFilterLevel.DISABLED.value,
|
||||
"roles": [],
|
||||
"emojis": [],
|
||||
"features": [],
|
||||
"mfa_level": MFALevel.NONE.value,
|
||||
"system_channel_flags": 0,
|
||||
"premium_tier": PremiumTier.NONE.value,
|
||||
"nsfw_level": GuildNSFWLevel.DEFAULT.value,
|
||||
"channels": [],
|
||||
"members": [],
|
||||
}
|
||||
for i in range(channel_count):
|
||||
base["channels"].append(
|
||||
{
|
||||
"id": f"{gid}-c{i}",
|
||||
"type": ChannelType.GUILD_TEXT.value,
|
||||
"guild_id": gid,
|
||||
"permission_overwrites": [],
|
||||
}
|
||||
)
|
||||
for i in range(member_count):
|
||||
base["members"].append(
|
||||
{
|
||||
"user": {
|
||||
"id": f"{gid}-m{i}",
|
||||
"username": f"u{i}",
|
||||
"discriminator": "0001",
|
||||
},
|
||||
"joined_at": "t",
|
||||
"roles": [],
|
||||
}
|
||||
)
|
||||
return base
|
||||
|
||||
|
||||
def test_cache_store_and_get():
|
||||
@ -15,3 +69,72 @@ def test_cache_ttl_expiry():
|
||||
assert cache.get("b") == 1
|
||||
time.sleep(0.02)
|
||||
assert cache.get("b") is None
|
||||
|
||||
|
||||
def test_cache_lru_eviction():
|
||||
cache = Cache(maxlen=2)
|
||||
cache.set("a", 1)
|
||||
cache.set("b", 2)
|
||||
assert cache.get("a") == 1
|
||||
cache.set("c", 3)
|
||||
assert cache.get("b") is None
|
||||
assert cache.get("a") == 1
|
||||
assert cache.get("c") == 3
|
||||
|
||||
|
||||
def test_get_or_fetch_uses_cache():
|
||||
cache = Cache()
|
||||
cache.set("a", 1)
|
||||
|
||||
def fetch():
|
||||
raise AssertionError("fetch should not be called")
|
||||
|
||||
assert cache.get_or_fetch("a", fetch) == 1
|
||||
|
||||
|
||||
def test_get_or_fetch_fetches_and_stores():
|
||||
cache = Cache()
|
||||
called = False
|
||||
|
||||
def fetch():
|
||||
nonlocal called
|
||||
called = True
|
||||
return 2
|
||||
|
||||
assert cache.get_or_fetch("b", fetch) == 2
|
||||
assert called
|
||||
assert cache.get("b") == 2
|
||||
|
||||
|
||||
def test_get_or_fetch_fetches_expired_item():
|
||||
cache = Cache(ttl=0.01)
|
||||
cache.set("c", 1)
|
||||
time.sleep(0.02)
|
||||
called = False
|
||||
|
||||
def fetch():
|
||||
nonlocal called
|
||||
called = True
|
||||
return 3
|
||||
|
||||
assert cache.get_or_fetch("c", fetch) == 3
|
||||
assert called
|
||||
|
||||
|
||||
def test_client_get_all_channels_and_members():
|
||||
client = Client(token="t")
|
||||
client.parse_guild(_guild_payload("1", 2, 2))
|
||||
client.parse_guild(_guild_payload("2", 1, 1))
|
||||
|
||||
channels = {c.id for c in client.get_all_channels()}
|
||||
members = {m.id for m in client.get_all_members()}
|
||||
|
||||
assert channels == {"1-c0", "1-c1", "2-c0"}
|
||||
assert members == {"1-m0", "1-m1", "2-m0"}
|
||||
|
||||
|
||||
def test_client_get_all_members_disabled_cache():
|
||||
client = Client(token="t", member_cache_flags=MemberCacheFlags.none())
|
||||
client.parse_guild(_guild_payload("1", 1, 2))
|
||||
|
||||
assert client.get_all_members() == []
|
||||
|
@ -14,12 +14,12 @@ from disagreement.enums import (
|
||||
from disagreement.permissions import Permissions
|
||||
|
||||
|
||||
class DummyClient:
|
||||
def __init__(self):
|
||||
self._guilds = {}
|
||||
from disagreement.client import Client
|
||||
|
||||
def get_guild(self, gid):
|
||||
return self._guilds.get(gid)
|
||||
|
||||
class DummyClient(Client):
|
||||
def __init__(self):
|
||||
super().__init__(token="test")
|
||||
|
||||
|
||||
def _base_guild(client):
|
||||
@ -40,7 +40,7 @@ def _base_guild(client):
|
||||
"nsfw_level": GuildNSFWLevel.DEFAULT.value,
|
||||
}
|
||||
guild = Guild(data, client_instance=client)
|
||||
client._guilds[guild.id] = guild
|
||||
client._guilds.set(guild.id, guild)
|
||||
return guild
|
||||
|
||||
|
||||
@ -52,7 +52,7 @@ def _member(guild, *roles):
|
||||
}
|
||||
member = Member(data, client_instance=None)
|
||||
member.guild_id = guild.id
|
||||
guild._members[member.id] = member
|
||||
guild._members.set(member.id, member)
|
||||
return member
|
||||
|
||||
|
||||
@ -81,7 +81,7 @@ def _channel(guild, client):
|
||||
"permission_overwrites": [],
|
||||
}
|
||||
channel = TextChannel(data, client_instance=client)
|
||||
guild._channels[channel.id] = channel
|
||||
guild._channels.set(channel.id, channel)
|
||||
return channel
|
||||
|
||||
|
||||
|
@ -1,7 +1,10 @@
|
||||
import asyncio
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
from disagreement.client import Client
|
||||
|
||||
|
||||
|
23
tests/test_client_message_cache.py
Normal file
23
tests/test_client_message_cache.py
Normal file
@ -0,0 +1,23 @@
|
||||
import pytest
|
||||
|
||||
from disagreement.client import Client
|
||||
|
||||
|
||||
def _add_message(client: Client, message_id: str) -> None:
|
||||
data = {
|
||||
"id": message_id,
|
||||
"channel_id": "c",
|
||||
"author": {"id": "u", "username": "u", "discriminator": "0001"},
|
||||
"content": "hi",
|
||||
"timestamp": "t",
|
||||
}
|
||||
client.parse_message(data)
|
||||
|
||||
|
||||
def test_client_message_cache_size():
|
||||
client = Client(token="t", message_cache_maxlen=1)
|
||||
_add_message(client, "1")
|
||||
assert client._messages.get("1").id == "1"
|
||||
_add_message(client, "2")
|
||||
assert client._messages.get("1") is None
|
||||
assert client._messages.get("2").id == "2"
|
42
tests/test_client_uptime.py
Normal file
42
tests/test_client_uptime.py
Normal file
@ -0,0 +1,42 @@
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from disagreement.client import Client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_records_start_time(monkeypatch):
|
||||
start = datetime(2020, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
monkeypatch.setattr("disagreement.client.utcnow", lambda: start)
|
||||
|
||||
client = Client(token="t")
|
||||
monkeypatch.setattr(client, "_initialize_gateway", AsyncMock())
|
||||
client._gateway = SimpleNamespace(connect=AsyncMock())
|
||||
monkeypatch.setattr(client, "wait_until_ready", AsyncMock())
|
||||
|
||||
assert client.start_time is None
|
||||
await client.connect()
|
||||
assert client.start_time == start
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_uptime(monkeypatch):
|
||||
start = datetime(2020, 1, 1, tzinfo=timezone.utc)
|
||||
end = start + timedelta(seconds=5)
|
||||
times = [start, end]
|
||||
|
||||
def fake_now():
|
||||
return times.pop(0)
|
||||
|
||||
monkeypatch.setattr("disagreement.client.utcnow", fake_now)
|
||||
|
||||
client = Client(token="t")
|
||||
monkeypatch.setattr(client, "_initialize_gateway", AsyncMock())
|
||||
client._gateway = SimpleNamespace(connect=AsyncMock())
|
||||
monkeypatch.setattr(client, "wait_until_ready", AsyncMock())
|
||||
|
||||
await client.connect()
|
||||
assert client.uptime() == timedelta(seconds=5)
|
@ -6,6 +6,7 @@ from disagreement.ext.commands.decorators import (
|
||||
check,
|
||||
cooldown,
|
||||
requires_permissions,
|
||||
is_owner,
|
||||
)
|
||||
from disagreement.ext.commands.errors import CheckFailure, CommandOnCooldown
|
||||
from disagreement.permissions import Permissions
|
||||
@ -133,3 +134,44 @@ async def test_requires_permissions_fail(message):
|
||||
|
||||
with pytest.raises(CheckFailure):
|
||||
await cmd.invoke(ctx)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_owner_pass(message):
|
||||
message._client.owner_ids = ["2"]
|
||||
|
||||
@is_owner()
|
||||
async def cb(ctx):
|
||||
pass
|
||||
|
||||
cmd = Command(cb)
|
||||
ctx = CommandContext(
|
||||
message=message,
|
||||
bot=message._client,
|
||||
prefix="!",
|
||||
command=cmd,
|
||||
invoked_with="test",
|
||||
)
|
||||
|
||||
await cmd.invoke(ctx)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_owner_fail(message):
|
||||
message._client.owner_ids = ["1"]
|
||||
|
||||
@is_owner()
|
||||
async def cb(ctx):
|
||||
pass
|
||||
|
||||
cmd = Command(cb)
|
||||
ctx = CommandContext(
|
||||
message=message,
|
||||
bot=message._client,
|
||||
prefix="!",
|
||||
command=cmd,
|
||||
invoked_with="test",
|
||||
)
|
||||
|
||||
with pytest.raises(CheckFailure):
|
||||
await cmd.invoke(ctx)
|
||||
|
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