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 |
50
.github/workflows/ci.yml
vendored
50
.github/workflows/ci.yml
vendored
@ -4,31 +4,43 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- '**.py'
|
||||
- 'requirements.txt'
|
||||
- 'pyproject.toml'
|
||||
- 'setup.py'
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- '**.py'
|
||||
- 'requirements.txt'
|
||||
- 'pyproject.toml'
|
||||
- 'setup.py'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.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: Run Pyright
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
pyright
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install -e .
|
||||
npm install -g pyright
|
||||
|
||||
- name: Run Pyright
|
||||
run: pyright
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest tests/
|
||||
- name: Run Tests
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
pytest tests/
|
||||
|
53
.github/workflows/docs.yml
vendored
Normal file
53
.github/workflows/docs.yml
vendored
Normal file
@ -0,0 +1,53 @@
|
||||
name: Deploy MkDocs
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- '.github/workflows/docs.yml'
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Target environment'
|
||||
required: true
|
||||
default: 'production'
|
||||
debug:
|
||||
description: 'Enable debug mode'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
run: |
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
pip install mkdocs mkdocs-material
|
||||
|
||||
- name: Configure Git author from GitHub Actions metadata
|
||||
run: |
|
||||
git config --global user.name "${{ github.actor }}"
|
||||
git config --global user.email "${{ github.actor }}@users.noreply.github.com"
|
||||
|
||||
- name: Deploy docs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
mkdocs gh-deploy --force --clean
|
9
.github/workflows/mirror.yml
vendored
9
.github/workflows/mirror.yml
vendored
@ -3,11 +3,12 @@ name: Mirror to Gitea
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master # or change to your default branch
|
||||
- master
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
@ -23,5 +24,7 @@ jobs:
|
||||
env:
|
||||
MIRROR_PAT: ${{ secrets.MIRROR_PAT }}
|
||||
run: |
|
||||
git remote add mirror https://slipstream:${MIRROR_PAT}@git.slipstreamm.dev/slipstream/disagreement.git
|
||||
if ! git remote | grep -q "^mirror$"; then
|
||||
git remote add mirror https://slipstream:${MIRROR_PAT}@git.slipstreamm.dev/slipstream/disagreement.git
|
||||
fi
|
||||
git push --mirror mirror
|
||||
|
17
.github/workflows/pypi.yml
vendored
17
.github/workflows/pypi.yml
vendored
@ -3,14 +3,14 @@ name: Publish to PyPI
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*' # only trigger on version tags like v1.0.0
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: self-hosted
|
||||
|
||||
permissions:
|
||||
id-token: write # required for trusted publishing, if used
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
@ -27,17 +27,19 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
run: |
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
source venv/bin/activate
|
||||
pip install build twine
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
python -m build
|
||||
|
||||
- name: Publish to PyPI
|
||||
@ -45,4 +47,5 @@ jobs:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
python -m twine upload dist/*
|
||||
|
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
|
File diff suppressed because it is too large
Load Diff
@ -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,61 +1,65 @@
|
||||
# disagreement/ext/commands/__init__.py
|
||||
|
||||
"""
|
||||
disagreement.ext.commands - A command framework extension for the Disagreement library.
|
||||
"""
|
||||
|
||||
from .cog import Cog
|
||||
from .core import (
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandHandler,
|
||||
) # CommandHandler might be internal
|
||||
from .decorators import (
|
||||
command,
|
||||
listener,
|
||||
check,
|
||||
check_any,
|
||||
cooldown,
|
||||
"""
|
||||
disagreement.ext.commands - A command framework extension for the Disagreement library.
|
||||
"""
|
||||
|
||||
from .cog import Cog
|
||||
from .core import (
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandHandler,
|
||||
) # CommandHandler might be internal
|
||||
from .decorators import (
|
||||
command,
|
||||
listener,
|
||||
check,
|
||||
check_any,
|
||||
cooldown,
|
||||
max_concurrency,
|
||||
requires_permissions,
|
||||
has_role,
|
||||
has_any_role,
|
||||
is_owner,
|
||||
)
|
||||
from .errors import (
|
||||
CommandError,
|
||||
CommandNotFound,
|
||||
BadArgument,
|
||||
MissingRequiredArgument,
|
||||
ArgumentParsingError,
|
||||
CheckFailure,
|
||||
CheckAnyFailure,
|
||||
CommandOnCooldown,
|
||||
CommandInvokeError,
|
||||
MaxConcurrencyReached,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Cog
|
||||
"Cog",
|
||||
# Core
|
||||
"Command",
|
||||
"CommandContext",
|
||||
# "CommandHandler", # Usually not part of public API for direct use by bot devs
|
||||
# Decorators
|
||||
"command",
|
||||
"listener",
|
||||
"check",
|
||||
"check_any",
|
||||
"cooldown",
|
||||
"max_concurrency",
|
||||
"requires_permissions",
|
||||
from .errors import (
|
||||
CommandError,
|
||||
CommandNotFound,
|
||||
BadArgument,
|
||||
MissingRequiredArgument,
|
||||
ArgumentParsingError,
|
||||
CheckFailure,
|
||||
CheckAnyFailure,
|
||||
CommandOnCooldown,
|
||||
CommandInvokeError,
|
||||
MaxConcurrencyReached,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Cog
|
||||
"Cog",
|
||||
# Core
|
||||
"Command",
|
||||
"CommandContext",
|
||||
# "CommandHandler", # Usually not part of public API for direct use by bot devs
|
||||
# Decorators
|
||||
"command",
|
||||
"listener",
|
||||
"check",
|
||||
"check_any",
|
||||
"cooldown",
|
||||
"max_concurrency",
|
||||
"requires_permissions",
|
||||
"has_role",
|
||||
"has_any_role",
|
||||
"is_owner",
|
||||
# Errors
|
||||
"CommandError",
|
||||
"CommandNotFound",
|
||||
"BadArgument",
|
||||
"MissingRequiredArgument",
|
||||
"ArgumentParsingError",
|
||||
"CheckFailure",
|
||||
"CheckAnyFailure",
|
||||
"CommandOnCooldown",
|
||||
"CommandInvokeError",
|
||||
"MaxConcurrencyReached",
|
||||
]
|
||||
"CommandError",
|
||||
"CommandNotFound",
|
||||
"BadArgument",
|
||||
"MissingRequiredArgument",
|
||||
"ArgumentParsingError",
|
||||
"CheckFailure",
|
||||
"CheckAnyFailure",
|
||||
"CommandOnCooldown",
|
||||
"CommandInvokeError",
|
||||
"MaxConcurrencyReached",
|
||||
]
|
||||
|
@ -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
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,219 +1,310 @@
|
||||
# disagreement/ext/commands/decorators.py
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import time
|
||||
from typing import Callable, Any, Optional, List, TYPE_CHECKING, Awaitable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .core import Command, CommandContext
|
||||
from disagreement.permissions import Permissions
|
||||
from disagreement.models import Member, Guild, Channel
|
||||
|
||||
|
||||
def command(
|
||||
name: Optional[str] = None, aliases: Optional[List[str]] = None, **attrs: Any
|
||||
) -> Callable:
|
||||
"""
|
||||
A decorator that transforms a function into a Command.
|
||||
|
||||
Args:
|
||||
name (Optional[str]): The name of the command. Defaults to the function name.
|
||||
aliases (Optional[List[str]]): Alternative names for the command.
|
||||
**attrs: Additional attributes to pass to the Command constructor
|
||||
(e.g., brief, description, hidden).
|
||||
|
||||
Returns:
|
||||
Callable: A decorator that registers the command.
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
func: Callable[..., Awaitable[None]],
|
||||
) -> Callable[..., Awaitable[None]]:
|
||||
if not asyncio.iscoroutinefunction(func):
|
||||
raise TypeError("Command callback must be a coroutine function.")
|
||||
|
||||
from .core import Command
|
||||
|
||||
cmd_name = name or func.__name__
|
||||
|
||||
if hasattr(func, "__command_attrs__"):
|
||||
raise TypeError("Function is already a command or has command attributes.")
|
||||
|
||||
cmd = Command(callback=func, name=cmd_name, aliases=aliases or [], **attrs)
|
||||
func.__command_object__ = cmd # type: ignore
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def listener(
|
||||
name: Optional[str] = None,
|
||||
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
|
||||
"""
|
||||
A decorator that marks a function as an event listener within a Cog.
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
func: Callable[..., Awaitable[None]],
|
||||
) -> Callable[..., Awaitable[None]]:
|
||||
if not asyncio.iscoroutinefunction(func):
|
||||
raise TypeError("Listener callback must be a coroutine function.")
|
||||
|
||||
actual_event_name = name or func.__name__
|
||||
setattr(func, "__listener_name__", actual_event_name)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def check(
|
||||
predicate: Callable[["CommandContext"], Awaitable[bool] | bool],
|
||||
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
|
||||
"""Decorator to add a check to a command."""
|
||||
|
||||
def decorator(
|
||||
func: Callable[..., Awaitable[None]],
|
||||
) -> Callable[..., Awaitable[None]]:
|
||||
checks = getattr(func, "__command_checks__", [])
|
||||
checks.append(predicate)
|
||||
setattr(func, "__command_checks__", checks)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def check_any(
|
||||
*predicates: Callable[["CommandContext"], Awaitable[bool] | bool]
|
||||
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
|
||||
"""Decorator that passes if any predicate returns ``True``."""
|
||||
|
||||
async def predicate(ctx: "CommandContext") -> bool:
|
||||
from .errors import CheckAnyFailure, CheckFailure
|
||||
|
||||
errors = []
|
||||
for p in predicates:
|
||||
try:
|
||||
result = p(ctx)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
if result:
|
||||
return True
|
||||
except CheckFailure as e:
|
||||
errors.append(e)
|
||||
raise CheckAnyFailure(errors)
|
||||
|
||||
return check(predicate)
|
||||
|
||||
|
||||
def max_concurrency(
|
||||
number: int, per: str = "user"
|
||||
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
|
||||
"""Limit how many concurrent invocations of a command are allowed.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
number:
|
||||
The maximum number of concurrent invocations.
|
||||
per:
|
||||
The scope of the limiter. Can be ``"user"``, ``"guild"`` or ``"global"``.
|
||||
"""
|
||||
|
||||
if number < 1:
|
||||
raise ValueError("Concurrency number must be at least 1.")
|
||||
if per not in {"user", "guild", "global"}:
|
||||
raise ValueError("per must be 'user', 'guild', or 'global'.")
|
||||
|
||||
def decorator(
|
||||
func: Callable[..., Awaitable[None]],
|
||||
) -> Callable[..., Awaitable[None]]:
|
||||
setattr(func, "__max_concurrency__", (number, per))
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def cooldown(
|
||||
rate: int, per: float
|
||||
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
|
||||
"""Simple per-user cooldown decorator."""
|
||||
|
||||
buckets: dict[str, dict[str, float]] = {}
|
||||
|
||||
async def predicate(ctx: "CommandContext") -> bool:
|
||||
from .errors import CommandOnCooldown
|
||||
|
||||
now = time.monotonic()
|
||||
user_buckets = buckets.setdefault(ctx.command.name, {})
|
||||
reset = user_buckets.get(ctx.author.id, 0)
|
||||
if now < reset:
|
||||
raise CommandOnCooldown(reset - now)
|
||||
user_buckets[ctx.author.id] = now + per
|
||||
return True
|
||||
|
||||
return check(predicate)
|
||||
|
||||
|
||||
def _compute_permissions(
|
||||
member: "Member", channel: "Channel", guild: "Guild"
|
||||
) -> "Permissions":
|
||||
"""Compute the effective permissions for a member in a channel."""
|
||||
return channel.permissions_for(member)
|
||||
|
||||
|
||||
def requires_permissions(
|
||||
*perms: "Permissions",
|
||||
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
|
||||
"""Check that the invoking member has the given permissions in the channel."""
|
||||
|
||||
async def predicate(ctx: "CommandContext") -> bool:
|
||||
from .errors import CheckFailure
|
||||
from disagreement.permissions import (
|
||||
has_permissions,
|
||||
missing_permissions,
|
||||
)
|
||||
from disagreement.models import Member
|
||||
|
||||
channel = getattr(ctx, "channel", None)
|
||||
if channel is None and hasattr(ctx.bot, "get_channel"):
|
||||
channel = ctx.bot.get_channel(ctx.message.channel_id)
|
||||
if channel is None and hasattr(ctx.bot, "fetch_channel"):
|
||||
channel = await ctx.bot.fetch_channel(ctx.message.channel_id)
|
||||
|
||||
if channel is None:
|
||||
raise CheckFailure("Channel for permission check not found.")
|
||||
|
||||
guild = getattr(channel, "guild", None)
|
||||
if not guild and hasattr(channel, "guild_id") and channel.guild_id:
|
||||
if hasattr(ctx.bot, "get_guild"):
|
||||
guild = ctx.bot.get_guild(channel.guild_id)
|
||||
if not guild and hasattr(ctx.bot, "fetch_guild"):
|
||||
guild = await ctx.bot.fetch_guild(channel.guild_id)
|
||||
|
||||
if not guild:
|
||||
is_dm = not hasattr(channel, "guild_id") or not channel.guild_id
|
||||
if is_dm:
|
||||
if perms:
|
||||
raise CheckFailure("Permission checks are not supported in DMs.")
|
||||
return True
|
||||
raise CheckFailure("Guild for permission check not found.")
|
||||
|
||||
member = ctx.author
|
||||
if not isinstance(member, Member):
|
||||
member = guild.get_member(ctx.author.id)
|
||||
if not member and hasattr(ctx.bot, "fetch_member"):
|
||||
member = await ctx.bot.fetch_member(guild.id, ctx.author.id)
|
||||
|
||||
if not member:
|
||||
raise CheckFailure("Could not resolve author to a guild member.")
|
||||
|
||||
perms_value = _compute_permissions(member, channel, guild)
|
||||
|
||||
if not has_permissions(perms_value, *perms):
|
||||
missing = missing_permissions(perms_value, *perms)
|
||||
missing_names = ", ".join(p.name for p in missing if p.name)
|
||||
raise CheckFailure(f"Missing permissions: {missing_names}")
|
||||
return True
|
||||
|
||||
return check(predicate)
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import time
|
||||
from typing import Callable, Any, Optional, List, TYPE_CHECKING, Awaitable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .core import Command, CommandContext
|
||||
from disagreement.permissions import Permissions
|
||||
from disagreement.models import Member, Guild, Channel
|
||||
|
||||
|
||||
def command(
|
||||
name: Optional[str] = None, aliases: Optional[List[str]] = None, **attrs: Any
|
||||
) -> Callable:
|
||||
"""
|
||||
A decorator that transforms a function into a Command.
|
||||
|
||||
Args:
|
||||
name (Optional[str]): The name of the command. Defaults to the function name.
|
||||
aliases (Optional[List[str]]): Alternative names for the command.
|
||||
**attrs: Additional attributes to pass to the Command constructor
|
||||
(e.g., brief, description, hidden).
|
||||
|
||||
Returns:
|
||||
Callable: A decorator that registers the command.
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
func: Callable[..., Awaitable[None]],
|
||||
) -> Callable[..., Awaitable[None]]:
|
||||
if not asyncio.iscoroutinefunction(func):
|
||||
raise TypeError("Command callback must be a coroutine function.")
|
||||
|
||||
from .core import Command
|
||||
|
||||
cmd_name = name or func.__name__
|
||||
|
||||
if hasattr(func, "__command_attrs__"):
|
||||
raise TypeError("Function is already a command or has command attributes.")
|
||||
|
||||
cmd = Command(callback=func, name=cmd_name, aliases=aliases or [], **attrs)
|
||||
func.__command_object__ = cmd # type: ignore
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def listener(
|
||||
name: Optional[str] = None,
|
||||
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
|
||||
"""
|
||||
A decorator that marks a function as an event listener within a Cog.
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
func: Callable[..., Awaitable[None]],
|
||||
) -> Callable[..., Awaitable[None]]:
|
||||
if not asyncio.iscoroutinefunction(func):
|
||||
raise TypeError("Listener callback must be a coroutine function.")
|
||||
|
||||
actual_event_name = name or func.__name__
|
||||
setattr(func, "__listener_name__", actual_event_name)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def check(
|
||||
predicate: Callable[["CommandContext"], Awaitable[bool] | bool],
|
||||
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
|
||||
"""Decorator to add a check to a command."""
|
||||
|
||||
def decorator(
|
||||
func: Callable[..., Awaitable[None]],
|
||||
) -> Callable[..., Awaitable[None]]:
|
||||
checks = getattr(func, "__command_checks__", [])
|
||||
checks.append(predicate)
|
||||
setattr(func, "__command_checks__", checks)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def check_any(
|
||||
*predicates: Callable[["CommandContext"], Awaitable[bool] | bool]
|
||||
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
|
||||
"""Decorator that passes if any predicate returns ``True``."""
|
||||
|
||||
async def predicate(ctx: "CommandContext") -> bool:
|
||||
from .errors import CheckAnyFailure, CheckFailure
|
||||
|
||||
errors = []
|
||||
for p in predicates:
|
||||
try:
|
||||
result = p(ctx)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
if result:
|
||||
return True
|
||||
except CheckFailure as e:
|
||||
errors.append(e)
|
||||
raise CheckAnyFailure(errors)
|
||||
|
||||
return check(predicate)
|
||||
|
||||
|
||||
def max_concurrency(
|
||||
number: int, per: str = "user"
|
||||
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
|
||||
"""Limit how many concurrent invocations of a command are allowed.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
number:
|
||||
The maximum number of concurrent invocations.
|
||||
per:
|
||||
The scope of the limiter. Can be ``"user"``, ``"guild"`` or ``"global"``.
|
||||
"""
|
||||
|
||||
if number < 1:
|
||||
raise ValueError("Concurrency number must be at least 1.")
|
||||
if per not in {"user", "guild", "global"}:
|
||||
raise ValueError("per must be 'user', 'guild', or 'global'.")
|
||||
|
||||
def decorator(
|
||||
func: Callable[..., Awaitable[None]],
|
||||
) -> Callable[..., Awaitable[None]]:
|
||||
setattr(func, "__max_concurrency__", (number, per))
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def cooldown(
|
||||
rate: int, per: float
|
||||
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
|
||||
"""Simple per-user cooldown decorator."""
|
||||
|
||||
buckets: dict[str, dict[str, float]] = {}
|
||||
|
||||
async def predicate(ctx: "CommandContext") -> bool:
|
||||
from .errors import CommandOnCooldown
|
||||
|
||||
now = time.monotonic()
|
||||
user_buckets = buckets.setdefault(ctx.command.name, {})
|
||||
reset = user_buckets.get(ctx.author.id, 0)
|
||||
if now < reset:
|
||||
raise CommandOnCooldown(reset - now)
|
||||
user_buckets[ctx.author.id] = now + per
|
||||
return True
|
||||
|
||||
return check(predicate)
|
||||
|
||||
|
||||
def _compute_permissions(
|
||||
member: "Member", channel: "Channel", guild: "Guild"
|
||||
) -> "Permissions":
|
||||
"""Compute the effective permissions for a member in a channel."""
|
||||
return channel.permissions_for(member)
|
||||
|
||||
|
||||
def requires_permissions(
|
||||
*perms: "Permissions",
|
||||
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
|
||||
"""Check that the invoking member has the given permissions in the channel."""
|
||||
|
||||
async def predicate(ctx: "CommandContext") -> bool:
|
||||
from .errors import CheckFailure
|
||||
from disagreement.permissions import (
|
||||
has_permissions,
|
||||
missing_permissions,
|
||||
)
|
||||
from disagreement.models import Member
|
||||
|
||||
channel = getattr(ctx, "channel", None)
|
||||
if channel is None and hasattr(ctx.bot, "get_channel"):
|
||||
channel = ctx.bot.get_channel(ctx.message.channel_id)
|
||||
if channel is None and hasattr(ctx.bot, "fetch_channel"):
|
||||
channel = await ctx.bot.fetch_channel(ctx.message.channel_id)
|
||||
|
||||
if channel is None:
|
||||
raise CheckFailure("Channel for permission check not found.")
|
||||
|
||||
guild = getattr(channel, "guild", None)
|
||||
if not guild and hasattr(channel, "guild_id") and channel.guild_id:
|
||||
if hasattr(ctx.bot, "get_guild"):
|
||||
guild = ctx.bot.get_guild(channel.guild_id)
|
||||
if not guild and hasattr(ctx.bot, "fetch_guild"):
|
||||
guild = await ctx.bot.fetch_guild(channel.guild_id)
|
||||
|
||||
if not guild:
|
||||
is_dm = not hasattr(channel, "guild_id") or not channel.guild_id
|
||||
if is_dm:
|
||||
if perms:
|
||||
raise CheckFailure("Permission checks are not supported in DMs.")
|
||||
return True
|
||||
raise CheckFailure("Guild for permission check not found.")
|
||||
|
||||
member = ctx.author
|
||||
if not isinstance(member, Member):
|
||||
member = guild.get_member(ctx.author.id)
|
||||
if not member and hasattr(ctx.bot, "fetch_member"):
|
||||
member = await ctx.bot.fetch_member(guild.id, ctx.author.id)
|
||||
|
||||
if not member:
|
||||
raise CheckFailure("Could not resolve author to a guild member.")
|
||||
|
||||
perms_value = _compute_permissions(member, channel, guild)
|
||||
|
||||
if not has_permissions(perms_value, *perms):
|
||||
missing = missing_permissions(perms_value, *perms)
|
||||
missing_names = ", ".join(p.name for p in missing if p.name)
|
||||
raise CheckFailure(f"Missing permissions: {missing_names}")
|
||||
return True
|
||||
|
||||
return check(predicate)
|
||||
|
||||
|
||||
def has_role(
|
||||
name_or_id: str | int,
|
||||
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
|
||||
"""Check that the invoking member has a role with the given name or ID."""
|
||||
|
||||
async def predicate(ctx: "CommandContext") -> bool:
|
||||
from .errors import CheckFailure
|
||||
from disagreement.models import Member
|
||||
|
||||
if not ctx.guild:
|
||||
raise CheckFailure("This command cannot be used in DMs.")
|
||||
|
||||
author = ctx.author
|
||||
if not isinstance(author, Member):
|
||||
try:
|
||||
author = await ctx.bot.fetch_member(ctx.guild.id, author.id)
|
||||
except Exception:
|
||||
raise CheckFailure("Could not resolve author to a guild member.")
|
||||
|
||||
if not author:
|
||||
raise CheckFailure("Could not resolve author to a guild member.")
|
||||
|
||||
# Create a list of the member's role objects by looking them up in the guild's roles list
|
||||
member_roles = [role for role in ctx.guild.roles if role.id in author.roles]
|
||||
|
||||
if any(
|
||||
role.id == str(name_or_id) or role.name == name_or_id
|
||||
for role in member_roles
|
||||
):
|
||||
return True
|
||||
|
||||
raise CheckFailure(f"You need the '{name_or_id}' role to use this command.")
|
||||
|
||||
return check(predicate)
|
||||
|
||||
|
||||
def has_any_role(
|
||||
*names_or_ids: str | int,
|
||||
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
|
||||
"""Check that the invoking member has any of the roles with the given names or IDs."""
|
||||
|
||||
async def predicate(ctx: "CommandContext") -> bool:
|
||||
from .errors import CheckFailure
|
||||
from disagreement.models import Member
|
||||
|
||||
if not ctx.guild:
|
||||
raise CheckFailure("This command cannot be used in DMs.")
|
||||
|
||||
author = ctx.author
|
||||
if not isinstance(author, Member):
|
||||
try:
|
||||
author = await ctx.bot.fetch_member(ctx.guild.id, author.id)
|
||||
except Exception:
|
||||
raise CheckFailure("Could not resolve author to a guild member.")
|
||||
|
||||
if not author:
|
||||
raise CheckFailure("Could not resolve author to a guild member.")
|
||||
|
||||
member_roles = [role for role in ctx.guild.roles if role.id in author.roles]
|
||||
# Convert names_or_ids to a set for efficient lookup
|
||||
names_or_ids_set = set(map(str, names_or_ids))
|
||||
|
||||
if any(
|
||||
role.id in names_or_ids_set or role.name in names_or_ids_set
|
||||
for role in member_roles
|
||||
):
|
||||
return True
|
||||
|
||||
role_list = ", ".join(f"'{r}'" for r in names_or_ids)
|
||||
raise CheckFailure(
|
||||
f"You need one of the following roles to use this command: {role_list}"
|
||||
)
|
||||
|
||||
return check(predicate)
|
||||
|
||||
|
||||
def is_owner() -> (
|
||||
Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]
|
||||
):
|
||||
"""Check that the invoking user is listed as a bot owner."""
|
||||
|
||||
async def predicate(ctx: "CommandContext") -> bool:
|
||||
from .errors import CheckFailure
|
||||
|
||||
owner_ids = getattr(ctx.bot, "owner_ids", [])
|
||||
if str(ctx.author.id) not in {str(o) for o in owner_ids}:
|
||||
raise CheckFailure("This command can only be used by the bot owner.")
|
||||
return True
|
||||
|
||||
return check(predicate)
|
||||
|
@ -1,5 +1,3 @@
|
||||
# disagreement/ext/commands/errors.py
|
||||
|
||||
"""
|
||||
Custom exceptions for the command extension.
|
||||
"""
|
||||
|
@ -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(
|
||||
*,
|
||||
|
File diff suppressed because it is too large
Load Diff
2508
disagreement/http.py
2508
disagreement/http.py
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,3 @@
|
||||
# disagreement/interactions.py
|
||||
|
||||
"""
|
||||
Data models for Discord Interaction objects.
|
||||
"""
|
||||
|
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
|
||||
|
@ -1,165 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Any, Callable, Coroutine, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from ..models import ActionRow
|
||||
from .item import Item
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..client import Client
|
||||
from ..interactions import Interaction
|
||||
|
||||
|
||||
class View:
|
||||
"""Represents a container for UI components that can be sent with a message.
|
||||
|
||||
Args:
|
||||
timeout (Optional[float]): The number of seconds to wait for an interaction before the view times out.
|
||||
Defaults to 180.
|
||||
"""
|
||||
|
||||
def __init__(self, *, timeout: Optional[float] = 180.0):
|
||||
self.timeout = timeout
|
||||
self.id = str(uuid.uuid4())
|
||||
self.__children: List[Item] = []
|
||||
self.__stopped = asyncio.Event()
|
||||
self._client: Optional[Client] = None
|
||||
self._message_id: Optional[str] = None
|
||||
|
||||
for item in self.__class__.__dict__.values():
|
||||
if isinstance(item, Item):
|
||||
self.add_item(item)
|
||||
|
||||
@property
|
||||
def children(self) -> List[Item]:
|
||||
return self.__children
|
||||
|
||||
def add_item(self, item: Item):
|
||||
"""Adds an item to the view."""
|
||||
if not isinstance(item, Item):
|
||||
raise TypeError("Only instances of 'Item' can be added to a View.")
|
||||
|
||||
if len(self.__children) >= 25:
|
||||
raise ValueError("A view can only have a maximum of 25 components.")
|
||||
|
||||
item._view = self
|
||||
self.__children.append(item)
|
||||
|
||||
@property
|
||||
def message_id(self) -> Optional[str]:
|
||||
return self._message_id
|
||||
|
||||
@message_id.setter
|
||||
def message_id(self, value: str):
|
||||
self._message_id = value
|
||||
|
||||
def to_components(self) -> List[ActionRow]:
|
||||
"""Converts the view's children into a list of ActionRow components.
|
||||
|
||||
This retains the original, simple layout behaviour where each item is
|
||||
placed in its own :class:`ActionRow` to ensure backward compatibility.
|
||||
"""
|
||||
|
||||
rows: List[ActionRow] = []
|
||||
|
||||
for item in self.children:
|
||||
if item.custom_id is None:
|
||||
item.custom_id = (
|
||||
f"{self.id}:{item.__class__.__name__}:{len(self.__children)}"
|
||||
)
|
||||
|
||||
rows.append(ActionRow(components=[item]))
|
||||
|
||||
return rows
|
||||
|
||||
def layout_components_advanced(self) -> List[ActionRow]:
|
||||
"""Group compatible components into rows following Discord rules."""
|
||||
|
||||
rows: List[ActionRow] = []
|
||||
|
||||
for item in self.children:
|
||||
if item.custom_id is None:
|
||||
item.custom_id = (
|
||||
f"{self.id}:{item.__class__.__name__}:{len(self.__children)}"
|
||||
)
|
||||
|
||||
target_row = item.row
|
||||
if target_row is not None:
|
||||
if not 0 <= target_row <= 4:
|
||||
raise ValueError("Row index must be between 0 and 4.")
|
||||
|
||||
while len(rows) <= target_row:
|
||||
if len(rows) >= 5:
|
||||
raise ValueError("A view can have at most 5 action rows.")
|
||||
rows.append(ActionRow())
|
||||
|
||||
rows[target_row].add_component(item)
|
||||
continue
|
||||
|
||||
placed = False
|
||||
for row in rows:
|
||||
try:
|
||||
row.add_component(item)
|
||||
placed = True
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not placed:
|
||||
if len(rows) >= 5:
|
||||
raise ValueError("A view can have at most 5 action rows.")
|
||||
new_row = ActionRow([item])
|
||||
rows.append(new_row)
|
||||
|
||||
return rows
|
||||
|
||||
def to_components_payload(self) -> List[Dict[str, Any]]:
|
||||
"""Converts the view's children into a list of component dictionaries
|
||||
that can be sent to the Discord API."""
|
||||
return [row.to_dict() for row in self.to_components()]
|
||||
|
||||
async def _dispatch(self, interaction: Interaction):
|
||||
"""Called by the client to dispatch an interaction to the correct item."""
|
||||
if self.timeout is not None:
|
||||
self.__stopped.set() # Reset the timeout on each interaction
|
||||
self.__stopped.clear()
|
||||
|
||||
if interaction.data:
|
||||
custom_id = interaction.data.custom_id
|
||||
for child in self.children:
|
||||
if child.custom_id == custom_id:
|
||||
if child.callback:
|
||||
await child.callback(self, interaction)
|
||||
break
|
||||
|
||||
async def wait(self) -> bool:
|
||||
"""Waits until the view has stopped interacting."""
|
||||
return await self.__stopped.wait()
|
||||
|
||||
def stop(self):
|
||||
"""Stops the view from listening to interactions."""
|
||||
if not self.__stopped.is_set():
|
||||
self.__stopped.set()
|
||||
|
||||
async def on_timeout(self):
|
||||
"""Called when the view times out."""
|
||||
pass # User can override this
|
||||
|
||||
async def _start(self, client: Client):
|
||||
"""Starts the view's internal listener."""
|
||||
self._client = client
|
||||
if self.timeout is not None:
|
||||
asyncio.create_task(self._timeout_task())
|
||||
|
||||
async def _timeout_task(self):
|
||||
"""The task that waits for the timeout and then stops the view."""
|
||||
try:
|
||||
await asyncio.wait_for(self.wait(), timeout=self.timeout)
|
||||
except asyncio.TimeoutError:
|
||||
self.stop()
|
||||
await self.on_timeout()
|
||||
if self._client and self._message_id:
|
||||
# Remove the view from the client's listeners
|
||||
self._client._views.pop(self._message_id, None)
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Any, Callable, Coroutine, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from ..models import ActionRow
|
||||
from .item import Item
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..client import Client
|
||||
from ..interactions import Interaction
|
||||
|
||||
|
||||
class View:
|
||||
"""Represents a container for UI components that can be sent with a message.
|
||||
|
||||
Args:
|
||||
timeout (Optional[float]): The number of seconds to wait for an interaction before the view times out.
|
||||
Defaults to 180.
|
||||
"""
|
||||
|
||||
def __init__(self, *, timeout: Optional[float] = 180.0):
|
||||
self.timeout = timeout
|
||||
self.id = str(uuid.uuid4())
|
||||
self.__children: List[Item] = []
|
||||
self.__stopped = asyncio.Event()
|
||||
self._client: Optional[Client] = None
|
||||
self._message_id: Optional[str] = None
|
||||
|
||||
# The below is a bit of a hack to support items defined as class members
|
||||
# e.g. button = Button(...)
|
||||
for item in self.__class__.__dict__.values():
|
||||
if isinstance(item, Item):
|
||||
self.add_item(item)
|
||||
|
||||
@property
|
||||
def children(self) -> List[Item]:
|
||||
return self.__children
|
||||
|
||||
def add_item(self, item: Item):
|
||||
"""Adds an item to the view."""
|
||||
if not isinstance(item, Item):
|
||||
raise TypeError("Only instances of 'Item' can be added to a View.")
|
||||
|
||||
if len(self.__children) >= 25:
|
||||
raise ValueError("A view can only have a maximum of 25 components.")
|
||||
|
||||
if self.timeout is None and item.custom_id is None:
|
||||
raise ValueError(
|
||||
"All components in a persistent view must have a 'custom_id'."
|
||||
)
|
||||
|
||||
item._view = self
|
||||
self.__children.append(item)
|
||||
|
||||
@property
|
||||
def message_id(self) -> Optional[str]:
|
||||
return self._message_id
|
||||
|
||||
@message_id.setter
|
||||
def message_id(self, value: str):
|
||||
self._message_id = value
|
||||
|
||||
def to_components(self) -> List[ActionRow]:
|
||||
"""Converts the view's children into a list of ActionRow components.
|
||||
|
||||
This retains the original, simple layout behaviour where each item is
|
||||
placed in its own :class:`ActionRow` to ensure backward compatibility.
|
||||
"""
|
||||
|
||||
rows: List[ActionRow] = []
|
||||
|
||||
for item in self.children:
|
||||
rows.append(ActionRow(components=[item]))
|
||||
|
||||
return rows
|
||||
|
||||
def layout_components_advanced(self) -> List[ActionRow]:
|
||||
"""Group compatible components into rows following Discord rules."""
|
||||
|
||||
rows: List[ActionRow] = []
|
||||
|
||||
for item in self.children:
|
||||
if item.custom_id is None:
|
||||
item.custom_id = (
|
||||
f"{self.id}:{item.__class__.__name__}:{len(self.__children)}"
|
||||
)
|
||||
|
||||
target_row = item.row
|
||||
if target_row is not None:
|
||||
if not 0 <= target_row <= 4:
|
||||
raise ValueError("Row index must be between 0 and 4.")
|
||||
|
||||
while len(rows) <= target_row:
|
||||
if len(rows) >= 5:
|
||||
raise ValueError("A view can have at most 5 action rows.")
|
||||
rows.append(ActionRow())
|
||||
|
||||
rows[target_row].add_component(item)
|
||||
continue
|
||||
|
||||
placed = False
|
||||
for row in rows:
|
||||
try:
|
||||
row.add_component(item)
|
||||
placed = True
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not placed:
|
||||
if len(rows) >= 5:
|
||||
raise ValueError("A view can have at most 5 action rows.")
|
||||
new_row = ActionRow([item])
|
||||
rows.append(new_row)
|
||||
|
||||
return rows
|
||||
|
||||
def to_components_payload(self) -> List[Dict[str, Any]]:
|
||||
"""Converts the view's children into a list of component dictionaries
|
||||
that can be sent to the Discord API."""
|
||||
return [row.to_dict() for row in self.to_components()]
|
||||
|
||||
async def _dispatch(self, interaction: Interaction):
|
||||
"""Called by the client to dispatch an interaction to the correct item."""
|
||||
if self.timeout is not None:
|
||||
self.__stopped.set() # Reset the timeout on each interaction
|
||||
self.__stopped.clear()
|
||||
|
||||
if interaction.data:
|
||||
custom_id = interaction.data.custom_id
|
||||
for child in self.children:
|
||||
if child.custom_id == custom_id:
|
||||
if child.callback:
|
||||
await child.callback(self, interaction)
|
||||
break
|
||||
|
||||
async def wait(self) -> bool:
|
||||
"""Waits until the view has stopped interacting."""
|
||||
return await self.__stopped.wait()
|
||||
|
||||
def stop(self):
|
||||
"""Stops the view from listening to interactions."""
|
||||
if not self.__stopped.is_set():
|
||||
self.__stopped.set()
|
||||
|
||||
async def on_timeout(self):
|
||||
"""Called when the view times out."""
|
||||
pass
|
||||
|
||||
async def _start(self, client: Client):
|
||||
"""Starts the view's internal listener."""
|
||||
self._client = client
|
||||
if self.timeout is not None:
|
||||
asyncio.create_task(self._timeout_task())
|
||||
|
||||
async def _timeout_task(self):
|
||||
"""The task that waits for the timeout and then stops the view."""
|
||||
try:
|
||||
await asyncio.wait_for(self.wait(), timeout=self.timeout)
|
||||
except asyncio.TimeoutError:
|
||||
self.stop()
|
||||
await self.on_timeout()
|
||||
if self._client and self._message_id:
|
||||
# Remove the view from the client's listeners
|
||||
self._client._views.pop(self._message_id, None)
|
||||
|
@ -3,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,162 +1,308 @@
|
||||
# disagreement/voice_client.py
|
||||
"""Voice gateway and UDP audio client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import socket
|
||||
from typing import Optional, Sequence
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .audio import AudioSource, FFmpegAudioSource
|
||||
|
||||
|
||||
class VoiceClient:
|
||||
"""Handles the Discord voice WebSocket connection and UDP streaming."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str,
|
||||
session_id: str,
|
||||
token: str,
|
||||
guild_id: int,
|
||||
user_id: int,
|
||||
*,
|
||||
ws=None,
|
||||
udp: Optional[socket.socket] = None,
|
||||
loop: Optional[asyncio.AbstractEventLoop] = None,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
self.endpoint = endpoint
|
||||
self.session_id = session_id
|
||||
self.token = token
|
||||
self.guild_id = str(guild_id)
|
||||
self.user_id = str(user_id)
|
||||
self._ws: Optional[aiohttp.ClientWebSocketResponse] = ws
|
||||
self._udp = udp
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
self._heartbeat_task: Optional[asyncio.Task] = None
|
||||
self._heartbeat_interval: Optional[float] = None
|
||||
self._loop = loop or asyncio.get_event_loop()
|
||||
self.verbose = verbose
|
||||
self.ssrc: Optional[int] = None
|
||||
self.secret_key: Optional[Sequence[int]] = None
|
||||
self._server_ip: Optional[str] = None
|
||||
self._server_port: Optional[int] = None
|
||||
"""Voice gateway and UDP audio client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import socket
|
||||
import threading
|
||||
from array import array
|
||||
|
||||
|
||||
def _apply_volume(data: bytes, volume: float) -> bytes:
|
||||
samples = array("h")
|
||||
samples.frombytes(data)
|
||||
for i, sample in enumerate(samples):
|
||||
scaled = int(sample * volume)
|
||||
if scaled > 32767:
|
||||
scaled = 32767
|
||||
elif scaled < -32768:
|
||||
scaled = -32768
|
||||
samples[i] = scaled
|
||||
return samples.tobytes()
|
||||
|
||||
|
||||
from typing import TYPE_CHECKING, Optional, Sequence
|
||||
|
||||
import aiohttp
|
||||
|
||||
# The following import is correct, but may be flagged by Pylance if the virtual
|
||||
# environment is not configured correctly.
|
||||
from nacl.secret import SecretBox
|
||||
|
||||
from .audio import AudioSink, AudioSource, FFmpegAudioSource
|
||||
from .models import User
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .client import Client
|
||||
|
||||
|
||||
class VoiceClient:
|
||||
"""Handles the Discord voice WebSocket connection and UDP streaming."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Client,
|
||||
endpoint: str,
|
||||
session_id: str,
|
||||
token: str,
|
||||
guild_id: int,
|
||||
user_id: int,
|
||||
*,
|
||||
ws=None,
|
||||
udp: Optional[socket.socket] = None,
|
||||
loop: Optional[asyncio.AbstractEventLoop] = None,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
self.client = client
|
||||
self.endpoint = endpoint
|
||||
self.session_id = session_id
|
||||
self.token = token
|
||||
self.guild_id = str(guild_id)
|
||||
self.user_id = str(user_id)
|
||||
self._ws: Optional[aiohttp.ClientWebSocketResponse] = ws
|
||||
self._udp = udp
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
self._heartbeat_task: Optional[asyncio.Task] = None
|
||||
self._receive_task: Optional[asyncio.Task] = None
|
||||
self._udp_receive_thread: Optional[threading.Thread] = None
|
||||
self._heartbeat_interval: Optional[float] = None
|
||||
try:
|
||||
self._loop = loop or asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
self._loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._loop)
|
||||
self.verbose = verbose
|
||||
self.ssrc: Optional[int] = None
|
||||
self.secret_key: Optional[Sequence[int]] = None
|
||||
self._server_ip: Optional[str] = None
|
||||
self._server_port: Optional[int] = None
|
||||
self._current_source: Optional[AudioSource] = None
|
||||
self._play_task: Optional[asyncio.Task] = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self._ws is None:
|
||||
self._session = aiohttp.ClientSession()
|
||||
self._ws = await self._session.ws_connect(self.endpoint)
|
||||
|
||||
hello = await self._ws.receive_json()
|
||||
self._heartbeat_interval = hello["d"]["heartbeat_interval"] / 1000
|
||||
self._heartbeat_task = self._loop.create_task(self._heartbeat())
|
||||
|
||||
await self._ws.send_json(
|
||||
{
|
||||
"op": 0,
|
||||
"d": {
|
||||
"server_id": self.guild_id,
|
||||
"user_id": self.user_id,
|
||||
"session_id": self.session_id,
|
||||
"token": self.token,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
ready = await self._ws.receive_json()
|
||||
data = ready["d"]
|
||||
self.ssrc = data["ssrc"]
|
||||
self._server_ip = data["ip"]
|
||||
self._server_port = data["port"]
|
||||
|
||||
if self._udp is None:
|
||||
self._udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self._udp.connect((self._server_ip, self._server_port))
|
||||
|
||||
await self._ws.send_json(
|
||||
{
|
||||
"op": 1,
|
||||
"d": {
|
||||
"protocol": "udp",
|
||||
"data": {
|
||||
"address": self._udp.getsockname()[0],
|
||||
"port": self._udp.getsockname()[1],
|
||||
"mode": "xsalsa20_poly1305",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
session_desc = await self._ws.receive_json()
|
||||
self.secret_key = session_desc["d"].get("secret_key")
|
||||
|
||||
async def _heartbeat(self) -> None:
|
||||
assert self._ws is not None
|
||||
assert self._heartbeat_interval is not None
|
||||
try:
|
||||
while True:
|
||||
await self._ws.send_json({"op": 3, "d": int(self._loop.time() * 1000)})
|
||||
await asyncio.sleep(self._heartbeat_interval)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def send_audio_frame(self, frame: bytes) -> None:
|
||||
if not self._udp:
|
||||
raise RuntimeError("UDP socket not initialised")
|
||||
self._udp.send(frame)
|
||||
|
||||
self._pause_event = asyncio.Event()
|
||||
self._pause_event.set()
|
||||
self._is_playing = False
|
||||
self._sink: Optional[AudioSink] = None
|
||||
self._ssrc_map: dict[int, int] = {}
|
||||
self._ssrc_lock = threading.Lock()
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self._ws is None:
|
||||
self._session = aiohttp.ClientSession()
|
||||
self._ws = await self._session.ws_connect(self.endpoint)
|
||||
|
||||
hello = await self._ws.receive_json()
|
||||
self._heartbeat_interval = hello["d"]["heartbeat_interval"] / 1000
|
||||
self._heartbeat_task = self._loop.create_task(self._heartbeat())
|
||||
|
||||
await self._ws.send_json(
|
||||
{
|
||||
"op": 0,
|
||||
"d": {
|
||||
"server_id": self.guild_id,
|
||||
"user_id": self.user_id,
|
||||
"session_id": self.session_id,
|
||||
"token": self.token,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
ready = await self._ws.receive_json()
|
||||
data = ready["d"]
|
||||
self.ssrc = data["ssrc"]
|
||||
self._server_ip = data["ip"]
|
||||
self._server_port = data["port"]
|
||||
|
||||
if self._udp is None:
|
||||
self._udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self._udp.connect((self._server_ip, self._server_port))
|
||||
|
||||
await self._ws.send_json(
|
||||
{
|
||||
"op": 1,
|
||||
"d": {
|
||||
"protocol": "udp",
|
||||
"data": {
|
||||
"address": self._udp.getsockname()[0],
|
||||
"port": self._udp.getsockname()[1],
|
||||
"mode": "xsalsa20_poly1305",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
session_desc = await self._ws.receive_json()
|
||||
self.secret_key = session_desc["d"].get("secret_key")
|
||||
|
||||
async def _heartbeat(self) -> None:
|
||||
assert self._ws is not None
|
||||
assert self._heartbeat_interval is not None
|
||||
try:
|
||||
while True:
|
||||
await self._ws.send_json({"op": 3, "d": int(self._loop.time() * 1000)})
|
||||
await asyncio.sleep(self._heartbeat_interval)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _receive_loop(self) -> None:
|
||||
assert self._ws is not None
|
||||
while True:
|
||||
try:
|
||||
msg = await self._ws.receive_json()
|
||||
op = msg.get("op")
|
||||
data = msg.get("d")
|
||||
if op == 5: # Speaking
|
||||
user_id = int(data["user_id"])
|
||||
ssrc = data["ssrc"]
|
||||
with self._ssrc_lock:
|
||||
self._ssrc_map[ssrc] = user_id
|
||||
except (asyncio.CancelledError, aiohttp.ClientError):
|
||||
break
|
||||
|
||||
def _udp_receive_loop(self) -> None:
|
||||
assert self._udp is not None
|
||||
assert self.secret_key is not None
|
||||
box = SecretBox(bytes(self.secret_key))
|
||||
while True:
|
||||
try:
|
||||
packet = self._udp.recv(4096)
|
||||
if len(packet) < 12:
|
||||
continue
|
||||
|
||||
ssrc = int.from_bytes(packet[8:12], "big")
|
||||
with self._ssrc_lock:
|
||||
if ssrc not in self._ssrc_map:
|
||||
continue
|
||||
user_id = self._ssrc_map[ssrc]
|
||||
user = self.client._users.get(str(user_id))
|
||||
if not user:
|
||||
continue
|
||||
|
||||
decrypted = box.decrypt(packet[12:])
|
||||
if self._sink:
|
||||
self._sink.write(user, decrypted)
|
||||
except (socket.error, asyncio.CancelledError):
|
||||
break
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f"Error in UDP receive loop: {e}")
|
||||
|
||||
async def send_audio_frame(self, frame: bytes) -> None:
|
||||
if not self._udp:
|
||||
raise RuntimeError("UDP socket not initialised")
|
||||
self._udp.send(frame)
|
||||
|
||||
async def _play_loop(self) -> None:
|
||||
assert self._current_source is not None
|
||||
self._is_playing = True
|
||||
try:
|
||||
while True:
|
||||
await self._pause_event.wait()
|
||||
data = await self._current_source.read()
|
||||
if not data:
|
||||
break
|
||||
volume = getattr(self._current_source, "volume", 1.0)
|
||||
if volume != 1.0:
|
||||
data = _apply_volume(data, volume)
|
||||
await self.send_audio_frame(data)
|
||||
finally:
|
||||
await self._current_source.close()
|
||||
self._current_source = None
|
||||
self._play_task = None
|
||||
|
||||
self._is_playing = False
|
||||
self._pause_event.set()
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._play_task:
|
||||
self._play_task.cancel()
|
||||
self._pause_event.set()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._play_task
|
||||
self._play_task = None
|
||||
self._is_playing = False
|
||||
if self._current_source:
|
||||
await self._current_source.close()
|
||||
self._current_source = None
|
||||
|
||||
async def play(self, source: AudioSource, *, wait: bool = True) -> None:
|
||||
"""|coro| Play an :class:`AudioSource` on the voice connection."""
|
||||
|
||||
await self.stop()
|
||||
self._current_source = source
|
||||
self._play_task = self._loop.create_task(self._play_loop())
|
||||
if wait:
|
||||
await self._play_task
|
||||
|
||||
|
||||
async def play(self, source: AudioSource, *, wait: bool = True) -> None:
|
||||
"""|coro| Play an :class:`AudioSource` on the voice connection."""
|
||||
|
||||
await self.stop()
|
||||
self._current_source = source
|
||||
self._play_task = self._loop.create_task(self._play_loop())
|
||||
if wait:
|
||||
await self._play_task
|
||||
|
||||
async def play_file(self, filename: str, *, wait: bool = True) -> None:
|
||||
"""|coro| Stream an audio file or URL using FFmpeg."""
|
||||
|
||||
await self.play(FFmpegAudioSource(filename), wait=wait)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.stop()
|
||||
if self._heartbeat_task:
|
||||
self._heartbeat_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._heartbeat_task
|
||||
if self._ws:
|
||||
await self._ws.close()
|
||||
if self._session:
|
||||
await self._session.close()
|
||||
if self._udp:
|
||||
self._udp.close()
|
||||
def pause(self) -> None:
|
||||
"""Pause the current audio source."""
|
||||
|
||||
if self._play_task and not self._play_task.done():
|
||||
self._pause_event.clear()
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resume playback of a paused source."""
|
||||
|
||||
if self._play_task and not self._play_task.done():
|
||||
self._pause_event.set()
|
||||
|
||||
def is_paused(self) -> bool:
|
||||
"""Return ``True`` if playback is currently paused."""
|
||||
|
||||
return bool(self._play_task and not self._pause_event.is_set())
|
||||
|
||||
def is_playing(self) -> bool:
|
||||
"""Return ``True`` if audio is actively being played."""
|
||||
return self._is_playing and self._pause_event.is_set()
|
||||
|
||||
def listen(self, sink: AudioSink) -> None:
|
||||
"""Start listening to voice and routing to a sink."""
|
||||
if not isinstance(sink, AudioSink):
|
||||
raise TypeError("sink must be an AudioSink instance")
|
||||
|
||||
self._sink = sink
|
||||
if not self._udp_receive_thread:
|
||||
self._udp_receive_thread = threading.Thread(
|
||||
target=self._udp_receive_loop, daemon=True
|
||||
)
|
||||
self._udp_receive_thread.start()
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.stop()
|
||||
if self._heartbeat_task:
|
||||
self._heartbeat_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._heartbeat_task
|
||||
if self._receive_task:
|
||||
self._receive_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._receive_task
|
||||
if self._ws:
|
||||
await self._ws.close()
|
||||
if self._session:
|
||||
await self._session.close()
|
||||
if self._udp:
|
||||
self._udp.close()
|
||||
if self._udp_receive_thread:
|
||||
self._udp_receive_thread.join(timeout=1)
|
||||
if self._sink:
|
||||
self._sink.close()
|
||||
|
||||
async def __aenter__(self) -> "VoiceClient":
|
||||
"""Enter the context manager by connecting to the voice gateway."""
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: Optional[type],
|
||||
exc: Optional[BaseException],
|
||||
tb: Optional[BaseException],
|
||||
) -> bool:
|
||||
"""Exit the context manager and close the connection."""
|
||||
await self.close()
|
||||
return False
|
||||
|
1
docs/CNAME
Normal file
1
docs/CNAME
Normal file
@ -0,0 +1 @@
|
||||
disagreement.xyz
|
@ -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'
|
114
pyproject.toml
114
pyproject.toml
@ -1,56 +1,58 @@
|
||||
[project]
|
||||
name = "disagreement"
|
||||
version = "0.2.0rc1"
|
||||
description = "A Python library for the Discord API."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = {text = "BSD 3-Clause"}
|
||||
authors = [
|
||||
{name = "Slipstream", email = "me@slipstreamm.dev"}
|
||||
]
|
||||
keywords = ["discord", "api", "bot", "async", "aiohttp"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: BSD License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Software Development :: Libraries",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Topic :: Internet",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"aiohttp>=3.9.0,<4.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=1.0.0",
|
||||
"hypothesis>=6.132.0",
|
||||
]
|
||||
dev = [
|
||||
"python-dotenv>=1.0.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/Slipstreamm/disagreement"
|
||||
Issues = "https://github.com/Slipstreamm/disagreement/issues"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
# Optional: for linting/formatting, e.g., Ruff
|
||||
# [tool.ruff]
|
||||
# line-length = 88
|
||||
# select = ["E", "W", "F", "I", "UP", "C4", "B"] # Example rule set
|
||||
# ignore = []
|
||||
|
||||
# [tool.ruff.format]
|
||||
# quote-style = "double"
|
||||
[project]
|
||||
name = "disagreement"
|
||||
version = "0.8.1"
|
||||
description = "A Python library for the Discord API."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = {text = "BSD 3-Clause"}
|
||||
authors = [
|
||||
{name = "Slipstream", email = "me@slipstreamm.dev"}
|
||||
]
|
||||
keywords = ["discord", "api", "bot", "async", "aiohttp"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: BSD License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Software Development :: Libraries",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Topic :: Internet",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"aiohttp>=3.9.0,<4.0.0",
|
||||
"PyNaCl>=1.5.0,<2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=1.0.0",
|
||||
"hypothesis>=6.132.0",
|
||||
]
|
||||
dev = [
|
||||
"python-dotenv>=1.0.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/Slipstreamm/disagreement"
|
||||
Issues = "https://github.com/Slipstreamm/disagreement/issues"
|
||||
Documentation = "https://disagreement.xyz/"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
# Optional: for linting/formatting, e.g., Ruff
|
||||
# [tool.ruff]
|
||||
# line-length = 88
|
||||
# select = ["E", "W", "F", "I", "UP", "C4", "B"] # Example rule set
|
||||
# ignore = []
|
||||
|
||||
# [tool.ruff.format]
|
||||
# quote-style = "double"
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"include": ["."],
|
||||
"exclude": ["**/node_modules", "**/__pycache__", "**/.venv", "**/.git", "**/dist", "**/build", "**/tests/**", "tavilytool.py"],
|
||||
"exclude": ["**/node_modules", "**/__pycache__", "**/.venv", "**/venv", "**/.git", "**/dist", "**/build", "**/tests/**", "tavilytool.py"],
|
||||
"ignore": [],
|
||||
"reportMissingImports": true,
|
||||
"reportMissingTypeStubs": false,
|
||||
|
@ -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