diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3a9ce24 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.github +.env +.venv +__pycache__ +*.pyc +*.db +.coverage +htmlcov +.mypy_cache +.pytest_cache +.ruff_cache +tests +docs + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1fc587e --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Telegram +TELEGRAM_BOT_TOKEN= +TELEGRAM_WEBHOOK_SECRET= + +# Public HTTPS origin for both webhook endpoints, without a trailing slash +PUBLIC_BASE_URL=https://bot.example.com + +# MakePay / MakeCrypto server-side API credentials +MAKEPAY_KEY_ID= +MAKEPAY_KEY_SECRET= +MAKEPAY_WEBHOOK_SECRET= + +# Optional runtime configuration +MAKEPAY_API_BASE_URL=https://www.makecrypto.io +MAKEPAY_CHECKOUT_BASE_URL=https://www.makepay.io +DATABASE_PATH=./data/bot.db +CATALOG_PATH=./catalog.json +RECONCILE_INTERVAL_SECONDS=60 +NOTIFICATION_INTERVAL_SECONDS=10 +MAX_WEBHOOK_BODY_BYTES=1048576 +LOG_LEVEL=INFO + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..41f4772 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + groups: + python-dependencies: + patterns: ["*"] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: ["*"] + + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..53bf69b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,17 @@ +## What changed + + + +## Security and payment impact + + + +## Validation + +- [ ] `ruff check .` +- [ ] `ruff format --check .` +- [ ] `mypy` +- [ ] `pytest --cov --cov-report=term-missing` +- [ ] `pip-audit --progress-spinner off` +- [ ] Container build + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3693aaf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.13"] + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install project and checks + run: python -m pip install --upgrade pip && python -m pip install -e '.[dev]' + + - name: Lint + run: ruff check . + + - name: Check formatting + run: ruff format --check . + + - name: Type check + if: matrix.python-version == '3.12' + run: mypy + + - name: Test + run: pytest --cov --cov-report=term-missing + + - name: Audit dependencies + if: matrix.python-version == '3.12' + run: pip-audit --progress-spinner off + + - name: Build wheel + run: python -m pip wheel . --no-deps --wheel-dir dist + + container: + name: Container build + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Build image + run: docker build --tag makepay-telegram-bot:test . + diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..4c6bc0d --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,30 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "23 4 * * 1" + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: Analyze Python + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Initialize CodeQL + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + with: + languages: python + + - name: Analyze + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f4a02a --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.env +.venv/ +__pycache__/ +*.py[cod] +*.db +*.db-shm +*.db-wal +.coverage +htmlcov/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +build/ +dist/ +*.egg-info/ +.DS_Store + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..24d926c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,25 @@ +# Contributing + +Issues and pull requests are welcome. + +1. Fork the repository and create a focused branch. +2. Install the development dependencies with + `python -m pip install -e '.[dev]'`. +3. Add or update tests for behavioral changes. +4. Run: + + ```bash + ruff check . + ruff format --check . + mypy + pytest --cov --cov-report=term-missing + pip-audit + ``` + +5. Describe the user impact, security implications, and validation in the pull + request. + +Never commit credentials, Telegram updates from real users, checkout URLs, or +production webhook payloads. By contributing, you agree that your contribution +is licensed under the MIT License. + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ef9a7eb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12.13-slim-bookworm + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN groupadd --system app && useradd --system --gid app --home-dir /app app + +COPY pyproject.toml README.md LICENSE ./ +COPY src ./src +RUN python -m pip install . + +COPY catalog.json ./ +RUN mkdir -p /app/data && chown -R app:app /app + +USER app +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"] + +CMD ["makepay-telegram-bot", "serve", "--host", "0.0.0.0", "--port", "8000"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..4faa9ae --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +# MakePay Telegram Bot + +An open-source, production-minded starter for accepting cryptocurrency payments +from a Telegram bot with [MakePay](https://www.makepay.io/). + +The bot presents a small product catalog, creates an idempotent MakePay payment +link, and sends the buyer to MakePay's hosted checkout. Signed MakePay webhooks +and background reconciliation update a durable local order record and notify the +buyer in Telegram. + +> This repository accepts payments; it does not implement product fulfillment. +> Connect your own fulfillment only after an order reaches the local `paid` +> state. + +## Why this starter + +- The bot never receives cryptocurrency, seed phrases, or private keys. +- Telegram and MakePay webhooks are independently authenticated. +- Payment-link creation is idempotent, so Telegram retries cannot create + duplicate checkouts. +- SQLite stores orders, webhook deduplication keys, and a retryable notification + outbox. +- A reconciliation worker recovers status changes if a webhook is delayed or + missed. +- The container runs as a non-root user and exposes health/readiness endpoints. +- Tests cover input validation, order transitions, webhook signatures, + credential isolation, and API behavior. + +## Payment flow + +```mermaid +sequenceDiagram + participant U as Telegram user + participant B as Bot + participant M as MakePay + participant D as SQLite + + U->>B: Choose a product + B->>D: Create/get order by callback ID + B->>M: Create payment link with idempotency key + M-->>B: Hosted checkout URL + B->>D: Store payment UID and URL + B-->>U: Pay securely with MakePay + U->>M: Complete hosted checkout + M->>B: Signed status webhook + B->>D: Deduplicate and update order + B-->>U: Payment status notification +``` + +See [the architecture notes](docs/architecture.md) for state transitions, +failure handling, and scaling guidance. + +## Quick start with Docker + +You need: + +- a Telegram bot token from [@BotFather](https://t.me/BotFather); +- a public HTTPS URL pointing to this service; +- a MakePay API key ID and secret; +- the MakePay webhook signing secret configured for your callback. + +Clone the repository and create your environment file: + +```bash +git clone https://github.com/makepay-io/makepay-telegram-bot.git +cd makepay-telegram-bot +cp .env.example .env +openssl rand -hex 32 +``` + +Put the generated value in `TELEGRAM_WEBHOOK_SECRET`, fill the other values in +`.env`, and edit `catalog.json`. Then start the bot: + +```bash +docker compose up --build +``` + +The service registers its Telegram webhook on startup. In MakePay, set the +company webhook/callback URL to: + +```text +https://YOUR_PUBLIC_HOST/webhooks/makepay +``` + +Store the associated signing secret as `MAKEPAY_WEBHOOK_SECRET`. Do not reuse +the Telegram secret or your MakePay API secret. + +Open the bot in a private Telegram chat and send `/shop`. + +## Local development + +Use Python 3.12 or newer: + +```bash +python -m venv .venv +source .venv/bin/activate +python -m pip install -e '.[dev]' +cp .env.example .env +set -a +source .env +set +a +makepay-telegram-bot serve --port 8000 +``` + +Telegram and MakePay require a public HTTPS callback, so expose port `8000` +through the tunnel or ingress of your choice and set `PUBLIC_BASE_URL` to that +origin. Plain HTTP is accepted only for `localhost` when +`ALLOW_INSECURE_LOCALHOST=true`. + +Useful commands: + +```bash +makepay-telegram-bot set-telegram-webhook +makepay-telegram-bot delete-telegram-webhook +pytest --cov --cov-report=term-missing +ruff check . +ruff format --check . +mypy +pip-audit +``` + +## Configure the catalog + +`catalog.json` is intentionally simple: + +```json +[ + { + "id": "coffee", + "name": "Coffee voucher", + "description": "A demo voucher fulfilled by the merchant after payment.", + "amount": "5.00", + "fiatCurrency": "USD" + } +] +``` + +Rules: + +- `id` must be 1–32 lowercase letters, numbers, `_`, or `-`; +- `amount` must be a positive decimal string with at most two decimal places; +- `fiatCurrency` must be a three-letter ISO code; +- the configured MakePay account controls settlement assets and addresses. + +Restart the service after changing the catalog. + +## Environment variables + +| Variable | Required | Purpose | +| --- | --- | --- | +| `TELEGRAM_BOT_TOKEN` | Yes | Token issued by BotFather | +| `TELEGRAM_WEBHOOK_SECRET` | Yes | Random secret Telegram sends in its webhook header | +| `PUBLIC_BASE_URL` | Yes | Public HTTPS origin, without a path | +| `MAKEPAY_KEY_ID` | Yes | MakePay/MakeCrypto API key identifier | +| `MAKEPAY_KEY_SECRET` | Yes | MakePay/MakeCrypto API key secret | +| `MAKEPAY_WEBHOOK_SECRET` | Yes | Secret used to verify `x-makepay-signature` | +| `MAKEPAY_API_BASE_URL` | No | Defaults to `https://www.makecrypto.io` | +| `MAKEPAY_CHECKOUT_BASE_URL` | No | Defaults to `https://www.makepay.io` | +| `DATABASE_PATH` | No | Defaults to `./data/bot.db` | +| `CATALOG_PATH` | No | Defaults to `./catalog.json` | +| `RECONCILE_INTERVAL_SECONDS` | No | Status recovery interval; default `60` | +| `NOTIFICATION_INTERVAL_SECONDS` | No | Outbox retry interval; default `10` | +| `MAX_WEBHOOK_BODY_BYTES` | No | Request limit; default 1 MiB | +| `LOG_LEVEL` | No | `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` | + +`ADMIN_TELEGRAM_USER_IDS` is reserved for merchant extensions. The starter does +not expose buyer or order administration through Telegram. + +## Webhook behavior + +- Telegram requests must contain the exact + `x-telegram-bot-api-secret-token`. +- MakePay signatures cover `timestamp + "." + exact_raw_body` using HMAC-SHA256. +- MakePay timestamps have a five-minute tolerance. +- `x-makepay-delivery-group-id` is the preferred deduplication key; older + deliveries fall back to `deliveryId`. +- Unknown but valid MakePay events are acknowledged without changing an order. +- A `paid` order never downgrades. A late confirmed payment may recover an + earlier failed or expired order. + +## Production checklist + +- Run exactly one replica while using SQLite. +- Mount `/app/data` on persistent storage and back it up. +- Terminate TLS at a trusted ingress and forward only to the container port. +- Restrict secret access to the service and rotate credentials after exposure. +- Configure MakePay settlement assets and addresses before taking live orders. +- Test the complete payment and refund/support journey with a low-value order. +- Replace the sample catalog and connect idempotent fulfillment to the `paid` + transition. +- For multiple replicas, move orders, webhook deduplication, and the outbox to + Postgres and use row-level work claiming. + +The public HTTP endpoints are: + +- `GET /healthz` — process liveness; +- `GET /readyz` — database and Telegram application readiness; +- `POST /webhooks/telegram` — authenticated Telegram updates; +- `POST /webhooks/makepay` — signed MakePay events. + +## Security + +Please read [SECURITY.md](SECURITY.md) before reporting a vulnerability. The bot +deliberately avoids logging payloads, checkout URLs, user IDs, and secrets. + +## License + +[MIT](LICENSE) + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..42085a5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,36 @@ +# Security policy + +## Supported versions + +Security fixes are applied to the latest release on `main`. + +## Report a vulnerability + +Please do not open a public issue for a suspected vulnerability. Email +`security@makepay.io` with: + +- the affected version or commit; +- clear reproduction steps; +- expected and observed impact; +- any suggested remediation. + +Do not include live API keys, webhook secrets, Telegram bot tokens, wallet +credentials, or other third-party personal data. Use test credentials and +redacted request samples. + +We aim to acknowledge a report within three business days. We will coordinate +validation, remediation, release timing, and credit with the reporter. + +## Security boundaries + +This starter: + +- creates MakePay hosted payment links; +- keeps Telegram identity and order mapping in the local database; +- verifies Telegram and MakePay webhook authenticity; +- never requests or stores seed phrases, private keys, or payer wallet secrets. + +The operator remains responsible for host security, TLS termination, secret +storage, database backups, MakePay account configuration, fulfillment, support, +and legal/compliance obligations. + diff --git a/catalog.json b/catalog.json new file mode 100644 index 0000000..9d5e359 --- /dev/null +++ b/catalog.json @@ -0,0 +1,24 @@ +[ + { + "id": "coffee", + "name": "Coffee voucher", + "description": "A demo voucher fulfilled by the merchant after payment.", + "amount": "5.00", + "fiatCurrency": "USD" + }, + { + "id": "sticker-pack", + "name": "Digital sticker pack", + "description": "A sample digital item fulfilled by the merchant after payment.", + "amount": "12.00", + "fiatCurrency": "USD" + }, + { + "id": "supporter", + "name": "Supporter package", + "description": "A sample supporter package fulfilled by the merchant after payment.", + "amount": "49.00", + "fiatCurrency": "USD" + } +] + diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..01b83a3 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,14 @@ +services: + bot: + build: . + env_file: .env + restart: unless-stopped + ports: + - "8000:8000" + volumes: + - bot-data:/app/data + - ./catalog.json:/app/catalog.json:ro + +volumes: + bot-data: + diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..e8d5a8b --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,85 @@ +# Architecture + +## Scope + +The starter demonstrates the payment boundary of a Telegram commerce bot: +catalog selection, MakePay checkout creation, authenticated status ingestion, +local order state, and buyer notifications. Inventory, taxes, refunds, +fulfillment, and merchant administration are intentionally outside its scope. + +## Trust boundaries + +1. Telegram is trusted only after the secret-token header matches. +2. MakePay is trusted only after its timestamped HMAC signature verifies + against the exact raw request body. +3. A signed MakePay event is correlated to an existing local order by payment + UID or the random UUID sent as `orderId`. +4. Hosted checkout URLs come only from the authenticated MakePay API response + or the configured MakePay checkout origin. +5. Buyers can inspect only orders matching both their Telegram user ID and chat + ID. + +Telegram identity is never sent to MakePay. MakePay receives the random local +order ID and non-sensitive integration metadata. + +## Order state + +```mermaid +stateDiagram-v2 + [*] --> creating + creating --> pending: payment link stored + creating --> creation_failed: API error + creation_failed --> pending: idempotent retry + pending --> processing: deposit received + pending --> underpaid + processing --> underpaid + pending --> paid + processing --> paid + underpaid --> paid + pending --> failed + pending --> expired + pending --> cancelled + failed --> paid: late confirmation + expired --> paid: late confirmation + cancelled --> paid: late confirmation + paid --> paid: terminal +``` + +Unknown MakePay states conservatively map to `pending`. A terminal state does +not regress, except any state may advance to `paid`. + +## Idempotency and delivery + +- Telegram callback query IDs have a unique constraint, so a retried update + loads the original order. +- The MakePay request uses `telegram-{order UUID}` as its `Idempotency-Key`. + Retries send the same body with the same key. +- Webhook retries use `x-makepay-delivery-group-id` as the unique key. Legacy + deliveries use `deliveryId`; a final body hash fallback protects very old + payloads. +- Order updates and notification creation occur in one SQLite transaction. +- Telegram notification delivery is at least once internally and effectively + once after `sent_at` is stored. A process crash immediately after Telegram + accepts a message can produce a duplicate message; order state remains + correct. + +## Recovery + +The reconciliation worker reads MakePay's public current-session endpoint for +non-terminal orders. This covers a missing webhook and drives the same +transactional update path. Manual “Check payment status” actions use the same +mechanism. + +## Deployment model + +SQLite WAL mode is appropriate for a single process with a persistent volume. +Do not run multiple replicas against one SQLite file on network storage. + +For horizontal scaling, replace `Database` with Postgres and: + +- preserve unique constraints on request and webhook deduplication keys; +- claim outbox rows with `FOR UPDATE SKIP LOCKED`; +- run migrations separately from application startup; +- add bounded retries and dead-letter monitoring; +- rate-limit reconciliation per MakePay account. + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..323b223 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,89 @@ +[build-system] +requires = ["hatchling>=1.27,<2"] +build-backend = "hatchling.build" + +[project] +name = "makepay-telegram-bot" +version = "1.0.0" +description = "Production-minded Telegram shop bot starter for accepting cryptocurrency with MakePay" +readme = "README.md" +requires-python = ">=3.12" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "MakePay", email = "support@makepay.io" }] +keywords = [ + "makepay", + "telegram", + "telegram-bot", + "crypto-payments", + "payment-links", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Framework :: FastAPI", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Office/Business :: Financial", +] +dependencies = [ + "fastapi==0.140.0", + "httpx==0.28.1", + "makepay==0.3.0", + "python-telegram-bot==22.8", + "uvicorn[standard]==0.51.0", +] + +[project.optional-dependencies] +dev = [ + "mypy==2.3.0", + "pip-audit==2.10.1", + "pytest==9.1.1", + "pytest-cov==7.1.0", + "ruff==0.16.0", +] + +[project.scripts] +makepay-telegram-bot = "makepay_telegram_bot.cli:main" + +[project.urls] +Documentation = "https://github.com/makepay-io/makepay-telegram-bot#readme" +Issues = "https://github.com/makepay-io/makepay-telegram-bot/issues" +Repository = "https://github.com/makepay-io/makepay-telegram-bot" + +[tool.hatch.build.targets.wheel] +packages = ["src/makepay_telegram_bot"] + +[tool.pytest.ini_options] +addopts = "--strict-config --strict-markers" +testpaths = ["tests"] + +[tool.coverage.run] +branch = true +source = ["makepay_telegram_bot"] +omit = [ + "src/makepay_telegram_bot/__main__.py", + "src/makepay_telegram_bot/cli.py", +] + +[tool.coverage.report] +fail_under = 85 +show_missing = true +skip_covered = true + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP", "ASYNC", "S", "RUF"] +ignore = ["S101"] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["S105", "S106"] + +[tool.mypy] +python_version = "3.12" +strict = true +warn_unreachable = true +packages = ["makepay_telegram_bot"] diff --git a/src/makepay_telegram_bot/__init__.py b/src/makepay_telegram_bot/__init__.py new file mode 100644 index 0000000..441307a --- /dev/null +++ b/src/makepay_telegram_bot/__init__.py @@ -0,0 +1,3 @@ +"""MakePay Telegram bot starter.""" + +__version__ = "1.0.0" diff --git a/src/makepay_telegram_bot/__main__.py b/src/makepay_telegram_bot/__main__.py new file mode 100644 index 0000000..4e28416 --- /dev/null +++ b/src/makepay_telegram_bot/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/src/makepay_telegram_bot/app.py b/src/makepay_telegram_bot/app.py new file mode 100644 index 0000000..e2e1d12 --- /dev/null +++ b/src/makepay_telegram_bot/app.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import secrets +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager, suppress +from dataclasses import dataclass + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse +from makepay import MakePayError, parse_makepay_webhook +from telegram import Update + +from . import __version__ +from .bot import BOT_COMMANDS, build_telegram_application +from .catalog import load_catalog +from .config import Settings +from .database import Database +from .makepay_client import MakePayClient +from .telegram_types import TelegramApplication +from .webhooks import extract_makepay_event +from .workers import notification_loop, reconciliation_loop + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class Runtime: + settings: Settings + database: Database + makepay: MakePayClient + telegram: TelegramApplication + notification_wakeup: asyncio.Event + + +def _configure_logging(level: str) -> None: + logging.basicConfig( + level=getattr(logging, level), + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + + +def _body_too_large(request: Request, limit: int) -> bool: + value = request.headers.get("content-length") + if value is None: + return False + try: + return int(value) > limit + except ValueError: + return True + + +def _runtime(request: Request) -> Runtime: + runtime = getattr(request.app.state, "runtime", None) + if not isinstance(runtime, Runtime): + raise HTTPException(status_code=503, detail="Service is starting.") + return runtime + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + settings = Settings.from_env() + _configure_logging(settings.log_level) + catalog = load_catalog(settings.catalog_path) + database = Database(settings.database_path) + makepay = MakePayClient( + key_id=settings.makepay_key_id, + key_secret=settings.makepay_key_secret, + api_base_url=settings.makepay_api_base_url, + checkout_base_url=settings.makepay_checkout_base_url, + ) + telegram = build_telegram_application( + token=settings.telegram_bot_token, + catalog=catalog, + database=database, + makepay=makepay, + ) + notification_wakeup = asyncio.Event() + await telegram.initialize() + await telegram.bot.set_my_commands(BOT_COMMANDS) + await telegram.bot.set_webhook( + url=settings.telegram_webhook_url, + secret_token=settings.telegram_webhook_secret, + allowed_updates=Update.ALL_TYPES, + drop_pending_updates=False, + ) + await telegram.start() + app.state.runtime = Runtime( + settings=settings, + database=database, + makepay=makepay, + telegram=telegram, + notification_wakeup=notification_wakeup, + ) + tasks = [ + asyncio.create_task( + notification_loop( + application=telegram, + database=database, + wakeup=notification_wakeup, + interval_seconds=settings.notification_interval_seconds, + ), + name="notification-outbox", + ), + asyncio.create_task( + reconciliation_loop( + makepay=makepay, + database=database, + notification_wakeup=notification_wakeup, + interval_seconds=settings.reconcile_interval_seconds, + ), + name="makepay-reconciliation", + ), + ] + logger.info("MakePay Telegram bot started") + try: + yield + finally: + app.state.runtime = None + for task in tasks: + task.cancel() + for task in tasks: + with suppress(asyncio.CancelledError): + await task + await telegram.stop() + await telegram.shutdown() + await makepay.close() + database.close() + logger.info("MakePay Telegram bot stopped") + + +def create_app() -> FastAPI: + app = FastAPI( + title="MakePay Telegram Bot", + version=__version__, + docs_url=None, + redoc_url=None, + lifespan=lifespan, + ) + + @app.get("/") + async def root() -> dict[str, str]: + return {"name": "makepay-telegram-bot", "version": __version__} + + @app.get("/healthz") + async def health() -> dict[str, str]: + return {"status": "ok"} + + @app.get("/readyz") + async def ready(request: Request) -> dict[str, str]: + runtime = _runtime(request) + if not runtime.database.ping() or not runtime.telegram.running: + raise HTTPException(status_code=503, detail="Service is not ready.") + return {"status": "ready"} + + @app.post("/webhooks/telegram") + async def telegram_webhook(request: Request) -> JSONResponse: + runtime = _runtime(request) + provided_secret = request.headers.get("x-telegram-bot-api-secret-token", "") + if not secrets.compare_digest( + provided_secret.encode(), runtime.settings.telegram_webhook_secret.encode() + ): + raise HTTPException(status_code=401, detail="Invalid Telegram webhook secret.") + if _body_too_large(request, runtime.settings.max_webhook_body_bytes): + raise HTTPException(status_code=413, detail="Webhook body is too large.") + raw_body = await request.body() + if len(raw_body) > runtime.settings.max_webhook_body_bytes: + raise HTTPException(status_code=413, detail="Webhook body is too large.") + try: + payload = json.loads(raw_body) + update = Update.de_json(payload, runtime.telegram.bot) + except (json.JSONDecodeError, TypeError, ValueError) as error: + raise HTTPException(status_code=400, detail="Invalid Telegram update.") from error + if runtime.telegram.update_queue.qsize() >= 1000: + raise HTTPException(status_code=503, detail="Telegram update queue is full.") + await runtime.telegram.update_queue.put(update) + return JSONResponse({"ok": True}) + + @app.post("/webhooks/makepay") + async def makepay_webhook(request: Request) -> JSONResponse: + runtime = _runtime(request) + if _body_too_large(request, runtime.settings.max_webhook_body_bytes): + raise HTTPException(status_code=413, detail="Webhook body is too large.") + raw_body = await request.body() + if len(raw_body) > runtime.settings.max_webhook_body_bytes: + raise HTTPException(status_code=413, detail="Webhook body is too large.") + try: + payload = parse_makepay_webhook( + raw_body, + request.headers.get("x-makepay-signature"), + runtime.settings.makepay_webhook_secret, + ) + except MakePayError as error: + raise HTTPException( + status_code=error.status if error.status in {400, 401} else 401, + detail="Invalid MakePay webhook.", + ) from error + + event = extract_makepay_event( + payload, + {key.lower(): value for key, value in request.headers.items()}, + raw_body, + ) + if event.status is None: + return JSONResponse({"ok": True, "matched": False}) + result = runtime.database.apply_payment_status( + dedupe_key=event.dedupe_key, + delivery_id=event.delivery_id, + event_type=event.event_type, + makepay_status=event.status, + payment_uid=event.payment_uid, + merchant_order_id=event.merchant_order_id, + session_id=event.session_id, + ) + if result.changed: + runtime.notification_wakeup.set() + return JSONResponse( + { + "ok": True, + "matched": result.order is not None, + "duplicate": result.duplicate, + } + ) + + return app + + +app = create_app() diff --git a/src/makepay_telegram_bot/bot.py b/src/makepay_telegram_bot/bot.py new file mode 100644 index 0000000..61b7fe7 --- /dev/null +++ b/src/makepay_telegram_bot/bot.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +import html +import logging + +from telegram import ( + BotCommand, + InlineKeyboardButton, + InlineKeyboardMarkup, + Message, + Update, +) +from telegram.constants import ChatType, ParseMode +from telegram.ext import ( + ApplicationBuilder, + CallbackQueryHandler, + CommandHandler, + ContextTypes, +) + +from .database import Database +from .makepay_client import MakePayAPIError, MakePayClient +from .models import Order, Product +from .telegram_types import TelegramApplication + +logger = logging.getLogger(__name__) + + +def _money(amount: str, currency: str) -> str: + return f"{amount} {currency}" + + +def _status_label(status: str) -> str: + return { + "creating": "creating checkout", + "creation_failed": "checkout creation failed", + "pending": "awaiting payment", + "processing": "processing", + "underpaid": "underpaid", + "paid": "paid", + "expired": "expired", + "cancelled": "cancelled", + "failed": "failed", + }.get(status, status) + + +class BotHandlers: + def __init__( + self, + *, + catalog: dict[str, Product], + database: Database, + makepay: MakePayClient, + ) -> None: + self._catalog = catalog + self._database = database + self._makepay = makepay + + async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + del context + if not await self._require_private_chat(update): + return + message = update.effective_message + if message is None: + return + await message.reply_text( + "Welcome! This demo shop creates a secure MakePay checkout inside " + "Telegram. Choose /shop to browse or /orders to check your payments." + ) + + async def help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + del context + message = update.effective_message + if message is None: + return + await message.reply_text( + "/shop — browse products\n" + "/orders — view your five latest orders\n" + "/help — show this message\n\n" + "Payments happen on MakePay's hosted checkout. The bot never asks " + "for a seed phrase, private key, or card details." + ) + + async def shop(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + del context + if not await self._require_private_chat(update): + return + message = update.effective_message + if message is None: + return + keyboard = [ + [ + InlineKeyboardButton( + f"{product.name} · {_money(product.amount, product.fiat_currency)}", + callback_data=f"buy:{product.id}", + ) + ] + for product in self._catalog.values() + ] + await message.reply_text( + "Choose a product. You will review and pay on MakePay's hosted checkout.", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + async def orders(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + del context + if not await self._require_private_chat(update): + return + message = update.effective_message + user = update.effective_user + chat = update.effective_chat + if message is None or user is None or chat is None: + return + orders = self._database.list_orders(chat.id, user.id) + if not orders: + await message.reply_text("You do not have any orders yet. Use /shop to start.") + return + lines = ["Your latest orders:"] + for order in orders: + lines.append( + f"• {html.escape(order.product_name)} · " + f"{_money(order.amount, order.fiat_currency)} · " + f"{_status_label(order.status)} · #{order.id[:8]}" + ) + await message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML) + + async def buy(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + del context + query = update.callback_query + user = update.effective_user + chat = update.effective_chat + message = update.effective_message + if query is None or user is None or chat is None or message is None: + return + if chat.type != ChatType.PRIVATE: + await query.answer("Open the bot in a private chat to buy.", show_alert=True) + return + await query.answer("Creating a secure checkout…") + product_id = (query.data or "").removeprefix("buy:") + product = self._catalog.get(product_id) + if product is None: + await message.reply_text("That product is no longer available. Use /shop again.") + return + + order = self._database.create_or_get_order( + request_key=f"telegram-callback:{query.id}", + telegram_chat_id=chat.id, + telegram_user_id=user.id, + product=product, + ) + if order.checkout_url: + await self._send_checkout(message, order) + return + + try: + link = await self._makepay.create_payment_link(order, product) + order = self._database.attach_payment_link(order.id, link.uid, link.checkout_url) + except MakePayAPIError as error: + logger.warning( + "MakePay checkout creation failed for order %s with status %s", + order.id, + error.status_code, + ) + self._database.mark_creation_failed( + order.id, f"makepay_http_{error.status_code or 'unknown'}" + ) + await message.reply_text( + f"We could not create checkout for order #{order.id[:8]}. " + "Please tap the product again in /shop; the retry is safe." + ) + return + await self._send_checkout(message, order) + + async def status(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + del context + query = update.callback_query + user = update.effective_user + chat = update.effective_chat + message = update.effective_message + if query is None or user is None or chat is None or message is None: + return + order_id = (query.data or "").removeprefix("status:") + order = self._database.get_order_for_owner(order_id, chat.id, user.id) + if order is None: + await query.answer("Order not found.", show_alert=True) + return + if order.makepay_payment_uid and order.status not in { + "paid", + "cancelled", + "expired", + "failed", + }: + try: + remote = await self._makepay.get_payment_status(order.makepay_payment_uid) + result = self._database.apply_payment_status( + dedupe_key=( + f"manual:{order.makepay_payment_uid}:{remote.status}:" + f"{remote.session_id or 'none'}" + ), + makepay_status=remote.status, + payment_uid=order.makepay_payment_uid, + merchant_order_id=order.id, + session_id=remote.session_id, + event_type="manual_reconciliation", + ) + order = result.order or order + except MakePayAPIError: + logger.warning("Manual status refresh failed for order %s", order.id) + await query.answer(f"Order #{order.id[:8]} is {_status_label(order.status)}.") + + async def error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: + logger.error( + "Unhandled Telegram update error", + exc_info=context.error, + extra={"update_type": type(update).__name__}, + ) + + async def _require_private_chat(self, update: Update) -> bool: + chat = update.effective_chat + message = update.effective_message + if chat is not None and chat.type == ChatType.PRIVATE: + return True + if message is not None: + await message.reply_text( + "For privacy, purchases are available only in a private chat with this bot." + ) + return False + + async def _send_checkout(self, message: Message, order: Order) -> None: + if not order.checkout_url: + return + keyboard = InlineKeyboardMarkup( + [ + [InlineKeyboardButton("Pay securely with MakePay", url=order.checkout_url)], + [InlineKeyboardButton("Check payment status", callback_data=f"status:{order.id}")], + ] + ) + await message.reply_text( + ( + f"{html.escape(order.product_name)}\n" + f"Total: {_money(order.amount, order.fiat_currency)}\n" + f"Order: {order.id}\n\n" + "Use the button below. Never share a seed phrase or private key." + ), + parse_mode=ParseMode.HTML, + reply_markup=keyboard, + disable_web_page_preview=True, + ) + + +def build_telegram_application( + *, + token: str, + catalog: dict[str, Product], + database: Database, + makepay: MakePayClient, +) -> TelegramApplication: + application = ApplicationBuilder().token(token).concurrent_updates(False).build() + handlers = BotHandlers(catalog=catalog, database=database, makepay=makepay) + application.add_handler(CommandHandler("start", handlers.start)) + application.add_handler(CommandHandler("help", handlers.help)) + application.add_handler(CommandHandler("shop", handlers.shop)) + application.add_handler(CommandHandler("orders", handlers.orders)) + application.add_handler(CallbackQueryHandler(handlers.buy, pattern=r"^buy:[a-z0-9_-]{1,32}$")) + application.add_handler( + CallbackQueryHandler( + handlers.status, + pattern=r"^status:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + ) + ) + application.add_error_handler(handlers.error) + return application + + +BOT_COMMANDS = [ + BotCommand("shop", "Browse products"), + BotCommand("orders", "View your latest orders"), + BotCommand("help", "Payment safety and commands"), +] diff --git a/src/makepay_telegram_bot/catalog.py b/src/makepay_telegram_bot/catalog.py new file mode 100644 index 0000000..32fac5d --- /dev/null +++ b/src/makepay_telegram_bot/catalog.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import json +import re +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any + +from .models import Product + +_PRODUCT_ID_PATTERN = re.compile(r"^[a-z0-9_-]{1,32}$") +_FIAT_CURRENCY_PATTERN = re.compile(r"^[A-Z]{3}$") + + +class CatalogError(ValueError): + """Raised when the product catalog is invalid.""" + + +def _text(item: dict[str, Any], key: str, *, maximum: int) -> str: + value = item.get(key) + if not isinstance(value, str) or not value.strip(): + raise CatalogError(f"Catalog field {key!r} must be a non-empty string.") + normalized = value.strip() + if len(normalized) > maximum: + raise CatalogError(f"Catalog field {key!r} is longer than {maximum} characters.") + return normalized + + +def _amount(item: dict[str, Any]) -> str: + value = item.get("amount") + if not isinstance(value, str): + raise CatalogError("Catalog field 'amount' must be a decimal string.") + try: + amount = Decimal(value) + except InvalidOperation as error: + raise CatalogError("Catalog field 'amount' must be a decimal string.") from error + exponent = amount.as_tuple().exponent + if not amount.is_finite() or amount <= 0 or not isinstance(exponent, int) or exponent < -2: + raise CatalogError("Catalog amount must be positive with at most two decimal places.") + return f"{amount:.2f}" + + +def load_catalog(path: Path) -> dict[str, Product]: + try: + decoded = json.loads(path.read_text(encoding="utf-8")) + except OSError as error: + raise CatalogError(f"Unable to read catalog at {path}.") from error + except json.JSONDecodeError as error: + raise CatalogError(f"Catalog at {path} is not valid JSON.") from error + + if not isinstance(decoded, list) or not decoded: + raise CatalogError("Catalog must be a non-empty JSON array.") + + products: dict[str, Product] = {} + for raw in decoded: + if not isinstance(raw, dict): + raise CatalogError("Every catalog item must be an object.") + product_id = _text(raw, "id", maximum=32).lower() + if not _PRODUCT_ID_PATTERN.fullmatch(product_id): + raise CatalogError( + "Catalog product IDs may contain only lowercase letters, numbers, _ and -." + ) + if product_id in products: + raise CatalogError(f"Duplicate catalog product ID: {product_id}.") + fiat_currency = str(raw.get("fiatCurrency", "USD")).strip().upper() + if not _FIAT_CURRENCY_PATTERN.fullmatch(fiat_currency): + raise CatalogError("fiatCurrency must be a three-letter ISO currency code.") + products[product_id] = Product( + id=product_id, + name=_text(raw, "name", maximum=80), + description=_text(raw, "description", maximum=240), + amount=_amount(raw), + fiat_currency=fiat_currency, + ) + return products diff --git a/src/makepay_telegram_bot/cli.py b/src/makepay_telegram_bot/cli.py new file mode 100644 index 0000000..658f625 --- /dev/null +++ b/src/makepay_telegram_bot/cli.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import argparse +import asyncio + +import uvicorn +from telegram import Bot + +from .config import Settings + + +async def _set_telegram_webhook(settings: Settings) -> None: + async with Bot(settings.telegram_bot_token) as bot: + await bot.set_webhook( + url=settings.telegram_webhook_url, + secret_token=settings.telegram_webhook_secret, + drop_pending_updates=False, + ) + print(f"Telegram webhook set to {settings.telegram_webhook_url}") + + +async def _delete_telegram_webhook(settings: Settings) -> None: + async with Bot(settings.telegram_bot_token) as bot: + await bot.delete_webhook(drop_pending_updates=False) + print("Telegram webhook deleted; pending updates were preserved.") + + +def main() -> None: + parser = argparse.ArgumentParser(description="MakePay Telegram bot") + subparsers = parser.add_subparsers(dest="command") + serve = subparsers.add_parser("serve", help="Run the webhook server") + serve.add_argument("--host", default="0.0.0.0") # noqa: S104 - container listener + serve.add_argument("--port", type=int, default=8000) + subparsers.add_parser("set-telegram-webhook", help="Register the Telegram webhook") + subparsers.add_parser("delete-telegram-webhook", help="Delete the Telegram webhook") + arguments = parser.parse_args() + + if arguments.command in {None, "serve"}: + uvicorn.run( + "makepay_telegram_bot.app:app", + host=getattr(arguments, "host", "0.0.0.0"), # noqa: S104 - container listener + port=getattr(arguments, "port", 8000), + ) + return + + settings = Settings.from_env() + if arguments.command == "set-telegram-webhook": + asyncio.run(_set_telegram_webhook(settings)) + elif arguments.command == "delete-telegram-webhook": + asyncio.run(_delete_telegram_webhook(settings)) + + +if __name__ == "__main__": + main() diff --git a/src/makepay_telegram_bot/config.py b/src/makepay_telegram_bot/config.py new file mode 100644 index 0000000..85c9e16 --- /dev/null +++ b/src/makepay_telegram_bot/config.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import os +import re +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlparse + +_TELEGRAM_SECRET_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,256}$") + + +class ConfigurationError(ValueError): + """Raised when runtime configuration is missing or unsafe.""" + + +def _required(env: Mapping[str, str], name: str) -> str: + value = env.get(name, "").strip() + if not value: + raise ConfigurationError(f"{name} is required.") + return value + + +def _positive_int(env: Mapping[str, str], name: str, default: int, minimum: int) -> int: + raw = env.get(name, str(default)).strip() + try: + value = int(raw) + except ValueError as error: + raise ConfigurationError(f"{name} must be an integer.") from error + if value < minimum: + raise ConfigurationError(f"{name} must be at least {minimum}.") + return value + + +def _https_url(value: str, name: str, *, allow_localhost_http: bool = False) -> str: + normalized = value.rstrip("/") + parsed = urlparse(normalized) + local_http = ( + allow_localhost_http + and parsed.scheme == "http" + and parsed.hostname in {"127.0.0.1", "localhost"} + ) + if ( + (parsed.scheme != "https" and not local_http) + or not parsed.netloc + or parsed.username + or parsed.password + or parsed.path not in {"", "/"} + or parsed.query + or parsed.fragment + ): + raise ConfigurationError(f"{name} must be a clean HTTPS origin.") + return normalized + + +def _admin_ids(value: str) -> frozenset[int]: + if not value.strip(): + return frozenset() + try: + parsed = frozenset(int(part.strip()) for part in value.split(",") if part.strip()) + except ValueError as error: + raise ConfigurationError("ADMIN_TELEGRAM_USER_IDS must contain integer IDs.") from error + if any(user_id <= 0 for user_id in parsed): + raise ConfigurationError("ADMIN_TELEGRAM_USER_IDS must contain positive IDs.") + return parsed + + +@dataclass(frozen=True, slots=True) +class Settings: + telegram_bot_token: str + telegram_webhook_secret: str + public_base_url: str + makepay_key_id: str + makepay_key_secret: str + makepay_webhook_secret: str + makepay_api_base_url: str + makepay_checkout_base_url: str + database_path: Path + catalog_path: Path + admin_telegram_user_ids: frozenset[int] + reconcile_interval_seconds: int + notification_interval_seconds: int + max_webhook_body_bytes: int + log_level: str + + @property + def telegram_webhook_url(self) -> str: + return f"{self.public_base_url}/webhooks/telegram" + + @property + def makepay_webhook_url(self) -> str: + return f"{self.public_base_url}/webhooks/makepay" + + @classmethod + def from_env(cls, source: Mapping[str, str] | None = None) -> Settings: + env = os.environ if source is None else source + allow_localhost = env.get("ALLOW_INSECURE_LOCALHOST", "").lower() == "true" + telegram_secret = _required(env, "TELEGRAM_WEBHOOK_SECRET") + if not _TELEGRAM_SECRET_PATTERN.fullmatch(telegram_secret): + raise ConfigurationError( + "TELEGRAM_WEBHOOK_SECRET may contain only A-Z, a-z, 0-9, _ and - " + "and must be 1-256 characters." + ) + + log_level = env.get("LOG_LEVEL", "INFO").strip().upper() + if log_level not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}: + raise ConfigurationError("LOG_LEVEL is invalid.") + + return cls( + telegram_bot_token=_required(env, "TELEGRAM_BOT_TOKEN"), + telegram_webhook_secret=telegram_secret, + public_base_url=_https_url( + _required(env, "PUBLIC_BASE_URL"), + "PUBLIC_BASE_URL", + allow_localhost_http=allow_localhost, + ), + makepay_key_id=_required(env, "MAKEPAY_KEY_ID"), + makepay_key_secret=_required(env, "MAKEPAY_KEY_SECRET"), + makepay_webhook_secret=_required(env, "MAKEPAY_WEBHOOK_SECRET"), + makepay_api_base_url=_https_url( + env.get("MAKEPAY_API_BASE_URL", "https://www.makecrypto.io"), + "MAKEPAY_API_BASE_URL", + allow_localhost_http=allow_localhost, + ), + makepay_checkout_base_url=_https_url( + env.get("MAKEPAY_CHECKOUT_BASE_URL", "https://www.makepay.io"), + "MAKEPAY_CHECKOUT_BASE_URL", + allow_localhost_http=allow_localhost, + ), + database_path=Path(env.get("DATABASE_PATH", "./data/bot.db")).expanduser(), + catalog_path=Path(env.get("CATALOG_PATH", "./catalog.json")).expanduser(), + admin_telegram_user_ids=_admin_ids(env.get("ADMIN_TELEGRAM_USER_IDS", "")), + reconcile_interval_seconds=_positive_int(env, "RECONCILE_INTERVAL_SECONDS", 60, 10), + notification_interval_seconds=_positive_int( + env, "NOTIFICATION_INTERVAL_SECONDS", 10, 1 + ), + max_webhook_body_bytes=_positive_int(env, "MAX_WEBHOOK_BODY_BYTES", 1_048_576, 1024), + log_level=log_level, + ) diff --git a/src/makepay_telegram_bot/database.py b/src/makepay_telegram_bot/database.py new file mode 100644 index 0000000..46f5f3f --- /dev/null +++ b/src/makepay_telegram_bot/database.py @@ -0,0 +1,416 @@ +from __future__ import annotations + +import sqlite3 +import threading +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import UTC, datetime +from pathlib import Path +from uuid import uuid4 + +from .models import Notification, Order, Product, StatusUpdate + +_PAID_STATUSES = {"complete", "completed", "confirmed", "paid", "succeeded", "success"} +_PROCESSING_STATUSES = { + "confirming", + "deposit_received", + "paying", + "processing", + "sending", + "swapping", + "verifying", + "waiting_confirmation", +} +_PENDING_STATUSES = {"awaiting_deposit", "created", "new", "pending", "quoted", "waiting"} +_CANCELLED_STATUSES = {"cancel", "canceled", "cancelled"} +_FAILED_STATUSES = {"chargeback", "fail", "failed"} +_TERMINAL_ORDER_STATUSES = {"paid", "cancelled", "expired", "failed"} +_NOTIFIABLE_STATUSES = {"processing", "underpaid", "paid", "cancelled", "expired", "failed"} + + +def utc_now() -> str: + return datetime.now(UTC).isoformat(timespec="milliseconds") + + +def normalize_order_status(makepay_status: str) -> str: + normalized = makepay_status.strip().lower() + if normalized in _PAID_STATUSES: + return "paid" + if normalized in _PROCESSING_STATUSES: + return "processing" + if normalized in _PENDING_STATUSES: + return "pending" + if normalized in _CANCELLED_STATUSES: + return "cancelled" + if normalized in _FAILED_STATUSES: + return "failed" + if normalized == "expired": + return "expired" + if normalized == "underpaid": + return "underpaid" + return "pending" + + +def _next_status(current: str, incoming: str) -> str: + if current == "paid" or current == incoming: + return current + if incoming == "paid": + return "paid" + if current in _TERMINAL_ORDER_STATUSES: + return current + if current == "underpaid" and incoming in {"pending", "processing"}: + return current + return incoming + + +def _order_from_row(row: sqlite3.Row) -> Order: + return Order( + id=str(row["id"]), + request_key=str(row["request_key"]), + telegram_chat_id=int(row["telegram_chat_id"]), + telegram_user_id=int(row["telegram_user_id"]), + product_id=str(row["product_id"]), + product_name=str(row["product_name"]), + amount=str(row["amount"]), + fiat_currency=str(row["fiat_currency"]), + status=str(row["status"]), + makepay_status=str(row["makepay_status"]) if row["makepay_status"] else None, + makepay_payment_uid=( + str(row["makepay_payment_uid"]) if row["makepay_payment_uid"] else None + ), + checkout_url=str(row["checkout_url"]) if row["checkout_url"] else None, + makepay_session_id=(str(row["makepay_session_id"]) if row["makepay_session_id"] else None), + last_error=str(row["last_error"]) if row["last_error"] else None, + created_at=str(row["created_at"]), + updated_at=str(row["updated_at"]), + ) + + +class Database: + """Small, transaction-safe SQLite order and notification store.""" + + def __init__(self, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + self._connection = sqlite3.connect(path, check_same_thread=False, timeout=10) + self._connection.row_factory = sqlite3.Row + self._lock = threading.RLock() + with self._lock: + self._connection.execute("PRAGMA journal_mode = WAL") + self._connection.execute("PRAGMA foreign_keys = ON") + self._connection.execute("PRAGMA busy_timeout = 5000") + self._connection.executescript( + """ + CREATE TABLE IF NOT EXISTS orders ( + id TEXT PRIMARY KEY, + request_key TEXT NOT NULL UNIQUE, + telegram_chat_id INTEGER NOT NULL, + telegram_user_id INTEGER NOT NULL, + product_id TEXT NOT NULL, + product_name TEXT NOT NULL, + amount TEXT NOT NULL, + fiat_currency TEXT NOT NULL, + status TEXT NOT NULL, + makepay_status TEXT, + makepay_payment_uid TEXT UNIQUE, + checkout_url TEXT, + makepay_session_id TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS orders_owner_created_idx + ON orders (telegram_user_id, telegram_chat_id, created_at DESC); + CREATE INDEX IF NOT EXISTS orders_status_updated_idx + ON orders (status, updated_at); + + CREATE TABLE IF NOT EXISTS webhook_deliveries ( + dedupe_key TEXT PRIMARY KEY, + delivery_id TEXT, + event_type TEXT, + received_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id TEXT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + sent_at TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + UNIQUE(order_id, status) + ); + """ + ) + self._connection.commit() + + @contextmanager + def _transaction(self) -> Iterator[sqlite3.Connection]: + with self._lock: + try: + self._connection.execute("BEGIN IMMEDIATE") + yield self._connection + self._connection.commit() + except Exception: + self._connection.rollback() + raise + + def close(self) -> None: + with self._lock: + self._connection.close() + + def ping(self) -> bool: + with self._lock: + return self._connection.execute("SELECT 1").fetchone() is not None + + def create_or_get_order( + self, + *, + request_key: str, + telegram_chat_id: int, + telegram_user_id: int, + product: Product, + ) -> Order: + now = utc_now() + order_id = str(uuid4()) + with self._transaction() as connection: + connection.execute( + """ + INSERT OR IGNORE INTO orders ( + id, request_key, telegram_chat_id, telegram_user_id, + product_id, product_name, amount, fiat_currency, status, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'creating', ?, ?) + """, + ( + order_id, + request_key, + telegram_chat_id, + telegram_user_id, + product.id, + product.name, + product.amount, + product.fiat_currency, + now, + now, + ), + ) + row = connection.execute( + "SELECT * FROM orders WHERE request_key = ?", (request_key,) + ).fetchone() + if row is None: + raise RuntimeError("Unable to create or load order.") + return _order_from_row(row) + + def attach_payment_link(self, order_id: str, uid: str, checkout_url: str) -> Order: + now = utc_now() + with self._transaction() as connection: + updated = connection.execute( + """ + UPDATE orders + SET makepay_payment_uid = COALESCE(makepay_payment_uid, ?), + checkout_url = COALESCE(checkout_url, ?), + status = CASE WHEN status IN ('creating', 'creation_failed') + THEN 'pending' ELSE status END, + makepay_status = COALESCE(makepay_status, 'pending'), + last_error = NULL, + updated_at = ? + WHERE id = ? + AND (makepay_payment_uid IS NULL OR makepay_payment_uid = ?) + """, + (uid, checkout_url, now, order_id, uid), + ) + if updated.rowcount == 0: + existing = connection.execute( + "SELECT makepay_payment_uid FROM orders WHERE id = ?", (order_id,) + ).fetchone() + if existing is None: + raise LookupError("Order not found.") + raise RuntimeError("Order is already linked to a different MakePay payment.") + row = connection.execute("SELECT * FROM orders WHERE id = ?", (order_id,)).fetchone() + if row is None: # pragma: no cover - guarded inside the transaction + raise RuntimeError("Unable to reload order.") + return _order_from_row(row) + + def mark_creation_failed(self, order_id: str, error_code: str) -> None: + with self._transaction() as connection: + connection.execute( + """ + UPDATE orders + SET status = CASE WHEN checkout_url IS NULL THEN 'creation_failed' ELSE status END, + last_error = ?, updated_at = ? + WHERE id = ? + """, + (error_code[:200], utc_now(), order_id), + ) + + def get_order_for_owner( + self, order_id: str, telegram_chat_id: int, telegram_user_id: int + ) -> Order | None: + with self._lock: + row = self._connection.execute( + """ + SELECT * FROM orders + WHERE id = ? AND telegram_chat_id = ? AND telegram_user_id = ? + """, + (order_id, telegram_chat_id, telegram_user_id), + ).fetchone() + return _order_from_row(row) if row else None + + def list_orders( + self, telegram_chat_id: int, telegram_user_id: int, *, limit: int = 5 + ) -> list[Order]: + with self._lock: + rows = self._connection.execute( + """ + SELECT * FROM orders + WHERE telegram_chat_id = ? AND telegram_user_id = ? + ORDER BY created_at DESC + LIMIT ? + """, + (telegram_chat_id, telegram_user_id, limit), + ).fetchall() + return [_order_from_row(row) for row in rows] + + def list_reconcilable_orders(self, *, limit: int = 50) -> list[Order]: + with self._lock: + rows = self._connection.execute( + """ + SELECT * FROM orders + WHERE makepay_payment_uid IS NOT NULL + AND status IN ('pending', 'processing', 'underpaid') + ORDER BY updated_at ASC + LIMIT ? + """, + (limit,), + ).fetchall() + return [_order_from_row(row) for row in rows] + + def apply_payment_status( + self, + *, + dedupe_key: str, + makepay_status: str, + payment_uid: str | None, + merchant_order_id: str | None, + session_id: str | None, + delivery_id: str | None = None, + event_type: str | None = None, + ) -> StatusUpdate: + normalized_makepay_status = makepay_status.strip().lower() + incoming_status = normalize_order_status(normalized_makepay_status) + now = utc_now() + with self._transaction() as connection: + inserted = connection.execute( + """ + INSERT OR IGNORE INTO webhook_deliveries + (dedupe_key, delivery_id, event_type, received_at) + VALUES (?, ?, ?, ?) + """, + (dedupe_key, delivery_id, event_type, now), + ).rowcount + if inserted == 0: + return StatusUpdate(order=None, changed=False, duplicate=True) + + row: sqlite3.Row | None = None + if payment_uid: + row = connection.execute( + "SELECT * FROM orders WHERE makepay_payment_uid = ?", (payment_uid,) + ).fetchone() + if row is None and merchant_order_id: + row = connection.execute( + "SELECT * FROM orders WHERE id = ?", (merchant_order_id,) + ).fetchone() + if row is None: + return StatusUpdate(order=None, changed=False, duplicate=False) + + current = _order_from_row(row) + next_status = _next_status(current.status, incoming_status) + changed = next_status != current.status + connection.execute( + """ + UPDATE orders + SET status = ?, + makepay_status = ?, + makepay_payment_uid = COALESCE(makepay_payment_uid, ?), + makepay_session_id = COALESCE(?, makepay_session_id), + updated_at = ? + WHERE id = ? + """, + ( + next_status, + normalized_makepay_status, + payment_uid, + session_id, + now, + current.id, + ), + ) + if changed and next_status in _NOTIFIABLE_STATUSES: + connection.execute( + """ + INSERT OR IGNORE INTO notifications + (order_id, status, created_at) + VALUES (?, ?, ?) + """, + (current.id, next_status, now), + ) + updated_row = connection.execute( + "SELECT * FROM orders WHERE id = ?", (current.id,) + ).fetchone() + return StatusUpdate( + order=_order_from_row(updated_row) if updated_row else None, + changed=changed, + duplicate=False, + ) + + def pending_notifications(self, *, limit: int = 20) -> list[Notification]: + with self._lock: + rows = self._connection.execute( + """ + SELECT n.id, n.order_id, n.status, o.telegram_chat_id, + o.product_name, o.amount, o.fiat_currency, o.checkout_url + FROM notifications AS n + JOIN orders AS o ON o.id = n.order_id + WHERE n.sent_at IS NULL + ORDER BY n.created_at ASC + LIMIT ? + """, + (limit,), + ).fetchall() + return [ + Notification( + id=int(row["id"]), + order_id=str(row["order_id"]), + telegram_chat_id=int(row["telegram_chat_id"]), + product_name=str(row["product_name"]), + amount=str(row["amount"]), + fiat_currency=str(row["fiat_currency"]), + status=str(row["status"]), + checkout_url=str(row["checkout_url"]) if row["checkout_url"] else None, + ) + for row in rows + ] + + def mark_notification_sent(self, notification_id: int) -> None: + with self._transaction() as connection: + connection.execute( + """ + UPDATE notifications + SET sent_at = ?, attempts = attempts + 1, last_error = NULL + WHERE id = ? + """, + (utc_now(), notification_id), + ) + + def mark_notification_failed(self, notification_id: int, error_code: str) -> None: + with self._transaction() as connection: + connection.execute( + """ + UPDATE notifications + SET attempts = attempts + 1, last_error = ? + WHERE id = ? + """, + (error_code[:200], notification_id), + ) diff --git a/src/makepay_telegram_bot/makepay_client.py b/src/makepay_telegram_bot/makepay_client.py new file mode 100644 index 0000000..d6e2373 --- /dev/null +++ b/src/makepay_telegram_bot/makepay_client.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from typing import Any +from urllib.parse import quote + +import httpx + +from .models import Order, PaymentLink, PaymentStatus, Product + + +class MakePayAPIError(RuntimeError): + def __init__(self, message: str, *, status_code: int = 0) -> None: + super().__init__(message) + self.status_code = status_code + + +def _record(value: object) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _text(value: object) -> str | None: + return value.strip() if isinstance(value, str) and value.strip() else None + + +class MakePayClient: + """Minimal async MakePay API client for this integration.""" + + def __init__( + self, + *, + key_id: str, + key_secret: str, + api_base_url: str, + checkout_base_url: str, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self._checkout_base_url = checkout_base_url.rstrip("/") + self._api_client = httpx.AsyncClient( + base_url=api_base_url.rstrip("/"), + follow_redirects=False, + timeout=httpx.Timeout(20), + transport=transport, + headers={ + "Accept": "application/json", + "User-Agent": "MakePayTelegramBot/1.0.0", + "X-MakeCrypto-Key-Id": key_id, + "X-MakeCrypto-Key-Secret": key_secret, + }, + ) + self._public_client = httpx.AsyncClient( + follow_redirects=False, + timeout=httpx.Timeout(20), + transport=transport, + headers={ + "Accept": "application/json", + "User-Agent": "MakePayTelegramBot/1.0.0", + }, + ) + + async def close(self) -> None: + await self._api_client.aclose() + await self._public_client.aclose() + + async def create_payment_link(self, order: Order, product: Product) -> PaymentLink: + response = await self._api_client.post( + "/api/partner/v1/makepay/payment-links", + headers={"Idempotency-Key": f"telegram-{order.id}"}, + json={ + "status": "active", + "sendPaymentRequestEmail": False, + "payload": { + "title": product.name, + "description": product.description, + "amount": product.amount, + "fiatCurrency": product.fiat_currency, + "orderId": order.id, + "expirationTime": "1h", + "metadata": { + "integration": "telegram-bot", + "telegramOrderId": order.id, + }, + }, + }, + ) + payload = self._decode(response, "create payment link") + payment_link = _record(payload.get("paymentLink")) + uid = _text(payment_link.get("uid")) + if not uid: + raise MakePayAPIError("MakePay response did not include a payment-link UID.") + checkout_url = _text(payment_link.get("publicUrl")) or _text( + payment_link.get("checkoutUrl") + ) + if not checkout_url: + checkout_url = f"{self._checkout_base_url}/payment/{quote(uid, safe='')}" + if not checkout_url.startswith("https://") and not checkout_url.startswith( + "http://localhost" + ): + raise MakePayAPIError("MakePay response included an unsafe checkout URL.") + return PaymentLink(uid=uid, checkout_url=checkout_url) + + async def get_payment_status(self, payment_uid: str) -> PaymentStatus: + response = await self._public_client.get( + f"{self._checkout_base_url}/api/public/payment-links/" + f"{quote(payment_uid, safe='')}/current-session" + ) + payload = self._decode(response, "read payment status") + session = _record(payload.get("session")) + status = _text(session.get("status")) + if not status: + return PaymentStatus(status="pending", session_id=None) + return PaymentStatus(status=status, session_id=_text(session.get("id"))) + + @staticmethod + def _decode(response: httpx.Response, action: str) -> dict[str, Any]: + try: + decoded: object = response.json() + except ValueError as error: + raise MakePayAPIError( + f"MakePay returned invalid JSON while trying to {action}.", + status_code=response.status_code, + ) from error + body = _record(decoded) + if not response.is_success: + message = _text(body.get("error")) or f"MakePay could not {action}." + raise MakePayAPIError(message, status_code=response.status_code) + return body diff --git a/src/makepay_telegram_bot/models.py b/src/makepay_telegram_bot/models.py new file mode 100644 index 0000000..04c78ed --- /dev/null +++ b/src/makepay_telegram_bot/models.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class Product: + id: str + name: str + description: str + amount: str + fiat_currency: str + + +@dataclass(frozen=True, slots=True) +class Order: + id: str + request_key: str + telegram_chat_id: int + telegram_user_id: int + product_id: str + product_name: str + amount: str + fiat_currency: str + status: str + makepay_status: str | None + makepay_payment_uid: str | None + checkout_url: str | None + makepay_session_id: str | None + last_error: str | None + created_at: str + updated_at: str + + +@dataclass(frozen=True, slots=True) +class PaymentLink: + uid: str + checkout_url: str + + +@dataclass(frozen=True, slots=True) +class PaymentStatus: + status: str + session_id: str | None + + +@dataclass(frozen=True, slots=True) +class StatusUpdate: + order: Order | None + changed: bool + duplicate: bool + + +@dataclass(frozen=True, slots=True) +class Notification: + id: int + order_id: str + telegram_chat_id: int + product_name: str + amount: str + fiat_currency: str + status: str + checkout_url: str | None diff --git a/src/makepay_telegram_bot/py.typed b/src/makepay_telegram_bot/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/makepay_telegram_bot/py.typed @@ -0,0 +1 @@ + diff --git a/src/makepay_telegram_bot/telegram_types.py b/src/makepay_telegram_bot/telegram_types.py new file mode 100644 index 0000000..45e7ae1 --- /dev/null +++ b/src/makepay_telegram_bot/telegram_types.py @@ -0,0 +1,16 @@ +from typing import Any + +from telegram.ext import Application, CallbackContext, ExtBot, JobQueue + +type UserData = dict[Any, Any] +type ChatData = dict[Any, Any] +type BotData = dict[Any, Any] +type TelegramContext = CallbackContext[ExtBot[None], UserData, ChatData, BotData] +type TelegramApplication = Application[ + ExtBot[None], + TelegramContext, + UserData, + ChatData, + BotData, + JobQueue[TelegramContext], +] diff --git a/src/makepay_telegram_bot/webhooks.py b/src/makepay_telegram_bot/webhooks.py new file mode 100644 index 0000000..4c65c29 --- /dev/null +++ b/src/makepay_telegram_bot/webhooks.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from dataclasses import dataclass +from hashlib import sha256 +from typing import Any + + +def _record(value: object) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _text(value: object, *, maximum: int = 300) -> str | None: + if not isinstance(value, str) or not value.strip(): + return None + return value.strip()[:maximum] + + +@dataclass(frozen=True, slots=True) +class MakePayWebhookEvent: + dedupe_key: str + delivery_id: str | None + event_type: str | None + payment_uid: str | None + merchant_order_id: str | None + session_id: str | None + status: str | None + + +def extract_makepay_event( + payload: dict[str, Any], headers: dict[str, str], raw_body: bytes +) -> MakePayWebhookEvent: + payment_link = _record(payload.get("paymentLink")) + session = _record(payload.get("session")) + event = _record(payload.get("event")) + delivery_id = _text(payload.get("deliveryId")) + delivery_group_id = _text(headers.get("x-makepay-delivery-group-id")) + event_type = ( + _text(headers.get("x-makepay-event")) + or _text(payload.get("type")) + or _text(event.get("type")) + ) + body_hash = sha256(raw_body).hexdigest() + return MakePayWebhookEvent( + dedupe_key=delivery_group_id or delivery_id or f"body:{body_hash}", + delivery_id=delivery_id, + event_type=event_type, + payment_uid=_text(payment_link.get("uid")), + merchant_order_id=_text(payment_link.get("merchantOrderId")), + session_id=_text(session.get("id")), + status=_text(session.get("status")) or _text(payload.get("status")), + ) diff --git a/src/makepay_telegram_bot/workers.py b/src/makepay_telegram_bot/workers.py new file mode 100644 index 0000000..7326ef2 --- /dev/null +++ b/src/makepay_telegram_bot/workers.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import asyncio +import html +import logging + +from telegram import InlineKeyboardButton, InlineKeyboardMarkup +from telegram.constants import ParseMode +from telegram.error import TelegramError + +from .database import Database +from .makepay_client import MakePayAPIError, MakePayClient +from .models import Notification +from .telegram_types import TelegramApplication + +logger = logging.getLogger(__name__) + + +def _notification_text(notification: Notification) -> str: + product = html.escape(notification.product_name) + order = f"{notification.order_id}" + messages = { + "processing": f"Payment received for {product} and is processing.\nOrder: {order}", + "underpaid": (f"Order {order} is underpaid. Open checkout to review the remaining amount."), + "paid": f"✅ Payment complete for {product}.\nOrder: {order}", + "expired": f"Payment window expired for {product}.\nOrder: {order}", + "cancelled": f"Payment was cancelled for {product}.\nOrder: {order}", + "failed": f"Payment failed for {product}.\nOrder: {order}", + } + return messages.get(notification.status, f"Order {order}: {notification.status}.") + + +async def notification_loop( + *, + application: TelegramApplication, + database: Database, + wakeup: asyncio.Event, + interval_seconds: int, +) -> None: + while True: + for notification in database.pending_notifications(): + keyboard = None + if notification.checkout_url and notification.status in {"underpaid", "processing"}: + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("Open MakePay checkout", url=notification.checkout_url)]] + ) + try: + await application.bot.send_message( + notification.telegram_chat_id, + _notification_text(notification), + parse_mode=ParseMode.HTML, + reply_markup=keyboard, + disable_web_page_preview=True, + ) + database.mark_notification_sent(notification.id) + except TelegramError as error: + logger.warning( + "Telegram notification failed for order %s: %s", + notification.order_id, + type(error).__name__, + ) + database.mark_notification_failed( + notification.id, f"telegram_{type(error).__name__}" + ) + wakeup.clear() + try: + await asyncio.wait_for(wakeup.wait(), timeout=interval_seconds) + except TimeoutError: + pass + + +async def reconciliation_loop( + *, + makepay: MakePayClient, + database: Database, + notification_wakeup: asyncio.Event, + interval_seconds: int, +) -> None: + while True: + for order in database.list_reconcilable_orders(): + if not order.makepay_payment_uid: + continue + try: + remote = await makepay.get_payment_status(order.makepay_payment_uid) + result = database.apply_payment_status( + dedupe_key=( + f"reconcile:{order.makepay_payment_uid}:{remote.status}:" + f"{remote.session_id or 'none'}" + ), + makepay_status=remote.status, + payment_uid=order.makepay_payment_uid, + merchant_order_id=order.id, + session_id=remote.session_id, + event_type="scheduled_reconciliation", + ) + if result.changed: + notification_wakeup.set() + except MakePayAPIError as error: + logger.warning( + "MakePay reconciliation failed for order %s with status %s", + order.id, + error.status_code, + ) + await asyncio.sleep(interval_seconds) diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..e5cb948 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,198 @@ +import asyncio +import hashlib +import hmac +import json +import time +from pathlib import Path +from types import SimpleNamespace + +import httpx +from telegram import Bot + +from makepay_telegram_bot.app import Runtime, create_app +from makepay_telegram_bot.config import Settings +from makepay_telegram_bot.database import Database +from makepay_telegram_bot.models import Product + +PRODUCT = Product( + id="coffee", + name="Coffee voucher", + description="A demo product", + amount="5.00", + fiat_currency="USD", +) + + +def settings(tmp_path: Path) -> Settings: + return Settings( + telegram_bot_token="123456:ABCDEF", + telegram_webhook_secret="telegram_secret", + public_base_url="https://bot.example.com", + makepay_key_id="key-id", + makepay_key_secret="key-secret", + makepay_webhook_secret="makepay-secret", + makepay_api_base_url="https://www.makecrypto.io", + makepay_checkout_base_url="https://www.makepay.io", + database_path=tmp_path / "orders.db", + catalog_path=tmp_path / "catalog.json", + admin_telegram_user_ids=frozenset(), + reconcile_interval_seconds=60, + notification_interval_seconds=10, + max_webhook_body_bytes=4096, + log_level="INFO", + ) + + +def signature(body: bytes, secret: str) -> str: + timestamp = int(time.time()) + digest = hmac.new( + secret.encode(), str(timestamp).encode() + b"." + body, hashlib.sha256 + ).hexdigest() + return f"t={timestamp},v1={digest}" + + +def test_http_endpoints_authenticate_and_apply_webhooks(tmp_path: Path) -> None: + async def run() -> None: + app = create_app() + configuration = settings(tmp_path) + database = Database(configuration.database_path) + order = database.create_or_get_order( + request_key="callback:1", + telegram_chat_id=100, + telegram_user_id=200, + product=PRODUCT, + ) + database.attach_payment_link(order.id, "pay_123", "https://www.makepay.io/payment/pay_123") + telegram = SimpleNamespace( + bot=Bot(configuration.telegram_bot_token), + update_queue=asyncio.Queue(), + running=True, + ) + app.state.runtime = Runtime( + settings=configuration, + database=database, + makepay=SimpleNamespace(), # type: ignore[arg-type] + telegram=telegram, # type: ignore[arg-type] + notification_wakeup=asyncio.Event(), + ) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, base_url="https://bot.example.com" + ) as client: + assert (await client.get("/")).status_code == 200 + assert (await client.get("/healthz")).json() == {"status": "ok"} + assert (await client.get("/readyz")).status_code == 200 + + invalid_telegram = await client.post( + "/webhooks/telegram", + headers={"x-telegram-bot-api-secret-token": "wrong"}, + json={"update_id": 1}, + ) + assert invalid_telegram.status_code == 401 + + valid_telegram = await client.post( + "/webhooks/telegram", + headers={"x-telegram-bot-api-secret-token": configuration.telegram_webhook_secret}, + json={"update_id": 1}, + ) + assert valid_telegram.status_code == 200 + assert telegram.update_queue.qsize() == 1 + + payload = { + "deliveryId": "delivery_1", + "paymentLink": { + "uid": "pay_123", + "merchantOrderId": order.id, + }, + "session": {"id": "session_1", "status": "complete"}, + } + body = json.dumps(payload, separators=(",", ":")).encode() + invalid_makepay = await client.post( + "/webhooks/makepay", + content=body, + headers={"x-makepay-signature": "t=1,v1=bad"}, + ) + assert invalid_makepay.status_code == 401 + + valid_makepay = await client.post( + "/webhooks/makepay", + content=body, + headers={ + "content-type": "application/json", + "x-makepay-signature": signature(body, configuration.makepay_webhook_secret), + "x-makepay-delivery-group-id": "group_1", + }, + ) + assert valid_makepay.json() == { + "ok": True, + "matched": True, + "duplicate": False, + } + updated = database.get_order_for_owner(order.id, 100, 200) + assert updated is not None and updated.status == "paid" + + duplicate = await client.post( + "/webhooks/makepay", + content=body, + headers={ + "content-type": "application/json", + "x-makepay-signature": signature(body, configuration.makepay_webhook_secret), + "x-makepay-delivery-group-id": "group_1", + }, + ) + assert duplicate.json()["duplicate"] is True + database.close() + + asyncio.run(run()) + + +def test_webhook_size_and_json_validation(tmp_path: Path) -> None: + async def run() -> None: + app = create_app() + configuration = settings(tmp_path) + database = Database(configuration.database_path) + telegram = SimpleNamespace( + bot=Bot(configuration.telegram_bot_token), + update_queue=asyncio.Queue(), + running=True, + ) + app.state.runtime = Runtime( + settings=configuration, + database=database, + makepay=SimpleNamespace(), # type: ignore[arg-type] + telegram=telegram, # type: ignore[arg-type] + notification_wakeup=asyncio.Event(), + ) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="https://bot.example.com", + ) as client: + headers = {"x-telegram-bot-api-secret-token": configuration.telegram_webhook_secret} + invalid = await client.post( + "/webhooks/telegram", + headers={**headers, "content-type": "application/json"}, + content=b"{", + ) + assert invalid.status_code == 400 + oversized = await client.post( + "/webhooks/telegram", + headers={**headers, "content-length": "5000"}, + content=b"{}", + ) + assert oversized.status_code == 413 + database.close() + + asyncio.run(run()) + + +def test_readiness_is_unavailable_before_runtime_initializes() -> None: + async def run() -> None: + app = create_app() + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="https://bot.example.com", + ) as client: + response = await client.get("/readyz") + assert response.status_code == 503 + + asyncio.run(run()) diff --git a/tests/test_bot.py b/tests/test_bot.py new file mode 100644 index 0000000..d17eedc --- /dev/null +++ b/tests/test_bot.py @@ -0,0 +1,159 @@ +import asyncio +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from makepay_telegram_bot.bot import BotHandlers +from makepay_telegram_bot.database import Database +from makepay_telegram_bot.models import PaymentLink, PaymentStatus, Product + +PRODUCT = Product( + id="coffee", + name="Coffee voucher", + description="A demo product", + amount="5.00", + fiat_currency="USD", +) + + +class FakeMakePay: + async def create_payment_link(self, order: object, product: object) -> PaymentLink: + return PaymentLink(uid="pay_123", checkout_url="https://www.makepay.io/payment/pay_123") + + async def get_payment_status(self, payment_uid: str) -> PaymentStatus: + return PaymentStatus(status="complete", session_id="session_123") + + +def private_update( + *, + data: str | None = None, + query_id: str = "query_1", + chat_type: str = "private", +) -> SimpleNamespace: + message = SimpleNamespace(reply_text=AsyncMock()) + query = ( + SimpleNamespace( + id=query_id, + data=data, + answer=AsyncMock(), + ) + if data is not None + else None + ) + return SimpleNamespace( + callback_query=query, + effective_user=SimpleNamespace(id=200), + effective_chat=SimpleNamespace(id=100, type=chat_type), + effective_message=message, + ) + + +def handlers(tmp_path: Path) -> tuple[BotHandlers, Database]: + database = Database(tmp_path / "orders.db") + instance = BotHandlers( + catalog={PRODUCT.id: PRODUCT}, + database=database, + makepay=FakeMakePay(), # type: ignore[arg-type] + ) + return instance, database + + +def test_start_help_and_shop(tmp_path: Path) -> None: + async def run() -> None: + instance, database = handlers(tmp_path) + start = private_update() + await instance.start(start, None) # type: ignore[arg-type] + await instance.help(start, None) # type: ignore[arg-type] + await instance.shop(start, None) # type: ignore[arg-type] + assert start.effective_message.reply_text.await_count == 3 + shop_call = start.effective_message.reply_text.await_args_list[-1] + assert shop_call.kwargs["reply_markup"].inline_keyboard[0][0].callback_data == "buy:coffee" + database.close() + + asyncio.run(run()) + + +def test_group_purchase_is_refused(tmp_path: Path) -> None: + async def run() -> None: + instance, database = handlers(tmp_path) + update = private_update(data="buy:coffee", chat_type="group") + await instance.buy(update, None) # type: ignore[arg-type] + update.callback_query.answer.assert_awaited_once() + assert database.list_orders(100, 200) == [] + database.close() + + asyncio.run(run()) + + +def test_buy_is_idempotent_and_sends_checkout(tmp_path: Path) -> None: + async def run() -> None: + instance, database = handlers(tmp_path) + first = private_update(data="buy:coffee") + await instance.buy(first, None) # type: ignore[arg-type] + second = private_update(data="buy:coffee") + await instance.buy(second, None) # type: ignore[arg-type] + + orders = database.list_orders(100, 200) + assert len(orders) == 1 + assert orders[0].makepay_payment_uid == "pay_123" + assert ( + first.effective_message.reply_text.await_args.kwargs["reply_markup"] + .inline_keyboard[0][0] + .url + == "https://www.makepay.io/payment/pay_123" + ) + assert second.effective_message.reply_text.await_count == 1 + database.close() + + asyncio.run(run()) + + +def test_unknown_product_and_empty_orders(tmp_path: Path) -> None: + async def run() -> None: + instance, database = handlers(tmp_path) + update = private_update(data="buy:missing") + await instance.buy(update, None) # type: ignore[arg-type] + assert "no longer available" in update.effective_message.reply_text.await_args.args[0] + + empty = private_update() + await instance.orders(empty, None) # type: ignore[arg-type] + assert "do not have any orders" in empty.effective_message.reply_text.await_args.args[0] + database.close() + + asyncio.run(run()) + + +def test_status_refresh_marks_order_paid(tmp_path: Path) -> None: + async def run() -> None: + instance, database = handlers(tmp_path) + buy = private_update(data="buy:coffee") + await instance.buy(buy, None) # type: ignore[arg-type] + order = database.list_orders(100, 200)[0] + + status = private_update(data=f"status:{order.id}", query_id="query_2") + await instance.status(status, None) # type: ignore[arg-type] + + refreshed = database.get_order_for_owner(order.id, 100, 200) + assert refreshed is not None and refreshed.status == "paid" + assert "is paid" in status.callback_query.answer.await_args.args[0] + database.close() + + asyncio.run(run()) + + +def test_orders_render_and_private_chat_guard(tmp_path: Path) -> None: + async def run() -> None: + instance, database = handlers(tmp_path) + buy = private_update(data="buy:coffee") + await instance.buy(buy, None) # type: ignore[arg-type] + + listing = private_update() + await instance.orders(listing, None) # type: ignore[arg-type] + assert "Coffee voucher" in listing.effective_message.reply_text.await_args.args[0] + + group = private_update(chat_type="group") + await instance.shop(group, None) # type: ignore[arg-type] + assert "private chat" in group.effective_message.reply_text.await_args.args[0] + database.close() + + asyncio.run(run()) diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 0000000..319552b --- /dev/null +++ b/tests/test_catalog.py @@ -0,0 +1,59 @@ +import json +from pathlib import Path + +import pytest + +from makepay_telegram_bot.catalog import CatalogError, load_catalog + + +def write_catalog(path: Path, value: object) -> None: + path.write_text(json.dumps(value), encoding="utf-8") + + +def test_loads_and_normalizes_catalog(tmp_path: Path) -> None: + path = tmp_path / "catalog.json" + write_catalog( + path, + [ + { + "id": "coffee", + "name": "Coffee voucher", + "description": "A demo product", + "amount": "5", + "fiatCurrency": "usd", + } + ], + ) + + product = load_catalog(path)["coffee"] + + assert product.amount == "5.00" + assert product.fiat_currency == "USD" + + +@pytest.mark.parametrize( + "value", + [ + [], + [{"id": "a", "name": "", "description": "Desc", "amount": "1"}], + [{"id": "a", "name": "Name", "description": "Desc", "amount": 1}], + [{"id": "UPPER CASE", "name": "Name", "description": "Desc", "amount": "5"}], + [{"id": "a", "name": "Name", "description": "Desc", "amount": "-1"}], + [{"id": "a", "name": "Name", "description": "Desc", "amount": "1.001"}], + [ + {"id": "a", "name": "Name", "description": "Desc", "amount": "1"}, + {"id": "a", "name": "Again", "description": "Desc", "amount": "2"}, + ], + ], +) +def test_rejects_invalid_catalog(tmp_path: Path, value: object) -> None: + path = tmp_path / "catalog.json" + write_catalog(path, value) + + with pytest.raises(CatalogError): + load_catalog(path) + + +def test_rejects_missing_file(tmp_path: Path) -> None: + with pytest.raises(CatalogError, match="Unable to read"): + load_catalog(tmp_path / "missing.json") diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..8ee1ebb --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,74 @@ +from pathlib import Path + +import pytest + +from makepay_telegram_bot.config import ConfigurationError, Settings + + +def valid_environment() -> dict[str, str]: + return { + "TELEGRAM_BOT_TOKEN": "123456:telegram-token", + "TELEGRAM_WEBHOOK_SECRET": "telegram_secret-123", + "PUBLIC_BASE_URL": "https://bot.example.com", + "MAKEPAY_KEY_ID": "key-id", + "MAKEPAY_KEY_SECRET": "key-secret", + "MAKEPAY_WEBHOOK_SECRET": "mkwhsec_example", + "DATABASE_PATH": "./state/orders.db", + "CATALOG_PATH": "./catalog.json", + } + + +def test_loads_settings_and_derived_webhook_urls() -> None: + settings = Settings.from_env(valid_environment()) + + assert settings.telegram_webhook_url == "https://bot.example.com/webhooks/telegram" + assert settings.makepay_webhook_url == "https://bot.example.com/webhooks/makepay" + assert settings.database_path == Path("./state/orders.db") + assert settings.makepay_api_base_url == "https://www.makecrypto.io" + assert settings.reconcile_interval_seconds == 60 + + +@pytest.mark.parametrize( + ("name", "value", "message"), + [ + ("PUBLIC_BASE_URL", "http://bot.example.com", "HTTPS origin"), + ("PUBLIC_BASE_URL", "https://user:pass@bot.example.com", "HTTPS origin"), + ("PUBLIC_BASE_URL", "https://bot.example.com/hidden-path", "HTTPS origin"), + ("PUBLIC_BASE_URL", "https://bot.example.com?token=bad", "HTTPS origin"), + ("TELEGRAM_WEBHOOK_SECRET", "spaces are unsafe", "may contain only"), + ("RECONCILE_INTERVAL_SECONDS", "zero", "integer"), + ("RECONCILE_INTERVAL_SECONDS", "2", "at least 10"), + ("LOG_LEVEL", "LOUD", "invalid"), + ], +) +def test_rejects_unsafe_or_invalid_settings(name: str, value: str, message: str) -> None: + environment = valid_environment() + environment[name] = value + with pytest.raises(ConfigurationError, match=message): + Settings.from_env(environment) + + +def test_allows_explicit_local_http_for_development() -> None: + environment = valid_environment() + environment["PUBLIC_BASE_URL"] = "http://localhost:8000" + environment["MAKEPAY_API_BASE_URL"] = "http://127.0.0.1:8001" + environment["ALLOW_INSECURE_LOCALHOST"] = "true" + + settings = Settings.from_env(environment) + + assert settings.public_base_url == "http://localhost:8000" + + +def test_parses_admin_ids() -> None: + environment = valid_environment() + environment["ADMIN_TELEGRAM_USER_IDS"] = "123, 456" + + assert Settings.from_env(environment).admin_telegram_user_ids == {123, 456} + + +def test_rejects_non_positive_admin_ids() -> None: + environment = valid_environment() + environment["ADMIN_TELEGRAM_USER_IDS"] = "-1" + + with pytest.raises(ConfigurationError, match="positive"): + Settings.from_env(environment) diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..b164ca8 --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,196 @@ +from pathlib import Path + +import pytest + +from makepay_telegram_bot.database import Database, normalize_order_status +from makepay_telegram_bot.models import Product + +PRODUCT = Product( + id="coffee", + name="Coffee voucher", + description="A demo product", + amount="5.00", + fiat_currency="USD", +) + + +def make_order(database: Database) -> str: + order = database.create_or_get_order( + request_key="callback:1", + telegram_chat_id=100, + telegram_user_id=200, + product=PRODUCT, + ) + database.attach_payment_link(order.id, "pay_123", "https://www.makepay.io/payment/pay_123") + return order.id + + +def test_order_creation_is_idempotent_and_owner_scoped(tmp_path: Path) -> None: + database = Database(tmp_path / "orders.db") + first = database.create_or_get_order( + request_key="callback:1", + telegram_chat_id=100, + telegram_user_id=200, + product=PRODUCT, + ) + second = database.create_or_get_order( + request_key="callback:1", + telegram_chat_id=100, + telegram_user_id=200, + product=PRODUCT, + ) + + assert first.id == second.id + assert database.get_order_for_owner(first.id, 100, 200) == first + assert database.get_order_for_owner(first.id, 100, 999) is None + database.close() + + +def test_status_updates_are_deduplicated_and_create_outbox_notifications( + tmp_path: Path, +) -> None: + database = Database(tmp_path / "orders.db") + order_id = make_order(database) + + processing = database.apply_payment_status( + dedupe_key="group:processing", + delivery_id="delivery-1", + event_type="makepay.payment.status_changed", + makepay_status="deposit_received", + payment_uid="pay_123", + merchant_order_id=order_id, + session_id="session_123", + ) + duplicate = database.apply_payment_status( + dedupe_key="group:processing", + delivery_id="delivery-2", + event_type="makepay.payment.status_changed", + makepay_status="deposit_received", + payment_uid="pay_123", + merchant_order_id=order_id, + session_id="session_123", + ) + + assert processing.changed is True + assert processing.order is not None and processing.order.status == "processing" + assert duplicate.duplicate is True + notification = database.pending_notifications()[0] + assert notification.status == "processing" + database.mark_notification_failed(notification.id, "telegram_timeout") + database.mark_notification_sent(notification.id) + assert database.pending_notifications() == [] + database.close() + + +def test_paid_is_final_but_late_payment_can_recover_failed_order(tmp_path: Path) -> None: + database = Database(tmp_path / "orders.db") + order_id = make_order(database) + database.apply_payment_status( + dedupe_key="failed", + makepay_status="failed", + payment_uid="pay_123", + merchant_order_id=order_id, + session_id="session_123", + ) + recovered = database.apply_payment_status( + dedupe_key="paid", + makepay_status="complete", + payment_uid="pay_123", + merchant_order_id=order_id, + session_id="session_123", + ) + stale = database.apply_payment_status( + dedupe_key="stale", + makepay_status="expired", + payment_uid="pay_123", + merchant_order_id=order_id, + session_id="session_123", + ) + + assert recovered.order is not None and recovered.order.status == "paid" + assert stale.order is not None and stale.order.status == "paid" + database.close() + + +def test_webhook_can_correlate_by_merchant_order_id_before_uid_is_attached( + tmp_path: Path, +) -> None: + database = Database(tmp_path / "orders.db") + order = database.create_or_get_order( + request_key="callback:1", + telegram_chat_id=100, + telegram_user_id=200, + product=PRODUCT, + ) + + result = database.apply_payment_status( + dedupe_key="early", + makepay_status="complete", + payment_uid="pay_early", + merchant_order_id=order.id, + session_id="session_early", + ) + + assert result.order is not None + assert result.order.makepay_payment_uid == "pay_early" + assert result.order.status == "paid" + database.close() + + +def test_unknown_webhook_is_acknowledged_without_order(tmp_path: Path) -> None: + database = Database(tmp_path / "orders.db") + result = database.apply_payment_status( + dedupe_key="unknown", + makepay_status="pending", + payment_uid="missing", + merchant_order_id=None, + session_id=None, + ) + + assert result.order is None + assert result.duplicate is False + database.close() + + +def test_failed_creation_can_be_retried_and_pending_orders_are_reconcilable( + tmp_path: Path, +) -> None: + database = Database(tmp_path / "orders.db") + order = database.create_or_get_order( + request_key="callback:1", + telegram_chat_id=100, + telegram_user_id=200, + product=PRODUCT, + ) + database.mark_creation_failed(order.id, "makepay_http_503") + failed = database.get_order_for_owner(order.id, 100, 200) + assert failed is not None and failed.status == "creation_failed" + assert failed.last_error == "makepay_http_503" + + database.attach_payment_link(order.id, "pay_retry", "https://www.makepay.io/payment/pay_retry") + assert database.list_reconcilable_orders()[0].id == order.id + assert database.list_orders(100, 200, limit=1)[0].id == order.id + database.close() + + +def test_payment_link_uid_cannot_change_after_early_webhook(tmp_path: Path) -> None: + database = Database(tmp_path / "orders.db") + order_id = make_order(database) + + with pytest.raises(RuntimeError, match="different MakePay payment"): + database.attach_payment_link( + order_id, "pay_different", "https://www.makepay.io/payment/pay_different" + ) + with pytest.raises(LookupError, match="Order not found"): + database.attach_payment_link( + "missing", "pay_missing", "https://www.makepay.io/payment/pay_missing" + ) + database.close() + + +def test_status_normalization() -> None: + assert normalize_order_status("COMPLETE") == "paid" + assert normalize_order_status("swapping") == "processing" + assert normalize_order_status("underpaid") == "underpaid" + assert normalize_order_status("cancelled") == "cancelled" + assert normalize_order_status("unknown_future_status") == "pending" diff --git a/tests/test_makepay_client.py b/tests/test_makepay_client.py new file mode 100644 index 0000000..b49470c --- /dev/null +++ b/tests/test_makepay_client.py @@ -0,0 +1,193 @@ +import asyncio +import json + +import httpx +import pytest + +from makepay_telegram_bot.makepay_client import MakePayAPIError, MakePayClient +from makepay_telegram_bot.models import Order, Product + +ORDER = Order( + id="05d0ab75-fc70-4058-b876-d9bb386734c7", + request_key="callback:1", + telegram_chat_id=100, + telegram_user_id=200, + product_id="coffee", + product_name="Coffee voucher", + amount="5.00", + fiat_currency="USD", + status="creating", + makepay_status=None, + makepay_payment_uid=None, + checkout_url=None, + makepay_session_id=None, + last_error=None, + created_at="2026-07-25T00:00:00Z", + updated_at="2026-07-25T00:00:00Z", +) +PRODUCT = Product( + id="coffee", + name="Coffee voucher", + description="A demo product", + amount="5.00", + fiat_currency="USD", +) + + +def test_creates_payment_link_with_auth_and_idempotency() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 201, + json={ + "paymentLink": { + "uid": "pay_123", + "publicUrl": "https://www.makepay.io/payment/pay_123", + } + }, + ) + + async def run() -> None: + client = MakePayClient( + key_id="key-id", + key_secret="key-secret", + api_base_url="https://www.makecrypto.io", + checkout_base_url="https://www.makepay.io", + transport=httpx.MockTransport(handler), + ) + link = await client.create_payment_link(ORDER, PRODUCT) + await client.close() + assert link.uid == "pay_123" + + asyncio.run(run()) + request = requests[0] + body = json.loads(request.content) + assert request.headers["idempotency-key"] == f"telegram-{ORDER.id}" + assert request.headers["x-makecrypto-key-id"] == "key-id" + assert body["payload"]["orderId"] == ORDER.id + assert body["payload"]["amount"] == "5.00" + assert "telegram_chat_id" not in request.content.decode() + + +def test_public_status_request_does_not_leak_api_credentials() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json={"session": {"id": "session_1", "status": "complete"}}) + + async def run() -> None: + client = MakePayClient( + key_id="secret-key-id", + key_secret="secret-key-value", + api_base_url="https://www.makecrypto.io", + checkout_base_url="https://www.makepay.io", + transport=httpx.MockTransport(handler), + ) + status = await client.get_payment_status("pay_123") + await client.close() + assert status.status == "complete" + + asyncio.run(run()) + assert requests[0].url.host == "www.makepay.io" + assert "x-makecrypto-key-id" not in requests[0].headers + assert "x-makecrypto-key-secret" not in requests[0].headers + + +def test_falls_back_to_canonical_checkout_url() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"paymentLink": {"uid": "pay/unsafe"}}) + + async def run() -> None: + client = MakePayClient( + key_id="id", + key_secret="secret", + api_base_url="https://api.example.com", + checkout_base_url="https://pay.example.com", + transport=httpx.MockTransport(handler), + ) + link = await client.create_payment_link(ORDER, PRODUCT) + await client.close() + assert link.checkout_url == "https://pay.example.com/payment/pay%2Funsafe" + + asyncio.run(run()) + + +def test_raises_sanitized_api_error() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"error": "Invalid API credentials."}) + + async def run() -> None: + client = MakePayClient( + key_id="id", + key_secret="secret", + api_base_url="https://api.example.com", + checkout_base_url="https://pay.example.com", + transport=httpx.MockTransport(handler), + ) + with pytest.raises(MakePayAPIError, match="Invalid API credentials") as caught: + await client.create_payment_link(ORDER, PRODUCT) + assert caught.value.status_code == 401 + await client.close() + + asyncio.run(run()) + + +def test_rejects_missing_uid_unsafe_url_and_invalid_json() -> None: + responses = iter( + [ + httpx.Response(200, json={"paymentLink": {}}), + httpx.Response( + 200, + json={ + "paymentLink": { + "uid": "pay_123", + "publicUrl": "javascript:alert(1)", + } + }, + ), + httpx.Response(200, content=b"not-json"), + ] + ) + + def handler(request: httpx.Request) -> httpx.Response: + return next(responses) + + async def run() -> None: + client = MakePayClient( + key_id="id", + key_secret="secret", + api_base_url="https://api.example.com", + checkout_base_url="https://pay.example.com", + transport=httpx.MockTransport(handler), + ) + with pytest.raises(MakePayAPIError, match="UID"): + await client.create_payment_link(ORDER, PRODUCT) + with pytest.raises(MakePayAPIError, match="unsafe"): + await client.create_payment_link(ORDER, PRODUCT) + with pytest.raises(MakePayAPIError, match="invalid JSON"): + await client.get_payment_status("pay_123") + await client.close() + + asyncio.run(run()) + + +def test_missing_current_session_is_pending() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"session": None}) + + async def run() -> None: + client = MakePayClient( + key_id="id", + key_secret="secret", + api_base_url="https://api.example.com", + checkout_base_url="https://pay.example.com", + transport=httpx.MockTransport(handler), + ) + status = await client.get_payment_status("pay_123") + assert status.status == "pending" + await client.close() + + asyncio.run(run()) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..f11832e --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,58 @@ +import hashlib +import hmac +import json +import time + +import pytest +from makepay import MakePayError, parse_makepay_webhook + +from makepay_telegram_bot.webhooks import extract_makepay_event + + +def sign(raw_body: bytes, secret: str, timestamp: int | None = None) -> str: + issued_at = int(time.time()) if timestamp is None else timestamp + signature = hmac.new( + secret.encode(), str(issued_at).encode() + b"." + raw_body, hashlib.sha256 + ).hexdigest() + return f"t={issued_at},v1={signature}" + + +def test_verifies_and_extracts_makepay_webhook() -> None: + payload = { + "deliveryId": "delivery_1", + "type": "makepay.payment.status_changed", + "paymentLink": {"uid": "pay_123", "merchantOrderId": "order_123"}, + "session": {"id": "session_123", "status": "complete"}, + } + raw_body = json.dumps(payload, separators=(",", ":")).encode() + parsed = parse_makepay_webhook(raw_body, sign(raw_body, "whsec"), "whsec") + + event = extract_makepay_event( + parsed, + { + "x-makepay-delivery-group-id": "group_1", + "x-makepay-event": "makepay.payment.status_changed", + }, + raw_body, + ) + + assert event.dedupe_key == "group_1" + assert event.payment_uid == "pay_123" + assert event.merchant_order_id == "order_123" + assert event.status == "complete" + + +def test_rejects_invalid_or_stale_signature() -> None: + body = b'{"ok":true}' + with pytest.raises(MakePayError): + parse_makepay_webhook(body, sign(body, "wrong"), "correct") + with pytest.raises(MakePayError): + parse_makepay_webhook(body, sign(body, "secret", 1), "secret") + + +def test_falls_back_to_stable_body_hash_without_delivery_headers() -> None: + body = b'{"status":"pending"}' + event = extract_makepay_event({"status": "pending"}, {}, body) + + assert event.dedupe_key == f"body:{hashlib.sha256(body).hexdigest()}" + assert event.status == "pending" diff --git a/tests/test_workers.py b/tests/test_workers.py new file mode 100644 index 0000000..cf2af49 --- /dev/null +++ b/tests/test_workers.py @@ -0,0 +1,83 @@ +import asyncio +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from makepay_telegram_bot.database import Database +from makepay_telegram_bot.models import PaymentStatus, Product +from makepay_telegram_bot.workers import notification_loop, reconciliation_loop + +PRODUCT = Product( + id="coffee", + name="Coffee voucher", + description="A demo product", + amount="5.00", + fiat_currency="USD", +) + + +class FakeMakePay: + async def get_payment_status(self, payment_uid: str) -> PaymentStatus: + return PaymentStatus(status="complete", session_id="session_123") + + +def prepared_database(path: Path) -> tuple[Database, str]: + database = Database(path) + order = database.create_or_get_order( + request_key="callback:1", + telegram_chat_id=100, + telegram_user_id=200, + product=PRODUCT, + ) + database.attach_payment_link(order.id, "pay_123", "https://www.makepay.io/payment/pay_123") + return database, order.id + + +def test_reconciliation_and_notification_workers(tmp_path: Path) -> None: + async def run() -> None: + database, order_id = prepared_database(tmp_path / "orders.db") + wakeup = asyncio.Event() + reconcile = asyncio.create_task( + reconciliation_loop( + makepay=FakeMakePay(), # type: ignore[arg-type] + database=database, + notification_wakeup=wakeup, + interval_seconds=3600, + ) + ) + for _ in range(20): + order = database.get_order_for_owner(order_id, 100, 200) + if order is not None and order.status == "paid": + break + await asyncio.sleep(0) + reconcile.cancel() + try: + await reconcile + except asyncio.CancelledError: + pass + assert database.pending_notifications()[0].status == "paid" + + bot = SimpleNamespace(send_message=AsyncMock()) + application = SimpleNamespace(bot=bot) + notify = asyncio.create_task( + notification_loop( + application=application, # type: ignore[arg-type] + database=database, + wakeup=wakeup, + interval_seconds=3600, + ) + ) + for _ in range(20): + if not database.pending_notifications(): + break + await asyncio.sleep(0) + notify.cancel() + try: + await notify + except asyncio.CancelledError: + pass + bot.send_message.assert_awaited_once() + assert "Payment complete" in bot.send_message.await_args.args[1] + database.close() + + asyncio.run(run())