commit d0c39c5930ae079301463c4d239ca6343691dc8f Author: Miwory Date: Thu Feb 26 17:59:13 2026 +0300 Релиз diff --git a/.gitea/workflows/dev.yaml b/.gitea/workflows/dev.yaml new file mode 100644 index 0000000..4f2f8cf --- /dev/null +++ b/.gitea/workflows/dev.yaml @@ -0,0 +1,58 @@ +name: Verify Dev Build +run-name: ${{ github.actor }} verifying dev build +on: + push: + branches: + - dev + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Cache uv binary + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/uv + key: uv-${{ runner.os }} + restore-keys: uv-${{ runner.os }} + + - name: Cache uv dependencies + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/.cache/uv + key: uv-${{ runner.os }} + restore-keys: uv-${{ runner.os }} + + - name: Cache pre-commit + uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit-cache-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} + restore-keys: pre-commit-cache-${{ runner.os }}- + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: "0.10.5" + enable-cache: true + cache-local-path: ${{ github.workspace }}/.cache/uv + tool-dir: ${{ github.workspace }}/.cache/uv + tool-bin-dir: ${{ github.workspace }}/.cache/uv + cache-dependency-glob: "" + + - name: Set up Python + run: uv python install + + - name: Install the project + run: uv sync --all-extras --no-install-project --cache-dir ${{ github.workspace }}/.cache/uv + + - name: Linter & Formatter + run: uv run pre-commit run --all-files + + - name: Build Package + run: uv build --cache-dir ${{ github.workspace }}/.cache/uv diff --git a/.gitea/workflows/latest.yaml b/.gitea/workflows/latest.yaml new file mode 100644 index 0000000..4783bca --- /dev/null +++ b/.gitea/workflows/latest.yaml @@ -0,0 +1,64 @@ +name: Build And Publish Package +run-name: ${{ github.actor }} builds and publishes package to PyPI +on: + push: + branches: + - latest + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Cache uv binary + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/uv + key: uv-${{ runner.os }} + restore-keys: uv-${{ runner.os }} + + - name: Cache uv dependencies + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/.cache/uv + key: uv-${{ runner.os }} + restore-keys: uv-${{ runner.os }} + + - name: Cache pre-commit + uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit-cache-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} + restore-keys: pre-commit-cache-${{ runner.os }}- + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: "0.10.5" + enable-cache: true + cache-local-path: ${{ github.workspace }}/.cache/uv + tool-dir: ${{ github.workspace }}/.cache/uv + tool-bin-dir: ${{ github.workspace }}/.cache/uv + cache-dependency-glob: "" + + - name: Set up Python + run: uv python install + + - name: Install the project + run: uv sync --all-extras --no-install-project --cache-dir ${{ github.workspace }}/.cache/uv + + - name: Linter & Formatter + run: uv run pre-commit run --all-files + + - name: Build Package + run: uv build --cache-dir ${{ github.workspace }}/.cache/uv + + - name: Publish to Gitea PyPI + run: | + uv publish \ + --index OxideTwitch \ + --token ${{ secrets.CI_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d48f6de --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# Ruff +.ruff_cache + +# uv +uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5eb171f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,34 @@ +repos: + - repo: https://github.com/crate-ci/typos + rev: v1.42.0 + hooks: + - id: typos + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.0 + hooks: + - id: ruff + args: [ --fix ] + - id: ruff-format + + - repo: https://github.com/RobertCraigie/pyright-python + rev: v1.1.408 + hooks: + - id: pyright + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: check-docstring-first + - id: check-added-large-files + - id: check-yaml + - id: debug-statements + - id: check-merge-conflict + - id: double-quote-string-fixer + - id: end-of-file-fixer + + - repo: meta + hooks: + - id: check-hooks-apply + - id: check-useless-excludes diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b0818d --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# 🧡 OxideDonationAlerts + +**OxideDonationAlerts** is a high-performance Python client for the [DonationAlerts API](https://www.donationalerts.com/apidoc), built on the [OxideHTTP](https://git.miwory.dev/OxideHTTP/OxideHTTP) core. It leverages Rust-powered HTTP calls to provide a type-safe, rate-limited, and cached interface for managing donations and alerts. + +--- + +## 🔥 Why OxideDonationAlerts? + +Managing real-time donations requires reliability and speed. OxideDonationAlerts simplifies the integration: + +* **⚡ Rust-Powered Core:** Utilizes `pyreqwest` via OxideHTTP for ultra-fast underlying networking. +* **🛡️ Distributed Rate Limiting:** Integrates with Redis to manage DonationAlerts' thresholds (60 requests/min) across multiple processes using the GCRA algorithm. +* **💾 Smart Caching:** Built-in support for caching API responses (like user profiles or donation history) to reduce latency and API load. +* **🏗️ Pydantic Validation:** All responses are fully validated against Pydantic models for perfect type hinting and data integrity. + +--- + +## 📦 Installation + +Configure your private registry in your `uv` environment, then install: + +```bash +uv add oxidedonationalerts + +``` + +--- + +## 🛠 Quick Start + +### Basic Usage: Fetching Donations + +OxideDonationAlerts manages the `base_url` and provides a clean interface for paginated data. + +```python +import asyncio +from oxidedonationalerts.api import DonationAlertsAPIClient + +async def main(): + async with DonationAlertsAPIClient( + redis_url="redis://localhost:6379" + ) as client: + # Fetch the first page of donations + donations = await client.alerts_donations( + access_token="your_token", + page=1 + ) + + for donation in donations.data: + print(f"Donor: {donation.username} | Amount: {donation.amount} {donation.currency}") + +asyncio.run(main()) + +``` + +### Subscribing to Real-time Events + +The client supports Centrifuge subscription calls for handling WebSocket-based real-time alerts. + +```python +async def setup_realtime(client, token): + # Get credentials for WebSocket connection + channels = ["$alerts:donation_741", "$goals:goal_123"] + subs = await client.centrifuge_subscribe( + access_token=token, + client="client_uuid_from_frontend", + subscriptions=channels + ) + print(f"Subscription Token: {subs.channels[0].token}") + +``` + +--- + +## ⚙️ Advanced: Configuration + +Ensure your `pyproject.toml` points to the correct registry for both **OxideDonationAlerts** and its core dependency **OxideHTTP**: + +```toml +[[tool.uv.index]] +name = "OxideHTTP" +url = "https://git.miwory.dev/api/packages/OxideHTTP/pypi/simple" + +``` + +### API Implementation Status + +The client is currently optimized for the following scopes: + +* **User Data:** `oauth-user-show` +* **Donations:** `oauth-donation-index` +* **Real-time:** `oauth-centrifuge-subscribe` + +--- + +Would you like me to generate the **Pydantic schemas** for the `UserOauth` or `AlertsDonations` models to match the official documentation? diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..08796ac --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,101 @@ +[project] +name = "oxidedonationalerts" +version = "1.0.0" +description = "Client for DonationAlerts API" +readme = "README.md" +authors = [{ name = "Miwory", email = "miwory.uwu@gmail.com" }] +requires-python = ">=3.14" +dependencies = ["oxidehttp>=1.0.3,<=2.0.0", "pydantic[email]>=2.12,<=2.13"] + +[build-system] +requires = ["uv_build>=0.9.2,<0.10.0"] +build-backend = "uv_build" + +[dependency-groups] +dev = [ + "ty>=0.0.17", + "ruff>=0.15.0", + "pyright>=1.1.408", + "poethepoet>=0.40.0", + "pre-commit>=4.5.1", +] + +[[tool.uv.index]] +name = "OxideHTTP" +url = "https://git.miwory.dev/api/packages/OxideHTTP/pypi/simple" + +[[tool.uv.index]] +name = "OxideDonationAlerts" +url = "https://git.miwory.dev/api/packages/OxideHTTP/pypi/simple" +publish-url = "https://git.miwory.dev/api/packages/OxideHTTP/pypi" +explicit = true + +[tool.poe.tasks] +_git = "git add ." +_lint = "pre-commit run --all-files" + +lint = ["_git", "_lint"] +check = "uv pip ls --outdated" + +major = "uv version --bump major" +minor = "uv version --bump minor" +patch = "uv version --bump patch" + +[tool.pyright] +venvPath = "." +venv = ".venv" +strictListInference = true +strictDictionaryInference = true +strictSetInference = true +deprecateTypingAliases = true +typeCheckingMode = "strict" +pythonPlatform = "All" +stubPath = "typings" + +[tool.ruff] +target-version = "py313" +line-length = 79 +fix = true + +[tool.ruff.lint] +preview = true +select = [ + "E", + "W", + "F", + "UP", + "A", + "B", + "C4", + "SIM", + "I", + "S", + "G", + "FAST", + "ASYNC", + "BLE", + "INT", + "ISC", + "ICN", + "PYI", + "INP", + "RSE", + "PIE", + "SLOT", + "TID", + "LOG", + "FBT", + "DTZ", + "EM", + "PERF", + "RUF", +] +ignore = ["RUF002", "RUF029", "S101", "S104", "W505"] + +[tool.ruff.lint.pydoclint] +ignore-one-line-docstrings = true + +[tool.ruff.format] +quote-style = "single" +indent-style = "space" +docstring-code-format = true diff --git a/src/oxidedonationalerts/__init__.py b/src/oxidedonationalerts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/oxidedonationalerts/api.py b/src/oxidedonationalerts/api.py new file mode 100644 index 0000000..77cfc3d --- /dev/null +++ b/src/oxidedonationalerts/api.py @@ -0,0 +1,145 @@ +from typing import Literal, Never + +from oxidehttp.client import OxideHTTP +from oxidehttp.schema import CachedResponse, Response +from pydantic import BaseModel + +from . import schema as s + + +class DonationAlertsAPIClient(OxideHTTP): + def __init__( + self, + redis_url: str | None = None, + proxy_url: str | None = None, + ) -> None: + self.base_uri = 'https://www.donationalerts.com/api/v1' + + super().__init__( + base_url=self.base_uri, + redis_url=redis_url, + ratelimit_key='donation_alerts' if redis_url else None, + ratelimit_limit=60 if redis_url else None, + proxy_url=proxy_url, + ) + + def _auth(self, access_token: str) -> dict[str, str]: + return {'Authorization': f'Bearer {access_token}'} + + async def _process[T: BaseModel]( + self, req: Response | CachedResponse, schema: type[T] + ) -> T: + if req.status_code >= 500: + raise s.InternalError(req.status_code, 'Internal Server Error') + + if req.status_code >= 400 and req.status_code < 500: + data = await req.json() + message = data.get('message', 'DonationAlerts API Error') + + raise s.ClientError(req.status_code, message) + + data = await req.json() + return schema.model_validate(data) + + async def user_oauth( + self, access_token: str, *, cache_ttl: int | None = None + ) -> s.UserOauth: + req = await self.get( + '/user/oauth', None, self._auth(access_token), cache_ttl + ) + + return await self._process(req, s.UserOauth) + + async def alerts_donations( + self, access_token: str, *, page: int = 1, cache_ttl: int | None = None + ) -> s.AlertsDonations: + req = await self.get( + '/alerts/donations', + {'page': page}, + self._auth(access_token), + cache_ttl, + ) + + return await self._process(req, s.AlertsDonations) + + async def custom_alert( + self, + access_token: str, + external_id: str, + header: str, + message: str, + image_url: str, + sound_url: str, + is_shown: Literal[0, 1] = 0, + ) -> Never: + raise NotImplementedError + + async def merchandise( + self, + access_token: str, + merchant_identifier: str, + merchandise_identifier: str, + title: dict[str, str], + currency: str, + price_user: int, + price_service: str, + url: str, + img_url: str, + end_at_ts: int, + is_active: Literal[0, 1] = 0, + is_percentage: Literal[0, 1] = 0, + ) -> Never: + raise NotImplementedError + + async def update_merchandise( + self, + access_token: str, + merchant_identifier: str, + merchandise_identifier: str, + title: dict[str, str], + currency: str, + price_user: int, + price_service: str, + url: str, + img_url: str, + end_at_ts: int, + is_active: Literal[0, 1] = 0, + is_percentage: Literal[0, 1] = 0, + ) -> Never: + raise NotImplementedError + + async def get_user_data_from_promocode( + self, + access_token: str, + promocode: str, + *, + cache_ttl: int | None = None, + ) -> Never: + raise NotImplementedError + + async def send_sale_alerts( + self, + access_token: str, + user_id: int, + external_id: str, + merchant_identifier: str, + merchandise_identifier: str, + amount: float, + currency: str, + username: str, + message: str, + bought_amount: int = 1, + ) -> Never: + raise NotImplementedError + + async def centrifuge_subscribe( + self, access_token: str, client: str, subscriptions: list[str] + ) -> s.CentrifugeSubscribe: + req = await self.post( + '/centrifuge/subscribe', + None, + self._auth(access_token), + {'client': client, 'channels': subscriptions}, + ) + + return await self._process(req, s.CentrifugeSubscribe) diff --git a/src/oxidedonationalerts/auth.py b/src/oxidedonationalerts/auth.py new file mode 100644 index 0000000..fe6a83d --- /dev/null +++ b/src/oxidedonationalerts/auth.py @@ -0,0 +1,78 @@ +from oxidehttp.client import OxideHTTP +from oxidehttp.schema import CachedResponse, Response +from pydantic import BaseModel + +from . import schema as s +from . import scopes as sp + + +class DonationAlertsAuthClient(OxideHTTP): + def __init__( + self, + client_id: str, + client_secret: str, + redirect_uri: str, + redis_url: str | None = None, + proxy_url: str | None = None, + ) -> None: + self.base_uri = 'https://www.donationalerts.com/oauth' + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + + super().__init__( + base_url=self.base_uri, + redis_url=redis_url, + ratelimit_key='donation_alerts' if redis_url else None, + ratelimit_limit=60 if redis_url else None, + proxy_url=proxy_url, + ) + + def _auth(self, access_token: str) -> dict[str, str]: + return {'Authorization': f'Bearer {access_token}'} + + async def _process[T: BaseModel]( + self, req: Response | CachedResponse, schema: type[T] + ) -> T: + if req.status_code >= 500: + raise s.InternalError(req.status_code, 'Internal Server Error') + + if req.status_code >= 400 and req.status_code < 500: + data = await req.json() + message = data.get('message', 'DonationAlerts API Error') + + raise s.ClientError(req.status_code, message) + + data = await req.json() + return schema.model_validate(data) + + async def user_access_token(self, code: str) -> s.UserAccessToken: + req = await self.post( + '/token', + json={ + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'redirect_uri': self.redirect_uri, + 'grant_type': 'authorization_code', + 'code': code, + }, + ) + + return await self._process(req, s.UserAccessToken) + + async def refresh_access_token( + self, refresh_code: str, scopes: list[sp.Any] + ) -> s.UserAccessToken: + req = await self.post( + '/token', + json={ + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'redirect_uri': self.redirect_uri, + 'grant_type': 'refresh_token', + 'refresh_token': refresh_code, + 'scopes': ' '.join(scopes), + }, + ) + + return await self._process(req, s.UserAccessToken) diff --git a/src/oxidedonationalerts/py.typed b/src/oxidedonationalerts/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/oxidedonationalerts/schema.py b/src/oxidedonationalerts/schema.py new file mode 100644 index 0000000..0917e51 --- /dev/null +++ b/src/oxidedonationalerts/schema.py @@ -0,0 +1,115 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, EmailStr, Field, HttpUrl + + +class Error(Exception): + status_code: int + error: str + + def __init__(self, status_code: int, error: str) -> None: + self.status_code = status_code + self.error = error + super().__init__(f'{status_code}: {error}') + + +class ClientError(Error): + pass + + +class InternalError(Error): + pass + + +class BaseSchema(BaseModel): + model_config = ConfigDict(extra='forbid') + + +class UserOauthData(BaseSchema): + id: int + code: str + name: str + avatar: HttpUrl + email: EmailStr + socket_connection_token: str + + +class UserOauth(BaseSchema): + data: UserOauthData + + +class AlertsDonationsDataPayinSystem(BaseSchema): + title: str + + +class AlertsDonationsDataRecipient(BaseSchema): + user_id: int + code: str + name: str + avatar: HttpUrl + + +class AlertsDonationsData(BaseSchema): + id: int + name: str + username: str | None + message: str | None + message_type: Literal['text', 'audio'] + payin_system: AlertsDonationsDataPayinSystem | None + amount: float + currency: str + is_shown: bool + amount_in_user_currency: float + recipient_name: str + recipient: AlertsDonationsDataRecipient + created_at: datetime + created_at_ts: int + shown_at: datetime | None + shown_at_ts: int | None + + +class AlertsDonationsLinks(BaseSchema): + first: HttpUrl + last: HttpUrl + prev: HttpUrl | None + next: HttpUrl | None + + +class MetaLink(BaseSchema): + url: HttpUrl | None + label: str + active: bool + + +class AlertsDonationsMeta(BaseSchema): + current_page: int + from_page: int | None = Field(alias='from') + last_page: int + links: list[MetaLink] + path: HttpUrl + per_page: int + to: int | None + total: int + + +class AlertsDonations(BaseSchema): + data: list[AlertsDonationsData] + links: AlertsDonationsLinks + meta: AlertsDonationsMeta + + +class CentrifugeSubscribeChannel(BaseSchema): + channel: str + token: str + + +class CentrifugeSubscribe(BaseSchema): + channels: list[CentrifugeSubscribeChannel] + + +class UserAccessToken(BaseSchema): + token_type: Literal['Bearer'] + access_token: str + refresh_token: str + expires_in: int diff --git a/src/oxidedonationalerts/scopes.py b/src/oxidedonationalerts/scopes.py new file mode 100644 index 0000000..732f5fd --- /dev/null +++ b/src/oxidedonationalerts/scopes.py @@ -0,0 +1,18 @@ +from typing import Literal + +OAUTH_USER_SHOW = 'oauth-user-show' +OAUTH_DONATION_SUBSCRIBE = 'oauth-donation-subscribe' +OAUTH_DONATION_INDEX = 'oauth-donation-index' +OAUTH_CUSTOM_ALERT_STORE = 'oauth-custom_alert-store' +OAUTH_GOAL_SUBSCRIBE = 'oauth-goal-subscribe' +OAUTH_POLL_SUBSCRIBE = 'oauth-poll-subscribe' + + +type Any = Literal[ + 'oauth-user-show', + 'oauth-donation-subscribe', + 'oauth-donation-index', + 'oauth-custom_alert-store', + 'oauth-goal-subscribe', + 'oauth-poll-subscribe', +]