64 Commits

Author SHA1 Message Date
e97c15d615 feat: Decode application ID from token during server initialization 2025-12-10 15:53:46 +01:00
Paillat
9e410d03f6 chore: Relax Python version requirement in pyproject.toml to >=3.12, <4.0 (#47) 2025-12-08 19:10:39 +01:00
renovate[bot]
d7d7ad62df ⬆️ Update lock file (#45)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-19 18:20:01 +01:00
renovate[bot]
aebdf2655d ⬆️ Update lock file (#43)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-12 21:37:20 +01:00
renovate[bot]
03d58369b8 ⬆️ Update lock file (#42)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-02 23:29:02 +02:00
renovate[bot]
444d9272f7 ⬆️ Update lock file (#41)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-23 00:20:27 +02:00
renovate[bot]
9d4859caba ⬆️ Update lock file (#40)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 10:58:30 +02:00
renovate[bot]
b37d736dc3 ⬆️ Upgrade actions/setup-python action to v6 (#37)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Paillat <paillat@pycord.dev>
2025-09-08 15:53:09 +02:00
renovate[bot]
5f3d6fab73 ⬆️ Update lock file (#38)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 15:51:30 +02:00
renovate[bot]
a63e620aad ⬆️ Update lock file (#36)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-31 12:55:23 +02:00
renovate[bot]
d4c54e236c ⬆️ Upgrade actions/checkout action to v5 (#35)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-31 12:52:15 +02:00
Paillat
7c4f032492 🔧 Update renovate.json to change base branch pattern from "dev" to "main" (#34) 2025-08-31 12:50:53 +02:00
renovate[bot]
041e8bb6e3 ⬆️: migrate renovate config (#33)
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-08-31 12:47:56 +02:00
Paillat
2e0e835aa3 🔧 Update renovate config to use nicebots config (#32) 2025-08-31 12:46:52 +02:00
Paillat
36b708f6c4 ⬆️ Upgrade to pycord 2.7 rc1 (#30) 2025-08-31 12:44:33 +02:00
Paillat
2318307378 🚨 Update pyproject.toml to fix type checking ignorer for examples/ (#31) 2025-08-31 12:43:37 +02:00
Paillat
e886d494d9 ⬆️ Update py-cord dependency to paillcord version 2.7.0a3 (#28) 2025-07-21 19:39:34 +02:00
Paillat
ec1efb1fb4 Revert "⬆️ Upgrade Python to ==3.13.* (#21)" (#26) 2025-06-18 10:39:44 +02:00
renovate[bot]
ed5ff75af6 ⬆️ Upgrade Python to ==3.13.* (#21)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Paillat <paillat@pycord.dev>
2025-06-18 10:35:31 +02:00
renovate[bot]
844e557fc9 ⬆️ Upgrade hashicorp/setup-copywrite digest to 32f9f1c (#25)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-18 10:34:19 +02:00
Paillat
99a516b0f0 📝 Remove pre-release from README.md (#23) 2025-05-27 19:56:45 +02:00
pre-commit-ci[bot]
c356d0f74d 👷 pre-commit autoupdate (#19)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Paillat <me@paillat.dev>
2025-05-27 19:53:20 +02:00
renovate[bot]
8394b56afe ⬆️ Upgrade astral-sh/setup-uv action to v6 (#20)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-25 00:40:48 +02:00
e971ba5f19 ⬆️ Update py-cord dependency to allow for newer versions (#18) 2025-04-17 00:52:16 +02:00
fd45c0305e 📌 Update py-cord dependency to allow for newer versions (#17) 2025-04-17 00:40:12 +02:00
pre-commit-ci[bot]
57c6a3c4cd 👷 pre-commit autoupdate (#3)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Paillat <me@paillat.dev>
2025-04-15 22:04:51 +02:00
renovate[bot]
013e4aba14 ⬆️: migrate renovate config (#15)
* ⬆️: migrate config 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-24 17:14:14 +01:00
124ce383bb Disable uvicorn server_header (#14) 2025-03-17 08:28:17 +01:00
5827a2e98a Add path_prefix parameter to constructor for customizable router prefix (#13) 2025-03-13 14:11:59 +01:00
5e84515c03 📝 Update pyproject.toml and README.md to use fancy pypi readme (#12) 2025-03-13 10:02:12 +01:00
190dce6f5d Refactor error handling by moving errors to a new file (#9)
*  Refactor error handling by moving errors to a new file
2025-03-13 09:23:56 +01:00
7a98827a23 Enhance not_supported decorator to issue a SyntaxWarning for unsupported functions (#10) 2025-03-13 09:22:16 +01:00
fb9e506d15 Implement close method to properly handle resource cleanup in App class (#11) 2025-03-13 09:21:01 +01:00
ad41014c94 Use ClassVars for constructor classes to allow more customization when subclassing (#8) 2025-03-13 08:47:21 +01:00
cd444d51d1 📝 Make badges link to something (#7) 2025-03-13 08:45:31 +01:00
a94ffb6729 Add not_supported decorator and override latency property in App class (#4) 2025-03-10 23:31:49 +01:00
353ae04dac 📝 Update README.md to clarify running the application with Pycord REST app 2025-03-09 20:54:10 +01:00
4fe7cb47a7 📝 Add "Getting Help" section to README.md with support resources 2025-03-09 20:14:40 +01:00
08bf3e9521 📝 Enhance README.md with badges 2025-03-09 19:30:24 +01:00
f6e7567d27 🔧 Update pyproject.toml to include additional metadata for typing and OS compatibility 2025-03-09 19:28:40 +01:00
06885ace8c 📝 Fix webhook_events example docstring and remove unnecessary logging setup 2025-03-09 18:25:57 +01:00
b840984780 🔧 Add ISC003 to pyproject.toml ruff ignore as it conflicts with basedpyright 2025-03-09 18:25:20 +01:00
7874951a18 ✏️ Ops 2025-03-09 18:22:10 +01:00
1ae66d05ee Add custom error handling for invalid credentials in the application 2025-03-09 18:04:00 +01:00
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
94f8ff06a3 👷 Update pre-commit configuration commit messages for clarity 2025-03-09 10:26:06 +01:00
b5b469addb 🧑‍💻 Add pre-commit configuration for code quality checks 2025-03-09 10:22:56 +01:00
253325b060 🎨 Fix .gitignore by adding missing newline at end of file 2025-03-09 10:15:28 +01:00
6e9558891b ♻️ Improve formatting and readability in README.md 2025-03-09 10:15:06 +01:00
9dc223951a ⬆️ Update ruff to version 0.9.10 and starlette to version 0.46.1 2025-03-09 10:14:55 +01:00
ef61e5c3ef ♻️ Simplify App initialization by removing default intents in README 2025-03-09 09:56:36 +01:00
67bfd94e56 ♻️ Remove wildcard import from discord in __init__.py 2025-03-09 09:56:11 +01:00
8533e4b23f ♻️ Remove unused imports and update quality checks to target the current directory 2025-03-08 22:03:34 +01:00
fdf2f8e718 📖 Remove production deployment configurations section from README 2025-03-08 22:02:06 +01:00
fea252daf1 📖 Update installation command in README for correct package name 2025-03-08 22:00:48 +01:00
14f0317a1f 📖 Update installation instructions to include pre-release flag 2025-03-08 21:56:50 +01:00
16 changed files with 1222 additions and 379 deletions

View File

@@ -3,21 +3,21 @@ 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:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: "Install uv" - name: "Install uv"
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v6
with: with:
enable-cache: true enable-cache: true
- name: "Set up Python" - name: "Set up Python"
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version-file: "pyproject.toml" python-version-file: "pyproject.toml"

View File

@@ -3,15 +3,15 @@ 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
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Setup Copywrite - name: Setup Copywrite
uses: hashicorp/setup-copywrite@5e3e8a26d7b9f8a508848ad0a069dfd2f7aa5339 uses: hashicorp/setup-copywrite@32f9f1c86f661b8a51100768976a06f1b281a035
- name: Check Header Compliance - name: Check Header Compliance
run: copywrite headers --plan --config .copywrite.hcl run: copywrite headers --plan --config .copywrite.hcl
@@ -24,26 +24,26 @@ jobs:
include: include:
- check: format - check: format
name: "Format Check" name: "Format Check"
command: "uv run ruff format --check src" command: "uv run ruff format --check ."
- check: lint - check: lint
name: "Lint Check" name: "Lint Check"
command: "uv run ruff check src" command: "uv run ruff check ."
- check: basedpyright - check: basedpyright
name: "Type Check" name: "Type Check"
command: "uv run basedpyright src" command: "uv run basedpyright ."
name: ${{ matrix.name }} name: ${{ matrix.name }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: "Install uv" - name: "Install uv"
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v6
with: with:
enable-cache: true enable-cache: true
- name: "Set up Python" - name: "Set up Python"
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version-file: "pyproject.toml" python-version-file: "pyproject.toml"

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

34
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,34 @@
# Copyright (c) NiceBots
# SPDX-License-Identifier: MIT
ci:
autoupdate_commit_msg: ":construction_worker: pre-commit autoupdate"
autofix_commit_msg: ":art: auto fixes from pre-commit.com hooks"
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: \.(po|pot|yml|yaml)$
- id: end-of-file-fixer
exclude: \.(po|pot|yml|yaml)$
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
args: [--prose-wrap=always, --print-width=88]
exclude: \.(po|pot|yml|yaml)$
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.11.11
hooks:
# Run the linter.
- id: ruff
args: [ --fix ]
# Run the formatter.
- id: ruff-format
- repo: https://github.com/bhundven/copywrite # waiting for https://github.com/hashicorp/copywrite/pull/120 to be merged
rev: 937f17f09c46992447dfa8977bb96eda512588c4
hooks:
- id: add-headers

287
README.md
View File

@@ -1,29 +1,80 @@
# Pycord REST <div align="center">
<h1>Pycord REST</h1>
A lightweight wrapper for Discord's HTTP interactions using py-cord and FastAPI. This library enables you to build Discord bots that work exclusively through Discord's HTTP interactions, without requiring a WebSocket gateway connection. <!-- badges -->
## About [![PyPI - Version](https://img.shields.io/pypi/v/pycord-rest-bot)](https://pypi.org/project/pycord-rest-bot/)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pycord-rest-bot)](https://pypi.org/project/pycord-rest-bot/)
[![PyPI - Types](https://img.shields.io/pypi/types/pycord-rest-bot)](https://pypi.org/project/pycord-rest-bot/)
[![PyPI - License](https://img.shields.io/pypi/l/pycord-rest-bot)](https://pypi.org/project/pycord-rest-bot/)
[![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/Paillat-dev/pycord-rest/CI.yaml)](https://github.com/Paillat-dev/pycord-rest/actions/workflows/CI.yaml)
[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/Paillat-dev/pycord-rest/main.svg)](https://results.pre-commit.ci/latest/github/Paillat-dev/pycord-rest/main)
Pycord REST allows you to build Discord bots that respond to interactions via HTTP endpoints as described in [Discord's interaction documentation](https://discord.com/developers/docs/interactions/receiving-and-responding) and [interaction overview](https://discord.com/developers/docs/interactions/overview#preparing-for-interactions). <!-- end badges -->
This project is built on: <!-- short description -->
- **FastAPI** - For handling the HTTP server
- **py-cord** - Leveraging Discord command builders and interaction handling A lightweight wrapper for Discord's HTTP interactions and webhook events using py-cord
and FastAPI.
<!-- end short description -->
</div>
<!-- toc -->
## Table of Contents
- [Overview](#overview)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Core Concepts](#core-concepts)
- [How It Works](#how-it-works)
- [Discord Application Setup](#discord-application-setup)
- [Features](#features)
- [Interaction Handling](#interaction-handling)
- [Webhook Events](#webhook-events)
- [Type Safety](#type-safety)
- [Usage Examples](#usage-examples)
- [Basic Commands](#basic-commands)
- [Event Handling](#event-handling)
- [Custom Routes](#custom-routes)
- [Configuration](#configuration)
- [Limitations](#limitations)
- [Getting Help](#getting-help)
- [Development](#development)
- [Local Testing](#local-testing)
- [Contributing](#contributing)
- [License](#license)
## Overview
Pycord REST enables you to build Discord applications that respond to:
- **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
```bash ```bash
pip install pycord-reactive-bot pip install pycord-rest-bot
``` ```
<!-- quick-start -->
## Quick Start ## Quick Start
```python ```python
from pycord_rest import App from pycord_rest import App
import discord import discord
app = App(intents=discord.Intents.default()) app = App()
@app.slash_command(name="ping", description="Responds with pong!") @app.slash_command(name="ping", description="Responds with pong!")
async def ping(ctx): async def ping(ctx):
@@ -40,44 +91,77 @@ if __name__ == "__main__":
) )
``` ```
For more examples, check out the [examples directory](/examples) which includes: ## Core Concepts
- Basic slash command setup
- Button interactions
- Modal forms
- Production deployment configurations
## How It Works ### How It Works
Under the hood, Pycord REST creates an HTTP server using FastAPI and Uvicorn that: Pycord REST creates an HTTP server that:
1. Listens for incoming Discord interaction requests on your specified endpoint 1. Listens for Discord interaction requests and webhook events
2. Verifies the request signature using your application's public key 2. Verifies request signatures using your application's public key
3. Routes the interaction to the appropriate command handler 3. Routes events to appropriate handlers
4. Returns the response back to Discord 4. Returns responses back to Discord
Unlike traditional Discord bots that maintain a persistent WebSocket connection to Discord's gateway, HTTP-based bots: Unlike traditional WebSocket-based Discord bots, HTTP-based applications:
- Only wake up when an interaction is received
- Don't receive real-time events from Discord
## Usage - Only wake up when receiving interactions or webhook events
- Don't maintain a persistent connection to Discord's gateway
- Don't receive most real-time Discord events
### Setting up your bot on Discord ### Discord Application Setup
1. Create a bot on the [Discord Developer Portal](https://discord.com/developers/applications) 1. Create an application on the
2. Enable the "Interactions Endpoint URL" for your application [Discord Developer Portal](https://discord.com/developers/applications)
3. Set the URL to your public endpoint where your bot will receive interactions 2. Copy your public key to verify signatures
4. Copy your public key from the Developer Portal to verify signatures 3. Run the Pycord REST app
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 easy to adapt existing bots: ### Interaction Handling
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
### Type Safety
Pycord REST is fully type-annotated and type-safe. It uses `basedpyright` for type
checking.
<!-- prettier-ignore -->
> [!NOTE]
> While Pycord REST itself is fully typed, the underlying py-cord library has limited type annotations, which may affect type checking in some areas.
## 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")
@@ -92,49 +176,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 WebSocket gateway: The possible events are:
- **No Cache**: Since there's no gateway connection, there's no cache of guilds, channels, users, etc. - `on_application_authorized` - When your app is added to a guild or authorized by a
- **Limited API Methods**: Many standard py-cord methods that rely on cache won't work properly: user
- `app.get_channel()`, `app.get_guild()`, `app.get_user()`, etc. - `on_entitlement_create` - When a user subscribes to your app's premium features
- 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 triggered by users. <!-- prettier-ignore -->
> [!NOTE]
## Configuration Options > For application installation events, use `on_application_authorized` instead of `on_guild_join`.
```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 include: Add your own FastAPI routes:
- 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
@@ -144,11 +207,64 @@ 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 development server: ```python
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
)
```
### 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
## Getting Help
If you encounter issues or have questions about pycord-rest:
- **GitHub Issues**:
[Submit a bug report or feature request](https://github.com/Paillat-dev/pycord-rest/issues)
- **Discord Support**:
- For py-cord related questions: Join the
[Pycord Official Server](https://discord.gg/pycord)
- For pycord-rest specific help: Join the
[Pycord Official Server](https://discord.gg/pycord) and mention `@paillat`
<!-- prettier-ignore -->
> [!TIP]
> Before asking for help, check if your question is already answered in the [examples directory](/examples) or existing GitHub issues.
## Development
### Local Testing
Use tunneling tools to expose your local development server:
- **ngrok**:
- **ngrok** - Creates a secure tunnel to your localhost
```bash ```bash
# Install ngrok # Install ngrok
npm install -g ngrok npm install -g ngrok
@@ -157,30 +273,29 @@ For faster development and testing, you can use tunneling tools to expose your l
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 during development, allowing you to test changes quickly without deploying to production. These tools provide temporary URLs for testing 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**:
- **uv**: For dependency management
- **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 any issues you encounter. > This is an early-stage project and may have unexpected behaviors or bugs. Please report any issues you encounter.
## License ## License
@@ -188,4 +303,4 @@ MIT License - Copyright (c) 2025 Paillat-dev
--- ---
Made with ❤ by Paillat-dev Made with ❤ by Paillat-dev

View File

@@ -7,7 +7,6 @@ This is a minimal example showing how to create slash commands.
""" """
import os import os
from pydoc import describe
import discord import discord
from dotenv import load_dotenv from dotenv import load_dotenv

View File

@@ -21,7 +21,7 @@ class MyView(discord.ui.View):
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.add_item( self.add_item(
discord.ui.Button( discord.ui.Button( # pyright: ignore[reportUnknownArgumentType]
style=discord.ButtonStyle.link, label="GitHub", url="https://github.com/Paillat-dev/pycord-rest" style=discord.ButtonStyle.link, label="GitHub", url="https://github.com/Paillat-dev/pycord-rest"
) )
) )

View File

@@ -3,7 +3,6 @@
"""Example showing how to work with modals in Pycord REST.""" """Example showing how to work with modals in Pycord REST."""
import asyncio
import os import os
from typing import Any from typing import Any

View File

@@ -0,0 +1,41 @@
# Copyright (c) Paillat-dev
# SPDX-License-Identifier: MIT
"""Example showing how to work with webhook events in Pycord REST."""
import os
from dotenv import load_dotenv
from pycord_rest import App, ApplicationAuthorizedEvent
# Load environment variables from .env file
load_dotenv()
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"
+ 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,25 +1,30 @@
[build-system]
requires = ["hatchling", "hatch-vcs", "hatch-fancy-pypi-readme"]
build-backend = "hatchling.build"
[project] [project]
name = "pycord-rest-bot" name = "pycord-rest-bot"
dynamic = ["version", "urls"] dynamic = ["version", "urls", "readme"]
description = "A discord rest-bot wrapper for pycord" description = "A lightweight wrapper for Discord's HTTP interactions and webhook events using py-cord and FastAPI"
readme = "README.md"
authors = [ authors = [
{ name = "Paillat-dev", email = "me@paillat.dev" } { name = "Paillat-dev", email = "me@paillat.dev" }
] ]
license = "MIT" license = "MIT"
requires-python = "==3.12.*" requires-python = ">=3.12, <4.0"
classifiers = [ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.12" "Programming Language :: Python :: 3.12",
"Typing :: Typed",
"Operating System :: OS Independent",
] ]
keywords = ["discord", "bot", "rest", "pycord"] keywords = ["discord", "bot", "rest", "pycord"]
dependencies = [ dependencies = [
"fastapi>=0.115.11", "fastapi>=0.115.11",
"orjson>=3.10.15", "orjson>=3.10.15",
"py-cord==2.6.1", "py-cord>=2.7.0rc1",
"pynacl>=1.5.0", "pynacl>=1.5.0",
"uvicorn>=0.34.0", "uvicorn>=0.34.0",
] ]
@@ -31,10 +36,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"
@@ -45,6 +46,34 @@ version-file = "src/pycord_rest/_version.py"
Homepage = "https://github.com/Paillat-dev/pycord-rest" Homepage = "https://github.com/Paillat-dev/pycord-rest"
source_archive = "https://github.com/Paillat-dev/pycord-rest/archive/{commit_hash}.zip" source_archive = "https://github.com/Paillat-dev/pycord-rest/archive/{commit_hash}.zip"
[tool.hatch.metadata.hooks.fancy-pypi-readme]
content-type = "text/markdown"
[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
path = "README.md"
start-after = "<!-- badges -->\n"
end-before = "\n<!-- end badges -->"
[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
text = "\n\n---\n"
[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
path = "README.md"
start-after = "## Overview\n"
end-before = "\n## Installation"
[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
path = "README.md"
start-after = "<!-- quick-start -->"
[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)'
replacement = '[\1](https://github.com/Paillat-dev/pycord-rest/tree/main\g<2>)'
[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
pattern = '\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]'
replacement = '**\1**:'
[tool.hatchling] [tool.hatchling]
name = "pycord-rest-bot" name = "pycord-rest-bot"
@@ -69,7 +98,7 @@ reportUnusedCallResult = false
reportAny = false reportAny = false
executionEnvironments = [ executionEnvironments = [
{ root = "src/pycord_rest/_version.py", reportDeprecated = false }, { root = "src/pycord_rest/_version.py", reportDeprecated = false },
{ root = "examples", reportExplicitAny = false, reportUnknownMemberType = false, reportUnusedParameter = false, reportImplicitOverride = false } { root = "examples", reportExplicitAny = false, reportUnknownMemberType = false, reportUnusedParameter = false, reportImplicitOverride = false, reportAttributeAccessIssue = false, reportUnknownVariableType = false },
] ]
[tool.ruff] [tool.ruff]
@@ -90,7 +119,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",
@@ -116,5 +145,6 @@ extend-ignore = [
"FBT002", "FBT002",
"PLR2004", "PLR2004",
"PLR0913", "PLR0913",
"C901" "C901",
"ISC003" # conflicts with basedpyright reportImplicitStringConcatenation
] ]

13
renovate.json Normal file
View File

@@ -0,0 +1,13 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"local>nicebots-xyz/renovate-config",
":semanticPrefixFixDepsChoreOthers",
":dependencyDashboard"
],
"forkProcessing": "enabled",
"baseBranchPatterns": ["main"],
"lockFileMaintenance": {
"enabled": true
}
}

View File

@@ -1,8 +1,7 @@
# Copyright (c) Paillat-dev # Copyright (c) Paillat-dev
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
from discord import * # noqa: F403, I001 # pyright: ignore [reportWildcardImportFromLibrary] from .app import App, ApplicationAuthorizedEvent
from .app import App
Bot = App Bot = App
__all__ = ["App", "Bot"] __all__ = ["App", "ApplicationAuthorizedEvent", "Bot"]

View File

@@ -1,7 +1,10 @@
# Copyright (c) Paillat-dev # Copyright (c) Paillat-dev
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
import base64
import functools
import logging import logging
import warnings
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
from functools import cached_property from functools import cached_property
from typing import Any, Never, override from typing import Any, Never, override
@@ -9,40 +12,82 @@ 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 nacl.exceptions import BadSignatureError from nacl.exceptions import BadSignatureError
from nacl.signing import VerifyKey from nacl.signing import VerifyKey
from .errors import InvalidCredentialsError
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 "")
+ ">"
)
def not_supported[T, U](func: Callable[[T], U]) -> Callable[[T], U]:
@functools.wraps(func)
def inner(*args: T, **kwargs: T) -> U:
logger.warning(f"{func.__qualname__} is not supported by REST apps.")
warnings.warn(
f"{func.__qualname__} is not supported by REST apps.",
SyntaxWarning,
stacklevel=2,
)
return func(*args, **kwargs)
return inner
class App(discord.Bot): class App(discord.Bot):
def __init__(self, *args: Any, **options: Any) -> None: # pyright: ignore [reportExplicitAny] _UvicornConfig: type[uvicorn.Config] = uvicorn.Config
_UvicornServer: type[uvicorn.Server] = uvicorn.Server
_FastAPI: type[FastAPI] = FastAPI
_APIRouter: type[APIRouter] = APIRouter
def __init__(self, *args: Any, path_prefix: str = "", **options: Any) -> None: # pyright: ignore [reportExplicitAny]
super().__init__(*args, **options) # pyright: ignore [reportUnknownMemberType] super().__init__(*args, **options) # pyright: ignore [reportUnknownMemberType]
self.app: FastAPI = FastAPI(openapi_url=None, docs_url=None, redoc_url=None) self._app: FastAPI = self._FastAPI(openapi_url=None, docs_url=None, redoc_url=None)
self.router: APIRouter = APIRouter() self.router: APIRouter = self._APIRouter(prefix=path_prefix)
self.public_key: str | None = None self._public_key: str | None = None
@property
@override
@not_supported
def latency(self) -> float:
return 0.0
@cached_property @cached_property
def _verify_key(self) -> VerifyKey: def _verify_key(self) -> VerifyKey:
if self.public_key is None: if self._public_key is None:
raise FastAPIError("No public key provided") raise InvalidCredentialsError("No public key provided")
return VerifyKey(bytes.fromhex(self.public_key)) return VerifyKey(bytes.fromhex(self._public_key))
async def _dispatch_view(self, component_type: int, custom_id: str, interaction: Interaction) -> None: async def _dispatch_view(self, component_type: int, custom_id: str, interaction: Interaction) -> None:
# Code taken from ViewStore.dispatch # Code taken from ViewStore.dispatch
self._connection._view_store._ViewStore__verify_integrity() # noqa: SLF001 # pyright: ignore [reportUnknownMemberType, reportAttributeAccessIssue, reportPrivateUsage] self._connection._view_store._ViewStore__verify_integrity() # noqa: SLF001 # pyright: ignore [reportUnknownMemberType, reportAttributeAccessIssue, reportPrivateUsage]
message_id: int | None = interaction.message and interaction.message.id message_id: int | None = interaction.message and interaction.message.id
key = (component_type, message_id, custom_id) key = (component_type, message_id, custom_id)
value = self._connection._view_store._views.get(key) or self._connection._view_store._views.get( # pyright: ignore [reportUnknownVariableType, reportUnknownMemberType, reportPrivateUsage] # noqa: SLF001 value = self._connection._view_store._views.get(key) or self._connection._view_store._views.get( # pyright: ignore [reportPrivateUsage] # noqa: SLF001
(component_type, None, custom_id) (component_type, None, custom_id)
) )
if value is None: if value is None:
return return
view, item = value # pyright: ignore [reportUnknownVariableType] view, item = value
item.refresh_state(interaction) item.refresh_state(interaction)
# Code taken from View._dispatch_item # Code taken from View._dispatch_item
@@ -52,7 +97,7 @@ class App(discord.Bot):
if interaction.message: if interaction.message:
view.message = interaction.message view.message = interaction.message
await view._scheduled_task(item, interaction) # noqa: SLF001 # pyright: ignore [reportPrivateUsage, reportUnknownMemberType] await view._scheduled_task(item, interaction) # noqa: SLF001 # pyright: ignore [reportPrivateUsage]
async def _verify_request(self, request: Request) -> None: async def _verify_request(self, request: Request) -> None:
signature = request.headers["X-Signature-Ed25519"] signature = request.headers["X-Signature-Ed25519"]
@@ -93,6 +138,7 @@ class App(discord.Bot):
async def process_application_commands( # noqa: PLR0912 async def process_application_commands( # noqa: PLR0912
self, interaction: Interaction, auto_sync: bool | None = None self, interaction: Interaction, auto_sync: bool | None = None
) -> None: ) -> None:
# Code taken from super().process_application_commands
if auto_sync is None: if auto_sync is None:
auto_sync = self._bot.auto_sync_commands # pyright: ignore [reportUnknownVariableType, reportUnknownMemberType] auto_sync = self._bot.auto_sync_commands # pyright: ignore [reportUnknownVariableType, reportUnknownMemberType]
# TODO: find out why the isinstance check below doesn't stop the type errors below # noqa: FIX002, TD002, TD003 # TODO: find out why the isinstance check below doesn't stop the type errors below # noqa: FIX002, TD002, TD003
@@ -162,6 +208,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,
@@ -170,16 +260,18 @@ class App(discord.Bot):
uvicorn_options: dict[str, Any] | None = None, # pyright: ignore [reportExplicitAny] uvicorn_options: dict[str, Any] | None = None, # pyright: ignore [reportExplicitAny]
health: bool = True, health: bool = True,
) -> 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)
uvicorn_options = uvicorn_options or {} uvicorn_options = uvicorn_options or {}
uvicorn_options["log_level"] = uvicorn_options.get("log_level", logging.root.level) uvicorn_options["log_level"] = uvicorn_options.get("log_level", logging.root.level)
config = uvicorn.Config(self.app, **uvicorn_options) uvicorn_options["server_header"] = uvicorn_options.get("server_header", False)
server = uvicorn.Server(config) config = self._UvicornConfig(self._app, **uvicorn_options)
server = self._UvicornServer(config)
self._connection.application_id = int(base64.b64decode(token.split(".")[0] + "==").decode("utf-8"))
try: try:
self.dispatch("connect") self.dispatch("connect")
await server.serve() await server.serve()
@@ -189,7 +281,10 @@ class App(discord.Bot):
@override @override
async def close(self) -> None: async def close(self) -> None:
pass self._closed: bool = True
await self.http.close()
self._ready.clear()
@override @override
async def start( # pyright: ignore [reportIncompatibleMethodOverride] async def start( # pyright: ignore [reportIncompatibleMethodOverride]
@@ -199,6 +294,10 @@ class App(discord.Bot):
uvicorn_options: dict[str, Any] | None = None, # pyright: ignore [reportExplicitAny] uvicorn_options: dict[str, Any] | None = None, # pyright: ignore [reportExplicitAny]
health: bool = True, health: bool = True,
) -> None: ) -> None:
if not token:
raise InvalidCredentialsError("No token provided")
if not public_key:
raise InvalidCredentialsError("No public key provided")
await self.login(token) await self.login(token)
await self.connect( await self.connect(
token=token, token=token,

12
src/pycord_rest/errors.py Normal file
View File

@@ -0,0 +1,12 @@
# Copyright (c) Paillat-dev
# SPDX-License-Identifier: MIT
import discord
class PycordRestError(discord.DiscordException):
pass
class InvalidCredentialsError(PycordRestError):
pass

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

944
uv.lock generated

File diff suppressed because it is too large Load Diff