Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/code-format.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ jobs:
- uses: actions/checkout@v3
- uses: chartboost/ruff-action@v1
with:
version: 0.1.15
version: 0.16.3
ruff-format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: chartboost/ruff-action@v1
with:
version: 0.1.15
version: 0.16.3
args: format
21 changes: 9 additions & 12 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,17 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11"]
python-version: ["3.12", "3.13"]

steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Run image
uses: abatilo/actions-poetry@v2.0.0
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
poetry-version: '1.2.0'
enable-cache: true
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
- name: Install Dependencies
run: poetry install
run: uv sync --all-extras --dev
- name: Run Tests
run: |
poetry run pytest
run: uv run pytest
3 changes: 0 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,3 @@ venv.bak/
# PyCharm
.idea
.vscode/

# Poetry
poetry.lock
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ fail_fast: true

repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.15
rev: v0.16.3
hooks:
- id: ruff
- id: ruff-check
args: [ --fix ]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
rev: v6.0.0
hooks:
- id: check-added-large-files
args: ['--maxkb=1024']
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## development

### 🎉 Features
- add support for message components (action rows, buttons, select menus, text inputs)
- add support for polls

## 2025-03-04 1.4.1

### 🩹 Fixes
Expand Down
151 changes: 142 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ webhook = DiscordWebhook(url="your webhook url", content="Webhook Message")
response = webhook.execute()
```

![Image](img/basic_webhook.png "Basic Example Result")

### Create multiple instances
If you want to use multiple URLs you need to create multiple instances.

Expand Down Expand Up @@ -89,8 +91,6 @@ webhook = DiscordWebhook(url="your webhook url", rate_limit_retry=True, content=
response = webhook.execute()
```

![Image](img/basic_webhook.png "Basic Example Result")

### Webhook with Embedded Content

```python
Expand Down Expand Up @@ -293,6 +293,139 @@ webhook.remove_file("example.jpg")
response = webhook.execute()
```

### Send a Poll

A poll can contain up to 10 answers and is open for 24 hours by default.
The `duration` is set in hours and can be up to 768 (32 days).

```python
from discord_webhook import (
DiscordComponentEmoji,
DiscordPoll,
DiscordPollAnswer,
DiscordWebhook,
)

# a simple poll with two answers
poll = DiscordPoll(question="Your favourite color?", answers=["Red", "Blue"])

webhook = DiscordWebhook(url="your webhook url", poll=poll)
response = webhook.execute()
```

Answers can also use emojis and be added afterwards:

```python
poll = DiscordPoll(
question="Which languages do you use?",
answers=[
DiscordPollAnswer(text="Python", emoji=DiscordComponentEmoji(name="🐍")),
DiscordPollAnswer(text="Rust", emoji=DiscordComponentEmoji(name="🦀")),
],
allow_multiselect=True,
duration=72,
)
poll.add_answer("Something else")

webhook = DiscordWebhook(url="your webhook url")
webhook.set_poll(poll)
response = webhook.execute()
```

Use `webhook.remove_poll()` to remove an already set poll and
`poll.get_answers()` / `poll.remove_answer(index)` to manage the answers.

### Add Components

Components like buttons and select menus are always wrapped in an action row.
A message can contain up to 5 action rows, an action row can contain up to 5 buttons
*or* a single select menu.

Note that Discord only renders interactive components for webhooks that are owned by an
application. Link buttons work with every webhook.

```python
from discord_webhook import (
DiscordComponentActionRow,
DiscordComponentButton,
DiscordComponentEmoji,
DiscordComponentStringSelect,
DiscordSelectOption,
DiscordWebhook,
constants,
)

webhook = DiscordWebhook(url="your webhook url", content="Webhook Message")

# a row with two buttons
buttons = DiscordComponentActionRow(
components=[
DiscordComponentButton(
style=constants.DISCORD_COMPONENT_BUTTON_STYLE_LINK,
label="Documentation",
url="https://github.com/lovvskillz/python-discord-webhook",
),
DiscordComponentButton(
style=constants.DISCORD_COMPONENT_BUTTON_STYLE_DANGER,
custom_id="delete_message",
label="Delete",
emoji=DiscordComponentEmoji(name="🗑️"),
),
]
)
webhook.add_component_row(buttons)

# a select menu needs its own action row
select = DiscordComponentStringSelect(
custom_id="favourite_color",
placeholder="Choose your favourite color",
options=[
DiscordSelectOption(label="Red", value="red", description="like a tomato"),
DiscordSelectOption(label="Blue", value="blue", default=True),
],
)
webhook.add_component_row(DiscordComponentActionRow(components=[select]))

response = webhook.execute()
```

These components are available:

| Class | Description |
|--------------------------------------|------------------------------------------------------|
| `DiscordComponentActionRow` | container for other components |
| `DiscordComponentButton` | button (styles 1 - 5) |
| `DiscordComponentStringSelect` | select menu with your own options |
| `DiscordComponentUserSelect` | select menu with users |
| `DiscordComponentRoleSelect` | select menu with roles |
| `DiscordComponentMentionableSelect` | select menu with users and roles |
| `DiscordComponentChannelSelect` | select menu with channels |
| `DiscordComponentTextInput` | text input (modals only) |
| `DiscordSelectOption` | option of a `DiscordComponentStringSelect` |
| `DiscordComponentEmoji` | emoji for buttons and select options |

The select menus that are populated by Discord accept default values:

```python
from discord_webhook import DiscordComponentChannelSelect, constants

channel_select = DiscordComponentChannelSelect(
custom_id="channel",
placeholder="Choose a channel",
channel_types=[constants.DISCORD_CHANNEL_TYPE_GUILD_TEXT],
max_values=3,
)
channel_select.add_default_value(123456789)
```

Already added action rows can be inspected and removed again:

```python
webhook.get_component_rows()
webhook.remove_component_row(0)
webhook.remove_component_rows()
```

### Allowed Mentions

Look into the [Discord Docs](https://discord.com/developers/docs/resources/channel#allowed-mentions-object) for examples and for more explanation.
Expand Down Expand Up @@ -430,20 +563,20 @@ optional arguments:
## Development

### Dev Setup
This project uses [Poetry](https://python-poetry.org/docs/) for dependency management and packaging.
This project uses [uv](https://docs.astral.sh/uv/) for dependency management and packaging.

Install Poetry and add Poetry to [Path](https://python-poetry.org/docs/#installation).
Install uv:

**Debian / Ubuntu / Mac**

`curl -sSL https://install.python-poetry.org | python3 -`
`curl -LsSf https://astral.sh/uv/install.sh | sh`

**Windows**

open powershell and run: `(Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | py -`
open powershell and run: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`

Install dependencies: `poetry install`
Install dependencies: `uv sync --all-extras --dev`

Install the defined pre-commit hooks: `poetry run pre-commit install`
Install the defined pre-commit hooks: `uv run pre-commit install`

Activate the virtualenv: `poetry shell`
Run a command in the virtualenv: `uv run pytest`
36 changes: 33 additions & 3 deletions discord_webhook/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
__all__ = ["DiscordWebhook", "DiscordEmbed", "AsyncDiscordWebhook"]
__all__ = [
"AsyncDiscordWebhook",
"DiscordComponentActionRow",
"DiscordComponentButton",
"DiscordComponentChannelSelect",
"DiscordComponentEmoji",
"DiscordComponentMentionableSelect",
"DiscordComponentRoleSelect",
"DiscordComponentStringSelect",
"DiscordComponentTextInput",
"DiscordComponentUserSelect",
"DiscordEmbed",
"DiscordPoll",
"DiscordPollAnswer",
"DiscordPollMedia",
"DiscordSelectOption",
"DiscordWebhook",
]


from .webhook import DiscordWebhook, DiscordEmbed
from .async_webhook import AsyncDiscordWebhook
from .components import ( # isort:skip
DiscordComponentActionRow,
DiscordComponentButton,
DiscordComponentChannelSelect,
DiscordComponentEmoji,
DiscordComponentMentionableSelect,
DiscordComponentRoleSelect,
DiscordComponentStringSelect,
DiscordComponentTextInput,
DiscordComponentUserSelect,
DiscordSelectOption,
)
from .poll import DiscordPoll, DiscordPollAnswer, DiscordPollMedia # isort:skip
from .webhook import DiscordEmbed, DiscordWebhook # isort:skip
from .async_webhook import AsyncDiscordWebhook # isort:skip
3 changes: 2 additions & 1 deletion discord_webhook/__main__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
""" Entry point to trigger webhook(s). """
"""Entry point to trigger webhook(s)."""

import argparse
import sys

Expand Down
30 changes: 14 additions & 16 deletions discord_webhook/async_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,7 @@ async def handle_rate_limit(self, response, request) -> "httpx.Response":
raise HTTPException(errors)
wh_sleep = float(errors["retry_after"]) + 0.15
logger.error(
"Webhook rate limited: sleeping for {wh_sleep} seconds...".format(
wh_sleep=round(wh_sleep, 2)
)
f"Webhook rate limited: sleeping for {round(wh_sleep, 2)} seconds..."
)
await asyncio.sleep(wh_sleep)
response = await request()
Expand Down Expand Up @@ -125,12 +123,12 @@ async def edit(self) -> "httpx.Response":
Edit an already sent webhook with updated data.
:return: Response of the sent webhook
"""
assert isinstance(
self.id, str
), "Webhook ID needs to be set in order to edit the webhook."
assert isinstance(
self.url, str
), "Webhook URL needs to be set in order to edit the webhook."
assert isinstance(self.id, str), (
"Webhook ID needs to be set in order to edit the webhook."
)
assert isinstance(self.url, str), (
"Webhook URL needs to be set in order to edit the webhook."
)
async with self.http_client as client: # type: httpx.AsyncClient
url = f"{self.url}/messages/{self.id}"
if bool(self.files) is False:
Expand All @@ -149,7 +147,7 @@ async def edit(self) -> "httpx.Response":
request = partial(client.patch, url, **patch_kwargs)
response = await request()
if response.status_code in [200, 204]:
logger.debug("Webhook with id {id} edited".format(id=self.id))
logger.debug(f"Webhook with id {self.id} edited")
elif response.status_code == 429 and self.rate_limit_retry:
response = await self.handle_rate_limit(response, request)
logger.debug("Webhook edited")
Expand All @@ -167,12 +165,12 @@ async def delete(self) -> "httpx.Response":
Delete the already sent webhook.
:return: webhook response
"""
assert isinstance(
self.id, str
), "Webhook ID needs to be set in order to delete the webhook."
assert isinstance(
self.url, str
), "Webhook URL needs to be set in order to delete the webhook."
assert isinstance(self.id, str), (
"Webhook ID needs to be set in order to delete the webhook."
)
assert isinstance(self.url, str), (
"Webhook URL needs to be set in order to delete the webhook."
)
url = f"{self.url}/messages/{self.id}"
async with self.http_client as client: # type: httpx.AsyncClient
response = await client.delete(
Expand Down
Loading
Loading