17 Commits

Author SHA1 Message Date
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
5 changed files with 151 additions and 31 deletions

View File

@@ -1,8 +1,28 @@
# Pycord REST
<div align="center">
<h1>Pycord REST</h1>
<!-- badges -->
[![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)
<!-- end badges -->
<!-- short description -->
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)
@@ -14,12 +34,14 @@ and FastAPI.
- [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)
@@ -48,6 +70,8 @@ pip install pycord-rest-bot --prerelease=allow
> [!NOTE]
> The package is currently in pre-release.
<!-- quick-start -->
## Quick Start
```python
@@ -93,7 +117,7 @@ Unlike traditional WebSocket-based Discord bots, HTTP-based applications:
1. Create an application on the
[Discord Developer Portal](https://discord.com/developers/applications)
2. Copy your public key to verify signatures
3. Run your FastAPI server
3. Run the Pycord REST app
4. Configure the endpoints:
- **Interactions Endpoint URL** - For slash commands and component interactions
@@ -124,6 +148,15 @@ Handle Discord webhook events such as:
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 -->
@@ -212,6 +245,22 @@ Since Pycord REST doesn't use Discord's WebSocket gateway:
- 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
@@ -243,6 +292,7 @@ These tools provide temporary URLs for testing without deploying to production.
**Development Tools**:
- **uv**: For dependency management
- **Ruff**: For linting and formatting
- **HashiCorp Copywrite**: For managing license headers
- **basedpyright**: For type checking

View File

@@ -1,12 +1,8 @@
# Copyright (c) Paillat-dev
# SPDX-License-Identifier: MIT
"""Basic Discord bot example using Pycord REST.
"""Example showing how to work with webhook events in Pycord REST."""
This is a minimal example showing how to create slash commands.
"""
import logging
import os
from dotenv import load_dotenv
@@ -16,10 +12,6 @@ from pycord_rest import App, ApplicationAuthorizedEvent
# Load environment variables from .env file
load_dotenv()
# Set up logging
logging.basicConfig(level=logging.DEBUG)
app = App()
@@ -31,7 +23,7 @@ async def on_application_authorized(event: ApplicationAuthorizedEvent) -> None:
else:
print(
f"Bot {event.user.display_name} ({event.user.id}) installed the application" # noqa: ISC003
f"Bot {event.user.display_name} ({event.user.id}) installed the application"
+ f" to guild {event.guild.name} ({event.guild.id})."
)

View File

@@ -1,12 +1,11 @@
[build-system]
requires = ["hatchling", "hatch-vcs"]
requires = ["hatchling", "hatch-vcs", "hatch-fancy-pypi-readme"]
build-backend = "hatchling.build"
[project]
name = "pycord-rest-bot"
dynamic = ["version", "urls"]
description = "A discord rest-bot wrapper for pycord"
readme = "README.md"
dynamic = ["version", "urls", "readme"]
description = "A lightweight wrapper for Discord's HTTP interactions and webhook events using py-cord and FastAPI"
authors = [
{ name = "Paillat-dev", email = "me@paillat.dev" }
]
@@ -17,7 +16,9 @@ classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"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"]
dependencies = [
@@ -45,6 +46,34 @@ version-file = "src/pycord_rest/_version.py"
Homepage = "https://github.com/Paillat-dev/pycord-rest"
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]
name = "pycord-rest-bot"
@@ -116,5 +145,6 @@ extend-ignore = [
"FBT002",
"PLR2004",
"PLR0913",
"C901"
"C901",
"ISC003" # conflicts with basedpyright reportImplicitStringConcatenation
]

View File

@@ -1,7 +1,9 @@
# Copyright (c) Paillat-dev
# SPDX-License-Identifier: MIT
import functools
import logging
import warnings
from collections.abc import Callable, Coroutine
from functools import cached_property
from typing import Any, Never, override
@@ -11,10 +13,10 @@ import discord
import uvicorn
from discord import Entitlement, Interaction, InteractionType
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, Response
from fastapi.exceptions import FastAPIError
from nacl.exceptions import BadSignatureError
from nacl.signing import VerifyKey
from .errors import InvalidCredentialsError
from .models import EventType, WebhookEventPayload, WebhookType
logger = logging.getLogger("pycord.rest")
@@ -35,18 +37,43 @@ class ApplicationAuthorizedEvent:
)
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):
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]
self.app: FastAPI = FastAPI(openapi_url=None, docs_url=None, redoc_url=None)
self.router: APIRouter = APIRouter()
self.public_key: str | None = None
self._app: FastAPI = self._FastAPI(openapi_url=None, docs_url=None, redoc_url=None)
self.router: APIRouter = self._APIRouter(prefix=path_prefix)
self._public_key: str | None = None
@property
@override
@not_supported
def latency(self) -> float:
return 0.0
@cached_property
def _verify_key(self) -> VerifyKey:
if self.public_key is None:
raise FastAPIError("No public key provided")
return VerifyKey(bytes.fromhex(self.public_key))
if self._public_key is None:
raise InvalidCredentialsError("No public key provided")
return VerifyKey(bytes.fromhex(self._public_key))
async def _dispatch_view(self, component_type: int, custom_id: str, interaction: Interaction) -> None:
# Code taken from ViewStore.dispatch
@@ -110,6 +137,7 @@ class App(discord.Bot):
async def process_application_commands( # noqa: PLR0912
self, interaction: Interaction, auto_sync: bool | None = None
) -> None:
# Code taken from super().process_application_commands
if auto_sync is None:
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
@@ -231,16 +259,17 @@ class App(discord.Bot):
uvicorn_options: dict[str, Any] | None = None, # pyright: ignore [reportExplicitAny]
health: bool = True,
) -> None:
self.public_key = public_key
self._public_key = public_key
_ = self._process_interaction_factory()
_ = self._webhook_event_factory()
if health:
_ = self._health_factory()
self.app.include_router(self.router)
self._app.include_router(self.router)
uvicorn_options = uvicorn_options or {}
uvicorn_options["log_level"] = uvicorn_options.get("log_level", logging.root.level)
config = uvicorn.Config(self.app, **uvicorn_options)
server = uvicorn.Server(config)
uvicorn_options["server_header"] = uvicorn_options.get("server_header", False)
config = self._UvicornConfig(self._app, **uvicorn_options)
server = self._UvicornServer(config)
try:
self.dispatch("connect")
await server.serve()
@@ -250,7 +279,10 @@ class App(discord.Bot):
@override
async def close(self) -> None:
pass
self._closed: bool = True
await self.http.close()
self._ready.clear()
@override
async def start( # pyright: ignore [reportIncompatibleMethodOverride]
@@ -260,6 +292,10 @@ class App(discord.Bot):
uvicorn_options: dict[str, Any] | None = None, # pyright: ignore [reportExplicitAny]
health: bool = True,
) -> 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.connect(
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