9 Commits

Author SHA1 Message Date
d94aadae02 📝 Update README.md to enhance documentation on webhook events and core concepts 2025-03-09 16:55:06 +01:00
3bce16b3e7 📝 Update README.md to enhance documentation on webhook events and core concepts 2025-03-09 16:54:42 +01:00
a133ca87cf Add webhook event handling for application authorization 2025-03-09 16:25:17 +01:00
2a77346690 Add ApplicationAuthorizedEvent class and webhook event handling 2025-03-09 16:24:36 +01:00
7fe62afc86 🔧 Enhance Renovate configuration with base branches, labels, and package rules 2025-03-09 11:54:11 +01:00
renovate[bot]
5d80029515 Configure Renovate (#1)
* Add renovate.json

* 🎨 auto fixes from pre-commit.com hooks

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-03-09 11:52:12 +01:00
d7b634107b ♻️ Change order to have build-system at the top 2025-03-09 11:40:49 +01:00
3d51f3092d 🙈 Update .gitignore to include .python-version and .idea for better environment management 2025-03-09 11:40:13 +01:00
12ffa85009 🎨 Rename jobs in workflow files for improved clarity 2025-03-09 11:39:39 +01:00
10 changed files with 329 additions and 123 deletions

View File

@@ -3,9 +3,9 @@ name: Quality Checks
on: on:
workflow_call: workflow_call:
jobs: jobs:
publish: publish:
name: Publish to PyPI
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: pypi environment: pypi
steps: steps:

View File

@@ -3,9 +3,9 @@ name: Quality Checks
on: on:
workflow_call: workflow_call:
jobs: jobs:
check-license-header: check-license-header:
name: License Header Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository

6
.gitignore vendored
View File

@@ -85,7 +85,7 @@ ipython_config.py
# pyenv # pyenv
# For a library or package, you might want to ignore these files since the code is # For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in: # intended to run in multiple environments; otherwise, check them in:
# .python-version .python-version
# pipenv # pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
@@ -165,11 +165,9 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear # and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ .idea/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
.python-version
.idea
_version.py _version.py

255
README.md
View File

@@ -1,21 +1,41 @@
# Pycord REST # Pycord REST
A lightweight wrapper for Discord's HTTP interactions using py-cord and FastAPI. This A lightweight wrapper for Discord's HTTP interactions and webhook events using py-cord
library enables you to build Discord bots that work exclusively through Discord's HTTP and FastAPI.
interactions, without requiring a WebSocket gateway connection.
## About ## Table of Contents
Pycord REST allows you to build Discord bots that respond to interactions via HTTP - [Overview](#overview)
endpoints as described in - [Installation](#installation)
[Discord's interaction documentation](https://discord.com/developers/docs/interactions/receiving-and-responding) - [Quick Start](#quick-start)
and - [Core Concepts](#core-concepts)
[interaction overview](https://discord.com/developers/docs/interactions/overview#preparing-for-interactions). - [How It Works](#how-it-works)
- [Discord Application Setup](#discord-application-setup)
- [Features](#features)
- [Interaction Handling](#interaction-handling)
- [Webhook Events](#webhook-events)
- [Usage Examples](#usage-examples)
- [Basic Commands](#basic-commands)
- [Event Handling](#event-handling)
- [Custom Routes](#custom-routes)
- [Configuration](#configuration)
- [Limitations](#limitations)
- [Development](#development)
- [Local Testing](#local-testing)
- [Contributing](#contributing)
- [License](#license)
This project is built on: ## Overview
- **FastAPI** - For handling the HTTP server Pycord REST enables you to build Discord applications that respond to:
- **py-cord** - Leveraging Discord command builders and interaction handling
- **Interactions** via HTTP endpoints (slash commands, components, modals)
- **Webhook events** such as application authorization and entitlements
Built on:
- **FastAPI** - For handling HTTP requests
- **py-cord** - For Discord command builders and interaction handling
- **uvicorn** - ASGI server implementation - **uvicorn** - ASGI server implementation
## Installation ## Installation
@@ -24,8 +44,9 @@ This project is built on:
pip install pycord-rest-bot --prerelease=allow pip install pycord-rest-bot --prerelease=allow
``` ```
Currently, the package is in pre-release, so you need to use the `--prerelease=allow` <!-- prettier-ignore -->
flag to install it. > [!NOTE]
> The package is currently in pre-release.
## Quick Start ## Quick Start
@@ -50,48 +71,68 @@ if __name__ == "__main__":
) )
``` ```
For more examples, check out the [examples directory](/examples) which includes: ## Core Concepts
- Basic slash command setup ### How It Works
- Button interactions
- Modal forms
## How It Works Pycord REST creates an HTTP server that:
Under the hood, Pycord REST creates an HTTP server using FastAPI and Uvicorn that: 1. Listens for Discord interaction requests and webhook events
2. Verifies request signatures using your application's public key
3. Routes events to appropriate handlers
4. Returns responses back to Discord
1. Listens for incoming Discord interaction requests on your specified endpoint Unlike traditional WebSocket-based Discord bots, HTTP-based applications:
2. Verifies the request signature using your application's public key
3. Routes the interaction to the appropriate command handler
4. Returns the response back to Discord
Unlike traditional Discord bots that maintain a persistent WebSocket connection to - Only wake up when receiving interactions or webhook events
Discord's gateway, HTTP-based bots: - Don't maintain a persistent connection to Discord's gateway
- Don't receive most real-time Discord events
- Only wake up when an interaction is received ### Discord Application Setup
- Don't receive real-time events from Discord
## Usage 1. Create an application on the
### Setting up your bot on Discord
1. Create a bot on the
[Discord Developer Portal](https://discord.com/developers/applications) [Discord Developer Portal](https://discord.com/developers/applications)
2. Enable the "Interactions Endpoint URL" for your application 2. Copy your public key to verify signatures
3. Set the URL to your public endpoint where your bot will receive interactions 3. Run your FastAPI server
4. Copy your public key from the Developer Portal to verify signatures 4. Configure the endpoints:
### Features - **Interactions Endpoint URL** - For slash commands and component interactions
(`https://example.com`)
- **Webhook URL** - For receiving application events (e.g.,
`https://example.com/webhook`)
- Slash Commands <!-- prettier-ignore -->
- UI Components (buttons, select menus) > [!IMPORTANT]
- Modal interactions > Don't forget to run your FastAPI server **before** setting up the application on Discord, or else Discord won't be able to verify the endpoints.
- Autocomplete for commands
### Similarities to py-cord ## Features
Syntax is equivalent to py-cord, as it is what is being used under the hood, making it ### Interaction Handling
easy to adapt existing bots:
Respond to Discord interactions such as:
- **Slash Commands** - Create and respond to application commands
- **UI Components** - Buttons, select menus, and other interactive elements
- **Modal Forms** - Pop-up forms for gathering user input
- **Autocomplete** - Dynamic option suggestions as users type
### Webhook Events
Handle Discord webhook events such as:
- **Application authorization** - When your app is added to a guild or authorized by a
user
- **Entitlement creation** - When a user subscribes to your app's premium features
## Usage Examples
<!-- prettier-ignore -->
> [!TIP]
> For complete examples, check out the [examples directory](/examples).
### Basic Commands
Commands use the familiar py-cord syntax:
```python ```python
@app.slash_command(name="hello", description="Say hello") @app.slash_command(name="hello", description="Say hello")
@@ -106,57 +147,28 @@ async def button(ctx):
await ctx.respond("Press the button!", view=view) await ctx.respond("Press the button!", view=view)
``` ```
## Limitations ### Event Handling
This library works differently than traditional bots because it does not use Discord's The possible events are:
WebSocket gateway:
- **No Cache**: Since there's no gateway connection, there's no cache of guilds, - `on_application_authorized` - When your app is added to a guild or authorized by a
channels, users, etc. user
- **Limited API Methods**: Many standard py-cord methods that rely on cache won't work - `on_entitlement_create` - When a user subscribes to your app's premium features
properly:
- `app.get_channel()`, `app.get_guild()`, `app.get_user()`, etc.
- Presence updates
- Voice support
- Member tracking
- **Event-Based Functions**: Only interaction-based events work; message events, etc.
don't work
Remember that this is an HTTP-only bot that responds exclusively to interactions <!-- prettier-ignore -->
triggered by users. > [!NOTE]
> For application installation events, use `on_application_authorized` instead of `on_guild_join`.
## Configuration Options
```python ```python
app.run( @app.listen("on_application_authorized")
token="YOUR_BOT_TOKEN", async def on_application_authorized(event: ApplicationAuthorizedEvent):
public_key="YOUR_PUBLIC_KEY", # Triggers when app is added to a guild OR when a user authorizes your app
uvicorn_options={ print(f"Authorization received: Guild={event.guild}, User={event.user}")
"host": "0.0.0.0", # Listen on all network interfaces
"port": 8000, # Port to listen on
"log_level": "info", # Uvicorn logging level
# Any valid uvicorn server options
},
health=True # Enable /health endpoint
)
``` ```
### Server Configuration ### Custom Routes
For Discord to reach your bot, you need a publicly accessible HTTPS URL. Options Add your own FastAPI routes:
include:
- Using a VPS with a domain and SSL certificate
- Deploying to a cloud service like Heroku, Railway, or Fly.io
### Health Check
By default, Pycord REST includes a `/health` endpoint that returns a 200 status code.
This endpoint is useful for monitoring services like UptimeRobot or health checks.
## Advanced Usage
### Adding Custom FastAPI Routes
```python ```python
from fastapi import Request from fastapi import Request
@@ -166,12 +178,47 @@ async def custom_endpoint(request: Request):
return {"message": "This is a custom endpoint"} return {"message": "This is a custom endpoint"}
``` ```
## Development Workflow ## Configuration
For faster development and testing, you can use tunneling tools to expose your local ```python
development server: app.run(
token="YOUR_BOT_TOKEN",
public_key="YOUR_PUBLIC_KEY",
uvicorn_options={
"host": "0.0.0.0", # Listen on all network interfaces
"port": 8000, # Port to listen on
"log_level": "info", # Uvicorn logging level
},
health=True # Enable /health endpoint for monitoring
)
```
- **ngrok** - Creates a secure tunnel to your localhost ### Integration Options
1. **Stand-alone HTTP Interaction Bot** - Commands and components only
2. **Webhook Event Handler Only** - Process application events alongside a separate
gateway bot
3. **Full HTTP Application** - Handle both interactions and webhook events
## Limitations
Since Pycord REST doesn't use Discord's WebSocket gateway:
- **No Cache** - No local storage of guilds, channels, or users
- **Limited API Methods** - Functions that rely on cache won't work:
- `app.get_channel()`, `app.get_guild()`, `app.get_user()`
- Presence updates
- Voice support
- Member tracking
- **Limited Events** - Only interaction-based and webhook events work
## Development
### Local Testing
Use tunneling tools to expose your local development server:
- **ngrok**:
```bash ```bash
# Install ngrok # Install ngrok
@@ -181,34 +228,28 @@ development server:
ngrok http 8000 ngrok http 8000
``` ```
- **Cloudflare Tunnel** - Provides a secure connection to your local server - **Cloudflare Tunnel** or **localtunnel** - Alternative tunneling options
- **localtunnel** - Simple tunnel service for exposing local endpoints
These tools provide temporary URLs that you can use in the Discord Developer Portal These tools provide temporary URLs for testing without deploying to production.
during development, allowing you to test changes quickly without deploying to
production.
## Contributing ### Contributing
Contributions are welcome! This project is in early development, so there might be bugs
or unexpected behaviors.
1. Fork the repository 1. Fork the repository
2. Create a feature branch 2. Create a feature branch
3. Make your changes 3. Make your changes
4. Run tests and linting: `ruff check` and `ruff format` 4. Run linter, formatter and type checker: `ruff check .`,`ruff format .`,
`basedpyright .`
5. Submit a pull request 5. Submit a pull request
### Development Tools **Development Tools**:
- **Ruff**: For linting and formatting - **Ruff**: For linting and formatting
- **HashiCorp Copywrite**: For managing license headers - **HashiCorp Copywrite**: For managing license headers
- **basedpyright**: For type checking - **basedpyright**: For type checking
## Disclaimer <!-- prettier-ignore -->
> [!NOTE]
This is an early-stage project and may have unexpected behaviors or bugs. Please report > This is an early-stage project and may have unexpected behaviors or bugs. Please report any issues you encounter.
any issues you encounter.
## License ## License
@@ -216,4 +257,4 @@ MIT License - Copyright (c) 2025 Paillat-dev
--- ---
Made with ❤ by Paillat-dev Made with ❤ by Paillat-dev

View File

@@ -0,0 +1,49 @@
# Copyright (c) Paillat-dev
# SPDX-License-Identifier: MIT
"""Basic Discord bot example using Pycord REST.
This is a minimal example showing how to create slash commands.
"""
import logging
import os
from dotenv import load_dotenv
from pycord_rest import App, ApplicationAuthorizedEvent
# Load environment variables from .env file
load_dotenv()
# Set up logging
logging.basicConfig(level=logging.DEBUG)
app = App()
# Register the event handler
@app.listen("on_application_authorized")
async def on_application_authorized(event: ApplicationAuthorizedEvent) -> None:
if not event.guild:
print(f"User {event.user.display_name} ({event.user.id}) installed the application.")
else:
print(
f"Bot {event.user.display_name} ({event.user.id}) installed the application" # noqa: ISC003
+ f" to guild {event.guild.name} ({event.guild.id})."
)
# Run the app
if __name__ == "__main__":
app.run(
token=os.environ["DISCORD_TOKEN"],
public_key=os.environ["DISCORD_PUBLIC_KEY"],
uvicorn_options={
"host": "0.0.0.0", # noqa: S104
"port": 8000,
"log_level": "info",
},
)

View File

@@ -1,3 +1,7 @@
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project] [project]
name = "pycord-rest-bot" name = "pycord-rest-bot"
dynamic = ["version", "urls"] dynamic = ["version", "urls"]
@@ -31,10 +35,6 @@ dev = [
"ruff>=0.9.9", "ruff>=0.9.9",
] ]
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[tool.hatch.version] [tool.hatch.version]
source = "vcs" source = "vcs"
@@ -90,7 +90,7 @@ exclude = [
[tool.ruff.lint] [tool.ruff.lint]
select = ["ALL"] select = ["ALL"]
per-file-ignores = { "examples/**/*" = ["INP001", "ARG002"] } per-file-ignores = { "examples/**/*" = ["INP001", "ARG002", "T201"] }
extend-ignore = [ extend-ignore = [
"N999", "N999",
"D104", "D104",

25
renovate.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"baseBranches": ["main"],
"labels": ["deps"],
"ignorePaths": ["requirements.txt"],
"commitMessagePrefix": "⬆️",
"commitMessageAction": "Upgrade",
"packageRules": [
{
"updateTypes": ["pin"],
"commitMessagePrefix": "📌",
"commitMessageAction": "Pin"
},
{
"updateTypes": ["rollback"],
"commitMessagePrefix": "⬇️",
"commitMessageAction": "Downgrade"
},
{
"matchDatasources": ["pypi"],
"addLabels": ["pypi"]
}
]
}

View File

@@ -1,7 +1,7 @@
# Copyright (c) Paillat-dev # Copyright (c) Paillat-dev
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
from .app import App from .app import App, ApplicationAuthorizedEvent
Bot = App Bot = App
__all__ = ["App", "Bot"] __all__ = ["App", "ApplicationAuthorizedEvent", "Bot"]

View File

@@ -9,15 +9,32 @@ from typing import Any, Never, override
import aiohttp import aiohttp
import discord import discord
import uvicorn import uvicorn
from discord import Interaction, InteractionType from discord import Entitlement, Interaction, InteractionType
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, Response
from fastapi.exceptions import FastAPIError from fastapi.exceptions import FastAPIError
from nacl.exceptions import BadSignatureError from nacl.exceptions import BadSignatureError
from nacl.signing import VerifyKey from nacl.signing import VerifyKey
from .models import EventType, WebhookEventPayload, WebhookType
logger = logging.getLogger("pycord.rest") logger = logging.getLogger("pycord.rest")
class ApplicationAuthorizedEvent:
def __init__(self, user: discord.User, guild: discord.Guild | None, type: discord.IntegrationType) -> None: # noqa: A002
self.type: discord.IntegrationType = type
self.user: discord.User = user
self.guild: discord.Guild | None = guild
@override
def __repr__(self) -> str:
return (
f"<ApplicationAuthorizedEvent type={self.type} user={self.user}"
+ (f" guild={self.guild}" if self.guild else "")
+ ">"
)
class App(discord.Bot): class App(discord.Bot):
def __init__(self, *args: Any, **options: Any) -> None: # pyright: ignore [reportExplicitAny] def __init__(self, *args: Any, **options: Any) -> None: # pyright: ignore [reportExplicitAny]
super().__init__(*args, **options) # pyright: ignore [reportUnknownMemberType] super().__init__(*args, **options) # pyright: ignore [reportUnknownMemberType]
@@ -162,6 +179,50 @@ class App(discord.Bot):
return health return health
async def _handle_webhook_event(self, data: dict[str, Any] | None, event_type: EventType) -> None: # pyright: ignore [reportExplicitAny]
if not data:
raise HTTPException(status_code=400, detail="Missing event data")
match event_type:
case EventType.APPLICATION_AUTHORIZED:
event = ApplicationAuthorizedEvent(
user=discord.User(state=self._connection, data=data["user"]),
guild=(discord.Guild(state=self._connection, data=data["guild"]) if data.get("guild") else None),
type=discord.IntegrationType.guild_install
if data.get("guild")
else discord.IntegrationType.user_install,
)
logger.debug("Dispatching application_authorized event")
self.dispatch("application_authorized", event)
if event.type == discord.IntegrationType.guild_install:
self.dispatch("guild_join", event.guild)
case EventType.ENTITLEMENT_CREATE:
entitlement = Entitlement(data=data, state=self._connection) # pyright: ignore [reportArgumentType]
logger.debug("Dispatching entitlement_create event")
self.dispatch("entitlement_create", entitlement)
case _:
logger.warning(f"Unsupported webhook event type received: {event_type}")
async def _webhook_event(self, payload: WebhookEventPayload) -> Response | dict[str, Any]: # pyright: ignore [reportExplicitAny]
match payload.type:
case WebhookType.PING:
return Response(status_code=204)
case WebhookType.Event:
if not payload.event:
raise HTTPException(status_code=400, detail="Missing event data")
await self._handle_webhook_event(payload.event.data, payload.event.type)
return {"ok": True}
def _webhook_event_factory(
self,
) -> Callable[[WebhookEventPayload], Coroutine[Any, Any, Response | dict[str, Any]]]: # pyright: ignore [reportExplicitAny]
@self.router.post("/webhook", dependencies=[Depends(self._verify_request)], response_model=None)
async def webhook_event(payload: WebhookEventPayload) -> Response | dict[str, Any]: # pyright: ignore [reportExplicitAny]
return await self._webhook_event(payload)
return webhook_event
@override @override
async def connect( # pyright: ignore [reportIncompatibleMethodOverride] async def connect( # pyright: ignore [reportIncompatibleMethodOverride]
self, self,
@@ -172,7 +233,7 @@ class App(discord.Bot):
) -> None: ) -> None:
self.public_key = public_key self.public_key = public_key
_ = self._process_interaction_factory() _ = self._process_interaction_factory()
self.app.include_router(self.router) _ = self._webhook_event_factory()
if health: if health:
_ = self._health_factory() _ = self._health_factory()
self.app.include_router(self.router) self.app.include_router(self.router)

32
src/pycord_rest/models.py Normal file
View File

@@ -0,0 +1,32 @@
# Copyright (c) Paillat-dev
# SPDX-License-Identifier: MIT
from datetime import datetime
from enum import Enum
from typing import Any
from pydantic import BaseModel
class WebhookType(Enum):
PING = 0
Event = 1
class EventType(Enum):
APPLICATION_AUTHORIZED = "APPLICATION_AUTHORIZED"
ENTITLEMENT_CREATE = "ENTITLEMENT_CREATE"
QUEST_USER_ENROLLMENT = "QUEST_USER_ENROLLMENT"
class EventBody(BaseModel):
type: EventType
timestamp: datetime
data: dict[str, Any] | None = None # pyright: ignore [reportExplicitAny]
class WebhookEventPayload(BaseModel):
version: int
application_id: int
type: WebhookType
event: EventBody | None = None