37 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
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
15 changed files with 535 additions and 150 deletions

View File

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

View File

@@ -3,9 +3,9 @@ name: Quality Checks
on:
workflow_call:
jobs:
check-license-header:
name: License Header Check
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -24,13 +24,13 @@ jobs:
include:
- check: format
name: "Format Check"
command: "uv run ruff format --check src"
command: "uv run ruff format --check ."
- check: lint
name: "Lint Check"
command: "uv run ruff check src"
command: "uv run ruff check ."
- check: basedpyright
name: "Type Check"
command: "uv run basedpyright src"
command: "uv run basedpyright ."
name: ${{ matrix.name }}

8
.gitignore vendored
View File

@@ -85,7 +85,7 @@ ipython_config.py
# pyenv
# 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:
# .python-version
.python-version
# pipenv
# 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
# 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.
#.idea/
.idea/
# PyPI configuration file
.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.9.10
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

293
README.md
View File

@@ -1,29 +1,84 @@
# 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:
- **FastAPI** - For handling the HTTP server
- **py-cord** - Leveraging Discord command builders and interaction handling
<!-- 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)
- [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
## Installation
```bash
pip install pycord-reactive-bot
pip install pycord-rest-bot --prerelease=allow
```
<!-- prettier-ignore -->
> [!NOTE]
> The package is currently in pre-release.
<!-- quick-start -->
## Quick Start
```python
from pycord_rest import App
import discord
app = App(intents=discord.Intents.default())
app = App()
@app.slash_command(name="ping", description="Responds with pong!")
async def ping(ctx):
@@ -40,44 +95,77 @@ if __name__ == "__main__":
)
```
For more examples, check out the [examples directory](/examples) which includes:
- Basic slash command setup
- Button interactions
- Modal forms
- Production deployment configurations
## Core Concepts
## 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
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
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
Unlike traditional Discord bots that maintain a persistent WebSocket connection to Discord's gateway, HTTP-based bots:
- Only wake up when an interaction is received
- Don't receive real-time events from Discord
Unlike traditional WebSocket-based Discord bots, HTTP-based applications:
## 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)
2. Enable the "Interactions Endpoint URL" for your application
3. Set the URL to your public endpoint where your bot will receive interactions
4. Copy your public key from the Developer Portal to verify signatures
1. Create an application on the
[Discord Developer Portal](https://discord.com/developers/applications)
2. Copy your public key 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
- UI Components (buttons, select menus)
- Modal interactions
- Autocomplete for commands
<!-- prettier-ignore -->
> [!IMPORTANT]
> 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.
### 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
@app.slash_command(name="hello", description="Say hello")
@@ -92,49 +180,28 @@ async def button(ctx):
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.
- **Limited API Methods**: Many standard py-cord methods that rely on cache won't work 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
- `on_application_authorized` - When your app is added to a guild or authorized by a
user
- `on_entitlement_create` - When a user subscribes to your app's premium features
Remember that this is an HTTP-only bot that responds exclusively to interactions triggered by users.
## Configuration Options
<!-- prettier-ignore -->
> [!NOTE]
> For application installation events, use `on_application_authorized` instead of `on_guild_join`.
```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
# Any valid uvicorn server options
},
health=True # Enable /health endpoint
)
@app.listen("on_application_authorized")
async def on_application_authorized(event: ApplicationAuthorizedEvent):
# Triggers when app is added to a guild OR when a user authorizes your app
print(f"Authorization received: Guild={event.guild}, User={event.user}")
```
### Server Configuration
### Custom Routes
For Discord to reach your bot, you need a publicly accessible HTTPS URL. Options 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
Add your own FastAPI routes:
```python
from fastapi import Request
@@ -144,43 +211,95 @@ async def custom_endpoint(request: Request):
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
# Install ngrok
npm install -g ngrok
# Expose your local server
ngrok http 8000
```
- **Cloudflare Tunnel** - Provides a secure connection to your local server
- **localtunnel** - Simple tunnel service for exposing local endpoints
- **Cloudflare Tunnel** or **localtunnel** - Alternative tunneling options
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
Contributions are welcome! This project is in early development, so there might be bugs or unexpected behaviors.
### Contributing
1. Fork the repository
2. Create a feature branch
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
### Development Tools
**Development Tools**:
- **uv**: For dependency management
- **Ruff**: For linting and formatting
- **HashiCorp Copywrite**: For managing license headers
- **basedpyright**: For type checking
## Disclaimer
This is an early-stage project and may have unexpected behaviors or bugs. Please report any issues you encounter.
<!-- prettier-ignore -->
> [!NOTE]
> This is an early-stage project and may have unexpected behaviors or bugs. Please report any issues you encounter.
## License
@@ -188,4 +307,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
from pydoc import describe
import discord
from dotenv import load_dotenv

View File

@@ -3,7 +3,6 @@
"""Example showing how to work with modals in Pycord REST."""
import asyncio
import os
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,8 +1,11 @@
[build-system]
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" }
]
@@ -13,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 = [
@@ -31,10 +36,6 @@ dev = [
"ruff>=0.9.9",
]
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[tool.hatch.version]
source = "vcs"
@@ -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"
@@ -90,7 +119,7 @@ exclude = [
[tool.ruff.lint]
select = ["ALL"]
per-file-ignores = { "examples/**/*" = ["INP001", "ARG002"] }
per-file-ignores = { "examples/**/*" = ["INP001", "ARG002", "T201"] }
extend-ignore = [
"N999",
"D104",
@@ -116,5 +145,6 @@ extend-ignore = [
"FBT002",
"PLR2004",
"PLR0913",
"C901"
"C901",
"ISC003" # conflicts with basedpyright reportImplicitStringConcatenation
]

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

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
@@ -9,27 +11,69 @@ from typing import Any, Never, override
import aiohttp
import discord
import uvicorn
from discord import Interaction, InteractionType
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
from fastapi.exceptions import FastAPIError
from discord import Entitlement, Interaction, InteractionType
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, Response
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")
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):
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
@@ -93,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
@@ -162,6 +207,50 @@ class App(discord.Bot):
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
async def connect( # pyright: ignore [reportIncompatibleMethodOverride]
self,
@@ -170,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.app.include_router(self.router)
_ = 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()
@@ -189,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]
@@ -199,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

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

44
uv.lock generated
View File

@@ -410,27 +410,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.9.9"
version = "0.9.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/c3/418441a8170e8d53d05c0b9dad69760dbc7b8a12c10dbe6db1e1205d2377/ruff-0.9.9.tar.gz", hash = "sha256:0062ed13f22173e85f8f7056f9a24016e692efeea8704d1a5e8011b8aa850933", size = 3717448 }
sdist = { url = "https://files.pythonhosted.org/packages/20/8e/fafaa6f15c332e73425d9c44ada85360501045d5ab0b81400076aff27cf6/ruff-0.9.10.tar.gz", hash = "sha256:9bacb735d7bada9cfb0f2c227d3658fc443d90a727b47f206fb33f52f3c0eac7", size = 3759776 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/c3/2c4afa9ba467555d074b146d9aed0633a56ccdb900839fb008295d037b89/ruff-0.9.9-py3-none-linux_armv6l.whl", hash = "sha256:628abb5ea10345e53dff55b167595a159d3e174d6720bf19761f5e467e68d367", size = 10027252 },
{ url = "https://files.pythonhosted.org/packages/33/d1/439e58487cf9eac26378332e25e7d5ade4b800ce1eec7dc2cfc9b0d7ca96/ruff-0.9.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6cd1428e834b35d7493354723543b28cc11dc14d1ce19b685f6e68e07c05ec7", size = 10840721 },
{ url = "https://files.pythonhosted.org/packages/50/44/fead822c38281ba0122f1b76b460488a175a9bd48b130650a6fb6dbcbcf9/ruff-0.9.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5ee162652869120ad260670706f3cd36cd3f32b0c651f02b6da142652c54941d", size = 10161439 },
{ url = "https://files.pythonhosted.org/packages/11/ae/d404a2ab8e61ddf6342e09cc6b7f7846cce6b243e45c2007dbe0ca928a5d/ruff-0.9.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3aa0f6b75082c9be1ec5a1db78c6d4b02e2375c3068438241dc19c7c306cc61a", size = 10336264 },
{ url = "https://files.pythonhosted.org/packages/6a/4e/7c268aa7d84cd709fb6f046b8972313142cffb40dfff1d2515c5e6288d54/ruff-0.9.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:584cc66e89fb5f80f84b05133dd677a17cdd86901d6479712c96597a3f28e7fe", size = 9908774 },
{ url = "https://files.pythonhosted.org/packages/cc/26/c618a878367ef1b76270fd027ca93692657d3f6122b84ba48911ef5f2edc/ruff-0.9.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abf3369325761a35aba75cd5c55ba1b5eb17d772f12ab168fbfac54be85cf18c", size = 11428127 },
{ url = "https://files.pythonhosted.org/packages/d7/9a/c5588a93d9bfed29f565baf193fe802fa676a0c837938137ea6cf0576d8c/ruff-0.9.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3403a53a32a90ce929aa2f758542aca9234befa133e29f4933dcef28a24317be", size = 12133187 },
{ url = "https://files.pythonhosted.org/packages/3e/ff/e7980a7704a60905ed7e156a8d73f604c846d9bd87deda9cabfa6cba073a/ruff-0.9.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:18454e7fa4e4d72cffe28a37cf6a73cb2594f81ec9f4eca31a0aaa9ccdfb1590", size = 11602937 },
{ url = "https://files.pythonhosted.org/packages/24/78/3690444ad9e3cab5c11abe56554c35f005b51d1d118b429765249095269f/ruff-0.9.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fadfe2c88724c9617339f62319ed40dcdadadf2888d5afb88bf3adee7b35bfb", size = 13771698 },
{ url = "https://files.pythonhosted.org/packages/6e/bf/e477c2faf86abe3988e0b5fd22a7f3520e820b2ee335131aca2e16120038/ruff-0.9.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6df104d08c442a1aabcfd254279b8cc1e2cbf41a605aa3e26610ba1ec4acf0b0", size = 11249026 },
{ url = "https://files.pythonhosted.org/packages/f7/82/cdaffd59e5a8cb5b14c408c73d7a555a577cf6645faaf83e52fe99521715/ruff-0.9.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d7c62939daf5b2a15af48abbd23bea1efdd38c312d6e7c4cedf5a24e03207e17", size = 10220432 },
{ url = "https://files.pythonhosted.org/packages/fe/a4/2507d0026225efa5d4412b6e294dfe54725a78652a5c7e29e6bd0fc492f3/ruff-0.9.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9494ba82a37a4b81b6a798076e4a3251c13243fc37967e998efe4cce58c8a8d1", size = 9874602 },
{ url = "https://files.pythonhosted.org/packages/d5/be/f3aab1813846b476c4bcffe052d232244979c3cd99d751c17afb530ca8e4/ruff-0.9.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4efd7a96ed6d36ef011ae798bf794c5501a514be369296c672dab7921087fa57", size = 10851212 },
{ url = "https://files.pythonhosted.org/packages/8b/45/8e5fd559bea0d2f57c4e12bf197a2fade2fac465aa518284f157dfbca92b/ruff-0.9.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ab90a7944c5a1296f3ecb08d1cbf8c2da34c7e68114b1271a431a3ad30cb660e", size = 11327490 },
{ url = "https://files.pythonhosted.org/packages/42/55/e6c90f13880aeef327746052907e7e930681f26a164fe130ddac28b08269/ruff-0.9.9-py3-none-win32.whl", hash = "sha256:6b4c376d929c25ecd6d87e182a230fa4377b8e5125a4ff52d506ee8c087153c1", size = 10227912 },
{ url = "https://files.pythonhosted.org/packages/35/b2/da925693cb82a1208aa34966c0f36cb222baca94e729dd22a587bc22d0f3/ruff-0.9.9-py3-none-win_amd64.whl", hash = "sha256:837982ea24091d4c1700ddb2f63b7070e5baec508e43b01de013dc7eff974ff1", size = 11355632 },
{ url = "https://files.pythonhosted.org/packages/31/d8/de873d1c1b020d668d8ec9855d390764cb90cf8f6486c0983da52be8b7b7/ruff-0.9.9-py3-none-win_arm64.whl", hash = "sha256:3ac78f127517209fe6d96ab00f3ba97cafe38718b23b1db3e96d8b2d39e37ddf", size = 10435860 },
{ url = "https://files.pythonhosted.org/packages/73/b2/af7c2cc9e438cbc19fafeec4f20bfcd72165460fe75b2b6e9a0958c8c62b/ruff-0.9.10-py3-none-linux_armv6l.whl", hash = "sha256:eb4d25532cfd9fe461acc83498361ec2e2252795b4f40b17e80692814329e42d", size = 10049494 },
{ url = "https://files.pythonhosted.org/packages/6d/12/03f6dfa1b95ddd47e6969f0225d60d9d7437c91938a310835feb27927ca0/ruff-0.9.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:188a6638dab1aa9bb6228a7302387b2c9954e455fb25d6b4470cb0641d16759d", size = 10853584 },
{ url = "https://files.pythonhosted.org/packages/02/49/1c79e0906b6ff551fb0894168763f705bf980864739572b2815ecd3c9df0/ruff-0.9.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5284dcac6b9dbc2fcb71fdfc26a217b2ca4ede6ccd57476f52a587451ebe450d", size = 10155692 },
{ url = "https://files.pythonhosted.org/packages/5b/01/85e8082e41585e0e1ceb11e41c054e9e36fed45f4b210991052d8a75089f/ruff-0.9.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47678f39fa2a3da62724851107f438c8229a3470f533894b5568a39b40029c0c", size = 10369760 },
{ url = "https://files.pythonhosted.org/packages/a1/90/0bc60bd4e5db051f12445046d0c85cc2c617095c0904f1aa81067dc64aea/ruff-0.9.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:99713a6e2766b7a17147b309e8c915b32b07a25c9efd12ada79f217c9c778b3e", size = 9912196 },
{ url = "https://files.pythonhosted.org/packages/66/ea/0b7e8c42b1ec608033c4d5a02939c82097ddcb0b3e393e4238584b7054ab/ruff-0.9.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524ee184d92f7c7304aa568e2db20f50c32d1d0caa235d8ddf10497566ea1a12", size = 11434985 },
{ url = "https://files.pythonhosted.org/packages/d5/86/3171d1eff893db4f91755175a6e1163c5887be1f1e2f4f6c0c59527c2bfd/ruff-0.9.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:df92aeac30af821f9acf819fc01b4afc3dfb829d2782884f8739fb52a8119a16", size = 12155842 },
{ url = "https://files.pythonhosted.org/packages/89/9e/700ca289f172a38eb0bca752056d0a42637fa17b81649b9331786cb791d7/ruff-0.9.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de42e4edc296f520bb84954eb992a07a0ec5a02fecb834498415908469854a52", size = 11613804 },
{ url = "https://files.pythonhosted.org/packages/f2/92/648020b3b5db180f41a931a68b1c8575cca3e63cec86fd26807422a0dbad/ruff-0.9.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d257f95b65806104b6b1ffca0ea53f4ef98454036df65b1eda3693534813ecd1", size = 13823776 },
{ url = "https://files.pythonhosted.org/packages/5e/a6/cc472161cd04d30a09d5c90698696b70c169eeba2c41030344194242db45/ruff-0.9.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60dec7201c0b10d6d11be00e8f2dbb6f40ef1828ee75ed739923799513db24c", size = 11302673 },
{ url = "https://files.pythonhosted.org/packages/6c/db/d31c361c4025b1b9102b4d032c70a69adb9ee6fde093f6c3bf29f831c85c/ruff-0.9.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d838b60007da7a39c046fcdd317293d10b845001f38bcb55ba766c3875b01e43", size = 10235358 },
{ url = "https://files.pythonhosted.org/packages/d1/86/d6374e24a14d4d93ebe120f45edd82ad7dcf3ef999ffc92b197d81cdc2a5/ruff-0.9.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ccaf903108b899beb8e09a63ffae5869057ab649c1e9231c05ae354ebc62066c", size = 9886177 },
{ url = "https://files.pythonhosted.org/packages/00/62/a61691f6eaaac1e945a1f3f59f1eea9a218513139d5b6c2b8f88b43b5b8f/ruff-0.9.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f9567d135265d46e59d62dc60c0bfad10e9a6822e231f5b24032dba5a55be6b5", size = 10864747 },
{ url = "https://files.pythonhosted.org/packages/ee/94/2c7065e1d92a8a8a46d46d9c3cf07b0aa7e0a1e0153d74baa5e6620b4102/ruff-0.9.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f202f0d93738c28a89f8ed9eaba01b7be339e5d8d642c994347eaa81c6d75b8", size = 11360441 },
{ url = "https://files.pythonhosted.org/packages/a7/8f/1f545ea6f9fcd7bf4368551fb91d2064d8f0577b3079bb3f0ae5779fb773/ruff-0.9.10-py3-none-win32.whl", hash = "sha256:bfb834e87c916521ce46b1788fbb8484966e5113c02df216680102e9eb960029", size = 10247401 },
{ url = "https://files.pythonhosted.org/packages/4f/18/fb703603ab108e5c165f52f5b86ee2aa9be43bb781703ec87c66a5f5d604/ruff-0.9.10-py3-none-win_amd64.whl", hash = "sha256:f2160eeef3031bf4b17df74e307d4c5fb689a6f3a26a2de3f7ef4044e3c484f1", size = 11366360 },
{ url = "https://files.pythonhosted.org/packages/35/85/338e603dc68e7d9994d5d84f24adbf69bae760ba5efd3e20f5ff2cec18da/ruff-0.9.10-py3-none-win_arm64.whl", hash = "sha256:5fd804c0327a5e5ea26615550e706942f348b197d5475ff34c19733aee4b2e69", size = 10436892 },
]
[[package]]
@@ -444,14 +444,14 @@ wheels = [
[[package]]
name = "starlette"
version = "0.46.0"
version = "0.46.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/b6/fb9a32e3c5d59b1e383c357534c63c2d3caa6f25bf3c59dd89d296ecbaec/starlette-0.46.0.tar.gz", hash = "sha256:b359e4567456b28d473d0193f34c0de0ed49710d75ef183a74a5ce0499324f50", size = 2575568 }
sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/94/8af675a62e3c91c2dee47cf92e602cfac86e8767b1a1ac3caf1b327c2ab0/starlette-0.46.0-py3-none-any.whl", hash = "sha256:913f0798bd90ba90a9156383bcf1350a17d6259451d0d8ee27fc0cf2db609038", size = 71991 },
{ url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 },
]
[[package]]