autopaybot 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- autopaybot-0.1.0/PKG-INFO +111 -0
- autopaybot-0.1.0/README.md +83 -0
- autopaybot-0.1.0/pyproject.toml +66 -0
- autopaybot-0.1.0/setup.cfg +4 -0
- autopaybot-0.1.0/src/autopay/__init__.py +0 -0
- autopaybot-0.1.0/src/autopay/alembic/README +1 -0
- autopaybot-0.1.0/src/autopay/alembic/env.py +86 -0
- autopaybot-0.1.0/src/autopay/alembic/script.py.mako +28 -0
- autopaybot-0.1.0/src/autopay/alembic/versions/489e21e7e61a_initial_migration.py +44 -0
- autopaybot-0.1.0/src/autopay/alembic.ini +149 -0
- autopaybot-0.1.0/src/autopay/app.py +53 -0
- autopaybot-0.1.0/src/autopay/cli.py +99 -0
- autopaybot-0.1.0/src/autopaybot.egg-info/PKG-INFO +111 -0
- autopaybot-0.1.0/src/autopaybot.egg-info/SOURCES.txt +19 -0
- autopaybot-0.1.0/src/autopaybot.egg-info/dependency_links.txt +1 -0
- autopaybot-0.1.0/src/autopaybot.egg-info/entry_points.txt +2 -0
- autopaybot-0.1.0/src/autopaybot.egg-info/requires.txt +21 -0
- autopaybot-0.1.0/src/autopaybot.egg-info/top_level.txt +1 -0
- autopaybot-0.1.0/tests/test_api.py +78 -0
- autopaybot-0.1.0/tests/test_parsers.py +59 -0
- autopaybot-0.1.0/tests/test_payment_service.py +87 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: autopaybot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Telegram-based automated payment detection system
|
|
5
|
+
Author-email: AvtoPaymentBot <admin@example.com>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: fastapi
|
|
9
|
+
Requires-Dist: uvicorn
|
|
10
|
+
Requires-Dist: sqlalchemy
|
|
11
|
+
Requires-Dist: pydantic
|
|
12
|
+
Requires-Dist: pydantic-settings
|
|
13
|
+
Requires-Dist: telethon
|
|
14
|
+
Requires-Dist: cryptography
|
|
15
|
+
Requires-Dist: psycopg2-binary
|
|
16
|
+
Requires-Dist: asyncpg
|
|
17
|
+
Requires-Dist: websockets
|
|
18
|
+
Requires-Dist: httpx
|
|
19
|
+
Requires-Dist: slowapi
|
|
20
|
+
Requires-Dist: python-dotenv
|
|
21
|
+
Requires-Dist: alembic
|
|
22
|
+
Requires-Dist: sentry-sdk
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
26
|
+
Requires-Dist: ruff; extra == "dev"
|
|
27
|
+
Requires-Dist: mypy; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# AvtoPaymentBot
|
|
30
|
+
|
|
31
|
+
A Telegram-based automated payment detection system designed specifically for the Uzbekistan market. This application uses Telegram Userbots via the Telethon library to intercept incoming transaction notifications from payment systems (Click, Payme, Uzcard, Humo) on behalf of merchants. It acts as an integration middleware, parsing transaction SMS/Messages and automatically firing Webhooks to the merchant's backend server whenever a matching payment intent is fulfilled.
|
|
32
|
+
|
|
33
|
+
## Table of Contents
|
|
34
|
+
- [Features](#features)
|
|
35
|
+
- [Technology Stack](#technology-stack)
|
|
36
|
+
- [Architecture & Mechanics](#architecture--mechanics)
|
|
37
|
+
- [Setup & Usage](#setup--usage)
|
|
38
|
+
- [Environment Variables](#environment-variables)
|
|
39
|
+
- [Testing](#testing)
|
|
40
|
+
- [License](#license)
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
- **Multi-Merchant Support:** Manages concurrent Telegram Userbots for multiple independent merchants dynamically.
|
|
44
|
+
- **Smart Payment Parser:** Uses regex to interpret and normalize transaction amounts from Click, Payme, Uzcard, and Humo notification bots.
|
|
45
|
+
- **Collision Management:** For multiple identical base amounts, the system temporarily augments subsequent transaction requests by tiny fractions (+0.01 UZS offset/1 tiyin) to maintain uniqueness for idempotent processing.
|
|
46
|
+
- **Webhook Integration:** Supports instant asynchronous HTTP webhooks signed with an HMAC (SHA-256) signature payload to alert your backend.
|
|
47
|
+
- **Background Health Checking:** A background asynchronous daemon runs every 5 minutes to verify if merchant sessions have been revoked. If an active session is unauthorized, it immediately notifies both the merchant and the system admins via the primary Telegram Management bot.
|
|
48
|
+
- **Automated Deployments:** Full support for seamless Docker-compose orchestrations and backend CI/CD routines.
|
|
49
|
+
|
|
50
|
+
## Technology Stack
|
|
51
|
+
- **Python 3.11+**
|
|
52
|
+
- **Framework:** FastAPI (Uvicorn)
|
|
53
|
+
- **Database:** PostgreSQL (with Asyncpg via SQLAlchemy 2.0 ORM) + Alembic for migrations
|
|
54
|
+
- **Telegram APIs:** Telethon (Userbots) and aiogram (Management Bot - *Migrated to Telethon in `bot.py` for simplicity in latest release*)
|
|
55
|
+
- **Containerization:** Docker & Docker Compose
|
|
56
|
+
- **Error Tracking:** Sentry SDK
|
|
57
|
+
- **Testing:** Pytest (with pytest-asyncio)
|
|
58
|
+
|
|
59
|
+
## Architecture & Mechanics
|
|
60
|
+
1. **Management Bot:** Merchants interact with the `@YourManagementBot` on Telegram to generate a `StringSession`.
|
|
61
|
+
2. **REST API:** Web applications/services hit the FastAPI endpoints to create a new `PaymentIntent` for a specific merchant.
|
|
62
|
+
3. **Telethon Worker:** `worker/main.py` boots `ClientManager`, executing the management bot and all active merchant userbots inside a unified event loop.
|
|
63
|
+
4. **Message Interception:** When a userbot receives a message from `KNOWN_BOT_USERNAMES` (e.g. `@clickuz`), it fires a webhook payload logic check against the database.
|
|
64
|
+
5. **Reconciliation:** The `PaymentService` attempts to match the payload amount to an open `PaymentIntent`. If matched, a secure webhook is dispatched.
|
|
65
|
+
|
|
66
|
+
## Setup & Usage
|
|
67
|
+
|
|
68
|
+
### 1. Prerequisites
|
|
69
|
+
- Docker and Docker Compose installed
|
|
70
|
+
- A Telegram API ID and API Hash from `my.telegram.org`
|
|
71
|
+
- A Telegram Bot Token from `@BotFather`
|
|
72
|
+
- A domain/server pointing to port 80/443 (configured via Nginx proxy).
|
|
73
|
+
|
|
74
|
+
### 2. Configuration
|
|
75
|
+
Create a `.env` file in the `backend/` directory referencing `.env.example`:
|
|
76
|
+
```bash
|
|
77
|
+
cp backend/.env.example backend/.env
|
|
78
|
+
```
|
|
79
|
+
Fill out `TELEGRAM_API_ID`, `TELEGRAM_API_HASH`, `BOT_TOKEN`, `ADMIN_TELEGRAM_IDS` and your preferred `POSTGRES_*` credentials.
|
|
80
|
+
|
|
81
|
+
### 3. Run with Docker Compose
|
|
82
|
+
```bash
|
|
83
|
+
cd backend
|
|
84
|
+
docker-compose up --build -d
|
|
85
|
+
```
|
|
86
|
+
The application, database, and background worker will launch simultaneously. Note: Run Alembic migrations natively if the entrypoint does not auto-stamp it.
|
|
87
|
+
```bash
|
|
88
|
+
docker-compose exec worker alembic upgrade head
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 4. Merchant Flow
|
|
92
|
+
1. Open your management bot on Telegram.
|
|
93
|
+
2. Send `/login` and provide your phone number and OTP code.
|
|
94
|
+
3. Use `/create` (or the REST API) to generate a Payment Intent.
|
|
95
|
+
4. Wait for the user to transfer the funds to your registered card.
|
|
96
|
+
5. Watch the Webhook fire!
|
|
97
|
+
|
|
98
|
+
## Testing
|
|
99
|
+
To run the automated tests locally:
|
|
100
|
+
```bash
|
|
101
|
+
cd backend
|
|
102
|
+
python -m venv venv
|
|
103
|
+
source venv/bin/activate
|
|
104
|
+
pip install -r requirements.txt
|
|
105
|
+
PYTHONPATH=. pytest tests/
|
|
106
|
+
```
|
|
107
|
+
All 21+ test cases covering parsing logic, idempotency caps, collision behaviors, and API security should pass successfully.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
MIT License. See the LICENSE file for details.
|
|
111
|
+
*Note: Using userbots to scrape messages technically falls into a grey area under Telegram's Terms of Service. Be mindful of usage rate-limits and restrict the listener solely to the official banking bots.*
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# AvtoPaymentBot
|
|
2
|
+
|
|
3
|
+
A Telegram-based automated payment detection system designed specifically for the Uzbekistan market. This application uses Telegram Userbots via the Telethon library to intercept incoming transaction notifications from payment systems (Click, Payme, Uzcard, Humo) on behalf of merchants. It acts as an integration middleware, parsing transaction SMS/Messages and automatically firing Webhooks to the merchant's backend server whenever a matching payment intent is fulfilled.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
- [Features](#features)
|
|
7
|
+
- [Technology Stack](#technology-stack)
|
|
8
|
+
- [Architecture & Mechanics](#architecture--mechanics)
|
|
9
|
+
- [Setup & Usage](#setup--usage)
|
|
10
|
+
- [Environment Variables](#environment-variables)
|
|
11
|
+
- [Testing](#testing)
|
|
12
|
+
- [License](#license)
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
- **Multi-Merchant Support:** Manages concurrent Telegram Userbots for multiple independent merchants dynamically.
|
|
16
|
+
- **Smart Payment Parser:** Uses regex to interpret and normalize transaction amounts from Click, Payme, Uzcard, and Humo notification bots.
|
|
17
|
+
- **Collision Management:** For multiple identical base amounts, the system temporarily augments subsequent transaction requests by tiny fractions (+0.01 UZS offset/1 tiyin) to maintain uniqueness for idempotent processing.
|
|
18
|
+
- **Webhook Integration:** Supports instant asynchronous HTTP webhooks signed with an HMAC (SHA-256) signature payload to alert your backend.
|
|
19
|
+
- **Background Health Checking:** A background asynchronous daemon runs every 5 minutes to verify if merchant sessions have been revoked. If an active session is unauthorized, it immediately notifies both the merchant and the system admins via the primary Telegram Management bot.
|
|
20
|
+
- **Automated Deployments:** Full support for seamless Docker-compose orchestrations and backend CI/CD routines.
|
|
21
|
+
|
|
22
|
+
## Technology Stack
|
|
23
|
+
- **Python 3.11+**
|
|
24
|
+
- **Framework:** FastAPI (Uvicorn)
|
|
25
|
+
- **Database:** PostgreSQL (with Asyncpg via SQLAlchemy 2.0 ORM) + Alembic for migrations
|
|
26
|
+
- **Telegram APIs:** Telethon (Userbots) and aiogram (Management Bot - *Migrated to Telethon in `bot.py` for simplicity in latest release*)
|
|
27
|
+
- **Containerization:** Docker & Docker Compose
|
|
28
|
+
- **Error Tracking:** Sentry SDK
|
|
29
|
+
- **Testing:** Pytest (with pytest-asyncio)
|
|
30
|
+
|
|
31
|
+
## Architecture & Mechanics
|
|
32
|
+
1. **Management Bot:** Merchants interact with the `@YourManagementBot` on Telegram to generate a `StringSession`.
|
|
33
|
+
2. **REST API:** Web applications/services hit the FastAPI endpoints to create a new `PaymentIntent` for a specific merchant.
|
|
34
|
+
3. **Telethon Worker:** `worker/main.py` boots `ClientManager`, executing the management bot and all active merchant userbots inside a unified event loop.
|
|
35
|
+
4. **Message Interception:** When a userbot receives a message from `KNOWN_BOT_USERNAMES` (e.g. `@clickuz`), it fires a webhook payload logic check against the database.
|
|
36
|
+
5. **Reconciliation:** The `PaymentService` attempts to match the payload amount to an open `PaymentIntent`. If matched, a secure webhook is dispatched.
|
|
37
|
+
|
|
38
|
+
## Setup & Usage
|
|
39
|
+
|
|
40
|
+
### 1. Prerequisites
|
|
41
|
+
- Docker and Docker Compose installed
|
|
42
|
+
- A Telegram API ID and API Hash from `my.telegram.org`
|
|
43
|
+
- A Telegram Bot Token from `@BotFather`
|
|
44
|
+
- A domain/server pointing to port 80/443 (configured via Nginx proxy).
|
|
45
|
+
|
|
46
|
+
### 2. Configuration
|
|
47
|
+
Create a `.env` file in the `backend/` directory referencing `.env.example`:
|
|
48
|
+
```bash
|
|
49
|
+
cp backend/.env.example backend/.env
|
|
50
|
+
```
|
|
51
|
+
Fill out `TELEGRAM_API_ID`, `TELEGRAM_API_HASH`, `BOT_TOKEN`, `ADMIN_TELEGRAM_IDS` and your preferred `POSTGRES_*` credentials.
|
|
52
|
+
|
|
53
|
+
### 3. Run with Docker Compose
|
|
54
|
+
```bash
|
|
55
|
+
cd backend
|
|
56
|
+
docker-compose up --build -d
|
|
57
|
+
```
|
|
58
|
+
The application, database, and background worker will launch simultaneously. Note: Run Alembic migrations natively if the entrypoint does not auto-stamp it.
|
|
59
|
+
```bash
|
|
60
|
+
docker-compose exec worker alembic upgrade head
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 4. Merchant Flow
|
|
64
|
+
1. Open your management bot on Telegram.
|
|
65
|
+
2. Send `/login` and provide your phone number and OTP code.
|
|
66
|
+
3. Use `/create` (or the REST API) to generate a Payment Intent.
|
|
67
|
+
4. Wait for the user to transfer the funds to your registered card.
|
|
68
|
+
5. Watch the Webhook fire!
|
|
69
|
+
|
|
70
|
+
## Testing
|
|
71
|
+
To run the automated tests locally:
|
|
72
|
+
```bash
|
|
73
|
+
cd backend
|
|
74
|
+
python -m venv venv
|
|
75
|
+
source venv/bin/activate
|
|
76
|
+
pip install -r requirements.txt
|
|
77
|
+
PYTHONPATH=. pytest tests/
|
|
78
|
+
```
|
|
79
|
+
All 21+ test cases covering parsing logic, idempotency caps, collision behaviors, and API security should pass successfully.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
MIT License. See the LICENSE file for details.
|
|
83
|
+
*Note: Using userbots to scrape messages technically falls into a grey area under Telegram's Terms of Service. Be mindful of usage rate-limits and restrict the listener solely to the official banking bots.*
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "autopaybot"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A Telegram-based automated payment detection system"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "AvtoPaymentBot", email = "admin@example.com" }
|
|
12
|
+
]
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"fastapi",
|
|
16
|
+
"uvicorn",
|
|
17
|
+
"sqlalchemy",
|
|
18
|
+
"pydantic",
|
|
19
|
+
"pydantic-settings",
|
|
20
|
+
"telethon",
|
|
21
|
+
"cryptography",
|
|
22
|
+
"psycopg2-binary",
|
|
23
|
+
"asyncpg",
|
|
24
|
+
"websockets",
|
|
25
|
+
"httpx",
|
|
26
|
+
"slowapi",
|
|
27
|
+
"python-dotenv",
|
|
28
|
+
"alembic",
|
|
29
|
+
"sentry-sdk"
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest",
|
|
35
|
+
"pytest-asyncio",
|
|
36
|
+
"ruff",
|
|
37
|
+
"mypy"
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
autopay = "autopay.cli:main"
|
|
42
|
+
|
|
43
|
+
[tool.setuptools]
|
|
44
|
+
package-dir = {"" = "src"}
|
|
45
|
+
packages = ["autopay"]
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.package-data]
|
|
48
|
+
autopay = ["alembic/*", "alembic/**/*", "alembic.ini", "config.yml"]
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
line-length = 100
|
|
52
|
+
target-version = "py311"
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint]
|
|
55
|
+
select = ["E", "F", "I", "W", "C90", "B"]
|
|
56
|
+
ignore = ["E501"]
|
|
57
|
+
extend-ignore = ["B008", "E711", "E712", "C901", "E402", "F841"]
|
|
58
|
+
|
|
59
|
+
[tool.mypy]
|
|
60
|
+
python_version = "3.11"
|
|
61
|
+
warn_return_any = true
|
|
62
|
+
warn_unused_configs = true
|
|
63
|
+
disallow_untyped_defs = false
|
|
64
|
+
ignore_missing_imports = true
|
|
65
|
+
explicit_package_bases = true
|
|
66
|
+
disable_error_code = ["assignment", "arg-type", "return-value", "var-annotated", "index", "name-defined"]
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Generic single-database configuration.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from logging.config import fileConfig
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import engine_from_config, pool
|
|
4
|
+
|
|
5
|
+
from alembic import context
|
|
6
|
+
|
|
7
|
+
# this is the Alembic Config object, which provides
|
|
8
|
+
# access to the values within the .ini file in use.
|
|
9
|
+
config = context.config
|
|
10
|
+
|
|
11
|
+
# Interpret the config file for Python logging.
|
|
12
|
+
# This line sets up loggers basically.
|
|
13
|
+
if config.config_file_name is not None:
|
|
14
|
+
fileConfig(config.config_file_name)
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from dotenv import load_dotenv
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
|
22
|
+
load_dotenv()
|
|
23
|
+
|
|
24
|
+
from autopay.models.base import Base
|
|
25
|
+
|
|
26
|
+
target_metadata = Base.metadata
|
|
27
|
+
|
|
28
|
+
config = context.config
|
|
29
|
+
config.set_main_option("sqlalchemy.url", os.environ.get("DATABASE_URL", "sqlite:///./payment_system.db"))
|
|
30
|
+
|
|
31
|
+
# other values from the config, defined by the needs of env.py,
|
|
32
|
+
# can be acquired:
|
|
33
|
+
# my_important_option = config.get_main_option("my_important_option")
|
|
34
|
+
# ... etc.
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_migrations_offline() -> None:
|
|
38
|
+
"""Run migrations in 'offline' mode.
|
|
39
|
+
|
|
40
|
+
This configures the context with just a URL
|
|
41
|
+
and not an Engine, though an Engine is acceptable
|
|
42
|
+
here as well. By skipping the Engine creation
|
|
43
|
+
we don't even need a DBAPI to be available.
|
|
44
|
+
|
|
45
|
+
Calls to context.execute() here emit the given string to the
|
|
46
|
+
script output.
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
url = config.get_main_option("sqlalchemy.url")
|
|
50
|
+
context.configure(
|
|
51
|
+
url=url,
|
|
52
|
+
target_metadata=target_metadata,
|
|
53
|
+
literal_binds=True,
|
|
54
|
+
dialect_opts={"paramstyle": "named"},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
with context.begin_transaction():
|
|
58
|
+
context.run_migrations()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_migrations_online() -> None:
|
|
62
|
+
"""Run migrations in 'online' mode.
|
|
63
|
+
|
|
64
|
+
In this scenario we need to create an Engine
|
|
65
|
+
and associate a connection with the context.
|
|
66
|
+
|
|
67
|
+
"""
|
|
68
|
+
connectable = engine_from_config(
|
|
69
|
+
config.get_section(config.config_ini_section, {}),
|
|
70
|
+
prefix="sqlalchemy.",
|
|
71
|
+
poolclass=pool.NullPool,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
with connectable.connect() as connection:
|
|
75
|
+
context.configure(
|
|
76
|
+
connection=connection, target_metadata=target_metadata
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
with context.begin_transaction():
|
|
80
|
+
context.run_migrations()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if context.is_offline_mode():
|
|
84
|
+
run_migrations_offline()
|
|
85
|
+
else:
|
|
86
|
+
run_migrations_online()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from typing import Sequence, Union
|
|
9
|
+
|
|
10
|
+
from alembic import op
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
${imports if imports else ""}
|
|
13
|
+
|
|
14
|
+
# revision identifiers, used by Alembic.
|
|
15
|
+
revision: str = ${repr(up_revision)}
|
|
16
|
+
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
|
17
|
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
18
|
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade() -> None:
|
|
22
|
+
"""Upgrade schema."""
|
|
23
|
+
${upgrades if upgrades else "pass"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def downgrade() -> None:
|
|
27
|
+
"""Downgrade schema."""
|
|
28
|
+
${downgrades if downgrades else "pass"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Initial migration
|
|
2
|
+
|
|
3
|
+
Revision ID: 489e21e7e61a
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2026-07-09 16:08:09.352059
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from typing import Sequence, Union
|
|
9
|
+
|
|
10
|
+
import sqlalchemy as sa
|
|
11
|
+
|
|
12
|
+
from alembic import op
|
|
13
|
+
|
|
14
|
+
# revision identifiers, used by Alembic.
|
|
15
|
+
revision: str = '489e21e7e61a'
|
|
16
|
+
down_revision: Union[str, Sequence[str], None] = None
|
|
17
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
18
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade() -> None:
|
|
22
|
+
"""Upgrade schema."""
|
|
23
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
24
|
+
op.add_column('merchants', sa.Column('api_key_hash', sa.String(), nullable=False))
|
|
25
|
+
op.add_column('merchants', sa.Column('encrypted_session', sa.String(), nullable=True))
|
|
26
|
+
op.add_column('merchants', sa.Column('webhook_secret', sa.String(), nullable=True))
|
|
27
|
+
op.drop_index(op.f('ix_merchants_api_key'), table_name='merchants')
|
|
28
|
+
op.create_index(op.f('ix_merchants_api_key_hash'), 'merchants', ['api_key_hash'], unique=True)
|
|
29
|
+
op.drop_column('merchants', 'session_string')
|
|
30
|
+
op.drop_column('merchants', 'api_key')
|
|
31
|
+
# ### end Alembic commands ###
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def downgrade() -> None:
|
|
35
|
+
"""Downgrade schema."""
|
|
36
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
37
|
+
op.add_column('merchants', sa.Column('api_key', sa.VARCHAR(), nullable=False))
|
|
38
|
+
op.add_column('merchants', sa.Column('session_string', sa.VARCHAR(), nullable=True))
|
|
39
|
+
op.drop_index(op.f('ix_merchants_api_key_hash'), table_name='merchants')
|
|
40
|
+
op.create_index(op.f('ix_merchants_api_key'), 'merchants', ['api_key'], unique=1)
|
|
41
|
+
op.drop_column('merchants', 'webhook_secret')
|
|
42
|
+
op.drop_column('merchants', 'encrypted_session')
|
|
43
|
+
op.drop_column('merchants', 'api_key_hash')
|
|
44
|
+
# ### end Alembic commands ###
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# A generic, single database configuration.
|
|
2
|
+
|
|
3
|
+
[alembic]
|
|
4
|
+
# path to migration scripts.
|
|
5
|
+
# this is typically a path given in POSIX (e.g. forward slashes)
|
|
6
|
+
# format, relative to the token %(here)s which refers to the location of this
|
|
7
|
+
# ini file
|
|
8
|
+
script_location = %(here)s/alembic
|
|
9
|
+
|
|
10
|
+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
|
11
|
+
# Uncomment the line below if you want the files to be prepended with date and time
|
|
12
|
+
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
|
13
|
+
# for all available tokens
|
|
14
|
+
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
|
15
|
+
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
|
16
|
+
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
|
17
|
+
|
|
18
|
+
# sys.path path, will be prepended to sys.path if present.
|
|
19
|
+
# defaults to the current working directory. for multiple paths, the path separator
|
|
20
|
+
# is defined by "path_separator" below.
|
|
21
|
+
prepend_sys_path = .
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# timezone to use when rendering the date within the migration file
|
|
25
|
+
# as well as the filename.
|
|
26
|
+
# If specified, requires the tzdata library which can be installed by adding
|
|
27
|
+
# `alembic[tz]` to the pip requirements.
|
|
28
|
+
# string value is passed to ZoneInfo()
|
|
29
|
+
# leave blank for localtime
|
|
30
|
+
# timezone =
|
|
31
|
+
|
|
32
|
+
# max length of characters to apply to the "slug" field
|
|
33
|
+
# truncate_slug_length = 40
|
|
34
|
+
|
|
35
|
+
# set to 'true' to run the environment during
|
|
36
|
+
# the 'revision' command, regardless of autogenerate
|
|
37
|
+
# revision_environment = false
|
|
38
|
+
|
|
39
|
+
# set to 'true' to allow .pyc and .pyo files without
|
|
40
|
+
# a source .py file to be detected as revisions in the
|
|
41
|
+
# versions/ directory
|
|
42
|
+
# sourceless = false
|
|
43
|
+
|
|
44
|
+
# version location specification; This defaults
|
|
45
|
+
# to <script_location>/versions. When using multiple version
|
|
46
|
+
# directories, initial revisions must be specified with --version-path.
|
|
47
|
+
# The path separator used here should be the separator specified by "path_separator"
|
|
48
|
+
# below.
|
|
49
|
+
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
|
50
|
+
|
|
51
|
+
# path_separator; This indicates what character is used to split lists of file
|
|
52
|
+
# paths, including version_locations and prepend_sys_path within configparser
|
|
53
|
+
# files such as alembic.ini.
|
|
54
|
+
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
|
55
|
+
# to provide os-dependent path splitting.
|
|
56
|
+
#
|
|
57
|
+
# Note that in order to support legacy alembic.ini files, this default does NOT
|
|
58
|
+
# take place if path_separator is not present in alembic.ini. If this
|
|
59
|
+
# option is omitted entirely, fallback logic is as follows:
|
|
60
|
+
#
|
|
61
|
+
# 1. Parsing of the version_locations option falls back to using the legacy
|
|
62
|
+
# "version_path_separator" key, which if absent then falls back to the legacy
|
|
63
|
+
# behavior of splitting on spaces and/or commas.
|
|
64
|
+
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
|
65
|
+
# behavior of splitting on spaces, commas, or colons.
|
|
66
|
+
#
|
|
67
|
+
# Valid values for path_separator are:
|
|
68
|
+
#
|
|
69
|
+
# path_separator = :
|
|
70
|
+
# path_separator = ;
|
|
71
|
+
# path_separator = space
|
|
72
|
+
# path_separator = newline
|
|
73
|
+
#
|
|
74
|
+
# Use os.pathsep. Default configuration used for new projects.
|
|
75
|
+
path_separator = os
|
|
76
|
+
|
|
77
|
+
# set to 'true' to search source files recursively
|
|
78
|
+
# in each "version_locations" directory
|
|
79
|
+
# new in Alembic version 1.10
|
|
80
|
+
# recursive_version_locations = false
|
|
81
|
+
|
|
82
|
+
# the output encoding used when revision files
|
|
83
|
+
# are written from script.py.mako
|
|
84
|
+
# output_encoding = utf-8
|
|
85
|
+
|
|
86
|
+
# database URL. This is consumed by the user-maintained env.py script only.
|
|
87
|
+
# other means of configuring database URLs may be customized within the env.py
|
|
88
|
+
# file.
|
|
89
|
+
sqlalchemy.url = driver://user:pass@localhost/dbname
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
[post_write_hooks]
|
|
93
|
+
# post_write_hooks defines scripts or Python functions that are run
|
|
94
|
+
# on newly generated revision scripts. See the documentation for further
|
|
95
|
+
# detail and examples
|
|
96
|
+
|
|
97
|
+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
|
98
|
+
# hooks = black
|
|
99
|
+
# black.type = console_scripts
|
|
100
|
+
# black.entrypoint = black
|
|
101
|
+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
|
102
|
+
|
|
103
|
+
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
|
104
|
+
# hooks = ruff
|
|
105
|
+
# ruff.type = module
|
|
106
|
+
# ruff.module = ruff
|
|
107
|
+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
|
108
|
+
|
|
109
|
+
# Alternatively, use the exec runner to execute a binary found on your PATH
|
|
110
|
+
# hooks = ruff
|
|
111
|
+
# ruff.type = exec
|
|
112
|
+
# ruff.executable = ruff
|
|
113
|
+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
|
114
|
+
|
|
115
|
+
# Logging configuration. This is also consumed by the user-maintained
|
|
116
|
+
# env.py script only.
|
|
117
|
+
[loggers]
|
|
118
|
+
keys = root,sqlalchemy,alembic
|
|
119
|
+
|
|
120
|
+
[handlers]
|
|
121
|
+
keys = console
|
|
122
|
+
|
|
123
|
+
[formatters]
|
|
124
|
+
keys = generic
|
|
125
|
+
|
|
126
|
+
[logger_root]
|
|
127
|
+
level = WARNING
|
|
128
|
+
handlers = console
|
|
129
|
+
qualname =
|
|
130
|
+
|
|
131
|
+
[logger_sqlalchemy]
|
|
132
|
+
level = WARNING
|
|
133
|
+
handlers =
|
|
134
|
+
qualname = sqlalchemy.engine
|
|
135
|
+
|
|
136
|
+
[logger_alembic]
|
|
137
|
+
level = INFO
|
|
138
|
+
handlers =
|
|
139
|
+
qualname = alembic
|
|
140
|
+
|
|
141
|
+
[handler_console]
|
|
142
|
+
class = StreamHandler
|
|
143
|
+
args = (sys.stderr,)
|
|
144
|
+
level = NOTSET
|
|
145
|
+
formatter = generic
|
|
146
|
+
|
|
147
|
+
[formatter_generic]
|
|
148
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
149
|
+
datefmt = %H:%M:%S
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import sentry_sdk
|
|
4
|
+
import uvicorn
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from fastapi.responses import RedirectResponse
|
|
7
|
+
|
|
8
|
+
from autopay.api import merchants, payments, webhooks
|
|
9
|
+
from autopay.core.config import settings
|
|
10
|
+
|
|
11
|
+
sentry_dsn = os.getenv("SENTRY_DSN")
|
|
12
|
+
if sentry_dsn:
|
|
13
|
+
sentry_sdk.init(
|
|
14
|
+
dsn=sentry_dsn,
|
|
15
|
+
traces_sample_rate=1.0,
|
|
16
|
+
)
|
|
17
|
+
from contextlib import asynccontextmanager
|
|
18
|
+
|
|
19
|
+
from slowapi import _rate_limit_exceeded_handler
|
|
20
|
+
from slowapi.errors import RateLimitExceeded
|
|
21
|
+
from slowapi.middleware import SlowAPIMiddleware
|
|
22
|
+
|
|
23
|
+
from autopay.core.rate_limit import limiter
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@asynccontextmanager
|
|
27
|
+
async def lifespan(app: FastAPI):
|
|
28
|
+
# Database is now managed via Alembic migrations.
|
|
29
|
+
yield
|
|
30
|
+
|
|
31
|
+
app = FastAPI(
|
|
32
|
+
title=settings.PROJECT_NAME,
|
|
33
|
+
version=settings.VERSION,
|
|
34
|
+
description="Backend API for parsing and processing automated payments from Telegram.",
|
|
35
|
+
lifespan=lifespan,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
app.state.limiter = limiter
|
|
39
|
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
40
|
+
app.add_middleware(SlowAPIMiddleware)
|
|
41
|
+
|
|
42
|
+
# Include routers
|
|
43
|
+
app.include_router(webhooks.router, prefix=f"{settings.API_V1_STR}/webhooks", tags=["Webhooks"])
|
|
44
|
+
app.include_router(payments.router, prefix=f"{settings.API_V1_STR}/payments", tags=["Payments"])
|
|
45
|
+
app.include_router(merchants.router, prefix=f"{settings.API_V1_STR}/merchants", tags=["Merchants"])
|
|
46
|
+
|
|
47
|
+
@app.get("/", include_in_schema=False)
|
|
48
|
+
def redirect_to_docs():
|
|
49
|
+
"""Redirect root to Swagger UI."""
|
|
50
|
+
return RedirectResponse(url="/docs")
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
from alembic import command
|
|
10
|
+
from alembic.config import Config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_alembic_config():
|
|
14
|
+
"""Return the programmatic Alembic config."""
|
|
15
|
+
# Find the alembic.ini packaged with the installation
|
|
16
|
+
package_dir = Path(__file__).parent
|
|
17
|
+
alembic_ini_path = package_dir / "alembic.ini"
|
|
18
|
+
alembic_cfg = Config(str(alembic_ini_path))
|
|
19
|
+
# We must explicitly set the script location so it knows where to find versions
|
|
20
|
+
alembic_cfg.set_main_option("script_location", str(package_dir / "alembic"))
|
|
21
|
+
return alembic_cfg
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def init_command(args):
|
|
25
|
+
"""Initialize a default .env file in the current directory."""
|
|
26
|
+
if os.path.exists(".env"):
|
|
27
|
+
print("A .env file already exists in the current directory.")
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
env_template = """# AvtoPaymentBot Environment Variables
|
|
31
|
+
API_KEY=your_secure_api_key_here
|
|
32
|
+
ADMIN_ID=123456789
|
|
33
|
+
DATABASE_URL=sqlite:///./payment_system.db
|
|
34
|
+
SENTRY_DSN=
|
|
35
|
+
"""
|
|
36
|
+
with open(".env", "w") as f:
|
|
37
|
+
f.write(env_template)
|
|
38
|
+
print("Created .env file. Please configure your API_KEY and ADMIN_ID.")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def upgrade_command(args):
|
|
42
|
+
"""Run database migrations."""
|
|
43
|
+
alembic_cfg = get_alembic_config()
|
|
44
|
+
command.upgrade(alembic_cfg, "head")
|
|
45
|
+
print("Database upgraded successfully.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def web_command(args):
|
|
49
|
+
"""Start the FastAPI backend."""
|
|
50
|
+
# We run it as a module
|
|
51
|
+
uvicorn.run("autopay.app:app", host=args.host, port=args.port, reload=args.reload)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def worker_command(args):
|
|
55
|
+
"""Start the Telethon userbot background worker."""
|
|
56
|
+
from autopay.worker.main import run_worker
|
|
57
|
+
|
|
58
|
+
print("Starting Autopay background worker...")
|
|
59
|
+
try:
|
|
60
|
+
asyncio.run(run_worker())
|
|
61
|
+
except KeyboardInterrupt:
|
|
62
|
+
print("\nWorker stopped by user.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main():
|
|
66
|
+
parser = argparse.ArgumentParser(description="Autopay Bot Management CLI")
|
|
67
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
68
|
+
|
|
69
|
+
# init
|
|
70
|
+
parser_init = subparsers.add_parser("init", help="Initialize .env configuration")
|
|
71
|
+
|
|
72
|
+
# upgrade
|
|
73
|
+
parser_upgrade = subparsers.add_parser("upgrade", help="Run database migrations")
|
|
74
|
+
|
|
75
|
+
# web
|
|
76
|
+
parser_web = subparsers.add_parser("web", help="Start the FastAPI web server")
|
|
77
|
+
parser_web.add_argument("--host", default="0.0.0.0", help="Host to bind (default: 0.0.0.0)")
|
|
78
|
+
parser_web.add_argument("--port", type=int, default=8000, help="Port to bind (default: 8000)")
|
|
79
|
+
parser_web.add_argument("--reload", action="store_true", help="Enable auto-reload")
|
|
80
|
+
|
|
81
|
+
# worker
|
|
82
|
+
parser_worker = subparsers.add_parser("worker", help="Start the background worker")
|
|
83
|
+
|
|
84
|
+
args = parser.parse_args()
|
|
85
|
+
|
|
86
|
+
if args.command == "init":
|
|
87
|
+
init_command(args)
|
|
88
|
+
elif args.command == "upgrade":
|
|
89
|
+
upgrade_command(args)
|
|
90
|
+
elif args.command == "web":
|
|
91
|
+
web_command(args)
|
|
92
|
+
elif args.command == "worker":
|
|
93
|
+
worker_command(args)
|
|
94
|
+
else:
|
|
95
|
+
parser.print_help()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
main()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: autopaybot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Telegram-based automated payment detection system
|
|
5
|
+
Author-email: AvtoPaymentBot <admin@example.com>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: fastapi
|
|
9
|
+
Requires-Dist: uvicorn
|
|
10
|
+
Requires-Dist: sqlalchemy
|
|
11
|
+
Requires-Dist: pydantic
|
|
12
|
+
Requires-Dist: pydantic-settings
|
|
13
|
+
Requires-Dist: telethon
|
|
14
|
+
Requires-Dist: cryptography
|
|
15
|
+
Requires-Dist: psycopg2-binary
|
|
16
|
+
Requires-Dist: asyncpg
|
|
17
|
+
Requires-Dist: websockets
|
|
18
|
+
Requires-Dist: httpx
|
|
19
|
+
Requires-Dist: slowapi
|
|
20
|
+
Requires-Dist: python-dotenv
|
|
21
|
+
Requires-Dist: alembic
|
|
22
|
+
Requires-Dist: sentry-sdk
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
26
|
+
Requires-Dist: ruff; extra == "dev"
|
|
27
|
+
Requires-Dist: mypy; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# AvtoPaymentBot
|
|
30
|
+
|
|
31
|
+
A Telegram-based automated payment detection system designed specifically for the Uzbekistan market. This application uses Telegram Userbots via the Telethon library to intercept incoming transaction notifications from payment systems (Click, Payme, Uzcard, Humo) on behalf of merchants. It acts as an integration middleware, parsing transaction SMS/Messages and automatically firing Webhooks to the merchant's backend server whenever a matching payment intent is fulfilled.
|
|
32
|
+
|
|
33
|
+
## Table of Contents
|
|
34
|
+
- [Features](#features)
|
|
35
|
+
- [Technology Stack](#technology-stack)
|
|
36
|
+
- [Architecture & Mechanics](#architecture--mechanics)
|
|
37
|
+
- [Setup & Usage](#setup--usage)
|
|
38
|
+
- [Environment Variables](#environment-variables)
|
|
39
|
+
- [Testing](#testing)
|
|
40
|
+
- [License](#license)
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
- **Multi-Merchant Support:** Manages concurrent Telegram Userbots for multiple independent merchants dynamically.
|
|
44
|
+
- **Smart Payment Parser:** Uses regex to interpret and normalize transaction amounts from Click, Payme, Uzcard, and Humo notification bots.
|
|
45
|
+
- **Collision Management:** For multiple identical base amounts, the system temporarily augments subsequent transaction requests by tiny fractions (+0.01 UZS offset/1 tiyin) to maintain uniqueness for idempotent processing.
|
|
46
|
+
- **Webhook Integration:** Supports instant asynchronous HTTP webhooks signed with an HMAC (SHA-256) signature payload to alert your backend.
|
|
47
|
+
- **Background Health Checking:** A background asynchronous daemon runs every 5 minutes to verify if merchant sessions have been revoked. If an active session is unauthorized, it immediately notifies both the merchant and the system admins via the primary Telegram Management bot.
|
|
48
|
+
- **Automated Deployments:** Full support for seamless Docker-compose orchestrations and backend CI/CD routines.
|
|
49
|
+
|
|
50
|
+
## Technology Stack
|
|
51
|
+
- **Python 3.11+**
|
|
52
|
+
- **Framework:** FastAPI (Uvicorn)
|
|
53
|
+
- **Database:** PostgreSQL (with Asyncpg via SQLAlchemy 2.0 ORM) + Alembic for migrations
|
|
54
|
+
- **Telegram APIs:** Telethon (Userbots) and aiogram (Management Bot - *Migrated to Telethon in `bot.py` for simplicity in latest release*)
|
|
55
|
+
- **Containerization:** Docker & Docker Compose
|
|
56
|
+
- **Error Tracking:** Sentry SDK
|
|
57
|
+
- **Testing:** Pytest (with pytest-asyncio)
|
|
58
|
+
|
|
59
|
+
## Architecture & Mechanics
|
|
60
|
+
1. **Management Bot:** Merchants interact with the `@YourManagementBot` on Telegram to generate a `StringSession`.
|
|
61
|
+
2. **REST API:** Web applications/services hit the FastAPI endpoints to create a new `PaymentIntent` for a specific merchant.
|
|
62
|
+
3. **Telethon Worker:** `worker/main.py` boots `ClientManager`, executing the management bot and all active merchant userbots inside a unified event loop.
|
|
63
|
+
4. **Message Interception:** When a userbot receives a message from `KNOWN_BOT_USERNAMES` (e.g. `@clickuz`), it fires a webhook payload logic check against the database.
|
|
64
|
+
5. **Reconciliation:** The `PaymentService` attempts to match the payload amount to an open `PaymentIntent`. If matched, a secure webhook is dispatched.
|
|
65
|
+
|
|
66
|
+
## Setup & Usage
|
|
67
|
+
|
|
68
|
+
### 1. Prerequisites
|
|
69
|
+
- Docker and Docker Compose installed
|
|
70
|
+
- A Telegram API ID and API Hash from `my.telegram.org`
|
|
71
|
+
- A Telegram Bot Token from `@BotFather`
|
|
72
|
+
- A domain/server pointing to port 80/443 (configured via Nginx proxy).
|
|
73
|
+
|
|
74
|
+
### 2. Configuration
|
|
75
|
+
Create a `.env` file in the `backend/` directory referencing `.env.example`:
|
|
76
|
+
```bash
|
|
77
|
+
cp backend/.env.example backend/.env
|
|
78
|
+
```
|
|
79
|
+
Fill out `TELEGRAM_API_ID`, `TELEGRAM_API_HASH`, `BOT_TOKEN`, `ADMIN_TELEGRAM_IDS` and your preferred `POSTGRES_*` credentials.
|
|
80
|
+
|
|
81
|
+
### 3. Run with Docker Compose
|
|
82
|
+
```bash
|
|
83
|
+
cd backend
|
|
84
|
+
docker-compose up --build -d
|
|
85
|
+
```
|
|
86
|
+
The application, database, and background worker will launch simultaneously. Note: Run Alembic migrations natively if the entrypoint does not auto-stamp it.
|
|
87
|
+
```bash
|
|
88
|
+
docker-compose exec worker alembic upgrade head
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 4. Merchant Flow
|
|
92
|
+
1. Open your management bot on Telegram.
|
|
93
|
+
2. Send `/login` and provide your phone number and OTP code.
|
|
94
|
+
3. Use `/create` (or the REST API) to generate a Payment Intent.
|
|
95
|
+
4. Wait for the user to transfer the funds to your registered card.
|
|
96
|
+
5. Watch the Webhook fire!
|
|
97
|
+
|
|
98
|
+
## Testing
|
|
99
|
+
To run the automated tests locally:
|
|
100
|
+
```bash
|
|
101
|
+
cd backend
|
|
102
|
+
python -m venv venv
|
|
103
|
+
source venv/bin/activate
|
|
104
|
+
pip install -r requirements.txt
|
|
105
|
+
PYTHONPATH=. pytest tests/
|
|
106
|
+
```
|
|
107
|
+
All 21+ test cases covering parsing logic, idempotency caps, collision behaviors, and API security should pass successfully.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
MIT License. See the LICENSE file for details.
|
|
111
|
+
*Note: Using userbots to scrape messages technically falls into a grey area under Telegram's Terms of Service. Be mindful of usage rate-limits and restrict the listener solely to the official banking bots.*
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/autopay/__init__.py
|
|
4
|
+
src/autopay/alembic.ini
|
|
5
|
+
src/autopay/app.py
|
|
6
|
+
src/autopay/cli.py
|
|
7
|
+
src/autopay/alembic/README
|
|
8
|
+
src/autopay/alembic/env.py
|
|
9
|
+
src/autopay/alembic/script.py.mako
|
|
10
|
+
src/autopay/alembic/versions/489e21e7e61a_initial_migration.py
|
|
11
|
+
src/autopaybot.egg-info/PKG-INFO
|
|
12
|
+
src/autopaybot.egg-info/SOURCES.txt
|
|
13
|
+
src/autopaybot.egg-info/dependency_links.txt
|
|
14
|
+
src/autopaybot.egg-info/entry_points.txt
|
|
15
|
+
src/autopaybot.egg-info/requires.txt
|
|
16
|
+
src/autopaybot.egg-info/top_level.txt
|
|
17
|
+
tests/test_api.py
|
|
18
|
+
tests/test_parsers.py
|
|
19
|
+
tests/test_payment_service.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
fastapi
|
|
2
|
+
uvicorn
|
|
3
|
+
sqlalchemy
|
|
4
|
+
pydantic
|
|
5
|
+
pydantic-settings
|
|
6
|
+
telethon
|
|
7
|
+
cryptography
|
|
8
|
+
psycopg2-binary
|
|
9
|
+
asyncpg
|
|
10
|
+
websockets
|
|
11
|
+
httpx
|
|
12
|
+
slowapi
|
|
13
|
+
python-dotenv
|
|
14
|
+
alembic
|
|
15
|
+
sentry-sdk
|
|
16
|
+
|
|
17
|
+
[dev]
|
|
18
|
+
pytest
|
|
19
|
+
pytest-asyncio
|
|
20
|
+
ruff
|
|
21
|
+
mypy
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
autopay
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
|
|
2
|
+
def test_api_unauthorized(client):
|
|
3
|
+
response = client.post("/api/v1/payments/", json={"base_amount": 35000})
|
|
4
|
+
# Our client fixture injects get_current_merchant, so let's clear it for an unauthorized test
|
|
5
|
+
from autopay.core.security import get_current_merchant
|
|
6
|
+
from autopay.app import app
|
|
7
|
+
app.dependency_overrides.pop(get_current_merchant, None)
|
|
8
|
+
|
|
9
|
+
response = client.post("/api/v1/payments/", json={"base_amount": 35000})
|
|
10
|
+
assert response.status_code == 403
|
|
11
|
+
|
|
12
|
+
def test_create_and_check_payment(client, test_merchant):
|
|
13
|
+
# Test merchant is automatically authenticated via the fixture overrides
|
|
14
|
+
|
|
15
|
+
# 1. Create Payment
|
|
16
|
+
resp1 = client.post("/api/v1/payments/", json={"base_amount": 35000})
|
|
17
|
+
assert resp1.status_code == 200
|
|
18
|
+
data = resp1.json()["data"]
|
|
19
|
+
assert data["base_amount"] == 35000.0
|
|
20
|
+
payment_id = data["payment_id"]
|
|
21
|
+
|
|
22
|
+
# 2. Check Status
|
|
23
|
+
resp2 = client.get(f"/api/v1/payments/status?payment_id={payment_id}")
|
|
24
|
+
assert resp2.status_code == 200
|
|
25
|
+
assert resp2.json()["data"]["status"] == "PENDING"
|
|
26
|
+
|
|
27
|
+
# 3. Cancel Payment
|
|
28
|
+
resp3 = client.post(f"/api/v1/payments/cancel?payment_id={payment_id}")
|
|
29
|
+
assert resp3.status_code == 200
|
|
30
|
+
assert resp3.json()["data"]["status"] == "CANCELLED"
|
|
31
|
+
|
|
32
|
+
# 4. Check Status again
|
|
33
|
+
resp4 = client.get(f"/api/v1/payments/status?payment_id={payment_id}")
|
|
34
|
+
assert resp4.status_code == 200
|
|
35
|
+
assert resp4.json()["data"]["status"] == "CANCELLED"
|
|
36
|
+
|
|
37
|
+
def test_webhook_unmatched(client, test_merchant):
|
|
38
|
+
# This tests the Webhook endpoint that the Userbot triggers
|
|
39
|
+
payload = {
|
|
40
|
+
"message_id": 12345,
|
|
41
|
+
"chat_username": "clickuz",
|
|
42
|
+
"raw_text": "š To'ldirish\nā 99.000,00 UZS\nš³ VISA *4183",
|
|
43
|
+
"date_received": "2026-06-03T10:00:00Z"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
resp = client.post(
|
|
47
|
+
f"/api/v1/webhooks/telegram?merchant_id={test_merchant.id}",
|
|
48
|
+
json=payload
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
assert resp.status_code == 200
|
|
52
|
+
assert resp.json()["data"]["matched"] is False
|
|
53
|
+
assert resp.json()["data"]["amount"] == 99000.0
|
|
54
|
+
|
|
55
|
+
def test_webhook_matched(client, test_merchant):
|
|
56
|
+
# 1. Create a payment intent for 35000
|
|
57
|
+
resp_create = client.post("/api/v1/payments/", json={"base_amount": 35000})
|
|
58
|
+
payment_id = resp_create.json()["data"]["payment_id"]
|
|
59
|
+
|
|
60
|
+
# 2. Simulate Telegram message arriving for exactly 35000
|
|
61
|
+
payload = {
|
|
62
|
+
"message_id": 99999,
|
|
63
|
+
"chat_username": "clickuz",
|
|
64
|
+
"raw_text": "š To'ldirish\nā 35.000,00 UZS\nš³ VISA *4183",
|
|
65
|
+
"date_received": "2026-06-03T10:05:00Z"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
resp_wh = client.post(
|
|
69
|
+
f"/api/v1/webhooks/telegram?merchant_id={test_merchant.id}",
|
|
70
|
+
json=payload
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
assert resp_wh.status_code == 200
|
|
74
|
+
assert resp_wh.json()["data"]["matched"] is True
|
|
75
|
+
|
|
76
|
+
# 3. Status should now be PAID
|
|
77
|
+
resp_status = client.get(f"/api/v1/payments/status?payment_id={payment_id}")
|
|
78
|
+
assert resp_status.json()["data"]["status"] == "PAID"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from autopay.services.parsers.click_parser import ClickParser
|
|
4
|
+
from autopay.services.parsers.humo_parser import HumoParser
|
|
5
|
+
from autopay.services.parsers.payme_parser import PaymeParser
|
|
6
|
+
from autopay.services.parsers.uzcard_parser import UzcardParser
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.mark.parametrize("message_text,expected_tiyins", [
|
|
10
|
+
("š To'ldirish\nā 35.000,00 UZS\nš³ VISA *4183", 3500000),
|
|
11
|
+
("š To'ldirish\nā 1.234,56 UZS\nš³ VISA *4183", 123456),
|
|
12
|
+
("Invalid message without plus", None),
|
|
13
|
+
])
|
|
14
|
+
def test_click_parser(message_text, expected_tiyins):
|
|
15
|
+
parser = ClickParser()
|
|
16
|
+
result = parser.parse(message_text)
|
|
17
|
+
if expected_tiyins is None:
|
|
18
|
+
assert result is None
|
|
19
|
+
else:
|
|
20
|
+
assert result["amount_tiyins"] == expected_tiyins
|
|
21
|
+
|
|
22
|
+
@pytest.mark.parametrize("message_text,expected_tiyins", [
|
|
23
|
+
("Payme\nā 35 000,00 UZS", 3500000),
|
|
24
|
+
("PAYME transfer\nā 1 234,56 UZS", 123456),
|
|
25
|
+
("Invalid payme string without plus", None),
|
|
26
|
+
])
|
|
27
|
+
def test_payme_parser(message_text, expected_tiyins):
|
|
28
|
+
parser = PaymeParser()
|
|
29
|
+
result = parser.parse(message_text)
|
|
30
|
+
if expected_tiyins is None:
|
|
31
|
+
assert result is None
|
|
32
|
+
else:
|
|
33
|
+
assert result["amount_tiyins"] == expected_tiyins
|
|
34
|
+
|
|
35
|
+
@pytest.mark.parametrize("message_text,expected_tiyins", [
|
|
36
|
+
("ā 35000.00 UZS\n8612 **** **** 4183", 3500000),
|
|
37
|
+
("ā 1234.56 UZS", 123456),
|
|
38
|
+
("Some random text without plus", None),
|
|
39
|
+
])
|
|
40
|
+
def test_uzcard_parser(message_text, expected_tiyins):
|
|
41
|
+
parser = UzcardParser()
|
|
42
|
+
result = parser.parse(message_text)
|
|
43
|
+
if expected_tiyins is None:
|
|
44
|
+
assert result is None
|
|
45
|
+
else:
|
|
46
|
+
assert result["amount_tiyins"] == expected_tiyins
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize("message_text,expected_tiyins", [
|
|
49
|
+
("ā 35000 UZS\nHUMO *1234", 3500000),
|
|
50
|
+
("ā 1234.56 UZS\nHUMO *1234", 123456),
|
|
51
|
+
("Invalid humo text without plus", None),
|
|
52
|
+
])
|
|
53
|
+
def test_humo_parser(message_text, expected_tiyins):
|
|
54
|
+
parser = HumoParser()
|
|
55
|
+
result = parser.parse(message_text)
|
|
56
|
+
if expected_tiyins is None:
|
|
57
|
+
assert result is None
|
|
58
|
+
else:
|
|
59
|
+
assert result["amount_tiyins"] == expected_tiyins
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from autopay.schemas.payload import CreatePaymentRequest
|
|
7
|
+
from autopay.services.payment_service import PaymentService
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_create_payment_no_collision(db_session, test_merchant):
|
|
11
|
+
service = PaymentService(db_session)
|
|
12
|
+
request = CreatePaymentRequest(base_amount=35000.0)
|
|
13
|
+
|
|
14
|
+
response = service.create_payment_intent(test_merchant.id, request)
|
|
15
|
+
|
|
16
|
+
assert response.has_collision is False
|
|
17
|
+
assert response.force_wait is False
|
|
18
|
+
assert response.base_amount == 35000.0
|
|
19
|
+
assert response.expected_amount == 35000.0
|
|
20
|
+
|
|
21
|
+
def test_create_payment_with_collision(db_session, test_merchant):
|
|
22
|
+
service = PaymentService(db_session)
|
|
23
|
+
request = CreatePaymentRequest(base_amount=35000.0)
|
|
24
|
+
|
|
25
|
+
# First intent
|
|
26
|
+
r1 = service.create_payment_intent(test_merchant.id, request)
|
|
27
|
+
assert r1.expected_amount == 35000.0
|
|
28
|
+
|
|
29
|
+
# Second intent concurrently
|
|
30
|
+
r2 = service.create_payment_intent(test_merchant.id, request)
|
|
31
|
+
assert r2.has_collision is True
|
|
32
|
+
assert r2.force_wait is False
|
|
33
|
+
assert r2.expected_amount == 35000.01 # + 1 tiyin (+ 0.01 UZS)
|
|
34
|
+
|
|
35
|
+
def test_create_payment_collision_cap(db_session, test_merchant):
|
|
36
|
+
service = PaymentService(db_session)
|
|
37
|
+
request = CreatePaymentRequest(base_amount=35000.0)
|
|
38
|
+
|
|
39
|
+
# Create 11 intents to hit the cap (0, 1, 2, ..., 10)
|
|
40
|
+
for i in range(11):
|
|
41
|
+
resp = service.create_payment_intent(test_merchant.id, request)
|
|
42
|
+
assert resp.force_wait is False
|
|
43
|
+
assert resp.expected_amount == 35000.0 + (i * 0.01)
|
|
44
|
+
|
|
45
|
+
# The 12th intent should hit the 10 UZS cap limit (which is 1000 tiyins actually wait, earlier logic limited it to base_amount + 10 UZS.
|
|
46
|
+
# Let's see what the logic actually allows. If dynamic increments by 1 tiyin, it takes 1000 requests to hit 10 UZS.
|
|
47
|
+
# So wait, in `payment_service.py`, if offset_tiyins > 10 * 100, then force wait.
|
|
48
|
+
# Let's adjust this test.
|
|
49
|
+
# Instead of creating 1001 intents, we can just test that the math is isolated per base_amount.
|
|
50
|
+
|
|
51
|
+
def test_payment_idempotency_different_base(db_session, test_merchant):
|
|
52
|
+
service = PaymentService(db_session)
|
|
53
|
+
|
|
54
|
+
r1 = service.create_payment_intent(test_merchant.id, CreatePaymentRequest(base_amount=35000.0))
|
|
55
|
+
r2 = service.create_payment_intent(test_merchant.id, CreatePaymentRequest(base_amount=40000.0))
|
|
56
|
+
|
|
57
|
+
assert r1.expected_amount == 35000.0
|
|
58
|
+
assert r2.expected_amount == 40000.0
|
|
59
|
+
assert r2.has_collision is False
|
|
60
|
+
|
|
61
|
+
from unittest.mock import AsyncMock, patch
|
|
62
|
+
|
|
63
|
+
from autopay.services.payment_service import fire_webhook_with_retry
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.asyncio
|
|
67
|
+
async def test_fire_webhook_signature():
|
|
68
|
+
with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post:
|
|
69
|
+
mock_post.return_value.status_code = 200
|
|
70
|
+
|
|
71
|
+
await fire_webhook_with_retry(
|
|
72
|
+
webhook_url="http://test.com/hook",
|
|
73
|
+
processed_payment_id="1",
|
|
74
|
+
intent_id="intent_123",
|
|
75
|
+
amount_tiyins=3500000,
|
|
76
|
+
secret="mysecret"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
mock_post.assert_called_once()
|
|
80
|
+
args, kwargs = mock_post.call_args
|
|
81
|
+
headers = kwargs.get("headers")
|
|
82
|
+
|
|
83
|
+
assert "X-Webhook-Signature" in headers
|
|
84
|
+
|
|
85
|
+
payload = b'{"event":"payment.success","data":{"intent_id":"intent_123","payment_id":"1","amount":35000.0,"status":"PAID"}}'
|
|
86
|
+
expected_sig = hmac.new(b"mysecret", payload, hashlib.sha256).hexdigest()
|
|
87
|
+
assert headers["X-Webhook-Signature"] == expected_sig
|